feat(b09): 자원 축 조인 키 — 카탈로그 밖 자원(AR-) 목록·규격 조인·범위 별칭

- 자재 0 해소 첫 걸음: 사급 자원 19건(AR-M 17·AR-X 2, 이름·규격·단위, 단가 칸 없음)
  resources/data_resource_catalog/ 신설 — 임도 60 + 사방 11 코드 범위의 진짜 자원만
- 규격이 조인 키: AR 항목은 후보가 하나여도 규격이 같아야 고름 · 품셈 칸에 규격이
  없으면 「규격 미정 — 후보 N」(성공으로 안 셈)
- 형식이 하나뿐인 계열만 규격으로 고름(공기압축기(이동식) 3.5·10.3) · 형식 둘이면 규격 미정
- 범위 별칭 한 벌: 화약공→화약취급공(1016) scope FP-09-05 · pum_edition 다르면 안 씀 ·
  scope 없음·겹친 두 코드는 읽을 때 오류
- 일위대가: 맞췄으나 단가 층 없는 줄은 조용히 안 빠지고 드러냄 · 기계 몫이면 막음
- ResourceAxis.py 700줄 초과분(조사용 덤프)을 _Dump.py 로 뗌
- 돌망태 품셈 절 표기 13-8 → 13-11 바로잡음
- 결과: 자원 줄 575→592(자재 0→11) · 못 맞춤 508→491 · 내역 금액 변화 0(막힌 공종 그대로)
- 시험 test_b09_resource_axis_join.py 8건 · 전체 1377 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-13 17:05:36 +09:00
co-authored by Claude Opus 5
parent 75b8532145
commit c541a6bd2f
9 changed files with 879 additions and 88 deletions
+40 -85
View File
@@ -20,11 +20,10 @@
from __future__ import annotations
import hashlib
import json
import os
import re
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from decimal import Decimal, InvalidOperation
from typing import Any
@@ -110,6 +109,9 @@ class CatalogEntry:
name: str
kind: str
spec: str = ""
#: ⚠ **규격이 조인 키인 항목**(`AR-` 자원 목록) — 후보가 하나여도 규격이 같아야 고른다
#: (명세 §11).
strict_spec: bool = False
@dataclass
@@ -118,8 +120,12 @@ class ResourceCatalog:
entries: list[CatalogEntry] = field(default_factory=list)
aliases: dict[str, str] = field(default_factory=dict)
#: 범위 별칭 `{from, to, scope, pum_edition}` — 그 공종 범위 안에서만 쓴다(명세 5장).
scoped_aliases: list[dict[str, str]] = field(default_factory=list)
#: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다.
_index: dict[str, list[CatalogEntry]] | None = None
#: 괄호 앞 이름 → 항목 색인(형식 계열). `_Join.family_members` 가 채운다.
_family: dict[str, list[CatalogEntry]] | None = None
def by_name(self, name: str) -> list[CatalogEntry]:
if self._index is None:
@@ -134,6 +140,8 @@ class ResourceCatalog:
found = self.by_name(name)
if not found:
return None
if any(entry.strict_spec for entry in found):
return pick_by_spec(found, spec) # 한 건뿐이어도 규격이 안 맞으면 None(판정 Ⓑ)
if len(found) == 1:
return found[0]
# 이름이 여럿이면 규격이 있어야 고를 수 있다.
@@ -341,13 +349,20 @@ def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
# 이름은 맞는데 규격이 없어 못 고른 경우 — 옆 칸에서 규격을 찾는다.
for name in (machine_name, plain_name):
if len(catalog.by_name(name)) <= 1:
found = catalog.by_name(name)
strict = any(entry.strict_spec for entry in found)
if len(found) <= 1 and not strict:
continue
for candidate in spec_candidates(cells[1:]):
# 조인 키 항목은 **글자 규격**(「복합비료」·「∅200mm」)도 본다 — 기존 항목 길은 그대로.
for candidate in (
*spec_candidates(cells[1:]),
*(text_spec_candidates(cells[1:]) if strict else ()),
):
entry = catalog.resolve(name, candidate)
if entry is not None:
return entry
return None
# 이름이 **형식 하나뿐인 계열**이면 규격으로 고른다(「공기압축기(3.5㎥/min)」 → 이동식 3.5).
return resolve_family(catalog, name_cell, cells)
#: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다.
@@ -542,18 +557,14 @@ def match_table(
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
# 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을
# 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다.
# 범위 별칭 — 그 공종 범위 안에서만 카탈로그 쪽 이름으로 바꾼다(「화약공」 → 화약취급공).
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"])
entry = _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
if entry is None:
if is_non_resource_label(name_cell):
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
name, spec = split_name_and_spec(name_cell)
found = catalog.by_name(name)
if len(found) > 1:
reason = "규격이 없어 같은 이름 여럿 중 고를 수 없음"
else:
# 지금 가진 카탈로그는 노임뿐이다. 기계·자재는 카탈로그 자체가 없어
# 못 맞추는 것이므로 사유를 갈라 적는다 — 「이름이 틀림」과 다르다.
reason = "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)"
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
reason = unmatched_reason(catalog, name_cell)
result.unmatched.append(
UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, reason)
)
@@ -633,6 +644,11 @@ def _group_ratio_of(cell: str) -> Decimal | None:
def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> AxisResult:
"""공종 축 전체를 훑어 자원 축을 만든다."""
# ⚠ 별칭 범위(`FP-*`)는 **품셈 판에 묶인다** — 판이 다른 줄은 쓰지 않는다(명세 17장).
edition = str(master.get("effective_date", ""))
if any(row.get("pum_edition") != edition for row in catalog.scoped_aliases):
kept = [row for row in catalog.scoped_aliases if row.get("pum_edition") == edition]
catalog = replace(catalog, scoped_aliases=kept, _index=None, _family=None)
result = AxisResult()
for node in master.get("work_items", []):
for table in node.get("tables", []):
@@ -640,78 +656,8 @@ def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> Axi
return result
#: 자원 축 산출물이 나가는 자리 — **메인의 `data_work_item_master/` 안에 넣지 않는다.**
#: 메인이 품셈을 다시 돌리면 그 폴더가 덮이므로 섞으면 사라진다.
OUTPUT_SUBPATH = ("resources", "data_cost_resource_axis")
def _master_file_fingerprint(master: dict[str, Any]) -> dict[str, str]:
"""공종 마스터 **파일 자체**의 지문. 낡은 파생물을 드러내는 유일한 근거다.
`dataset_version`(품셈 원판 지문)은 마스터가 다시 생성돼도 그대로라, 그것만
적어 두면 「내 자원 축이 옛 마스터에서 나왔다」는 사실이 안 보인다.
"""
effective_date = master.get("effective_date", "")
file_name = f"work_item_master_{effective_date}.json"
path = os.path.join(_project_root(), *_MASTER_SUBPATH, file_name)
try:
with open(path, "rb") as handle:
digest = hashlib.sha256(handle.read()).hexdigest()
except OSError:
return {"file": file_name, "sha256": ""}
return {"file": file_name, "sha256": digest}
def write_resource_axis(
result: AxisResult,
master: dict[str, Any],
*,
output_dir: str | None = None,
) -> dict[str, str]:
"""자원 축과 못 맞춘 목록을 파일로 낸다. 만든 파일 경로를 돌려준다."""
directory = output_dir or os.path.join(_project_root(), *OUTPUT_SUBPATH)
os.makedirs(directory, exist_ok=True)
effective_date = master.get("effective_date", "")
axis_path = os.path.join(directory, f"resource_axis_{effective_date}.json")
unmatched_path = os.path.join(directory, f"unmatched_{effective_date}.json")
axis_payload = {
"schema_version": "1.0",
"dataset_id": "resource_axis_forest",
"effective_date": effective_date,
# 어느 공종 축 판에 붙인 것인지 — 세 쪽을 그대로 옮겨 적는다(PLAN 9-2).
"source_dataset_version": master.get("dataset_version", {}),
# ⚠ 위 지문은 **품셈 원판**의 것이라 B08 이 마스터를 다시 생성해도 안 움직인다.
# 낡음을 실제로 드러내려면 **마스터 파일 자체의 지문**이 있어야 한다.
"source_master_file": _master_file_fingerprint(master),
"policy": {
"axis": "resource_only",
"work_item_axis_owner": "B08",
"material_amounts_are_before_surcharge": True,
},
"stats": {
"rows": len(result.rows),
"unmatched": len(result.unmatched),
"skipped_forms": result.skipped_forms,
},
"rows": [r.as_dict() for r in result.rows],
}
unmatched_payload = {
"schema_version": "1.0",
"effective_date": effective_date,
"note": (
"못 맞춘 자원 이름. 빈칸으로 두지 않고 여기 모은다. "
"기계·자재 카탈로그가 아직 없어 그 계열은 전부 여기로 온다."
),
"rows": [u.as_dict() for u in result.unmatched],
}
for path, payload in ((axis_path, axis_payload), (unmatched_path, unmatched_payload)):
with open(path, "w", encoding="utf-8", newline="\n") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
return {"resource_axis": axis_path, "unmatched": unmatched_path}
# 조사용 덤프(`write_resource_axis`)는 700줄 제한으로 `_Dump` 파일로 옮겼다(2026-09-13).
# ⚠ 그 파일이 쓰는 JSON 은 **정본이 아니다** — 정본은 매번 메모리에서 새로 돈 값이다.
# 카탈로그 적재·셀 파싱은 700줄 제한으로 `_Sources` 파일로 옮겼다.
@@ -730,3 +676,12 @@ from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402
spec_candidates,
split_name_and_spec,
)
# 조인 키 규칙(규격 · 형식 계열 · 범위 별칭)은 `_Join` 에 둔다(2026-09-13 축 C 1장).
from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( # noqa: E402
apply_scoped_alias,
pick_by_spec,
resolve_family,
text_spec_candidates,
unmatched_reason,
)