From 463214d921f3c107bde00711ec142aab47c495d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:16:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=A7=91=EA=B3=84=ED=91=9C=20?= =?UTF-8?q?=EB=84=B7=20=E2=80=94=20=EC=9E=AC=EB=A3=8C=EB=B9=84=20=C2=B7=20?= =?UTF-8?q?=EB=85=B8=EB=AC=B4=EB=B9=84=20=C2=B7=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=EA=B8=88=EC=95=A1=20=C2=B7=20=EC=A4=91?= =?UTF-8?q?=EA=B8=B0=EC=8B=9C=EA=B0=84=EA=B8=88=EC=95=A1(=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=97=AD=EC=A0=84=EA=B0=9C=20=C2=B7=20145-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 역전개(서버 단독) = 설계내역서 수량 × 호표 구성(겹 호표 끝까지 · 깊이 한도) → 자원 총수량 · 내역 자재 줄 그대로 - 끝수 = 엑셀 자리 — 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 중기 비목마다 ROUND · 단가 없으면 금액 빈칸 - 호표 구성 = hopyo_lines 자리(145-1 · 2 가 들어오면 채움) · GET 응답 summary · 화면 탭 넷 + 목록표 이동 - 시험 = test_145_5_b09_summary(엑셀 중기 42.95 시간 5,411,700 · 건축목공 2,222,201 · 각재 280,370 · 못 5,321 · 구역화물 678,345) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Router.py | 3 + B09_Estimation/B09_Estimation_Summary.py | 124 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 10 ++ .../B09_Estimation_UI_Tabs_Summary.ts | 82 ++++++++++++ resources/tester/test_145_5_b09_summary.py | 97 ++++++++++++++ 5 files changed, 316 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_Summary.py create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts create mode 100644 resources/tester/test_145_5_b09_summary.py diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index ce3f67830..26ed32865 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -29,6 +29,7 @@ from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation from B09_Estimation.B09_Estimation_Linked import linked_resource_keys +from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -69,6 +70,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "book": {"made": book.get("made")} if book else None, "sheets": compute_estimation(inputs, user), "notes": notes, + # 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜 + "summary": compute_summary(compute_estimation(inputs, user), hopyo_lines(root)), } diff --git a/B09_Estimation/B09_Estimation_Summary.py b/B09_Estimation/B09_Estimation_Summary.py new file mode 100644 index 000000000..2812af643 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Summary.py @@ -0,0 +1,124 @@ +"""B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표 +(서버 단독). + +역전개 = 설계내역서 항목 수량 × 호표 구성(산근 · 대가 줄 · 겹 호표 끝까지) → 자원마다 총수량. + 호표 구성 = `hopyo_lines` — 145-1 · 2(산근 · 대가)가 `{호표 키: {"lines": [{kind, key, qty}]}}` + (호표 1 단위당 · kind = labor · material · expense · machine · hopyo)로 줌 · 그전엔 빈 묶음 + 4 자재대 · 관급자재대 자재 줄 = 그 자재 그대로(호표 없음) +끝수 = 엑셀 자리 그대로 — 총수량 ROUND 2 · 금액 = ROUND(수량 × 단가, 0)(여기만 반올림) · + 중기 = 노무 · 재료 · 경비 각 ROUND(시간 × 시간당, 0) · 합계 = 셋 합. +단가 = 목록표(노무비 · 재료비 · 경비 · 중기목록표) — 없으면 금액 빈칸(수량은 둠). +결과는 GET 때 셈(저장 안 함) · 조작 중엔 다시 안 셈([저장] 뒤 서버 정본). +""" + +from __future__ import annotations + +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num, round_half + +#: 겹 호표 깊이 한도(엑셀 최대 대가 → 대가 → 산근 → 중기 = 4) — 돌고 도는 구성 막음 +MAX_DEPTH = 8 +KINDS = ("material", "labor", "expense", "machine") + + +def expand( + lines_of: dict[str, Any], key: str, qty: float, out: dict[str, dict[str, float]], depth: int = 0 +) -> None: + """호표 하나 × 수량 → 자원 총수량에 더함(겹 호표는 끝까지 · 깊이 한도 넘으면 멈춤).""" + if depth > MAX_DEPTH: + return + for line in (lines_of.get(key) or {}).get("lines") or []: + q = num(line.get("qty")) + if q is None: + continue + kind = line.get("kind") + if kind == "hopyo": + expand(lines_of, str(line.get("key")), qty * q, out, depth + 1) + elif kind in KINDS: + bucket = out.setdefault(kind, {}) + bucket[str(line.get("key"))] = bucket.get(str(line.get("key")), 0.0) + qty * q + + +def totals(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, dict[str, float]]: + """설계내역서 항목 → 자원 총수량(내역 차례 · 관급 포함).""" + out: dict[str, dict[str, float]] = {} + for row in (sheets.get("bill") or {}).get("rows") or []: + qty = num(row.get("qty")) + ref = row.get("ref") or {} + if row.get("kind") != "item" or qty is None or not ref.get("key"): + continue + if ref.get("tab") == "material": + bucket = out.setdefault("material", {}) + bucket[ref["key"]] = bucket.get(ref["key"], 0.0) + qty + else: + expand(lines_of, str(ref["key"]), qty, out) + return out + + +def _list(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {row["key"]: row for row in rows} + + +def _resource_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]], unit_default: str): + rows = [] + for no, (key, qty) in enumerate(used.items(), 1): + src = listed.get(key) or {} + q = round_half(qty, 2) + price = num(src.get("price")) + rows.append( + { + "no": no, + "key": key, + "name": src.get("name") or key, + "spec": src.get("spec") or "", + "unit": src.get("unit") or unit_default, + "qty": q, + "price": price, + "amount": round_half(q * price, 0) if price is not None else None, + "note": "" if src else "목록표에 없음", + } + ) + return rows + + +def _machine_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]]): + rows = [] + for no, (key, qty) in enumerate(used.items(), 1): + src = listed.get(key) or {} + q = round_half(qty, 2) + parts = {} + for part in ("labor", "material", "expense"): + unit = num(src.get(part)) + parts[part] = round_half(q * unit, 0) if unit is not None else None + known = all(v is not None for v in parts.values()) + rows.append( + { + "no": no, + "key": key, + "name": src.get("name") or key, + "spec": src.get("spec") or "", + "unit": "시간", + "qty": q, + **parts, + "sum": parts["labor"] + parts["material"] + parts["expense"] if known else None, + "note": "" if src else "목록표에 없음", + } + ) + return rows + + +def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, Any]: + """집계표 넷 — `sheets` = 짝 셈 결과(바탕 + 설계내역서).""" + used = totals(sheets, lines_of) + return { + "sum_material": _resource_sheet(used.get("material", {}), _list(sheets["material"]), ""), + "sum_labor": _resource_sheet(used.get("labor", {}), _list(sheets["labor"]), "인"), + "sum_expense": _resource_sheet(used.get("expense", {}), _list(sheets["expense"]), ""), + "sum_machine": _machine_sheet(used.get("machine", {}), _list(sheets["machine"])), + } + + +def hopyo_lines(project_root: Any) -> dict[str, Any]: + """호표 구성(1 단위당 자원 · 겹 호표) — 145-1 · 2 가 들어오면 채움 · 그전엔 빈 묶음.""" + return {} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 90a050903..871afeb9a 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -45,6 +45,12 @@ import { buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; +import { + buildSumExpense, + buildSumLabor, + buildSumMachine, + buildSumMaterial, +} from "./B09_Estimation_UI_Tabs_Summary"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -67,6 +73,10 @@ const BUILDERS: Partial> = { labor: buildLabor, expense: buildExpense, compare: buildCompare, + sum_material: buildSumMaterial, + sum_labor: buildSumLabor, + sum_expense: buildSumExpense, + sum_machine: buildSumMachine, }; async function request(path: string, init: RequestInit = {}): Promise { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts new file mode 100644 index 000000000..d659be555 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -0,0 +1,82 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Summary.ts + * B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표(실무 엑셀 꼴). + * + * 값 = 서버 단독 역전개(`B09_Estimation_Summary` · GET 응답 `summary`) — 조작 중엔 안 바뀜 · [저장] 뒤 정본 + * 끝수 = 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 줄 누름 = 단가를 낸 목록표 줄 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { + money, + qty, + text, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +const HINT = + "총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표"; + +function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild { + return ({ payload }): TabTable => { + const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r })); + const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 140), + qty("qty", "수량", 2), + text("unit", "단위", 56), + money("price", unitPrice), + money("amount", "금액"), + text("note", "비고", 120), + ], + rows, + total: rows.length ? { name: "계", amount: sum } : null, + frozen: 2, + hint: HINT, + link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null), + }; + }; +} + +export const buildSumMaterial = resourceTab("sum_material", "material", "단가"); +export const buildSumLabor = resourceTab("sum_labor", "labor", "단가"); +export const buildSumExpense = resourceTab("sum_expense", "expense", "단가"); + +export const buildSumMachine: TabBuild = ({ payload }): TabTable => { + const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r })); + const add = (key: string) => + rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 120), + qty("qty", "수량", 2), + text("unit", "단위", 56), + money("sum", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + text("note", "비고", 120), + ], + rows, + total: rows.length + ? { + name: "계", + sum: add("sum"), + labor: add("labor"), + material: add("material"), + expense: add("expense"), + } + : null, + frozen: 2, + hint: `${HINT} · 비목마다 ROUND(시간 × 시간당, 0)`, + link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), + }; +}; diff --git a/resources/tester/test_145_5_b09_summary.py b/resources/tester/test_145_5_b09_summary.py new file mode 100644 index 000000000..28c08fa71 --- /dev/null +++ b/resources/tester/test_145_5_b09_summary.py @@ -0,0 +1,97 @@ +"""145-5 B09 집계표 넷 — 역전개(겹 호표) · 엑셀 끝수 · 검산 대조(서버 단독 · 거울 없음). + +견본 = 기번3 공통 엑셀 집계표: + 중기시간금액집계표 4줄 불도우저19ton 42.95 시간 → 2,392,315 · 1,584,340 · 1,435,045 = 5,411,700 + 노무비수량금액집계표 4줄 건축목공 8.29 인 × 268,058 = 2,222,201 + 재료비수량금액집계표 각재 0.56 × 500,661 = 280,370 · 못 3.54 × 1,503 = 5,321 + 경비수량금액집계표 구역화물자동차 11.03 회 × 61,500 = 678,345 +""" + +import asyncio +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 +from B09_Estimation.B09_Estimation_Summary import compute_summary # noqa: E402 + +SHEETS = { + "labor": [{"key": "L1", "name": "건축목공", "spec": "", "unit": "인", "price": 268058}], + "material": [ + {"key": "M3", "name": "각재", "spec": "외송", "unit": "M3", "price": 500661}, + {"key": "M4", "name": "못", "spec": "N 50", "unit": "KG", "price": 1503}, + ], + "expense": [ + {"key": "E16", "name": "구역화물자동차", "spec": "5톤", "unit": "회", "price": 61500} + ], + "machine": [ + { + "key": "X1", + "name": "불도우저19ton", + "spec": "(토사)", + "unit": "시간", + "sum": 126000, + "labor": 55700, + "material": 36888, + "expense": 33412, + }, + ], # fmt: skip + "bill": { + "rows": [ + {"kind": "title", "key": "sec:1", "qty": None}, + {"kind": "item", "key": "a", "qty": 10, "ref": {"tab": "sg_list", "key": "A"}}, + {"kind": "item", "key": "m", "qty": 0.56, "ref": {"tab": "material", "key": "M3"}}, + {"kind": "item", "key": "none", "qty": None, "ref": {"tab": "sg_list", "key": "A"}}, + {"kind": "item", "key": "loop", "qty": 1, "ref": {"tab": "dg_list", "key": "C"}}, + ] + }, +} +LINES = { + "A": {"lines": [ + {"kind": "machine", "key": "X1", "qty": 4.295}, + {"kind": "hopyo", "key": "B", "qty": 0.5}, + {"kind": "material", "key": "M4", "qty": 0.354}, + {"kind": "expense", "key": "E16", "qty": 1.103}, + ]}, + "B": {"lines": [{"kind": "labor", "key": "L1", "qty": 1.658}, + {"kind": "labor", "key": "L9", "qty": 1}]}, + "C": {"lines": [{"kind": "hopyo", "key": "C", "qty": 1}]}, # 스스로 부름 — 깊이 한도에서 멈춤 +} # fmt: skip + + +def test_역전개_엑셀_금액(): + s = compute_summary(SHEETS, LINES) + machine = s["sum_machine"][0] + assert (machine["qty"], machine["labor"], machine["material"], machine["expense"]) == ( + 42.95, 2392315, 1584340, 1435045, + ) # fmt: skip + assert machine["sum"] == 5411700 + labor = {r["key"]: r for r in s["sum_labor"]} + assert (labor["L1"]["qty"], labor["L1"]["amount"]) == ( + 8.29, + 2222201, + ) # 겹 호표 10 × 0.5 × 1.658 + assert labor["L9"]["amount"] is None and labor["L9"]["note"] == "목록표에 없음" + material = {r["key"]: (r["qty"], r["amount"]) for r in s["sum_material"]} + assert material == {"M4": (3.54, 5321), "M3": (0.56, 280370)} # 내역 자재 줄 그대로 + assert [(r["qty"], r["amount"]) for r in s["sum_expense"]] == [(11.03, 678345)] + + +def test_호표_구성_없으면_자재_줄만(): + s = compute_summary(SHEETS, {}) + assert s["sum_labor"] == [] and s["sum_machine"] == [] and s["sum_expense"] == [] + assert [r["key"] for r in s["sum_material"]] == ["M3"] + + +def test_GET_응답에_집계표(tmp_path, monkeypatch): + async def fake_materials(_project_id): + return {"material": {"rows": [{"key": "sand", "group": "골재", "spec": "모래", + "unit": "㎥", "amount": 16}]}} # fmt: skip + + monkeypatch.setattr(router_mod, "material_sheets", fake_materials) + body = asyncio.run(router_mod.payload("p", tmp_path)) + assert set(body["summary"]) == {"sum_material", "sum_labor", "sum_expense", "sum_machine"} + assert body["summary"]["sum_material"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음