"""B09 원가계산 — 자재대 표 (PLAN 8-7 「자재대·관급자재대(금액)는 B09」). **무엇인가** — B08 자재총괄이 낸 **수량·할증**에 **단가**를 붙여 금액을 내는 표다. 수량은 B08 것이 정본이고 여기서 다시 세지 않는다. **관급과 사급은 자리가 다르다** (PLAN 8-2 · 9-1) - **사급** — 도급 재료비. 내역서 안에 들어간다. - **관급** — **총원가 밖 별도 표기** + 조달수수료. ⑤ 공사원가계산서의 「관급자재대」와 같은 값이라 그쪽과 이어야 한다. - **`unknown`** — 관급·사급이 안 갈린 것. **어느 쪽에도 안 넣는다** — 넣는 순간 총액이 틀리고, 어느 쪽으로 넣었는지 나중에 못 가린다. ⚠ **할증은 여기서 한 번만** (PLAN 8-7 ㉠). B08 이 `total_amount` 에 이미 할증을 반영해 보내면 그 값을 쓰고, 여기서 또 곱하지 않는다. `surcharge_status` 가 `rate_unavailable` 이면 **할증 전 값**임을 표에 드러낸다. ⚠ **단가가 없으면 금액을 만들지 않는다.** 사급 물가지가 미결(No.18)이라 지금은 대부분이 그 자리다 — 0 으로 때우면 자재비가 통째로 사라진 채 총액이 그럴듯해진다. """ from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_MaterialCatalog import ( SUPPLY_CONTRACTOR, SUPPLY_OWNER, MaterialCatalog, load_material_catalog, ) from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at _ZERO = Decimal(0) #: 관급·사급이 안 갈린 값. B08 이 실제로 보낸다. SUPPLY_UNKNOWN = "unknown" #: 할증 깃발 — B08 과 맞춘 세 갈래(2026-09-08). SURCHARGE_APPLIED = "applied" SURCHARGE_NOT_APPLIED = "not_applied" SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" @dataclass class MaterialSheetRow: """자재대 한 줄. 금액이 `None` 이면 **단가를 못 세운 것**이지 0 이 아니다.""" name: str spec: str unit: str net_amount: Decimal total_amount: Decimal supply_type: str unit_price_krw: Decimal | None = None amount_krw: Decimal | None = None surcharge_pct: Decimal | None = None source_structure: tuple[str, ...] = () 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 { "name": self.name, "spec": self.spec, "unit": self.unit, "net_amount": str(self.net_amount), "total_amount": str(self.total_amount), "supply_type": self.supply_type, "unit_price_krw": money(self.unit_price_krw), "amount_krw": money(self.amount_krw), "surcharge_pct": money(self.surcharge_pct), "source_structure": list(self.source_structure), "note": self.note, } @dataclass class MaterialSheet: """자재대 한 벌 — 사급·관급·미정 셋으로 갈린다.""" contractor_rows: list[MaterialSheetRow] = field(default_factory=list) owner_rows: list[MaterialSheetRow] = field(default_factory=list) unknown_rows: list[MaterialSheetRow] = field(default_factory=list) #: 단가를 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다.** missing: list[dict[str, str]] = field(default_factory=list) notes: list[str] = field(default_factory=list) @property def contractor_total_krw(self) -> Decimal: """사급 자재비 합계 — 도급 재료비로 들어간다.""" return sum((row.amount_krw or _ZERO for row in self.contractor_rows), _ZERO) @property def owner_total_krw(self) -> Decimal: """관급자재대 — **총원가 밖 별도 표기**. ⑤ 의 관급자재대와 같은 값이어야 한다.""" return sum((row.amount_krw or _ZERO for row in self.owner_rows), _ZERO) def as_dict(self) -> dict[str, Any]: return { "contractor": [row.as_dict() for row in self.contractor_rows], "owner": [row.as_dict() for row in self.owner_rows], "unknown": [row.as_dict() for row in self.unknown_rows], "contractor_total_krw": str(self.contractor_total_krw), # 관급자재대는 **천원 올림** 자리다(단수처리 규칙). "owner_total_krw": str( round_at(self.owner_total_krw, OutputPlace.OWNER_MATERIAL_TOTAL) ), "missing": self.missing, "notes": self.notes, } def build_material_sheet( materials: list, *, surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE, catalog: MaterialCatalog | None = None, ) -> MaterialSheet: """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.""" book = catalog or load_material_catalog() sheet = MaterialSheet() if surcharge_status == SURCHARGE_RATE_UNAVAILABLE: sheet.notes.append( "할증률이 아직 없어 **할증 전 수량**입니다 — 할증은 자재총괄에서 한 번만 " "붙습니다 (PLAN 8-7 ㉠)." ) for material in materials: row = MaterialSheetRow( name=getattr(material, "material_name", ""), spec=getattr(material, "spec", ""), unit=getattr(material, "unit", ""), net_amount=getattr(material, "net_amount", _ZERO), total_amount=getattr(material, "total_amount", _ZERO), supply_type=getattr(material, "supply_type", SUPPLY_UNKNOWN), surcharge_pct=getattr(material, "surcharge_pct", None), source_structure=tuple(getattr(material, "source_structure", ()) or ()), ) if row.supply_type == SUPPLY_UNKNOWN: # ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다. row.note = "관급·사급이 안 갈렸습니다 — 어느 쪽 합계에도 넣지 않습니다." sheet.unknown_rows.append(row) sheet.missing.append( {"name": row.name, "unit": row.unit, "reason": "공급 구분 미정(unknown)"} ) continue found = book.resolve(row.name, row.spec) if found is None: row.note = ( "자재 단가가 없습니다 — 유료 물가지 미결(No.18). " "6번 슬롯(적용 단가) 수동 입력 대기." ) sheet.missing.append( {"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"} ) else: row.unit_price_krw = found.price_krw # 자재대 줄도 **내역서 본체와 같은 절사** 자리다. row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW) if row.supply_type == SUPPLY_OWNER: sheet.owner_rows.append(row) elif row.supply_type == SUPPLY_CONTRACTOR: sheet.contractor_rows.append(row) else: sheet.unknown_rows.append(row) if sheet.owner_rows: sheet.notes.append( "관급자재대는 **총원가 밖 별도 표기**입니다 — ⑤ 공사원가계산서의 " "관급자재대와 같은 값이어야 합니다." ) return sheet