"""B09 원가계산 — 자재 카탈로그 (PLAN 9-3 · 9-4). **관급과 사급을 처음부터 가른다.** 섞어 두면 나중에 못 가른다 — 관급은 **총원가 밖 별도 표기 + 조달수수료**라 계산 자리가 아예 다르다(PLAN 8-2 인계 6필드). 구분 이름은 두 창이 맞춘 것을 쓴다 (2026-09-07 확정): - `supply_type` = `owner_supplied`(관급) / `contractor_supplied`(사급) - `owner_supplied_install_by` = `contractor`(도급자설치) / `owner` / `None` ⚠ **모르면 `None` 으로 두고 「설치 주체 미지정」으로 드러낸다.** 안전관리비 대상액이 **관급 전액이 아니라 도급자설치분**을 쓰므로(PLAN 8-10), 잘못 찍으면 금액이 조용히 틀린다. 원천 - 관급 = `mat_price_public_2026-08-14.json` — 나라장터 **6,999건**. `vat_basis: "부가가치세별도"` 라 **부가세 제외 단가**이고 원가에 그대로 쓴다. ⚠ 철근·레미콘·아스콘은 그 파일의 `excluded_named_groups` 로 **빠져 있다**. - 사급 = **없다.** 유료 물가지 미결(No.18). **값을 지어내지 않고 공백으로 드러낸다.** ⚠ **자재 단가는 할증 전 값이다** (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳에서만 붙인다. """ from __future__ import annotations import json import os from dataclasses import dataclass, field from decimal import Decimal from typing import Any _CATALOG_SUBPATH = ("resources", "data_cost_input_value") #: 두 창이 맞춘 구분 이름 — 값을 바꾸면 B08 자재총괄과 안 맞는다. SUPPLY_OWNER = "owner_supplied" SUPPLY_CONTRACTOR = "contractor_supplied" INSTALL_BY_CONTRACTOR = "contractor" INSTALL_BY_OWNER = "owner" class MaterialCatalogError(LookupError): """자재 단가를 못 세운 경우. 0 으로 때우지 않는다.""" def _project_root() -> str: return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _read_json(file_name: str) -> dict[str, Any]: with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h: return json.load(h) @dataclass(frozen=True) class MaterialItem: """자재 한 줄. **단가는 할증 전·부가세 제외 값**이다.""" item_code: str name: str specification: str unit: str price_krw: Decimal supply_type: str #: 관급일 때만 뜻이 있다. `None` = **설치 주체 미지정**(안전관리비 대상액에 못 넣음). owner_supplied_install_by: str | None = None vat_excluded: bool = True notice_date: str = "" @property def display_name(self) -> str: return f"{self.name} {self.specification}".strip() @dataclass class MaterialCatalog: """자재 목록. **이름만으로는 못 고른다** — 같은 품명에 규격이 여럿이다.""" items: dict[str, MaterialItem] = field(default_factory=dict) #: 채우지 못한 것 — 사급 미결·제외 품목. **빈칸이 아니라 목록으로 든다.** gaps: list[str] = field(default_factory=list) def by_name(self, name: str) -> list[MaterialItem]: return [m for m in self.items.values() if m.name == name] def resolve(self, name: str, specification: str) -> MaterialItem | None: """품명 + 규격으로 한 줄을 고른다. 규격이 없으면 **고르지 않는다**. 6,999건 중 같은 품명이 수십 개인 것이 흔하다 — 이름만 맞추면 엉뚱한 규격의 단가가 조용히 붙는다. """ found = self.by_name(name) if not found: return None if len(found) == 1 and not specification: return found[0] narrowed = [m for m in found if m.specification == specification] return narrowed[0] if len(narrowed) == 1 else None def get(self, item_code: str) -> MaterialItem: try: return self.items[item_code] except KeyError as exc: raise MaterialCatalogError(f"자재 카탈로그에 없는 코드입니다: {item_code}") from exc def count_by_supply(self) -> dict[str, int]: counts: dict[str, int] = {} for item in self.items.values(): counts[item.supply_type] = counts.get(item.supply_type, 0) + 1 return counts def load_material_catalog( public_file: str = "mat_price_public_2026-08-14.json", ) -> MaterialCatalog: """관급 자재를 읽고, 사급은 **없다는 사실을 목록으로** 남긴다.""" payload = _read_json(public_file) catalog = MaterialCatalog() for row in payload["variables"]["mat_price"]["records"]: code = str(row["item_code"]) catalog.items[code] = MaterialItem( item_code=code, name=row.get("classification_name", ""), specification=row.get("specification", ""), unit=row.get("unit", ""), price_krw=Decimal(str(row.get("price_krw", 0))), supply_type=SUPPLY_OWNER, # ⚠ 나라장터 자료에 설치 주체가 없다 — 지어내지 않고 미지정으로 둔다. owner_supplied_install_by=None, vat_excluded=row.get("vat_basis", "") == "부가가치세별도", notice_date=str(row.get("notice_datetime", ""))[:10], ) # 사급 — 원천이 아직 없다. **값을 지어내지 않는다.** catalog.gaps.append( "사급 자재 단가 없음 — 유료 물가지 미결(No.18). 6번 슬롯(적용 단가) 수동 입력으로 채웁니다." ) for group in payload.get("excluded_named_groups", []): catalog.gaps.append(f"관급 제외 품목: {group.get('group', '')} — {group.get('reason', '')}") return catalog def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]: """화면에 낼 요약 — **무엇이 없는지**를 함께 낸다.""" unspecified = [ m for m in catalog.items.values() if m.supply_type == SUPPLY_OWNER and m.owner_supplied_install_by is None ] return { "items": len(catalog.items), "by_supply": catalog.count_by_supply(), "owner_supplied_install_unspecified": len(unspecified), "gaps": list(catalog.gaps), "notes": [ "자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", "관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.", ], }