Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
83 lines
3.7 KiB
Python
83 lines
3.7 KiB
Python
"""공종 불변 열쇠 **길목** — 목차 코드(`FP-`·`CP-`) → 불변 열쇠(`FW-`·`CW-`) (2026-09-17 PLAN_공종축 8-6 이행).
|
|
|
|
코덱스 조사(FP- 참조 630곳 · 77파일)의 결론대로 **경계 두 곳에서만** 부름 — 630곳은 안 고침.
|
|
B08 인계 출구 `B08_Quantity_Engine_Handoff.build_handoff` 줄마다 `work_item_key` 를 더함
|
|
B09 인계 입구 `B09_Estimation_BillOfQuantities_Input.parse_handoff` 옛 인계(열쇠 없음)에도 채움
|
|
`work_item_code` 는 **그대로 남김** — 사람이 보는 목차 번호(화면·로그에 보이는 것이 값짐).
|
|
|
|
정본은 공종 마스터 파일의 줄(`work_item_code` ↔ `work_item_key` · 판 `pum_edition`) — 장부를 따로 안 읽음
|
|
(마스터가 장부로 지은 결과라 두 벌이 안 됨).
|
|
⚠ `AX-WK-`·`AX-ST-` 는 난수 8자리(명세 2장 ④ · 동등 비교만)라 **이미 불변** — 그대로 열쇠.
|
|
⚠ 못 찾은 코드는 `None` — 지어내지 않음. 받는 쪽이 목록으로 드러냄(`unkeyed_work_item_codes`).
|
|
⚠ 한 판 안에서 같은 목차 코드가 두 줄이면(목차 오기) 어느 열쇠인지 못 가름 → `None`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
MASTER_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_work_item_master"
|
|
#: 우리가 세운 공종·구조물 — 난수 코드가 곧 불변 열쇠
|
|
_STABLE_CODE = re.compile(r"^AX-(?:WK|ST)-[0-9a-f]{8}$")
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def _read(path: str, _mtime_ns: int) -> dict[tuple[str, str], str | None]:
|
|
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
edition = str(doc.get("pum_edition") or doc.get("effective_date") or "")
|
|
keys: dict[tuple[str, str], str | None] = {}
|
|
for item in doc.get("work_items") or []:
|
|
slot = (edition, str(item.get("work_item_code") or ""))
|
|
keys[slot] = None if slot in keys else item.get("work_item_key")
|
|
return keys
|
|
|
|
|
|
def _keys() -> dict[tuple[str, str], str | None]:
|
|
out: dict[tuple[str, str], str | None] = {}
|
|
for path in sorted(MASTER_DIR.glob("*work_item_master_*.json")): # 산림 + 건설(`const_`)
|
|
out.update(_read(str(path), path.stat().st_mtime_ns))
|
|
return out
|
|
|
|
|
|
def work_item_key(code: str | None, pum_edition: str | None = None) -> str | None:
|
|
"""목차 코드 → 불변 열쇠. 판을 모르면 코드가 한 판에만 있을 때만 줌."""
|
|
if not code:
|
|
return None
|
|
if _STABLE_CODE.match(code):
|
|
return code
|
|
keys = _keys()
|
|
if pum_edition:
|
|
return keys.get((pum_edition, code))
|
|
found = {key for (_, each), key in keys.items() if each == code}
|
|
return found.pop() if len(found) == 1 else None
|
|
|
|
|
|
def work_item_code_of(key: str | None, pum_edition: str | None = None) -> str | None:
|
|
"""불변 열쇠 → 그 판의 목차 코드(되짚기). 한 곳에서만 찾아질 때만."""
|
|
if not key:
|
|
return None
|
|
if _STABLE_CODE.match(key):
|
|
return key
|
|
found = {
|
|
code
|
|
for (edition, code), each in _keys().items()
|
|
if each == key and (pum_edition is None or edition == pum_edition)
|
|
}
|
|
return found.pop() if len(found) == 1 else None
|
|
|
|
|
|
def attach_keys(rows: Iterable[dict[str, Any]]) -> list[str]:
|
|
"""인계 줄마다 `work_item_key` 를 더함(코드 없는 줄은 `None` — 칸은 늘 있음 · 인계 계약) ·
|
|
코드는 있는데 열쇠를 못 찾은 코드 목록을 돌려줌."""
|
|
missing: set[str] = set()
|
|
for row in rows:
|
|
code = row.get("work_item_code")
|
|
row["work_item_key"] = work_item_key(code, row.get("pum_edition"))
|
|
if code and row["work_item_key"] is None:
|
|
missing.add(str(code))
|
|
return sorted(missing)
|