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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user