From d13b5b17537196232a0192547a00d977bf36755a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 12:29:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(799-1):=20=EC=88=98=EC=8B=9D=20=ED=83=AD?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EC=86=8C=EA=B3=84=20?= =?UTF-8?q?=C2=B7=20=EB=81=9D=EC=88=98=20=C2=B7=20=ED=95=A9=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EB=B8=94=EB=A1=9D=EB=A7=88=EB=8B=A4=20=EB=85=B8=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=20=C2=B7=20=EA=B2=BD=20=EC=86=8C=EA=B3=84=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EA=B8=B0=EB=A7=8C(=EA=B8=88=EC=95=A1=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 master_equation.subtotals — 호표 줄(하위 로직 블록 한 줄씩 · 자원 · 덧줄)의 버린 줄 비목 + 끝수(합계 − 줄 합) - 부품 ui_template_equation_sum — 줄 이음 아래 소계 표(M01 · M02 수식 탭 같이) - 시험 test_799_1_subtotals — 줄 합 + 끝수 = 합계 · 합계 = 엔진 끝수 입힌 비목 합(전후 같음) Co-Authored-By: Claude Sonnet 5.5 --- .../M01_MasterData_UI_LogicLab_Equation.ts | 3 + .../master_data/scripts/master_equation.py | 37 +++++++ resources/tester/test_799_1_subtotals.py | 102 ++++++++++++++++++ ui_template/ui_template_equation.css | 33 ++++++ ui_template/ui_template_equation.ts | 14 ++- ui_template/ui_template_equation_sum.ts | 47 ++++++++ 6 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_799_1_subtotals.py create mode 100644 ui_template/ui_template_equation_sum.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts index 0b90859a9..df9e25363 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -15,6 +15,7 @@ import { createBanner } from "@ui/ui_template_banner"; import { createBadge } from "@ui/ui_template_badge"; import { createButton, createIconButton } from "@ui/ui_template_button"; import { createEqChain } from "@ui/ui_template_equation_line"; +import { createEqSum } from "@ui/ui_template_equation_sum"; import { eqText, type EqLine, type EqTree, type EqVarRow } from "@ui/ui_template_equation"; import { el, @@ -457,6 +458,7 @@ export function createLabEquation(opt: LabEquationOptions) { return; } const edit = canEdit(); + const sum = createEqSum(eq); body.replaceChildren( createEqChain(eq.줄, { path: "", @@ -472,6 +474,7 @@ export function createLabEquation(opt: LabEquationOptions) { onVar: edit ? varDialog : undefined, slot: edit ? editor : undefined, }), + ...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계 ); } diff --git a/resources/master_data/scripts/master_equation.py b/resources/master_data/scripts/master_equation.py index b81983c03..7514473a4 100644 --- a/resources/master_data/scripts/master_equation.py +++ b/resources/master_data/scripts/master_equation.py @@ -389,6 +389,39 @@ def _line(ctx: _Ctx, kind: str, key: str, text: str, value, **more) -> dict: } +def subtotals(logic: dict, cut: dict, total: dict) -> dict: + """호표 줄 → 소계(799-1 · 보이기만) — 하위 로직 줄 = 블록마다 · 그 밖 자원 · 덧줄은 한 줄씩 + 끝수. + + 값 = `master_cut.engine` 이 버린 줄 비목(0.1 원) 그대로 · 합계 = 엔진 비목 합(1 원 버림) · + 끝수 = 합계 − 줄 합(금액 셈은 안 바꿈 · 사용자 확정 뒤에 정함). + """ + items, adds = logic.get("호표") or [], logic.get("덧줄") or [] + skip = mcut.ms.skipped(logic) if mcut.ms.uses(logic) else set() + rows: list[dict] = [] + pool: dict[str, dict] = {} + for n, line in enumerate(cut.get("줄") or []): + if n in skip: + continue + split = {k: line.get("비목", {}).get(k) or Decimal(0) for k in mf.COST_ITEMS} + if n < len(items) and items[n].get("종류") == "로직": + rows.append({"갈래": "블록", "이름": str(items[n].get("이름") or ""), **split}) + continue + kind = "덧줄" if n >= len(items) else "자원" + one = pool.setdefault( + kind, {"갈래": kind, "이름": kind, **dict.fromkeys(mf.COST_ITEMS, Decimal(0))} + ) + for k in mf.COST_ITEMS: + one[k] += split[k] + rows += [pool[k] for k in ("자원", "덧줄") if k in pool] + for one in rows: + one["계"] = sum((one[k] for k in mf.COST_ITEMS), Decimal(0)) + rest = { + k: (total.get(k) or Decimal(0)) - sum((one[k] for one in rows), Decimal(0)) + for k in mf.BUNDLE + } + return {"줄": rows, "끝수": rest} + + def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: """로직 키 → 수식 {열쇠, 이름, 결과단위, 줄, 풀이, 변수표, 합계 | 결과} — 셈은 엔진 한 벌.""" row = master.logic(key) @@ -402,6 +435,7 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: more = {"표시": step["표시"]} if step.get("표시") is not None else {} out_lines.append(_line(ctx, "중간", name, step["식"], env.get(name), **more)) total: dict = {} + sub: dict = {} if "결과" in row: out_lines.append(_line(ctx, "결과", "결과", row["결과"]["식"], raw["결과"])) total = {"결과": raw["결과"]} @@ -415,6 +449,8 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: one = _line(ctx, "덧줄", extra["이름"], extra["식"], got, 차례=at) out_lines.append(one | {"비목": extra.get("비목")}) total = {k: cut.get(k) for k in mf.BUNDLE} + if depth == 0: + sub = {"소계": subtotals(row, cut, total)} return _clean( { "열쇠": row.get("키"), @@ -424,6 +460,7 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: "풀이": list(ctx.used.values()), "변수표": table(row, master), "합계": total, + **sub, } ) diff --git a/resources/tester/test_799_1_subtotals.py b/resources/tester/test_799_1_subtotals.py new file mode 100644 index 000000000..9ca89b3e0 --- /dev/null +++ b/resources/tester/test_799_1_subtotals.py @@ -0,0 +1,102 @@ +"""799-1 단가산출 소계 · 끝수 · 합계 지킴(보이기만 — 금액 셈은 그대로). + +① 예시 셋 — 굴착기(블록 한 줄) · 거푸집(자원 + 덧줄) · 지게(자원) : 줄 합 + 끝수 = 합계(비목마다). +② 표본 — 호표 로직 여럿 : 합계 = 엔진 끝수 입힌 비목 합(전후 같음) · 줄 합 + 끝수 = 합계 · 결과 로직은 소계 없음. +③ 부품 짝 — `ui_template_equation_sum.ts` 가 서버 칸 이름을 그대로 읽음. +""" + +from __future__ import annotations + +import importlib +from decimal import Decimal as D +from pathlib import Path + +from M01_MasterData import M01_MasterData_Store as store + +mf = importlib.import_module("master_formula") +mcut = importlib.import_module("master_cut") +meq = importlib.import_module("master_equation") + +COSTS = ("노무비", "재료비", "경비", "계") + + +def _whole(): + return store.loaded()[1] + + +def _logics() -> list[dict]: + """M01 로직 줄 전부(키 순).""" + w = _whole() + return [ + r + for tid, rows in sorted(w.index.items()) + if mf.mk.GROUP_OF.get(tid) == "로직" + for r in rows.values() + ] + + +def _export(key: str) -> dict: + w = _whole() + return meq.export(w, key, meq.given_of(w, w.logic(key), None)) + + +def _closes(eq: dict) -> None: + sub = eq["소계"] + for k in COSTS: + got = sum((r[k] for r in sub["줄"]), D(0)) + sub["끝수"][k] + assert got == eq["합계"][k], (eq["열쇠"], k, got, eq["합계"][k]) + + +def test_굴착기_블록_한_줄_끝수(): + eq = _export("GC001148") + rows = eq["소계"]["줄"] + assert [r["갈래"] for r in rows] == ["블록"] and rows[0]["이름"] == "굴착기(무한궤도)" + assert (rows[0]["노무비"], rows[0]["재료비"], rows[0]["경비"]) == ( + D("437.1"), + D("88.2"), + D("116.0"), + ) + assert eq["합계"]["계"] == D("641.0") and eq["소계"]["끝수"]["계"] == D("-0.3") + _closes(eq) + + +def test_거푸집_자원_덧줄_지게_자원(): + eq = _export("GC000211") + assert [r["갈래"] for r in eq["소계"]["줄"]] == ["자원", "덧줄"] + _closes(eq) + eq = _export("GC001140") + assert [r["갈래"] for r in eq["소계"]["줄"]] == ["자원"] + _closes(eq) + + +def test_표본_합계는_엔진_끝수_그대로(): + w = _whole() + keys = [x["키"] for x in _logics() if x.get("호표")][::25] + assert keys, "표본 없음" + seen = 0 + for key in keys: + try: + eq = _export(key) + except mf.FormulaError: + continue + if "소계" not in eq: + continue + seen += 1 + _closes(eq) + raw = mf.run(w, key, meq.given_of(w, w.logic(key), None)) + cut = mcut.engine(w, key, raw) + assert {k: eq["합계"][k] for k in COSTS} == {k: cut[k] for k in COSTS}, key + assert seen >= 10 + + +def test_결과_로직은_소계_없음(): + key = next(x["키"] for x in _logics() if "결과" in x) + assert "소계" not in _export(key) + + +def test_화면_부품이_서버_칸을_읽음(): + ts = Path("ui_template/ui_template_equation_sum.ts").read_text(encoding="utf-8") + for name in ("노무비", "재료비", "경비", "끝수", "합계", "자원", "덧줄"): + assert name in ts + line = Path("M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8") + assert "createEqSum(eq)" in line diff --git a/ui_template/ui_template_equation.css b/ui_template/ui_template_equation.css index 5a0f77f5f..7293b4929 100644 --- a/ui_template/ui_template_equation.css +++ b/ui_template/ui_template_equation.css @@ -322,3 +322,36 @@ :is(.ui-eq__block-body, .ui-eq__bad)[hidden] { display: none !important; } + +/* ── 단가산출 소계 · 합계(799-1) ── */ +.ui-eq__sum { + display: flex; + flex-direction: column; + border-top: 1px solid var(--color-border); + font-variant-numeric: tabular-nums; +} + +.ui-eq__sum-row { + display: grid; + grid-template-columns: minmax(var(--spacing-96), 2fr) repeat(4, minmax(0, 1fr)); + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +.ui-eq__sum-head { + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.ui-eq__sum-cell { + text-align: right; +} + +.ui-eq__sum-row[data-eq-sum-kind="끝수"] { + color: var(--color-text-secondary); +} + +.ui-eq__sum-row[data-eq-sum-kind="합계"] { + font-weight: var(--font-weight-semibold); +} diff --git a/ui_template/ui_template_equation.ts b/ui_template/ui_template_equation.ts index c66e15559..693f340b9 100644 --- a/ui_template/ui_template_equation.ts +++ b/ui_template/ui_template_equation.ts @@ -44,7 +44,17 @@ export interface EqNode { 사슬?: boolean; } -/** 로직 하나의 수식(줄 · 변수표 · 합계) */ +/** 단가산출 소계 줄(799-1) — 갈래 블록 | 자원 | 덧줄 */ +export interface EqSumRow { + 갈래: string; + 이름: string; + 노무비: unknown; + 재료비: unknown; + 경비: unknown; + 계: unknown; +} + +/** 로직 하나의 수식(줄 · 변수표 · 합계 · 소계) */ export interface EqTree { 열쇠: string; 이름: string; @@ -52,6 +62,8 @@ export interface EqTree { 줄: EqLine[]; 변수표: EqVarRow[]; 합계: Record; + /** 799-1 — 하위 로직 블록마다 노 · 재 · 경 소계 + 끝수(합계 − 줄 합) · 결과 로직은 없음 */ + 소계?: { 줄: EqSumRow[]; 끝수: Record }; } export interface EqVarRow { diff --git a/ui_template/ui_template_equation_sum.ts b/ui_template/ui_template_equation_sum.ts new file mode 100644 index 000000000..2087f9504 --- /dev/null +++ b/ui_template/ui_template_equation_sum.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * ui_template_equation_sum.ts + * 부품 「수식」 셋째 파일(799-1 · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로). + * 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합) + * 값 = 서버 `소계`(`master_equation.subtotals` · 버린 줄 비목 0.1 원) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림 + * 쓰는 법 = `createEqSum(tree)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 + * ========================================================================== */ + +import { eqText, type EqTree } from "./ui_template_equation"; + +const COLS = ["노무비", "재료비", "경비", "계"] as const; +const KIND: Record = { 자원: "자기 자원", 덧줄: "덧줄" }; + +const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => { + const out = document.createElement(tag); + out.className = className; + if (text) out.textContent = text; + return out; +}; + +function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement { + const line = node("div", "ui-eq__sum-row"); + line.dataset.eqSumKind = kind; + line.append(node("span", "ui-eq__sum-name", name)); + for (const k of COLS) { + const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0"); + cell.dataset.eqSumCol = k; + line.append(cell); + } + return line; +} + +export function createEqSum(tree: Pick): HTMLElement | null { + const sub = tree.소계; + if (!sub?.줄.length) return null; + 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); + for (const r of sub.줄) box.append(row(r.갈래, KIND[r.갈래] ?? r.이름, (k) => r[k])); + const rest = sub.끝수; + if (COLS.some((k) => Number(rest[k] ?? 0) !== 0)) box.append(row("끝수", "끝수", (k) => rest[k])); + box.append(row("합계", "합계", (k) => tree.합계[k])); + return box; +}