diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts index b8e41e639..348cc5e5a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -520,7 +520,8 @@ export function createLabEquation(opt: LabEquationOptions) { return; } const edit = canEdit(); - const sum = createEqSum(eq, open, page?.sum); + const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠); + const sum = createEqSum(eq, open, page?.sum, keys); const left = el("div", { className: "m01-eq__left", children: [ @@ -537,8 +538,9 @@ export function createLabEquation(opt: LabEquationOptions) { } : undefined, editor: edit ? editor : undefined, + after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null), }), - ...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계 + ...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계 ], }); defsBox = buildDefs(); diff --git a/resources/master_data/scripts/master_equation_view.py b/resources/master_data/scripts/master_equation_view.py index 561c06c9a..8cbdc64cc 100644 --- a/resources/master_data/scripts/master_equation_view.py +++ b/resources/master_data/scripts/master_equation_view.py @@ -94,6 +94,8 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[ name, link = key[2:].strip("'"), f"호표 줄 금액 — {key[2:].strip(chr(39))}" else: link = _link({**node, "갈래": kind}, specs.get(key), leaf.get(key)) + if kind == "블록" and not leaf.get(key): + link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함 name = (look.get("이름") or key) if mine else look.get("이름") or "" name = node.get("칸") or name if kind == "찾기" else name one = { diff --git a/resources/tester/test_800_2_equation_layout.py b/resources/tester/test_800_2_equation_layout.py index 565c0f11e..0d23aa77c 100644 --- a/resources/tester/test_800_2_equation_layout.py +++ b/resources/tester/test_800_2_equation_layout.py @@ -95,3 +95,28 @@ def test_candidates(): assert eq.links({"갈래": "요소", "검색": "형틀목공", "분류": "인력"})["후보"] assert eq.links({"갈래": "로직", "검색": "합판거푸집", "분류": ""})["후보"] assert whole is not None + + +def test_groups_add_up_to_total(): + """800-4 — 묶음(블록 · 자원 · 덧줄) 소계 합 + 끝수 = 합계(비목마다) · 한 묶음 소계 = 노 + 재 + 경.""" + for key in ("GC001148", "GC000211"): + eqn = eq.read(key, {})["수식"] + sub, total = eqn["소계"], eqn["합계"] + for k in ("노무비", "재료비", "경비"): + got = sum(float(r[k]) for r in sub["줄"]) + float(sub["끝수"][k]) + assert abs(got - float(total[k])) < 1e-6, (key, k) + assert all( + abs(float(r["계"]) - sum(float(r[k]) for k in ("노무비", "재료비", "경비"))) < 1e-6 + for r in sub["줄"] + ) + + +def test_screen_source_groups_and_inline_edit(): + ui = ROOT / "ui_template" + sum_ts = (ui / "ui_template_equation_sum.ts").read_text(encoding="utf-8") + line_ts = (ui / "ui_template_equation_line.ts").read_text(encoding="utf-8") + page = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8") + assert "ORDER" in sum_ts and "ui-eq__grp" in sum_ts # 가 · 나 · 다 묶음 + assert "o.after?.(line)" in line_ts and "after:" in page # 블록 식 줄 바로 아래 + assert "block-key" not in line_ts # 줄 곁 DB 열쇠 글 없음 + assert "ui-eq__solve" not in line_ts and "ui-eq__step" not in line_ts # 풀이 · 줄 번호 걷음 diff --git a/ui_template/ui_template_equation.css b/ui_template/ui_template_equation.css index e801a1d4e..eafb95fb3 100644 --- a/ui_template/ui_template_equation.css +++ b/ui_template/ui_template_equation.css @@ -253,11 +253,6 @@ color: var(--color-text-secondary); } -.ui-eq__block-key { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - .ui-eq__block-body { margin-top: var(--spacing-4); } @@ -437,3 +432,46 @@ .ui-eq__def-empty { color: var(--color-text-muted); } + +/* ── 기준 단가산출별 묶음(800-4 · 요구 661) — 가. 이름 → 노 · 재 · 경 → 소 계 ── */ +.ui-eq__grp { + font-size: var(--text-body-sm); + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8) 0 var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} + +.ui-eq__grp-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: 0 var(--spacing-8); + font-weight: var(--font-weight-semibold); +} + +.ui-eq__grp-line { + display: flex; + align-items: baseline; + gap: var(--spacing-4); + padding: 0 var(--spacing-8) 0 var(--spacing-24); + font-variant-numeric: tabular-nums; +} + +.ui-eq__grp-key { + min-width: 4.5em; + color: var(--color-text-secondary); +} + +.ui-eq__grp-total .ui-eq__sum-cell::before { + content: attr(data-eq-sum-col) " "; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-eq__grp-total { + border-bottom: 0; + font-weight: var(--font-weight-medium); +} diff --git a/ui_template/ui_template_equation_line.ts b/ui_template/ui_template_equation_line.ts index 54b9b0f56..2dec7bf36 100644 --- a/ui_template/ui_template_equation_line.ts +++ b/ui_template/ui_template_equation_line.ts @@ -35,6 +35,8 @@ export interface EqLineOptions { onEdit?: (line: EqLine, id: string) => void; /** 고치는 줄의 식 칸 */ editor?: (line: EqLine, id: string) => HTMLElement; + /** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */ + after?: (line: EqLine) => HTMLElement | null; /** 결과 줄 값 뒤 단위 */ resultUnit?: string; } @@ -108,10 +110,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement { body.hidden = !on; }; body.hidden = true; - box.append( - div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")), - body, - ); + box.append(div("ui-eq__block-row", head, bundle(node)), body); if (o.open.has(id)) toggle(true); return box; } @@ -206,6 +205,8 @@ export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement { if (line.뺌) root.dataset.removed = "1"; root.append(div("ui-eq__main", body)); blocks.forEach((b, i) => root.append(blockPanel(b, i + 1, o))); + const extra = o.after?.(line); + if (extra) root.append(extra); return root; } diff --git a/ui_template/ui_template_equation_sum.ts b/ui_template/ui_template_equation_sum.ts index 7fecae481..eab9950c9 100644 --- a/ui_template/ui_template_equation_sum.ts +++ b/ui_template/ui_template_equation_sum.ts @@ -1,10 +1,12 @@ /* ============================================================================= * ui_template_equation_sum.ts - * 부품 「수식」 셋째 파일(799-1 · 796-2c · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로). - * 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합) - * 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림 - * M02 SK 줄 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0 - * 쓰는 법 = `createEqSum(tree, open?, hooks?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 · + * 부품 「수식」 셋째 파일(799-1 · 796-2c · 800-4 · 요구 655-1 · 661 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로). + * 묶음 = 기준 단가산출마다(하위 로직 블록 · 자기 자원 · 덧줄) 「가. 이름」 머리 → 노무비 · 재료비 · 경비 줄 → 소 계(노 · 재 · 경 · 계 칸) + * 맨 끝 「소 계」 묶음 = 묶음 소계를 더한 식(노무비 : 2,299.4 + 5,162 = 7,461.4) → 끝수(합계 − 묶음 합 · 0 이면 줄 없음) → 합계(엔진 비목 합) + * 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 소계가 없으면(결과 로직) 아무것도 안 그림 + * M02 SK 묶음 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0 + * 자리 = 하위 로직 블록 묶음은 그 블록 식 줄 바로 아래(`inline` · 줄 열쇠 = 블록 이름 · `createEqChain` 의 `after`) · 나머지(자기 자원 · 덧줄 · 더한 기초) · 맨 끝 소 계 · 끝수 · 합계는 `tail` + * 쓰는 법 = `createEqSum(tree, open?, hooks?, lineKeys?)` → { inline, tail } | null — `lineKeys` = 수식 영역 호표 줄 열쇠들 · `open` = 펼친 블록 이름표 모음 · * `hooks` = 페이지가 더한 기초 줄 머리 단추(`head`) · 접힘 속(`body`)을 바꿔 끼움(796-2e 연결 끊기) * ========================================================================== */ @@ -14,8 +16,14 @@ import { eqText, type EqSumHooks, type EqSumRow, type EqTree } from "./ui_templa import { createEqChain } from "./ui_template_equation_line"; const COLS = ["노무비", "재료비", "경비", "계"] as const; +const PARTS = ["노무비", "재료비", "경비"] as const; +type Col = (typeof COLS)[number]; const KIND: Record = { 자원: "자기 자원", 덧줄: "덧줄" }; const TONE: Record = { 더함: "success", 덮음: "warning", 뺌: "neutral" }; +const ORDER = "가나다라마바사아자차카타파하"; + +/** 묶음 번호 — 가 · 나 · 다 … (열네 개를 넘으면 숫자) */ +const mark = (at: number): string => (at < ORDER.length ? (ORDER[at] ?? "") : String(at + 1)); const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => { const out = document.createElement(tag); @@ -24,7 +32,12 @@ const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => return out; }; -function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): void { +const num = (v: unknown): number => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +function cells(line: HTMLElement, get: (k: Col) => unknown): void { for (const k of COLS) { const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0"); cell.dataset.eqSumCol = k; @@ -32,7 +45,7 @@ function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): v } } -function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement { +function row(kind: string, name: string, get: (k: Col) => unknown): HTMLElement { const line = node("div", "ui-eq__sum-row"); line.dataset.eqSumKind = kind; line.append(node("span", "ui-eq__sum-name", name)); @@ -40,19 +53,33 @@ function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unkn return line; } -/** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */ -function itemRow(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks): HTMLElement { - const item = node("div", "ui-eq__sum-item"); - const line = node("div", "ui-eq__sum-row"); - line.dataset.eqSumKind = r.갈래; - const name = node("span", "ui-eq__sum-name"); +/** 묶음 안 한 줄 「노무비 : 값」 */ +const part = (label: string, text: string): HTMLElement => { + const line = node("div", "ui-eq__grp-line"); + line.append( + node("span", "ui-eq__grp-key", label), + node("span", "ui-eq__op", ":"), + node("span", "ui-eq__grp-val", text), + ); + line.dataset.eqGrpPart = label; + return line; +}; + +/** 기준 단가산출 묶음 하나 — 「가. 이름」 머리(+ 상태 · 접힘 수식) → 노 · 재 · 경 줄 → 소 계 */ +function group(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks): HTMLElement { + const box = node("div", "ui-eq__grp"); + box.dataset.eqSumItem = r.갈래; + box.dataset.eqSumState = r.상태 ?? "바탕"; + const name = node("div", "ui-eq__grp-head"); + const title = r.갈래 === r.이름 ? (KIND[r.갈래] ?? r.이름) : r.이름; const tree = r.나무; const id = `sum:${at}:${r.이름}`; const body = node("div", "ui-eq__sum-tree"); body.hidden = true; + name.append(node("span", "ui-eq__grp-mark", `${mark(at)}.`)); if (tree) { const head = createButton({ - label: r.이름, + label: title, variant: "ghost", size: 22, iconStart: "chevronRight", @@ -66,67 +93,89 @@ function itemRow(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks) if (!body.childElementCount) body.append( hooks?.body?.(r, at) ?? - createEqChain(tree.줄, { - path: id, - open, - resultUnit: tree.결과단위, - }), + createEqChain(tree.줄, { path: id, open, resultUnit: tree.결과단위 }), ); } else open.delete(id); - item.dataset.open = on ? "1" : ""; + box.dataset.open = on ? "1" : ""; body.hidden = !on; }; name.append(head); if (open.has(id)) toggle(true); - } else name.append(node("span", "ui-eq__sum-label", r.이름)); + } else name.append(node("span", "ui-eq__sum-label", title)); if (r.상태 && r.상태 !== "바탕") { const detail = r.덮개?.length ? ` · ${r.덮개.join(" · ")}` : ""; name.append(createBadge(`${r.상태}${detail}`, TONE[r.상태] ?? "neutral", { size: 18 })); } name.append(...(hooks?.head?.(r, at) ?? [])); - if (r.합밖) { - name.append(createBadge("합 밖", "neutral", { size: 18 })); - line.dataset.eqSumOut = "1"; - } + const out = !!r.합밖; + if (out) name.append(createBadge("합 밖", "neutral", { size: 18 })); const notes: string[] = []; - if (r.합밖) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`); + if (out) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`); if (r.상태 === "더함" && r.수량 !== undefined && r.수량 !== null) notes.push(`× ${eqText(r.수량)}`); if (r.원래 !== undefined && r.원래 !== null && r.상태 !== "더함") notes.push(`원래 ${eqText(r.원래)}`); if (r.까닭) notes.push(r.까닭); if (notes.length) name.append(node("span", "ui-eq__sum-was", notes.join(" · "))); - line.dataset.eqSumState = r.상태 ?? "바탕"; - line.append(name); - cells(line, (k) => r[k]); - item.dataset.eqSumItem = r.갈래; - item.append(line, body); - return item; + box.append(name, body); + for (const k of PARTS) if (num(r[k]) !== 0) box.append(part(k, eqText(r[k]))); + const total = row("소계", "소 계", (k) => r[k]); + total.classList.add("ui-eq__grp-total"); + if (out) total.dataset.eqSumOut = "1"; + box.append(total); + return box; +} + +/** 맨 끝 「소 계」 묶음 — 묶음 소계를 더한 식 */ +function final(rows: EqSumRow[], at: number): HTMLElement { + const box = node("div", "ui-eq__grp"); + box.dataset.eqSumItem = "소계"; + const head = node("div", "ui-eq__grp-head"); + head.append( + node("span", "ui-eq__grp-mark", `${mark(at)}.`), + node("span", "ui-eq__sum-label", "소 계"), + ); + box.append(head); + for (const k of PARTS) { + const adds = rows.map((r) => num(r[k])).filter((v) => v !== 0); + if (!adds.length) continue; + const text = adds.map((v) => eqText(v)).join(" + "); + const sum = eqText(adds.reduce((a, b) => a + b, 0)); + box.append(part(k, adds.length > 1 ? `${text} = ${sum}` : text)); + } + return box; +} + +export interface EqSumParts { + /** 블록 이름 → 그 블록 식 줄 아래에 끼울 묶음 */ + inline: Map; + /** 수식 영역 맨 끝 — 남은 묶음 · 소 계 · 끝수 · 합계 */ + tail: HTMLElement; } export function createEqSum( tree: Pick, open: Set = new Set(), hooks?: EqSumHooks, -): HTMLElement | null { + lineKeys: string[] = [], +): EqSumParts | null { const sub = tree.소계; if (!sub?.줄.length) return null; + const inline = new Map(); const box = node("div", "ui-eq__sum"); box.dataset.eqSum = ""; - const head = node("div", "ui-eq__sum-row ui-eq__sum-head"); - head.append(node("span", "ui-eq__sum-name", "소계")); - for (const k of COLS) head.append(node("span", "ui-eq__sum-cell", k)); - box.append(head); sub.줄.forEach((r, at) => { - if (r.상태) box.append(itemRow(r, open, at, hooks)); - else - box.append(row(r.갈래, r.이름 === r.갈래 ? (KIND[r.갈래] ?? r.이름) : r.이름, (k) => r[k])); + const one = group(r, open, at, hooks); + if (r.갈래 === "블록" && lineKeys.includes(r.이름) && !inline.has(r.이름)) + inline.set(r.이름, one); + else box.append(one); }); + box.append(final(sub.줄, sub.줄.length)); const rest = sub.끝수; const odd = COLS.some((k) => Number(rest[k] ?? 0) !== 0); if (odd) box.append(row("끝수", "끝수", (k) => rest[k])); box.append(row("합계", "합계", (k) => tree.합계[k])); const note = [odd ? sub.끝수까닭 : "", tree.답까닭].filter(Boolean).join(" · "); if (note) box.append(node("div", "ui-eq__sum-note", note)); - return box; + return { inline, tail: box }; }