"""구조물도 하단 **일위대가 표** — 양식 줄 조합 × B09 단가표로 단위당 금액 미리보기(PLAN 3장). ⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값. ⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」. ⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2). 하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — 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, 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"} def _unit(text: Any) -> str: value = str(text or "").strip() 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]: """줄이 가리키는 단가표 코드 — 못 정하면 `None` 과 까닭.""" if row.get("ref_code"): return str(row["ref_code"]), "" code = row.get("work_item_code") if not code: return None, "공종 코드 미정" var = row.get("variant_from") if not var: return f"B-{code}", "" value = values.get(var) found = find_variant(str(code), str(value if value is not None else "")) if found: return found, "" return None, f"{code} 에서 {var}={value} 에 맞는 갈래를 못 찾음" 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 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 _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], 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") if not spec: return None sheet_rows = {row.get("no"): row for row in sheet.get("rows") or []} 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] = { "seq": row.get("seq"), "name": row.get("name") or "", "spec": row.get("spec") or "", "ref_code": "", "unit": _unit(row.get("unit")), "quantity": None, "skipped": False, "reason": "", } source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None if source is not None and (source.get("skipped") or source.get("error")): # 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등). out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "") if source.get("error"): out.update(skipped=False, reason=f"원단위 줄이 안 풀림 — {source['error']}") blocked += 1 rows.append(out) continue if "from_row" in row: if source is None or source.get("unit_amount") is None: out["reason"] = f"원단위 줄 {row['from_row']} 이 없음" blocked += 1 rows.append(out) continue quantity = Decimal(str(source["unit_amount"])) out["unit"] = out["unit"] or _unit(source.get("unit")) elif row.get("quantity") is not None: quantity = Decimal(str(row["quantity"])) else: out["reason"] = "수량 없음" blocked += 1 rows.append(out) continue out["quantity"] = float(quantity) ref, why = _ref_of(row, values, ctx.find_variant) out["ref_code"] = ref or "" priced = None if ref: try: 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 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), "expense": round_at(money.expense * quantity, OutputPlace.UNIT_PRICE_ROW), } 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), **{key: float(value) for key, value in cells.items()}, total=float(sum(cells.values())), ) rows.append(out) table = { "code": f"B-{template.get('code')}" if template.get("code") else "", "name": template.get("name") or "", "unit": sheet.get("billing_unit") or "", "rows": rows, **{key: float(value) for key, value in sums.items()}, # 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음. "total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)), "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