feat(B09): 자재 카탈로그 — 관급/사급 분리, 그리고 ⚠ 임도 자재가 관급에 없다는 발견

**`B09_Estimation_MaterialCatalog.py` 신설** — 두 창이 맞춘 이름을 그대로 씀:
`supply_type` = `owner_supplied` / `contractor_supplied` ·
`owner_supplied_install_by` = `contractor` / `owner` / `None`.

- 관급 = 나라장터 **6,999건**(`vat_basis: 부가가치세별도` → 부가세 제외 단가).
- **설치 주체는 지어내지 않음** — 원천에 없으므로 전부 `None`(미지정)으로 두고 **세어서
  알림**. 안전관리비 대상액이 관급 전액이 아니라 **도급자설치분**을 쓰므로(8-10)
  잘못 찍으면 금액이 조용히 틀리는 자리임.
- **규격 필수** — 같은 품명에 규격이 수백 개(「연돌」 410 · 「육각볼트」 323). 이름만
  맞추면 엉뚱한 규격 단가가 붙음.
- 매칭 카탈로그에 자재를 붙이면서 **이름 색인**을 넣음(7,700건이라 매번 훑으면 느림).

**⚠ 붙여 보고 알아낸 것 — 관급 목록에 임도 자재가 거의 없음**
- 나라장터 목록이 **건축·설비 자재 중심**(덕트·밸브·복공판·스피커…)이고,
  **시멘트·모래·자갈·레미콘·철근·파형강관이 한 건도 없음**.
- 까닭 둘 — ① 철근·레미콘·아스콘은 그 파일이 `excluded_named_groups` 로 **명시적으로
  빼 둠**(쇼핑몰 스냅샷 미보관) ② **시멘트·모래·자갈은 애초에 사급**임. 실무 울진에서
  관급은 **파형강관 Ø800/Ø1000 뿐**이었음.
- ⇒ **자재 매칭 0건.** 억지로 이름을 느슨하게 맞추지 않음 — 그렇게 하면 엉뚱한
  건축 자재 단가가 임도 공종에 붙음.
- **이 사실을 시험으로 못 박음**(`test_forest_road_materials_are_absent_from_owner_catalog`)
  — 나중에 「자재가 왜 안 붙나」를 다시 파는 일이 없도록.

**화면 요약 문구를 실측대로 갈아 끼움** — 「자재 카탈로그가 없어서」가 아니라
「관급 6,999건을 붙였으나 임도 자재가 거의 없고, **사급 단가가 미결**이라 구조물 계열이
안 섬」으로. 설치 주체 미지정 건수도 함께 냄.

자체검증 — 신규 6건 포함 `pytest tmp/tests/ -q` **129 passed** · ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 23:40:02 +09:00
co-authored by Claude Opus 5
parent 3762a06b25
commit 0df6acb57d
3 changed files with 215 additions and 9 deletions
@@ -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": [
"자재 단가는 **할증 전·부가세 제외** 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.",
"관급 자재의 **설치 주체가 미지정**이라 안전관리비 대상액에 자동으로 넣지 않습니다.",
],
}
+31 -4
View File
@@ -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,
)
+27 -5
View File
@@ -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(),
}