"""구조물도 하단 **일위대가 표** — 양식 줄 조합 × B09 단가표로 단위당 금액 미리보기(PLAN 3장). ⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 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장). """ from __future__ import annotations from collections.abc import Callable from decimal import Decimal from typing import Any #: 하위 구조물 일위대가 코드 머리 — 명세 2장 ② `B-AX-ST-3f9a2b17`. SUB_STRUCTURE_PREFIX = "B-AX-ST-" _UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"} def _unit(text: Any) -> str: value = str(text or "").strip() return _UNIT_ALIASES.get(value, value) 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 _resolve(book: Any, ref: str) -> Any: """단가 한 줄 — ⚠ 하위 구조물 일위대가는 재귀 일감에서 붙임(지금은 막힘으로).""" from B09_Estimation.B09_Estimation_PriceBook import PriceBookError if ref.startswith(SUB_STRUCTURE_PREFIX): raise PriceBookError(f"하위 구조물 일위대가({ref})는 아직 못 풂 — 재귀 일감") return book.resolve(ref) def unit_price_table( 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 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)} 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, find_variant) out["ref_code"] = ref or "" money = 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"]) except PriceBookError as exc: why = str(exc) if money is None: out["reason"] = why blocked += 1 rows.append(out) continue 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( 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) return { "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, }