feat(M01): PLAN 7-5 — 안 쓰는 마스터 old/ 로 마저 옮기고 죽은 코드 셋 끊음

- 아무도 안 읽는 최상위·ref 파일 7개를 old/ 로(지우지 않음) — _원문목록·_열목록_화면표·
  수량_돌쌓기찰라이브러리_소광리·공종_수량연결_실무·수량_구조물원단위_울진·
  품셈_산림_재료할증_울진·품셈_산림_돌종류계수_울진(뒤 넷은 old_code 만 읽어 이제 0)
- 공종_자원별칭_실무도 old/ 로 — common_util_aliases.py 를 읽던 시험 둘이 이미 깨져 있어(
  B08·B09 모듈 없음) 실제로 부르는 살아 있는 코드가 없었음
- 살아 있는 common_util/ 에서 dead 모듈 셋(work_item_key·work_item_link·aliases)을
  old_code/common_util/ 로 옮김 — old_code 만 부르던 것이라 옮겨도 부르는 자리 그대로(
  old_code 는 old_code 를 스스로 루트로 읽는 관례) · 상대경로 계산만 한 단 보정
- 깨져 있던 시험 둘(test_b09_fuel_kind_vibrator·test_b08_root_removal_excavator)도
  resources/tester/ 에서 old_code/resources/tester/ 로 — 7-3 이 놓친 것 마저 정리
- UnitPrice.py 의 load_basis_missing() — old/ 스냅샷 대신 새 최상위 마스터
  `기준수량없음_산림품셈_2026-01-01.json` 으로(브레인 판정: 마스터 쪽) · old_code 로
  옮겨진 뒤 root 계산이 한 단 부족해 조용히 빈 딕셔너리를 돌려주던 것도 같이 바로잡음
  (79건 견본 대조 통과) · check_master.py 틀 검사에 특수 파일 면제 한 줄 추가
- 검증 — typecheck · check_master 틀 0건 · M01·자재품목 시험 215 + masterdata·m01_api 98 통과 ·
  전체 모으기 1475 그대로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-22 14:35:46 +09:00
