Files
Aislo/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py
T
eomsangdonandClaude Opus 5 3c47120f2a feat(b08): 구조물도 하단 일위대가 표 틀 — 양식 줄 조합 × B09 단가표 미리보기
- 양식에 unit_price.rows(수량은 원단위 줄 from_row · 코드는 work_item_code+variant_from 또는 ref_code)
- 서버가 B09 단가표(PriceBook.resolve, 읽기만)로 줄마다 재료·노무·경비 · 금액란 0.1원 버림 · 계금 1원 버림
- 못 푼 줄은 막힘 + 까닭(0 아님), 원단위 줄이 안 선 장은 안 섬 · 단위 다름·하위 구조물(B-AX-ST)은 막힘
- 창구 /structure-sheets/unit-price 를 장 조회와 따로 둠(단가표 첫 조립 14초)
- 찰쌓기: 돌쌓기 B-FP-13-04-05#갈래 · 기초잡석 B-FP-12-25 · 모르터는 공종 코드 미정
- 시험 4개 추가 · 전체 1539 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 19:15:18 +09:00

160 lines
6.7 KiB
Python

"""구조물도 하단 **일위대가 표** — 양식 줄 조합 × 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,
}