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:
@@ -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": [
|
||||
"자재 단가는 **할증 전·부가세 제외** 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.",
|
||||
"관급 자재의 **설치 주체가 미지정**이라 안전관리비 대상액에 자동으로 넣지 않습니다.",
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user