diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index cc229dec..6a89f027 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -77,6 +77,14 @@ def project_templates(project_root: str | Path | None) -> dict[str, dict[str, An } +def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]: + """하위 일위대가(`B-AX-ST-*`)를 찾을 양식들 — 프로그램 기본 뒤에 **프로젝트에 박힌 것**(이김). + + ⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ). + """ + return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()] + + def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None: """고른 항목을 프로젝트 작업본에 박음 — 같은 종류의 옛 것은 지움(종류당 하나). diff --git a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py index 40ed9c03..68671920 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py @@ -3,18 +3,24 @@ ⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값. ⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」. ⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2). -⚠ 하위 구조물 일위대가(`B-AX-ST-*`)는 `_resolve` 한 자리로 들어옴 — 레시피 150/350 이 2단 이상 - (명세 16장). B-FP·X·L 의 재귀·순환 막이는 단가표가 이미 함. 깊이 5단은 재귀 일감(PLAN 10장). + 하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — B09 `resolve` 도 조립 중엔 안 자름. +⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어 + 일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장). + 깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함. +⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ). """ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable +from dataclasses import dataclass from decimal import Decimal from typing import Any #: 하위 구조물 일위대가 코드 머리 — 명세 2장 ② `B-AX-ST-3f9a2b17`. SUB_STRUCTURE_PREFIX = "B-AX-ST-" +#: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」). +MAX_DEPTH = 5 _UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"} @@ -23,6 +29,23 @@ def _unit(text: Any) -> str: return _UNIT_ALIASES.get(value, value) +@dataclass +class _Context: + book: Any + find_variant: Callable[[str, str], str | None] + library: dict[str, dict[str, Any]] + + +@dataclass +class _Priced: + """줄 단가 한 벌 — 안 자른 3분할과 이름·단위.""" + + money: Any + name: str + spec: str + unit: str + + def _ref_of( row: dict[str, Any], values: dict[str, Any], find_variant: Callable[[str, str], str | None] ) -> tuple[str | None, str]: @@ -42,27 +65,83 @@ def _ref_of( return None, f"{code} 에서 {var}={value} 에 맞는 갈래를 못 찾음" -def _resolve(book: Any, ref: str) -> Any: - """단가 한 줄 — ⚠ 하위 구조물 일위대가는 재귀 일감에서 붙임(지금은 막힘으로).""" +def _sub_sheet(sub: dict[str, Any], sub_vars: dict[str, Any]) -> dict[str, Any]: + """하위 양식을 **줄이 준 제원**으로 단위당(L=1) 풂 — 윗 장과 같은 모양의 장 한 벌.""" + from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets + from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_sheet from B09_Estimation.B09_Estimation_PriceBook import PriceBookError - if ref.startswith(SUB_STRUCTURE_PREFIX): - raise PriceBookError(f"하위 구조물 일위대가({ref})는 아직 못 풂 — 재귀 일감") - return book.resolve(ref) + values: dict[str, Any] = {} + missing = [] + for name, spec in (sub.get("vars") or {}).items(): + if name in sub_vars: + values[name] = sub_vars[name] + elif "default" in spec: + values[name] = spec["default"] + elif spec.get("source") == "length_m": + values[name] = 1.0 + else: + missing.append(name) + if missing: + raise PriceBookError( + f"하위 양식 {sub.get('code')} 의 제원이 비어 있음: {', '.join(missing)}" + ) + body = template_sheet(sub, values) + solved = evaluate_sheets([body]) + if solved is None: + raise PriceBookError("식 풀이기를 못 돌려 하위 양식을 못 풂") + units = {row["seq"]: row.get("unit") for row in body["rows"]} + rows = [ + { + "no": result["seq"], + "unit": units.get(result["seq"]), + "unit_amount": None if result.get("amount") is None else float(result["amount"]), + "skipped": bool(result.get("skipped")), + "reason": result.get("reason") or "", + "error": result.get("error") or "", + } + for result in solved[0] + ] + unit = (sub.get("unit_price") or {}).get("unit") or "m" + return {"rows": rows, "formula_sheet": {"vars": values}, "billing_unit": unit} -def unit_price_table( +def _price( + ref: str, row: dict[str, Any], ctx: _Context, depth: int, seen: tuple[str, ...] +) -> _Priced: + """줄 하나의 단가 — B-AX-ST 는 하위 양식을 재귀로, 나머지는 B09 단가표.""" + from B09_Estimation.B09_Estimation_PriceBook import PriceBookError + + if not ref.startswith(SUB_STRUCTURE_PREFIX): + title = ctx.book.title(ref) + return _Priced(ctx.book.resolve(ref), title.name, title.spec, _unit(title.unit)) + code = ref[2:] + if code in seen: + raise PriceBookError(f"하위 일위대가가 돌고 있음: {' → '.join((*seen, code))}") + if depth + 1 > MAX_DEPTH: + raise PriceBookError(f"하위 일위대가가 {MAX_DEPTH}단을 넘음: {' → '.join((*seen, code))}") + sub = ctx.library.get(code) + if sub is None: + raise PriceBookError(f"하위 양식 {code} 을 프로젝트·프로그램 기본에서 못 찾음") + sheet = _sub_sheet(sub, row.get("sub_vars") or {}) + assembled = _assemble(sub, sheet, ctx, depth + 1, (*seen, code)) + if assembled is None: + raise PriceBookError(f"하위 양식 {code} 에 일위대가 줄이 없음") + table, money = assembled + if table["blocked"]: + raise PriceBookError(f"하위 일위대가 {code} 미완 — 막힌 줄 {table['blocked']}") + return _Priced(money, str(sub.get("name") or code), "", _unit(sheet["billing_unit"])) + + +def _assemble( template: dict[str, Any], sheet: dict[str, Any], - book: Any, - find_variant: Callable[[str, str], str | None], -) -> dict[str, Any] | None: - """장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`. - - 수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`. - 코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래). - """ - from B09_Estimation.B09_Estimation_PriceBook import PriceBookError + ctx: _Context, + depth: int, + seen: tuple[str, ...], +) -> tuple[dict[str, Any], Any] | None: + """표 한 벌과 **안 자른** 단위당 3분할 합(윗 표가 쓸 값).""" + from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at spec = template.get("unit_price") @@ -72,6 +151,7 @@ def unit_price_table( values = (sheet.get("formula_sheet") or {}).get("vars") or {} rows: list[dict[str, Any]] = [] sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)} + exact = Money3() blocked = 0 for row in spec.get("rows") or []: out: dict[str, Any] = { @@ -110,26 +190,25 @@ def unit_price_table( continue out["quantity"] = float(quantity) - ref, why = _ref_of(row, values, find_variant) + ref, why = _ref_of(row, values, ctx.find_variant) out["ref_code"] = ref or "" - money = None + priced = None if ref: try: - money = _resolve(book, ref) - title = book.title(ref) - if out["unit"] and _unit(title.unit) and _unit(title.unit) != out["unit"]: - why = f"단위가 다름 — 수량 {out['unit']} ↔ 단가 {_unit(title.unit)}" - money = None - else: - out.update(name=title.name or out["name"], spec=title.spec or out["spec"]) + priced = _price(ref, row, ctx, depth, seen) + if out["unit"] and priced.unit and priced.unit != out["unit"]: + why = f"단위가 다름 — 수량 {out['unit']} ↔ 단가 {priced.unit}" + priced = None except PriceBookError as exc: why = str(exc) - if money is None: + if priced is None: out["reason"] = why blocked += 1 rows.append(out) continue + money = priced.money + exact = exact + money.scaled(quantity) cells = { "material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW), "labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW), @@ -138,6 +217,9 @@ def unit_price_table( for key, value in cells.items(): sums[key] += value out.update( + name=priced.name or out["name"], + spec=priced.spec or out["spec"], + depth=depth, unit_material=float(money.material), unit_labor=float(money.labor), unit_expense=float(money.expense), @@ -146,7 +228,7 @@ def unit_price_table( ) rows.append(out) - return { + table = { "code": f"B-{template.get('code')}" if template.get("code") else "", "name": template.get("name") or "", "unit": sheet.get("billing_unit") or "", @@ -157,3 +239,23 @@ def unit_price_table( "blocked": blocked, "complete": blocked == 0, } + return table, exact + + +def unit_price_table( + template: dict[str, Any], + sheet: dict[str, Any], + book: Any, + find_variant: Callable[[str, str], str | None], + library: Iterable[dict[str, Any]] = (), +) -> dict[str, Any] | None: + """장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`. + + 수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`. + 코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래). + `library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본). + """ + ctx = _Context(book, find_variant, {str(t["code"]): t for t in library if t.get("code")}) + code = str(template.get("code") or "") + assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ()) + return assembled[0] if assembled else None diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index 2c41a660..28178e7a 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -454,7 +454,10 @@ async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResp ⚠ 장 조회와 창구를 나눔 — 단가표 첫 조립이 십여 초라 장 조회를 늦추지 않게. ⚠ 값을 여기서 정본으로 적지 않음 — 보이기만(내역 금액은 B09 가 셈). """ - from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( + available_templates, + project_templates, + ) from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code @@ -486,6 +489,7 @@ async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResp picked, build.book, lambda code, value: find_variant_code(code, value, build), + available_templates(project_root), ) return JSONResponse(content={"status": "success", "unit_price": table}) diff --git a/resources/tester/test_b08_structure_unit_price.py b/resources/tester/test_b08_structure_unit_price.py index 8eef1707..f930019c 100644 --- a/resources/tester/test_b08_structure_unit_price.py +++ b/resources/tester/test_b08_structure_unit_price.py @@ -4,8 +4,9 @@ ① 줄 금액 = 원단위 줄 수량 × B09 단가(3분할) · 금액란 0.1원 버림 · 계금 1원 버림 ② 못 푼 줄은 0 이 아니라 막힘 + 까닭 · 막힌 줄이 있으면 미완 ③ 원단위 줄이 안 선 장(버림 「안 넣음」)은 일위대가 줄도 안 섬 — 막힘이 아님 - ④ 단위가 다르거나 하위 구조물 일위대가(B-AX-ST)면 막힘 + ④ 단위가 다르거나 하위 양식을 못 찾으면 막힘 ⑤ 창구가 실제 단가표로 찰쌓기 갈래를 찾아 값을 냄 + ⑥ 재귀 — 하위 구조물 일위대가(B-AX-ST)를 제원으로 풀어 넣음 · 돌면 막힘 · 5단까지 """ from __future__ import annotations @@ -131,7 +132,82 @@ def test_갈래를_못_찾거나_단위가_다르거나_하위_구조물이면_ rows = {row["seq"]: row for row in unit_price_table(template, sheet, BOOK, _variant)["rows"]} assert "갈래를 못 찾음" in rows[1]["reason"] assert rows[2]["reason"].startswith("단위가 다름") - assert "재귀" in rows[3]["reason"] + assert "못 찾음" in rows[3]["reason"] # 하위 양식이 프로젝트·기본에 없음 + + +def _leaf(code: str, next_code: str | None = None) -> dict: + """하위 양식 — 면적 = H×L×2(㎥) 한 줄. 다음 코드가 있으면 그것을 1m 부름.""" + unit_rows = [{"seq": 1, "name": "잡석", "from_row": 1, "work_item_code": "FP-12-25"}] + if next_code: + unit_rows = [ + { + "seq": 1, + "quantity": 1, + "unit": "m", + "ref_code": f"B-{next_code}", + "sub_vars": {"H": 1}, + } + ] + return { + "code": code, + "name": f"하위 {code[-2:]}", + "vars": {"H": {"source": "height_m"}, "L": {"source": "length_m"}}, + "tables": {}, + "rows": [ + { + "seq": 1, + "name": "면적", + "formula": "H*L*2", + "unit": "㎥", + "destination": "unit_price", + "rounding": {"mode": "none", "digits": 0}, + } + ], + "unit_price": {"unit": "m", "rows": unit_rows}, + } + + +def _parent(ref: str, sub_vars: dict | None = None) -> dict: + row = {"seq": 1, "name": "하위 부름", "quantity": 3, "unit": "m", "ref_code": ref} + if sub_vars is not None: + row["sub_vars"] = sub_vars + return {"code": "AX-ST-a0000000", "unit_price": {"rows": [row]}} + + +def test_하위_구조물_일위대가를_제원으로_풀어_윗_표에_넣는다() -> None: + """PLAN 3장 재귀 — 하위 양식 1m = 면적 3㎥ × 잡석(1000·500) · 윗 표 3m.""" + library = [_leaf("AX-ST-c0000001")] + table = unit_price_table( + _parent("B-AX-ST-c0000001", {"H": 1.5}), _sheet(), BOOK, _variant, library + ) + row = table["rows"][0] + assert row["name"] == "하위 01" and row["reason"] == "" + assert row["material"] == pytest.approx(9000.0) and row["labor"] == pytest.approx(4500.0) + assert table["complete"] is True and table["total"] == pytest.approx(13500.0) + + # 제원을 안 주면 막힘 — 0 으로 풀지 않음. + missing = unit_price_table(_parent("B-AX-ST-c0000001"), _sheet(), BOOK, _variant, library) + assert "제원이 비어 있음: H" in missing["rows"][0]["reason"] + + +def test_하위_일위대가가_돌면_막힘() -> None: + library = [_leaf("AX-ST-c0000002", next_code="AX-ST-c0000002")] + table = unit_price_table( + _parent("B-AX-ST-c0000002", {"H": 1}), _sheet(), BOOK, _variant, library + ) + assert "미완" in table["rows"][0]["reason"] and table["complete"] is False + + +def test_재귀는_5단까지() -> None: + """맨 윗 표 1단 + 하위 넷 = 5단은 섬 · 하위 다섯이면 6단이라 막힘.""" + codes = [f"AX-ST-d000000{i}" for i in range(1, 6)] + library = [ + _leaf(code, codes[i + 1] if i + 1 < len(codes) else None) for i, code in enumerate(codes) + ] + five = unit_price_table(_parent(f"B-{codes[1]}", {"H": 1}), _sheet(), BOOK, _variant, library) + assert five["complete"] is True # 윗 표 → d2 → d3 → d4 → d5(잎) = 5단 + six = unit_price_table(_parent(f"B-{codes[0]}", {"H": 1}), _sheet(), BOOK, _variant, library) + assert six["complete"] is False # 윗 표 → d1 → … → d5 = 6단 @pytest.fixture()