"""B09 원가계산 — ④ 예산내역서 **인계 읽기**(자료형 · 파싱). `B09_Estimation_BillOfQuantities` 가 700줄에 닿아 **그대로 옮겨 온 자리**다 (2026-09-15 · 순수 분리 — 글자 한 자 안 고쳤다). 조판·계층은 그쪽에 그대로 있다. ⚠ **모르는 칸을 채우지 않는다** — B08 이 안 보낸 값은 `None`·빈 문자열로 남긴다. ⚠ 쓰던 자리가 안 바뀌도록 본 모듈이 다시 내보낸다 — `from ...BillOfQuantities import HandoffWorkItem` 가 그대로 돈다. """ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal from typing import Any _ZERO = Decimal(0) #: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인). #: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. SUPPLY_UNKNOWN = "unknown" #: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2). SUPPLY_OWNER = "owner_supplied" #: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄. BLOCKED_UNCONFIRMED = "unconfirmed" 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 #: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다. haul_distance_m: Decimal | None = None haul_equipment: str | None = None in_bill_reason: str = "" origin: str = "" ground_class: str = "" #: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**. application_ratio_pct: Decimal | None = None #: 반영률 적용 **전** 수량. 산출근거에만 쓴다. quantity_gross: Decimal | None = None #: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다. application_ratio_breakdown: dict | None = None quantity_breakdown: dict | None = None #: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지. composite_parts: tuple = () #: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다. composite_not_ready: tuple = () structure_kind: str = "" #: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다). blocked_reason: str = "" #: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) / #: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다. blocked_kind: str = "" #: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의). variant_axis: str = "" variant_value: str = "" #: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구. #: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다. spec_class: str = "" spec_class_basis: str = "" #: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장). pum_edition: str = "" @property def display_name(self) -> str: return f"{self.name} {self.spec}".strip() @property def unconfirmed(self) -> bool: """B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에 **빈 금액 + 빨간 테두리**로 서고 합계에 안 듦 · 머리에 「미확정 N건 — 금액에 안 들어감」.""" return self.blocked_kind == BLOCKED_UNCONFIRMED @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() 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 "", haul_distance_m=_decimal(row.get("haul_distance_m"), None), haul_equipment=row.get("haul_equipment"), # ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①). application_ratio_pct=_decimal(row.get("application_ratio_pct"), None), quantity_gross=_decimal(row.get("quantity_gross"), None), application_ratio_breakdown=row.get("application_ratio_breakdown"), quantity_breakdown=row.get("quantity_breakdown"), composite_parts=tuple(row.get("composite_parts") or ()), composite_not_ready=tuple(row.get("composite_not_ready") or ()), structure_kind=row.get("structure_kind") or "", blocked_reason=row.get("blocked_reason") or "", blocked_kind=row.get("blocked_kind") or "", variant_axis=row.get("variant_axis") or "", variant_value=str(row.get("variant_value") or ""), spec_class=row.get("spec_class") or "", spec_class_basis=row.get("spec_class_basis") or "", pum_edition=str(row.get("pum_edition") or ""), ) 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