"""B09 원가계산 — ④ 예산내역서 조판 (PLAN 9-5 「B08 출력을 붙이는 자리」). **하는 일** — B08 인계 응답(공종 수량 + 자재)을 받아 **계층이 선 내역서 한 장**으로 접는다. 수량은 B08 것을 그대로 쓰고(다시 세지 않는다), 단가는 ③ 일위대가에서 가져오며, 금액은 이 자리에서 `수량 × 단가` 로 만든다. **계층은 코드에 안 박는다** (PLAN 9-3 · STmate `BOQ11` 해부 결과). 공종 마스터가 이미 `parent_code` · `level` · `sort_order`(256 간격)를 들고 있으므로, 쓰인 공종의 **조상만 남긴 가지치기 나무**를 세우고 거기서 ITEM NO. 를 매긴다. 깊이를 코드 글자수로 세지 않는다 — `FP-09-03-02` 가 3층이라는 보장이 없다. **빈칸을 지어내지 않는다** — 단가가 없는 줄, 관급/사급이 안 갈린 자재는 금액을 0 으로 때우지 않고 `missing` 에 이름째 남긴다. 화면이 그것을 그대로 보인다. ⚠ **`in_bill=false` 줄에는 단가를 붙이지 않는다** (PLAN 8-7 ㉡ 와 같은 성격). 보정량계·무대 같은 검산용 줄이라 금액을 매기면 같은 것을 두 번 세게 된다. 주석으로 막지 않고 `check_excluded_rows_not_priced()` 가 수치로 멈춘다. """ from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_Guards import check_excluded_rows_not_priced from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build _ZERO = Decimal(0) #: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인). #: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. SUPPLY_UNKNOWN = "unknown" class BillError(ValueError): """내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다.""" @dataclass(frozen=True) class HandoffWorkItem: """B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다.""" work_item_code: str | None name: str spec: str unit: str quantity: Decimal in_bill: bool in_bill_reason: str = "" origin: str = "" ground_class: str = "" #: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**. application_ratio_pct: Decimal | None = None @property def display_name(self) -> str: return f"{self.name} {self.spec}".strip() @dataclass(frozen=True) class HandoffMaterial: """B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정).""" material_name: str spec: str unit: str net_amount: Decimal total_amount: Decimal supply_type: str surcharge_pct: Decimal | None = None surcharge_note: str = "" install_by: str | None = None source_structure: tuple[str, ...] = () @property def display_name(self) -> str: return f"{self.material_name} {self.spec}".strip() @dataclass class BillRow: """내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다.""" item_no: str level: int code: str | None name: str spec: str = "" unit: str = "" quantity: Decimal | None = None unit_price_krw: Decimal | None = None amount_krw: Decimal | None = None #: 3분할 — ⑤ 로 넘길 때 **뭉치지 않고** 성분 그대로 간다(PLAN 8-9 규칙 2). material_krw: Decimal = _ZERO labor_krw: Decimal = _ZERO expense_krw: Decimal = _ZERO is_group: bool = False in_bill: bool = True note: str = "" def as_dict(self) -> dict[str, Any]: def money(value: Decimal | None) -> str | None: return None if value is None else str(value) return { "item_no": self.item_no, "level": self.level, "code": self.code, "name": self.name, "spec": self.spec, "unit": self.unit, "quantity": money(self.quantity), "unit_price_krw": money(self.unit_price_krw), "amount_krw": money(self.amount_krw), "material_krw": str(self.material_krw), "labor_krw": str(self.labor_krw), "expense_krw": str(self.expense_krw), "is_group": self.is_group, "in_bill": self.in_bill, "note": self.note, } @dataclass class BillResult: """④ 예산내역서 한 장.""" rows: list[BillRow] = field(default_factory=list) #: 금액을 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다**. missing: list[dict[str, str]] = field(default_factory=list) #: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다. excluded: list[BillRow] = field(default_factory=list) #: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다. material_rows: list[BillRow] = field(default_factory=list) notes: list[str] = field(default_factory=list) @property def direct_material_krw(self) -> Decimal: return sum((r.material_krw for r in self.rows if not r.is_group), _ZERO) @property def direct_labor_krw(self) -> Decimal: return sum((r.labor_krw for r in self.rows if not r.is_group), _ZERO) @property def direct_expense_krw(self) -> Decimal: return sum((r.expense_krw for r in self.rows if not r.is_group), _ZERO) @property def body_total_krw(self) -> Decimal: """내역서 **본체** 합계 — 줄마다 절사한 금액의 합. ⚠ 집계표(반올림) 합계와 원 단위로 어긋나는 것이 정상이다 (`B09_Estimation_Rounding.SUMMARY_MISMATCH_NOTE`). """ return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO) def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None: if value is None or value == "": return default return Decimal(str(value)) def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]: """인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**""" if "work_items" not in payload or "materials" not in payload: raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.") work_items = [ HandoffWorkItem( work_item_code=row.get("work_item_code"), name=row.get("name", ""), spec=row.get("spec") or "", unit=row.get("unit") or "", quantity=_decimal(row.get("quantity")) or _ZERO, in_bill=bool(row.get("in_bill", True)), in_bill_reason=row.get("in_bill_reason") or "", origin=row.get("origin") or "", ground_class=row.get("ground_class") or "", # ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①). application_ratio_pct=_decimal(row.get("application_ratio_pct"), None), ) for row in payload["work_items"] ] materials = [ HandoffMaterial( material_name=row.get("material_name", ""), spec=row.get("spec") or "", unit=row.get("unit") or "", net_amount=_decimal(row.get("net_amount")) or _ZERO, total_amount=_decimal(row.get("total_amount")) or _ZERO, supply_type=row.get("supply_type") or SUPPLY_UNKNOWN, surcharge_pct=_decimal(row.get("surcharge_pct"), None), surcharge_note=row.get("surcharge_note") or "", install_by=row.get("install_by"), source_structure=tuple(row.get("source_structure") or ()), ) for row in payload["materials"] ] return work_items, materials @dataclass(frozen=True) class _MasterNode: code: str name: str level: int parent_code: str | None sort_order: int def _master_index(master: dict[str, Any]) -> dict[str, _MasterNode]: return { node["work_item_code"]: _MasterNode( code=node["work_item_code"], name=node.get("name", ""), level=int(node.get("level", 1)), parent_code=node.get("parent_code"), sort_order=int(node.get("sort_order", 0)), ) for node in master.get("work_items", []) if node.get("work_item_code") } def _ancestor_chain(code: str, index: dict[str, _MasterNode]) -> list[_MasterNode]: """뿌리 → 자기 순서의 조상 사슬. **코드 글자수로 깊이를 세지 않는다.**""" chain: list[_MasterNode] = [] seen: set[str] = set() cursor: str | None = code while cursor and cursor in index and cursor not in seen: seen.add(cursor) node = index[cursor] chain.append(node) cursor = node.parent_code chain.reverse() return chain def _number_of(path: tuple[int, ...]) -> str: """ITEM NO. — 자리마다 1 부터. 「1」·「1-2」·「1-2-3」 모양.""" return "-".join(str(n) for n in path) def build_bill( payload: dict[str, Any], *, build: UnitPriceBuild | None = None, master: dict[str, Any] | None = None, ) -> BillResult: """인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.""" work_items, materials = parse_handoff(payload) unit_prices = build or cached_build() index = _master_index(master or load_work_item_master()) result = BillResult() # ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ──────────────────────────────── # 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다. used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = [] orphans: list[HandoffWorkItem] = [] for item in work_items: if not item.in_bill: # ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라 # **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할 # 줄」로 잘못 읽힌다. result.excluded.append(_excluded_row(item)) continue if not item.work_item_code or item.work_item_code not in index: orphans.append(item) continue used.append(((), item, _ancestor_chain(item.work_item_code, index))) def sort_key(entry: tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]) -> tuple: return tuple(node.sort_order for node in entry[2]) used.sort(key=sort_key) emitted: dict[str, str] = {} # 코드 → ITEM NO. counters: dict[str, int] = {} # 부모 ITEM NO. → 마지막 번호 def next_number(parent_no: str) -> str: counters[parent_no] = counters.get(parent_no, 0) + 1 return f"{parent_no}-{counters[parent_no]}" if parent_no else str(counters[parent_no]) for _, item, chain in used: parent_no = "" # 조상 줄(머리글)을 먼저 세운다 — 이미 선 것은 다시 안 세운다. for node in chain[:-1]: if node.code in emitted: parent_no = emitted[node.code] continue parent_no = next_number(parent_no) emitted[node.code] = parent_no result.rows.append( BillRow( item_no=parent_no, level=node.level, code=node.code, name=node.name, is_group=True, ) ) leaf = chain[-1] item_no = emitted.get(leaf.code) or next_number(parent_no) emitted[leaf.code] = item_no result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result)) # ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ──────────────────────────────────── for item in orphans: result.missing.append( { "name": item.display_name, "unit": item.unit, "quantity": str(item.quantity), "reason": "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다.", } ) # ── 3) 자재 벌 ──────────────────────────────────────────────────────────── for material in materials: result.material_rows.append(_material_row(material, result)) # ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ────────────────────── check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded]) if any(m.surcharge_pct is None for m in materials): result.notes.append( "자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. " "할증은 자재총괄에서 한 번만 붙습니다 (PLAN 8-7 ㉠)." ) return result def _excluded_row(item: HandoffWorkItem) -> BillRow: """검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**""" return BillRow( item_no="", level=1, code=item.work_item_code, name=item.name, spec=item.spec, unit=item.unit, quantity=item.quantity, in_bill=False, note=item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.", ) def _leaf_row( item_no: str, node: _MasterNode, item: HandoffWorkItem, unit_prices: UnitPriceBuild, result: BillResult, ) -> BillRow: """세부 공종 한 줄. 단가가 없으면 **금액을 비우고** `missing` 에 남긴다.""" row = BillRow( item_no=item_no, level=node.level, code=node.code, name=item.name or node.name, spec=item.spec, unit=item.unit, quantity=item.quantity, in_bill=item.in_bill, ) if item.application_ratio_pct is not None: # ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다. row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량" if not item.in_bill: # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). row.quantity = item.quantity row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다." result.excluded.append(row) return row price_code = f"B-{node.code}" if price_code not in unit_prices.book.titles: # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 # (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도). children = sorted( code for code in unit_prices.book.titles if code.startswith(f"{price_code}-") and code.count("-") == price_code.count("-") + 1 ) if children: names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}" reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" else: row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." reason = "일위대가 없음" result.missing.append( { "name": row.name, "code": node.code, "unit": row.unit, "quantity": str(item.quantity), "reason": reason, "candidates": ", ".join(children), } ) return row covered = unit_prices.partial_ratio.get(node.code) if covered is not None: # ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만 # 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다. row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)." result.missing.append( { "name": row.name, "code": node.code, "unit": row.unit, "quantity": str(item.quantity), "reason": f"단가 일부만 섬(붙은 몫 {covered}%)", } ) return row unit_money = unit_prices.book.resolve(price_code) line = unit_money.scaled(item.quantity) row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) # 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상. row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) # 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다. row.material_krw = line.material row.labor_krw = line.labor row.expense_krw = line.expense return row def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" row = BillRow( item_no="", level=1, code=None, name=material.material_name, spec=material.spec, unit=material.unit, quantity=material.total_amount, note=material.surcharge_note, ) if material.supply_type == SUPPLY_UNKNOWN: row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." result.missing.append( { "name": material.display_name, "unit": material.unit, "quantity": str(material.total_amount), "reason": "공급 구분 미정(unknown)", } ) return row # 사급 자재 단가는 아직 원천이 없다(미결 No.18) — 여기서도 지어내지 않는다. row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." result.missing.append( { "name": material.display_name, "unit": material.unit, "quantity": str(material.total_amount), "reason": "자재 단가 없음(미결 No.18)", } ) return row def bill_summary(result: BillResult) -> dict[str, Any]: """화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다.""" return { "rows": len(result.rows), "detail_rows": sum(1 for r in result.rows if not r.is_group), "group_rows": sum(1 for r in result.rows if r.is_group), "excluded_rows": len(result.excluded), "material_rows": len(result.material_rows), "missing": result.missing, "body_total_krw": str(result.body_total_krw), "direct_material_krw": str(result.direct_material_krw), "direct_labor_krw": str(result.direct_labor_krw), "direct_expense_krw": str(result.direct_expense_krw), "notes": result.notes, } def cost_input_from_bill(result: BillResult, **cost_input_kwargs): """④ 내역서 합계를 ⑤ 원가계산서 입력으로 접어 넣는다. **뭉치지 않는다** — 재료·노무·경비 성분이 그대로 간다(PLAN 8-9 규칙 2). ⑤ 표에 찍히는 자리이므로 **자원 집계표 규칙(반올림)** 으로 자른다. """ from B09_Estimation.B09_Estimation_Engine_Cost import CostInput summary = OutputPlace.RESOURCE_SUMMARY return CostInput( direct_material_krw=round_at(result.direct_material_krw, summary), direct_labor_krw=round_at(result.direct_labor_krw, summary), direct_expense_krw=round_at(result.direct_expense_krw, summary), **cost_input_kwargs, )