diff --git a/B09_Estimation/B09_Estimation_MaterialCatalog.py b/B09_Estimation/B09_Estimation_MaterialCatalog.py new file mode 100644 index 00000000..8999ad19 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialCatalog.py @@ -0,0 +1,157 @@ +"""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": [ + "자재 단가는 **할증 전·부가세 제외** 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", + "관급 자재의 **설치 주체가 미지정**이라 안전관리비 대상액에 자동으로 넣지 않습니다.", + ], + } diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 881409c3..09b5f57b 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -110,10 +110,16 @@ class ResourceCatalog: entries: list[CatalogEntry] = field(default_factory=list) aliases: dict[str, str] = field(default_factory=dict) + #: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다. + _index: dict[str, list[CatalogEntry]] | None = None def by_name(self, name: str) -> list[CatalogEntry]: - cleaned = _normalize(name) - return [e for e in self.entries if _normalize(e.name) == cleaned] + if self._index is None: + index: dict[str, list[CatalogEntry]] = {} + for entry in self.entries: + index.setdefault(_normalize(entry.name), []).append(entry) + self._index = index + return self._index.get(_normalize(name), []) def resolve(self, name: str, spec: str) -> CatalogEntry | None: """이름(+규격)으로 한 줄을 고른다. 못 고르면 None — 0 으로 안 때운다.""" @@ -205,11 +211,32 @@ def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list ] +def load_material_catalog_entries( + file_name: str = "mat_price_public_2026-08-14.json", +) -> list[CatalogEntry]: + """관급 자재 6,999건을 매칭용 항목으로 편다. + + ⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323). + **규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog + + catalog = load_material_catalog(file_name) + return [ + CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification) + for m in catalog.items.values() + ] + + def load_combined_catalog() -> ResourceCatalog: - """노임 + 기종을 한 벌로. 자재는 카탈로그가 아직 없다.""" + """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다.""" labor = load_labor_catalog() return ResourceCatalog( - entries=[*labor.entries, *load_machine_catalog_entries()], + entries=[ + *labor.entries, + *load_machine_catalog_entries(), + *load_material_catalog_entries(), + ], aliases=labor.aliases, ) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index bf17bf8d..bb1485e8 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -278,6 +278,32 @@ def cached_build() -> UnitPriceBuild: return build_unit_prices() +def _status_notes() -> list[str]: + """화면에 낼 「지금 무엇이 안 선 상태인가」. + + 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가 + 거의 없다는 것이 이 자리의 진짜 공백이다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import ( + catalog_summary, + load_material_catalog, + ) + + summary = catalog_summary(load_material_catalog()) + return [ + f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " + "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " + "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", + "**사급 자재 단가가 미결**이라 구조물 계열 일위대가가 아직 서지 않습니다 — " + "값을 지어내지 않고 6번 슬롯(적용 단가) 수동 입력으로 채웁니다.", + ( + f"관급 자재 **설치 주체가 미지정**" + f"({summary['owner_supplied_install_unspecified']:,}건)이라 " + "안전관리비 대상액에 자동으로 넣지 않습니다." + ), + ] + + def build_summary(build: UnitPriceBuild) -> dict: """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" kinds: dict[str, int] = {} @@ -291,11 +317,7 @@ def build_summary(build: UnitPriceBuild) -> dict: "incomplete_machines": len(build.incomplete_machines), "kinds": kinds, # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). - "notes": [ - "자재 카탈로그가 아직 없어 **구조물 계열 일위대가가 서지 않습니다** — " - "지금 선 것은 노무·기계 성분뿐입니다(연료만 자재로 섭니다).", - "사급 잡자재 단가는 미결입니다 — 값을 지어내지 않고 비워 둡니다.", - ], + "notes": _status_notes(), }