diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index d855e45fc..16c335f82 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -189,6 +189,9 @@ export interface StructureNumbers { /** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */ 일위대가합계?: number; 합계기준?: boolean; + /** 합계 칸 색(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` · 까닭 글은 칸 툴팁 */ + 합계상태?: "ok" | "short" | "error"; + 합계까닭?: string; } /** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 1c4f38552..c63a4f697 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -237,20 +237,39 @@ def _total(sums: Any) -> float | None: return None +def _sum_state(calc: dict[str, Any]) -> dict[str, Any]: + """합계 칸 색 상태(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` + 까닭 글. + 붉음 = 기준이 안 나온 돈 줄(까닭 있음) · 기준 단가 없는 자재 / 노랑 = 수량 없는 줄 · 자재 / + 돈 아닌 로직 줄(`결과`)은 판정에서 뺌.""" + bad = short = 0 + for one in (*(calc.get("줄") or []), *(calc.get("자재") or [])): + if not isinstance(one, dict): + continue + if one.get("기준", one.get("기준단가")) is None: + bad += 1 if one.get("까닭") else 0 + elif one.get("수량") is None: + short += 1 + why = [f"{t} {n}" for t, n in (("기준 단가 없음", bad), ("수량 없는 줄", short)) if n] + return { + "합계상태": "error" if bad else "short" if short else "ok", + "합계까닭": " · ".join(why) or "모두 반영", + } + + def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]: """일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 · - 담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙).""" + 담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙) · `합계상태`(101-1).""" if not doc.get("담은로직"): return {} calc = doc.get("계산값") or {} if total := _total(calc.get("반영합")): - return {"일위대가합계": total, "합계기준": False} + return {"일위대가합계": total, "합계기준": False, **_sum_state(calc)} base = _total(calc.get("기준합")) - return {} if base is None else {"일위대가합계": base, "합계기준": True} + return {} if base is None else {"일위대가합계": base, "합계기준": True, **_sum_state(calc)} def structure_numbers() -> dict[str, dict[str, Any]]: - """`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에 + """`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준, 합계상태, 합계까닭)}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만).""" out: dict[str, dict[str, Any]] = {} for kind, key, _, _ in PARTS: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 892af0823..881a4607b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -58,7 +58,6 @@ const TEXT = { noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", - baseNote: "(기준)", }; /** 집계표 줄 열쇠 → 문서 */ @@ -74,11 +73,17 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */ export type ColumnNumbers = Record; -/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */ +/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 없으면 기준합 · 계산값 없음 = 빈 글(「—」) · 색은 `laborSumState` */ export function laborSumText(numbers: StructureNumbers | undefined): string { if (typeof numbers?.일위대가합계 !== "number") return ""; - const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); - return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text; + return numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); +} + +/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — 계산값 없음 · 담은 것 없음 = 색 없음 */ +export function laborSumState( + numbers: StructureNumbers | undefined, +): { state: string; why: string } | undefined { + return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined; } /** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 1a9d3fb11..a4f70db1c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -22,6 +22,7 @@ import { comboMerge, comboView, createStructurePanel, + laborSumState, laborSumText, pillsFor, STRUCTURE_TABLE, @@ -361,6 +362,7 @@ export function createMain( drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3 + laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1 combo: { ...(readOnly || !extras.onHeadRename ? {} diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index 193f23b41..0d7ba931c 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -118,6 +118,39 @@ def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest. assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == "" +def test_일위대가_합계_상태_넷(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """101-1 — 합계 칸 색: 붉음(기준 안 나온 돈 줄 · 단가 없는 자재) > 노랑(수량 없는 줄 · 자재) > + 초록(모두 반영) · 돈 아닌 로직 줄은 판정에서 뺌 · 담은 것 없음 = 상태 없음.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + monkeypatch.setattr(store, "FOLDER", tmp_path) + (tmp_path / "labor").mkdir() + money = {"기준": {"계": 100}, "까닭": None} + docs = { + "ok": {"줄": [{**money, "수량": 2}, {"결과": 3, "기준": None, "까닭": None}], "자재": [ + {"기준단가": 5, "수량": 1} + ]}, + "short": {"줄": [{**money, "수량": 2}, {**money, "수량": None}], "자재": []}, + "error": {"줄": [{**money, "수량": None}, {"기준": None, "까닭": "식 오류"}], "자재": [ + {"기준단가": None, "까닭": "단가 없음", "수량": None}, + {"기준단가": 5, "수량": None}, + ]}, + } # fmt: skip + for name, calc in docs.items(): + doc = {"담은로직": ["x"], "계산값": {**calc, "반영합": {"계": 7}, "기준합": {"계": 9}}} + (tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8") + empty = {"담은로직": [], "계산값": {"줄": [], "반영합": {"계": 7}}} + (tmp_path / "labor" / "empty.json").write_text(json.dumps(empty), encoding="utf-8") + got = store.structure_numbers() + assert (got["ok"]["합계상태"], got["ok"]["합계까닭"]) == ("ok", "모두 반영") + assert (got["short"]["합계상태"], got["short"]["합계까닭"]) == ("short", "수량 없는 줄 1") + assert (got["error"]["합계상태"], got["error"]["합계까닭"]) == ( + "error", + "기준 단가 없음 2 · 수량 없는 줄 2", + ) + assert "합계상태" not in got["empty"] + + def test_일위대가_합계_줄_표_부품과_연결(): sheet = _text(SHEET / "ui_template_sheet.ts") assert "laborSumLabel: opts.laborSumLabel," in sheet @@ -131,6 +164,12 @@ def test_일위대가_합계_줄_표_부품과_연결(): assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main + assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main + assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움 + assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text( + SHEET / "ui_template_sheet.css" + ) + assert "markSum(c)" in sheet # 101-2 머리 고르면 합계 칸도 같이 assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기 struct = _text(M02 / "M02_MasterTemplete_Structure.ts") assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 0bb187110..b6a171187 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양(): ), name assert "fetchStructureNumbers = (): Promise>" in body assert re.search( - r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}", + r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;(\s*/\*\*[^*]*\*/)?(\s*합계\w+\?: [^;]+;)*\s*\}", body, ) assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰 @@ -93,7 +93,7 @@ def test_집계표만_컨테이너를_붙임(): # 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로 # 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`) assert re.search( - r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*' + r"toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*" r'\.\.\.\(edited as Pick\),\s*\}\)', body, ) diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index 6f2beb712..57f73268a 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -107,6 +107,19 @@ background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas)); } +/* 구조물 조합 일위대가 합계 칸(101-1) — 초록 모두 반영 · 노랑 일부 빠짐 · 붉음 기준 단가 안 나옴(설계값·계산 칸보다 진함) */ +.ui-sheet__table td.is-sum-ok { + background: color-mix(in srgb, var(--color-success) 26%, var(--color-canvas)); +} + +.ui-sheet__table td.is-sum-short { + background: color-mix(in srgb, var(--color-warning) 34%, var(--color-canvas)); +} + +.ui-sheet__table td.is-sum-error { + background: color-mix(in srgb, var(--color-danger) 28%, var(--color-canvas)); +} + .ui-sheet--project .ui-sheet__table tbody td.is-locked { color: var(--color-text-secondary); } @@ -141,6 +154,43 @@ outline-offset: -2px; } +/* 구조물 조합 — 고른 열의 머리 칸 + 합계 칸을 한 상자로(머리 아래 · 합계 위 변 없음 = 중간선 없음) */ +.ui-sheet--combo .ui-sheet__table:has(tr.ui-sheet__meta td.is-selected) th.is-head-picked { + border-bottom-width: 0; + outline: 0; + box-shadow: + inset 2px 0 0 var(--color-focus-ring), + inset -2px 0 0 var(--color-focus-ring), + inset 0 2px 0 var(--color-focus-ring); +} + +.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected { + outline: 0; + box-shadow: + inset 2px 0 0 var(--color-focus-ring), + inset -2px 0 0 var(--color-focus-ring), + inset 0 -2px 0 var(--color-focus-ring); +} + +/* 틀고정 칸의 오른쪽 선 그림자(:87-91)와 합침 */ +.ui-sheet--combo + .ui-sheet__table:has(tr.ui-sheet__meta td.is-selected) + th.is-head-picked.is-frozen { + box-shadow: + inset 2px 0 0 var(--color-focus-ring), + inset -2px 0 0 var(--color-focus-ring), + inset 0 2px 0 var(--color-focus-ring), + 1px 0 0 var(--color-border); +} + +.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected.is-frozen { + box-shadow: + inset 2px 0 0 var(--color-focus-ring), + inset -2px 0 0 var(--color-focus-ring), + inset 0 -2px 0 var(--color-focus-ring), + 1px 0 0 var(--color-border); +} + .ui-sheet__table tr.is-fixed td { border-bottom: 2px solid var(--color-border); } diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 3d952c266..bab9d0661 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -25,6 +25,7 @@ import { addLevel, deleteColumn, isBlankPick, + KEY_SUM, KEY_UNIT, navRows, PICK_ROWS, @@ -61,6 +62,8 @@ export interface SheetOptions { areaLabel?: (colId: string) => string; /** 구조물 조합 표(`combo`) 「일위대가 합계」 줄 칸 글 — 주면 읽기 전용 줄이 생김(86-3). */ laborSumLabel?: (colId: string) => string; + /** 합계 칸 색 상태 · 툴팁 까닭(101-1) — `state` = ok · short · error · 없으면 색 없음. */ + laborSumState?: (colId: string) => { state: string; why: string } | undefined; /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; /** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름. @@ -93,6 +96,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio drawingLabel: opts.drawingLabel, areaLabel: opts.areaLabel, laborSumLabel: opts.laborSumLabel, + laborSumState: opts.laborSumState, pick: !!opts.onPick, headLocked: !!opts.lockHead, combo: !!opts.combo, @@ -186,7 +190,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.result = recalcSheet(state.doc); const { scrollLeft, scrollTop } = scroll; scroll.replaceChildren(renderSheet(state)); - if (opts.combo && state.sel) deepestHead(state.sel.c)?.classList.add("is-head-picked"); + if (opts.combo && state.sel) { + deepestHead(state.sel.c)?.classList.add("is-head-picked"); + markSum(state.sel.c); + } scroll.scrollLeft = scrollLeft; scroll.scrollTop = scrollTop; syncToolbar(); @@ -212,6 +219,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio } return best; }; + /** 구조물 조합 — 머리를 고르면 그 열 합계 칸도 늘 같이 고름 표시(101-2) */ + const markSum = (colId: string): void => { + for (const cell of cellsAt(KEY_SUM, colId)) cell.classList.add("is-selected"); + }; const select = (r: string, c: string, reveal = false): void => { for (const cell of scroll.querySelectorAll(".is-selected, .is-head-picked")) cell.classList.remove("is-selected", "is-head-picked"); @@ -220,6 +231,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio head?.classList.add("is-head-picked"); const cells = cellsAt(r, c); for (const cell of cells) cell.classList.add("is-selected"); + if (opts.combo) markSum(c); if (reveal) (cells[0] ?? head)?.scrollIntoView({ block: "nearest", inline: "nearest" }); syncToolbar(); opts.onSelect?.(selectionOf(state.doc, r, c)); diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 5c804be8b..72c2c7c31 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -46,6 +46,8 @@ export interface RenderState { areaLabel?: (colId: string) => string; /** 구조물 조합 표 「일위대가 합계」 줄 칸 글 — 주면 combo 표에 읽기 전용 줄이 생김(86-3). */ laborSumLabel?: (colId: string) => string; + /** 합계 칸 색 상태 · 툴팁 까닭(101-1) */ + laborSumState?: (colId: string) => { state: string; why: string } | undefined; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ pick?: boolean; /** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */ @@ -194,6 +196,14 @@ export function renderSheet(state: RenderState): HTMLElement { const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글 const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); + if (key === KEY_SUM) { + td.classList.remove("is-calc", "is-bound", "is-hand"); // 합계 칸은 열 꼴 색 없음 — 상태 색만 + const sum = blank ? undefined : state.laborSumState?.(col.id); + if (sum) { + td.classList.add(`is-sum-${sum.state}`); + td.title = sum.why; + } + } if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick"); tr.append(td); });