co-authored by Claude Sonnet 5
parent 04784a808a
commit e937978116
16 changed files with 15 additions and 7 deletions
+122
View File
@@ -0,0 +1,122 @@
"""별칭표 한 벌 — 우리 이름 ↔ 품셈·실무 이름 (2026-09-13 축 C 명세 5장·17장).
정본 파일 `resources/data_aliases/aliases_<판>.json`. **B08·B09 는 이 파일만 읽는다** — 매핑표·
단가 코드·인계본에 따로 두었더니 세 벌이 서로 달라졌다(리핑암 → 파쇄암·암절취 / 파쇄암만 / 안 읽음).
한 줄 = `{axis, from, to, scope, pum_edition, basis}`
axis `resource`(자원 이름 → 카탈로그 코드) · `variant`(우리 갈래 이름 → 품셈 갈래 이름)
scope 이 별칭이 **참인 `FP-*` 범위** — 없으면 오류. 별칭은 표 범위에서만 참이다
(「리핑암 = 파쇄암」은 10-11 f 표 안에서만 — 발파암도 캐고 나면 파쇄암 상태다)
pum_edition scope 가 묶인 품셈 판 — 판이 다르면 쓰지 않는다(`FP-*` 는 판 안에서만 유일)
⚠ **별칭 ≠ 대체** — 대응이 아예 없어 갈음하는 것은 여기 넣지 않는다(설계가 고른다).
⚠ 같은 이름이 **겹친 범위**에서 두 곳으로 가면 **읽을 때 오류** — 조용히 첫째를 고르지 않는다.
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
ALIAS_FILE = (
Path(__file__).resolve().parents[2]
/ "resources"
/ "master_data"
/ "old"
/ "공종_자원별칭_실무_2026-01-01.json"
)
AXES = frozenset({"resource", "variant"})
_FIELDS = ("axis", "from", "to", "scope", "pum_edition")
class AliasError(ValueError):
"""별칭표가 규약을 어겼다 — 조용히 넘기지 않는다."""
def _key(text: str) -> str:
"""이름 비교 키 — 내부 공백만 지운다(「화 약 공」 = 「화약공」). 다른 글자는 안 건드린다."""
return re.sub(r"\s+", "", str(text or ""))
def in_scope(work_item_code: str, scope: str) -> bool:
"""`FP-*` 범위 — 그 코드 자신이거나 그 아래. ⚠ `AX`·`AR` 코드에는 계층이 없어 쓰지 않는다."""
return work_item_code == scope or work_item_code.startswith(scope + "-")
def parse_aliases(rows: list[dict[str, Any]]) -> list[dict[str, str]]:
"""줄을 검사해 돌려준다. 칸 빠짐 · 모르는 축 · 겹친 범위의 두 대상은 오류."""
aliases: list[dict[str, str]] = []
for row in rows:
picked = {key: str(row.get(key) or "").strip() for key in _FIELDS}
if not all(picked.values()):
raise AliasError(f"별칭 줄에 {'·'.join(_FIELDS)} 가 다 있어야 합니다: {row}")
if picked["axis"] not in AXES:
raise AliasError(f"모르는 별칭 축입니다: {picked['axis']}")
picked["basis"] = str(row.get("basis") or "")
aliases.append(picked)
for index, left in enumerate(aliases):
for right in aliases[index + 1 :]:
if (
left["axis"] == right["axis"]
and _key(left["from"]) == _key(right["from"])
and left["to"] != right["to"]
and left["pum_edition"] == right["pum_edition"]
and (
in_scope(left["scope"], right["scope"])
or in_scope(right["scope"], left["scope"])
)
):
raise AliasError(
f"같은 이름 「{left['from']}」이 겹친 범위에서 두 곳으로 갑니다: "
f"{left['to']}({left['scope']}) · {right['to']}({right['scope']})"
)
return aliases
@lru_cache(maxsize=4)
def _load(path: str) -> tuple[dict[str, str], ...]:
target = Path(path)
if not target.is_file():
return () # 파일이 없으면 별칭 없이 돈다 — 못 맞춘 줄로 드러난다
payload = json.loads(target.read_text(encoding="utf-8"))
return tuple(parse_aliases(list(payload.get("aliases") or [])))
def load_aliases(axis: str, path: Path | None = None) -> list[dict[str, str]]:
"""그 축의 별칭 줄. 파일은 한 번만 읽는다."""
return [dict(row) for row in _load(str(path or ALIAS_FILE)) if row["axis"] == axis]
def alias_target(
rows: list[dict[str, str]],
name: str,
work_item_code: str,
edition: str,
*,
reverse: bool = False,
) -> str | None:
"""범위·판이 맞는 줄에서 이름을 옮긴다. `reverse` 면 남의 이름 → 우리 이름(양방향 조회)."""
source, target = ("to", "from") if reverse else ("from", "to")
wanted = _key(name)
for row in rows:
if (
row["pum_edition"] == edition
and _key(row[source]) == wanted
and in_scope(work_item_code, row["scope"])
):
return row[target]
return None
def names_view(rows: list[dict[str, str]]) -> dict[str, dict[str, Any]]:
"""인계본 모양 `{우리 이름: {names, scopes, basis}}` — B08 `ground_class_aliases` 가 싣는다."""
view: dict[str, dict[str, Any]] = {}
for row in rows:
entry = view.setdefault(row["from"], {"names": [], "scopes": {}, "basis": ""})
entry["names"].append(row["to"])
entry["scopes"][row["to"]] = row["scope"]
entry["basis"] = " · ".join(part for part in (entry["basis"], row["basis"]) if part)
return view
@@ -0,0 +1,82 @@
"""공종 불변 열쇠 **길목** — 목차 코드(`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.parent / "resources" / "master_data" / "old"
#: 우리가 세운 공종·구조물 — 난수 코드가 곧 불변 열쇠
_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("2_공종_*_마스터_*.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)
@@ -0,0 +1,87 @@
"""연결고리 표(`work_item_link`)를 읽는 **한 자리** — 산림·건설 어느 품셈 품을 쓸지 **표가 정함**
(2026-09-17 브레인 · 코덱스 크로스체크 ② 「연결고리 미소비」).
고시 총칙 7 「본 품셈에 명시되지 않은 품은 타 부문 표준품셈 적용 · 유사 공종은 본 품셈 우선」 —
그 규칙은 표의 `policy`(`both_precedence` · `unconfirmed_action`)에 있고 코드는 **읽기만** 함(「산림 우선」을 코드에 안 박음).
확정 · both → `both_precedence` 쪽 열쇠(지금 표는 forest)
확정 · 한쪽만 → 그 열쇠 그대로
미판정 → **안 고름** · 사유를 붙여 막음(`unconfirmed_action: do_not_link`)
표에 없음 → 제 품셈 그대로(품셈에 명시된 품)
⚠ 미판정 행 가운데 열쇠가 빈 행(여러 절 묶음)은 줄에 못 닿음 — `undecided_links()` 로 목록만 드러냄.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
FOLDER = Path(__file__).resolve().parent.parent.parent / "resources" / "master_data" / "old"
CONFIRMED = "확정"
#: 표 `policy.both_precedence` 낱말 → 그 행의 열쇠 칸
_KEY_COLUMN = {"forest": "forest_key", "construction": "const_key"}
@dataclass(frozen=True)
class Choice:
"""쓸 열쇠 · 막힌 까닭(있으면 금액을 안 세움)."""
key: str | None
blocked: str = ""
@lru_cache(maxsize=4)
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _doc() -> dict[str, Any]:
found = sorted(FOLDER.glob("2_공종_산림건설_연결_*.json")) if FOLDER.is_dir() else []
return _read(str(found[-1]), found[-1].stat().st_mtime_ns) if found else {}
def choose(key: str | None) -> Choice:
"""열쇠 하나(FW-·CW-) → 실제로 쓸 열쇠. 미판정 행에 걸리면 안 고르고 막음."""
if not key:
return Choice(key)
doc = _doc()
policy = doc.get("policy") or {}
rows = [
link
for link in doc.get("links") or []
if key in (link.get("forest_key"), link.get("const_key"))
]
for link in rows:
if link.get("status") != CONFIRMED:
return Choice(
None,
"연결고리 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 고르지 않음"
f"({link.get('evidence') or '근거 없음'})",
)
for link in rows:
if link.get("branch") == "both":
column = _KEY_COLUMN.get(str(policy.get("both_precedence") or ""))
chosen = link.get(column) if column else None
if not chosen:
return Choice(
None, "연결고리 표에 둘 다 있는 줄의 우선 품셈이 안 적혔음 — 고르지 않음"
)
return Choice(chosen)
return Choice(key)
def undecided_links() -> list[dict[str, str]]:
"""미판정 행 — 열쇠가 비어 줄에 못 닿는 행도 드러냄(조용히 안 버림)."""
return [
{
"branch": str(link.get("branch") or ""),
"forest_key": str(link.get("forest_key") or ""),
"const_key": str(link.get("const_key") or ""),
"evidence": str(link.get("evidence") or ""),
}
for link in _doc().get("links") or []
if link.get("status") != CONFIRMED
]