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
@@ -37,7 +37,7 @@ WITHHELD_FORMS: dict[str, str] = {
"콘크리트 기슭막이는 돌쌓기(품셈 13-4)가 아니라 콘크리트 구조물이라 전개식이 다름 "
"— 벽 두께·저판이 정본에 없어 물량이 서지 않음"
),
"돌망태": ("돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"),
"돌망태": ("돌망태는 품셈 13-11 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"),
"통나무·목재틀": (
"목재틀은 품셈 13-13 이고 밑수가 ㎥당 목공 품이라 돌쌓기 표를 못 씀 "
"— 각재·판재 자재가 카탈로그에 없어 값이 모자람"
@@ -161,7 +161,7 @@ EROSION_CHECK_FORMS: frozenset[str] = frozenset({"돌"})
#: 나머지 형식과 **왜 없는지**. 지어내지 않는다.
EROSION_CHECK_WITHHELD: dict[str, str] = {
"돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
"돌망태": "돌망태는 품셈 13-11 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
"콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음",
"통나무": (
"통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보"
+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,
)
@@ -0,0 +1,94 @@
"""B09 원가계산 — 자원 축 **조사용 덤프** (`B09_Estimation_ResourceAxis` 에서 갈라냄).
⚠ **정본이 아니다.** 자원 축의 정본은 `build_unit_prices` 가 부를 때마다
**메모리에서 새로 돈 값**이고, 이 파일이 쓰는 JSON 은 시험·조사용 자취일 뿐이다
(명세 1장 2026-09-13 정정 — 옛 덤프 418/497 을 정본으로 읽어 추산이 틀렸던 자리).
부르는 곳이 없어도 지우지 않는다.
⚠ **왜 갈랐나** — 본 파일이 700줄 제한을 넘어(732줄) 조인 키 규칙을 더하기 전에 뗐다.
"""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
_MASTER_SUBPATH,
AxisResult,
_project_root,
)
#: 자원 축 산출물이 나가는 자리 — **메인의 `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}
@@ -0,0 +1,248 @@
"""B09 원가계산 — 자원 축 **조인 키 규칙** (`_ResourceAxis` 보조 · 2026-09-13 축 C 1장).
가지를 자리에 둔다.
**규격이 조인 키인 항목** `resources/data_resource_catalog/` `AR-` 자원 목록
(기존 카탈로그에 없는 자원). 이름이 맞아도 **규격이 같아야** 고르고,
**후보가 하나여도 자동 채택하지 않는다**(명세 §11 · 판정 ).
품셈 칸이 규격을 주면 규격 미정 후보 N으로 드러낸다.
**형식이 하나뿐인 계열 이름** 공기압축기(3.5/min) 공기압축기(이동식) 3.5.
형식이 이상이면 고르지 않는다(공압식·전기식은 다른 장비 · 판정 ).
**범위 별칭** `{from, to, scope, pum_edition}` 공종 코드 범위 안에서만 바꾼다(명세 5).
scope 없는 · 겹친 범위에서 코드로 가는 이름은 **읽을 오류** 세운다.
대체(대응이 아예 없어 갈음) 별칭이 아니다 여기 넣지 않는다.
단가는 다루지 않는다 코드·이름·규격만 선다(7 단가 보류).
"""
from __future__ import annotations
import json
import os
import re
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
RANGE_DASHES,
CatalogEntry,
ResourceAxisError,
ResourceCatalog,
_normalize,
_project_root,
)
from B09_Estimation.B09_Estimation_ResourceAxis_Sources import (
parse_amount,
parse_machine_cell,
split_name_and_spec,
)
_EXT_SUBPATH = ("resources", "data_resource_catalog")
EXT_CATALOG_FILE = "resource_catalog_ext_2026-01-01.json"
ALIASES_FILE = "aliases_2026-01-01.json"
#: 명세 §2 ③ — `AR-<M|L|X>-<8자리 소문자 16진>`. 종류 글자는 PriceKind 글자 그대로.
_RE_AR_CODE = re.compile(r"^AR-([MLX])-[0-9a-f]{8}$")
_KIND_LETTER = {"material": "M", "labor": "L", "machine": "X"}
#: 규격 키 — 공백을 지우고 물결표만 한 종류로 모은다(`normalize_variant_key` 와 같은 두 규칙).
_TILDES = "".join(ch for ch in RANGE_DASHES if ch not in "-–‐")
#: 옆 칸에서 글자 규격을 찾을 때 **단위 칸**은 건너뛴다.
_UNIT_WORDS = frozenset(
{"kg", "", "g", "t", "ton", "", "", "m3", "", "m2", "m", "", "", "mm", "", "",
"", "EA", "ea", "개소", "", "hr", "h", "시간", "시간(h)", "", "", "L", "", "%", ""}
) # fmt: skip
#: 「0.016/2.5/2」 — 나눗셈으로 적힌 **값 칸**. 여기서 멈춘다.
_RE_QUOTIENT = re.compile(r"^\d+(?:\.\d+)?(?:/\d+(?:\.\d+)?)+$")
#: 규격 첫 수 — 「5,500ℓ」 처럼 천 단위 쉼표가 든 것도 한 수로 읽는다.
_RE_NUMBER_TOKEN = re.compile(r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?")
def _read_optional(file_name: str) -> dict[str, Any]:
"""파일이 없으면 빈 벌 — 목록이 없던 때와 같이 돈다."""
path = os.path.join(_project_root(), *_EXT_SUBPATH, file_name)
if not os.path.isfile(path):
return {}
with open(path, encoding="utf-8") as handle:
return json.load(handle)
def parse_ext_entries(rows: list[dict[str, Any]]) -> list[CatalogEntry]:
"""`AR-` 자원 줄 → 조인 키 항목. 코드 모양·종류 글자·겹침을 **읽을 때** 막는다."""
entries: list[CatalogEntry] = []
for row in rows:
code, kind = str(row.get("code") or ""), str(row.get("kind") or "")
found = _RE_AR_CODE.match(code)
if found is None or found.group(1) != _KIND_LETTER.get(kind):
raise ResourceAxisError(f"자원 코드 모양이 규약과 다릅니다: {code!r} ({kind})")
if not str(row.get("name") or "").strip() or not str(row.get("unit") or "").strip():
raise ResourceAxisError(f"자원 줄에 이름·단위가 있어야 합니다: {code}")
entries.append(
CatalogEntry(
code=code,
name=str(row["name"]),
kind=kind,
spec=str(row.get("spec") or ""),
strict_spec=True,
)
)
if len({entry.code for entry in entries}) != len(entries):
raise ResourceAxisError("자원 코드가 겹칩니다 — 난수 8자리를 다시 뽑을 것")
return entries
def load_ext_entries(file_name: str = EXT_CATALOG_FILE) -> list[CatalogEntry]:
"""기존 카탈로그에 없는 자원(`AR-`)."""
return parse_ext_entries(list(_read_optional(file_name).get("entries") 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_scoped_aliases(rows: list[dict[str, Any]]) -> list[dict[str, str]]:
"""자원 축 별칭만 골라 검사한다. **scope 없는 줄 · 겹친 범위의 두 코드는 오류.**"""
aliases: list[dict[str, str]] = []
for row in rows:
if row.get("axis") != "resource":
continue
picked = {
key: str(row.get(key) or "").strip() for key in ("from", "to", "scope", "pum_edition")
}
if not all(picked.values()):
raise ResourceAxisError(
f"별칭 줄에 from·to·scope·pum_edition 이 다 있어야 합니다: {row}"
)
aliases.append(picked)
for index, left in enumerate(aliases):
for right in aliases[index + 1 :]:
if (
_normalize(left["from"]) == _normalize(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 ResourceAxisError(
f"같은 이름 「{left['from']}」이 겹친 범위에서 두 코드로 갑니다: "
f"{left['to']} · {right['to']}"
)
return aliases
def load_scoped_aliases(file_name: str = ALIASES_FILE) -> list[dict[str, str]]:
return parse_scoped_aliases(list(_read_optional(file_name).get("aliases") or []))
def apply_scoped_alias(catalog: ResourceCatalog, name_cell: str, work_item_code: str) -> str:
"""범위 안이면 카탈로그 쪽 **이름**으로 바꾼다. 범위 밖이거나 대상 코드가 없으면 원문 그대로."""
wanted = _normalize(name_cell)
for row in catalog.scoped_aliases:
if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]):
continue
target = next((entry for entry in catalog.entries if entry.code == row["to"]), None)
if target is not None:
return target.name
return name_cell
def spec_key(text: str) -> str:
tight = _normalize(text)
return "".join("~" if ch in _TILDES else ch for ch in tight)
def same_spec(catalog_spec: str, text: str) -> bool:
"""규격이 같은가. 카탈로그가 **수만** 적은 규격(「3.5」)은 품셈 첫 수(「3.5㎥/min」)로 본다."""
left, right = spec_key(catalog_spec), spec_key(text)
if not left or not right:
return False
if left == right:
return True
number = parse_amount(left)
if number is None:
return False
found = _RE_NUMBER_TOKEN.search(right)
return found is not None and parse_amount(found.group(0)) == number
def text_spec_candidates(cells: list[str]) -> list[str]:
"""옆 칸의 **글자 규격** — 「복합비료」·「∅200mm」·「직경4~6㎝, 길이120㎝ 기준」.
(·나눗셈) 만나면 멈춘다 뒤는 소요량·비고라 규격이 아니다. 단위 칸은 건너뛴다.
"""
found: list[str] = []
for cell in cells:
text = _normalize(cell)
if not text:
continue
if parse_amount(text) is not None or _RE_QUOTIENT.match(text):
break
if text in _UNIT_WORDS or len(text) > 40:
continue
found.append(str(cell).strip())
return found
def pick_by_spec(found: list[CatalogEntry], spec: str) -> CatalogEntry | None:
"""조인 키 항목 고르기 — 규격이 같은 것이 **정확히 한 건**일 때만."""
hits = [entry for entry in found if same_spec(entry.spec, spec)]
return hits[0] if len(hits) == 1 else None
def _family_base(name: str) -> str:
return _normalize(name).split("(")[0].split("")[0]
def family_members(catalog: ResourceCatalog, name_cell: str) -> list[CatalogEntry]:
"""괄호 앞 이름이 같은 카탈로그 항목(「공기압축기」 → 「공기압축기(이동식)」 여섯 규격)."""
if catalog._family is None:
index: dict[str, list[CatalogEntry]] = {}
for entry in catalog.entries:
if "(" in entry.name or "" in entry.name:
index.setdefault(_family_base(entry.name), []).append(entry)
catalog._family = index
name, _ = parse_machine_cell(name_cell)
return catalog._family.get(_family_base(name), [])
def resolve_family(
catalog: ResourceCatalog, name_cell: str, cells: list[str]
) -> CatalogEntry | None:
"""카탈로그에 그 이름이 없고 **형식이 하나뿐인 계열**일 때만 규격으로 고른다."""
name, spec = parse_machine_cell(name_cell)
if catalog.by_name(name):
return None # 이름이 그대로 있으면 계열로 넓히지 않는다
members = family_members(catalog, name_cell)
if len({_normalize(entry.name) for entry in members}) != 1:
return None
specs = [text for text in (spec, *text_spec_candidates(cells[1:])) if text]
hits = [entry for entry in members if any(same_spec(entry.spec, text) for text in specs)]
return hits[0] if len(hits) == 1 else None
def unmatched_reason(catalog: ResourceCatalog, name_cell: str) -> str:
"""못 맞춘 까닭 — 「규격 미정」·「같은 이름 여럿」·「카탈로그에 없는 이름」을 가른다."""
name, _ = split_name_and_spec(name_cell)
found = catalog.by_name(name)
if any(entry.strict_spec for entry in found):
pool = found
else:
pool = [] if found else family_members(catalog, name_cell)
if pool:
labels = sorted(
{
f"{entry.name} {entry.spec}".strip()[:30]
for entry in pool
if entry.spec or not entry.strict_spec
}
)
if not labels:
codes = ", ".join(sorted(entry.code for entry in pool)[:2])
return f"규격 미정 — 규격 목록 미확보(코드 {codes} 만 섬) · 설계·라이브러리가 고름"
shown = " / ".join(labels[:3]) + ("" if len(labels) > 3 else "")
return f"규격 미정 — 후보 {len(labels)}건 ({shown}) · 설계·라이브러리가 고름"
if len(found) > 1:
return "규격이 없어 같은 이름 여럿 중 고를 수 없음"
# 기계·자재는 카탈로그가 없어 못 맞추는 것이라 사유를 갈라 적는다 — 「이름이 틀림」과 다르다.
return "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)"
@@ -78,15 +78,26 @@ def load_material_catalog_entries(
def load_combined_catalog() -> ResourceCatalog:
"""노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다."""
"""노임 + 기종 + **관급 자재** + **카탈로그 밖 자원(`AR-`)** 을 한 벌로.
사급 자재 원천(물가지) 아직 없다 `AR-` 목록은 **이름·규격·단위만** 싣고 단가는 없다
(2026-09-13 C 1). 범위 별칭도 여기서 함께 싣는다.
"""
from B09_Estimation.B09_Estimation_ResourceAxis_Join import (
load_ext_entries,
load_scoped_aliases,
)
labor = load_labor_catalog()
return ResourceCatalog(
entries=[
*labor.entries,
*load_machine_catalog_entries(),
*load_material_catalog_entries(),
*load_ext_entries(),
],
aliases=labor.aliases,
scoped_aliases=load_scoped_aliases(),
)
@@ -742,6 +742,22 @@ def build_unit_prices(
for row in rows
]
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
# ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히
# 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(2026-09-13 축 C).
# 자재는 종전처럼 드러내기만 한다(자재 단가 층은 7장 수동입력이 선 뒤).
attached_ids = {id(row) for row, _ in attachable}
for row in rows:
if id(row) in attached_ids:
continue
label = f"{row.resource_name} {row.resource_spec}".strip()
labels = build.unattached.setdefault(work_item_code, [])
if label not in labels:
labels.append(label)
if row.resource_kind == "machine":
build.component_gaps.setdefault(
work_item_code, f"{label} 줄이 맞춰졌으나 기계 단가 층이 없습니다"
)
build.partial_ratio.setdefault(work_item_code, _ZERO)
if (
not attachable
and not capacity_rows.get(work_item_code)
@@ -0,0 +1,15 @@
{
"schema_version": "1.0",
"dataset_id": "aliases",
"note": "별칭 한 벌(명세 5장). 한 줄 = {axis, from, to, scope, pum_edition, basis}. scope 없는 줄은 오류 · 같은 이름이 겹친 범위에서 두 코드로 가면 오류. 대체(대응이 아예 없어 갈음)는 여기 넣지 않음. scope 는 품셈 판에 묶임 — 판이 다르면 적용 안 함(명세 17장).",
"aliases": [
{
"axis": "resource",
"from": "화약공",
"to": "1016",
"scope": "FP-09-05",
"pum_edition": "2026-01-01",
"basis": "노임 표준 직종명은 「화약취급공」(노임 카탈로그 1016) — 품셈 9-5 표가 줄여 적음. 2026-09-13 사용자 위임 판정(브레인)"
}
]
}
@@ -0,0 +1,264 @@
{
"schema_version": "1.0",
"dataset_id": "resource_catalog_ext",
"effective_date": "2026-01-01",
"note": "기존 카탈로그(노임·기계·관급 자재)에 없는 자원의 이름·규격·단위. 단가 칸 없음(명세 §2③·7장 보류). 규격은 조인 키 — 품셈 칸이 규격을 안 주면 매칭 성공이 아님(「규격 미정 — 후보 N」). STmate 코드는 교차 확인용 별칭일 뿐 우리 코드가 아님(명세 §11).",
"policy": {
"code_shape": "AR-<M|L|X>-<8자리 소문자 16진>",
"code_compare": "equality_only",
"spec_is_join_key": true,
"auto_adopt_single_candidate": false,
"no_price": true
},
"entries": [
{
"code": "AR-M-b0853497",
"kind": "material",
"name": "비료",
"spec": "복합비료",
"unit": "kg",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0135",
"F0136"
]
}
},
{
"code": "AR-M-61df616d",
"kind": "material",
"name": "피복제",
"spec": "화이버",
"unit": "kg",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0135",
"F0136"
]
}
},
{
"code": "AR-M-86ddf05f",
"kind": "material",
"name": "침식안정제",
"spec": "합성접착제",
"unit": "kg",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0135",
"F0136"
]
}
},
{
"code": "AR-M-ae538556",
"kind": "material",
"name": "색소",
"spec": "색소",
"unit": "kg",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0135",
"F0136"
]
}
},
{
"code": "AR-M-dce99e46",
"kind": "material",
"name": "말뚝",
"spec": "직경46㎝, 길이120㎝ 기준",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0120"
]
}
},
{
"code": "AR-M-3f3d8612",
"kind": "material",
"name": "브레이드",
"spec": "320400mm",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0256"
]
}
},
{
"code": "AR-M-369b67f2",
"kind": "material",
"name": "유공관",
"spec": "∅200mm",
"unit": "m",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0345"
]
}
},
{
"code": "AR-M-d2c51025",
"kind": "material",
"name": "접합몰탈",
"spec": "1:2",
"unit": "㎥",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0347"
]
}
},
{
"code": "AR-M-f89c57d4",
"kind": "material",
"name": "종자",
"spec": "생태복원형,초본류",
"unit": "kg",
"source": {
"stmate_codes": [
"M00086",
"M00661"
]
}
},
{
"code": "AR-M-d52b8fc1",
"kind": "material",
"name": "종자",
"spec": "생태복원형,목본류",
"unit": "kg",
"source": {
"stmate_codes": [
"M00087",
"M00671"
]
}
},
{
"code": "AR-M-8f852514",
"kind": "material",
"name": "종자",
"spec": "초본류",
"unit": "kg",
"source": {
"stmate_codes": [
"M01225"
]
}
},
{
"code": "AR-M-e39ee36e",
"kind": "material",
"name": "종자",
"spec": "목본류",
"unit": "kg",
"source": {
"stmate_codes": [
"M01226"
]
}
},
{
"code": "AR-M-6fc2930f",
"kind": "material",
"name": "폭약",
"spec": "28㎜(메가마이트Ⅰ)",
"unit": "kg",
"source": {
"stmate_codes": [
"M00815"
]
}
},
{
"code": "AR-M-0965627d",
"kind": "material",
"name": "뇌관",
"spec": "",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0243"
]
}
},
{
"code": "AR-M-a1a18ec4",
"kind": "material",
"name": "비트",
"spec": "",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0243"
]
}
},
{
"code": "AR-M-5257ad1e",
"kind": "material",
"name": "철망태",
"spec": "",
"unit": "㎥",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0436"
]
}
},
{
"code": "AR-M-7d8a0fbc",
"kind": "material",
"name": "채움재",
"spec": "",
"unit": "㎥",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0436"
]
}
},
{
"code": "AR-X-79f64ab7",
"kind": "machine",
"name": "종자살포기",
"spec": "2,500-3,000",
"unit": "시간",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0135",
"F0136"
]
}
},
{
"code": "AR-X-95cb3904",
"kind": "machine",
"name": "착암기",
"spec": "2.7㎥/min",
"unit": "hr",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0243"
]
}
}
]
}
@@ -0,0 +1,188 @@
"""자원 축 조인 키 규칙 (2026-09-13 축 C 1장 · 명세 §2③·§5·§11·§17).
지키는
`AR-` 자원 코드 모양 · 종류 글자 · 겹침 읽을 막는다
**규격이 조인 ** 후보가 하나여도 규격이 맞으면 고르지 않는다(판정 )
**형식이 하나뿐인 계열** 규격으로 고른다 형식이 둘이면 규격 미정(판정 )
**범위 별칭** 범위 · 다름이면 쓴다 · scope 없는 줄과 겹친 코드는 오류
맞췄으나 단가 층이 없는 기계 줄은 일위대가에서 **조용히 빠지고 막힌다**
"""
from __future__ import annotations
import re
import pytest
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
CatalogEntry,
ResourceAxisError,
ResourceCatalog,
build_resource_axis,
load_combined_catalog,
load_work_item_master,
match_table,
)
from B09_Estimation.B09_Estimation_ResourceAxis_Join import (
_read_optional,
parse_ext_entries,
parse_scoped_aliases,
resolve_family,
unmatched_reason,
)
ALIAS = {
"axis": "resource",
"from": "화약공",
"to": "1016",
"scope": "FP-09-05",
"pum_edition": "2026-01-01",
}
def _table(*rows: list[str]) -> dict:
return {
"pum_table_id": "T-시험",
"pum_form": "requirement",
"basis_quantity": None,
"basis_unit": "",
"raw_row": [list(row) for row in rows],
}
def test_자원_목록_코드_모양과_겹침():
entries = parse_ext_entries(_read_optional("resource_catalog_ext_2026-01-01.json")["entries"])
assert entries, "자원 목록이 비었음"
for entry in entries:
assert re.fullmatch(r"AR-[MLX]-[0-9a-f]{8}", entry.code)
assert entry.strict_spec
assert len({e.code for e in entries}) == len(entries)
with pytest.raises(ResourceAxisError):
parse_ext_entries(
[{"code": "AR-X-0000000a", "kind": "material", "name": "비료", "unit": "kg"}]
)
with pytest.raises(ResourceAxisError):
parse_ext_entries([{"code": "M00042", "kind": "material", "name": "비료", "unit": "kg"}])
def test_규격이_조인_키_후보_하나여도_자동_채택_안_함():
catalog = ResourceCatalog(
entries=[CatalogEntry("AR-M-0000000a", "비료", "material", "복합비료", strict_spec=True)]
)
assert catalog.resolve("비료", "") is None
assert catalog.resolve("비료", "요소") is None
assert catalog.resolve("비료", "복합 비료").code == "AR-M-0000000a"
def test_옆_칸_글자_규격으로_자재가_선다_규격_없으면_규격_미정():
catalog = ResourceCatalog(
entries=[
CatalogEntry("AR-M-0000000a", "비료", "material", "복합비료", strict_spec=True),
CatalogEntry("AR-M-0000000b", "종자", "material", "초본류", strict_spec=True),
]
)
result = AxisResult()
node = {"work_item_code": "FP-05-24-01"}
match_table(
node,
_table(["자재", "종 자", "", "kg", "0.025"], ["비 료", "복합비료", "kg", "0.1"]),
catalog,
result,
)
assert [(r.resource_code, str(r.amount)) for r in result.rows] == [("AR-M-0000000a", "0.1")]
assert len(result.unmatched) == 1
assert result.unmatched[0].reason.startswith("규격 미정 — 후보 1건")
def test_형식이_하나뿐인_계열만_규격으로_고른다():
catalog = ResourceCatalog(
entries=[
CatalogEntry("5205-0035", "공기압축기(이동식)", "machine", "3.5"),
CatalogEntry("5205-0103", "공기압축기(이동식)", "machine", "10.3"),
CatalogEntry("5205-5500", "물탱크(살수차)", "machine", "5,500"),
CatalogEntry("5210-0010", "소형브레이커(공압식)", "machine", "1.0㎥/min"),
CatalogEntry("5220-0015", "소형브레이커(전기식)", "machine", "1.0㎥/min"),
]
)
assert resolve_family(catalog, "공기압축기(3.5㎥/min)", ["x"]).code == "5205-0035"
assert (
resolve_family(catalog, "공기압축기", ["공기압축기", "10.3㎥/min", "hr", "0.074"]).code
== "5205-0103"
)
assert (
resolve_family(catalog, "물탱크", ["물탱크", "5,500", "시간", "0.0036"]).code
== "5205-5500"
)
assert resolve_family(catalog, "공기압축기(99㎥/min)", ["x"]) is None
# 형식이 둘 — 공압식·전기식은 다른 장비라 규격이 같아도 고르지 않는다
assert (
resolve_family(catalog, "소형브레이커", ["소형브레이커", "1.0㎥/min", "hr", "0.2"]) is None
)
assert unmatched_reason(catalog, "소형브레이커").startswith("규격 미정 — 후보 2건")
def test_범위_별칭은_범위_안에서만_판이_다르면_안_씀():
catalog = ResourceCatalog(
entries=[CatalogEntry("1016", "화약취급공", "labor")],
scoped_aliases=parse_scoped_aliases([ALIAS]),
)
row = ["인력", "화 약 공", "", "", "0.041"]
inside = AxisResult()
match_table({"work_item_code": "FP-09-05-01"}, _table(row), catalog, inside)
assert [r.resource_code for r in inside.rows] == ["1016"]
outside = AxisResult()
match_table({"work_item_code": "FP-12-10"}, _table(row), catalog, outside)
assert not outside.rows and outside.unmatched
node = {"work_item_code": "FP-09-05-01", "tables": [_table(row)]}
other_edition = build_resource_axis(
{"effective_date": "2027-01-01", "work_items": [node]}, catalog
)
assert not other_edition.rows, "판이 다른 별칭 범위를 썼음(명세 17장)"
same_edition = build_resource_axis(
{"effective_date": "2026-01-01", "work_items": [node]}, catalog
)
assert [r.resource_code for r in same_edition.rows] == ["1016"]
def test_별칭_scope_없음과_겹친_두_코드는_오류():
with pytest.raises(ResourceAxisError):
parse_scoped_aliases([{**ALIAS, "scope": ""}])
with pytest.raises(ResourceAxisError):
parse_scoped_aliases([ALIAS, {**ALIAS, "to": "1002", "scope": "FP-09-05-01"}])
# 범위가 안 겹치면 같은 이름이 다른 코드로 가도 된다
assert len(parse_scoped_aliases([ALIAS, {**ALIAS, "to": "1002", "scope": "FP-12-10"}])) == 2
@pytest.fixture(scope="module")
def real_axis():
return build_resource_axis(load_work_item_master(), load_combined_catalog())
def test_실데이터_자재가_서고_규격_없는_종자는_규격_미정(real_axis):
materials = {
(r.work_item_code, r.resource_name) for r in real_axis.rows if r.resource_kind == "material"
}
assert ("FP-05-24-01", "비료") in materials
assert ("FP-12-10", "유공관") in materials
seed = [u for u in real_axis.unmatched if u.work_item_code == "FP-05-24-01" and "" in u.cell]
assert seed and seed[0].reason.startswith("규격 미정")
blasting = {
(r.resource_code, r.resource_kind)
for r in real_axis.rows
if r.work_item_code == "FP-09-05-01"
}
assert ("1016", "labor") in blasting
def test_단가_층_없는_기계_줄은_조용히_안_빠지고_막힌다():
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices
build = build_unit_prices()
# 착암기는 카탈로그 밖(AR-X) — 맞췄으나 기계 층이 없어 단가를 막아야 한다
assert "착암기" in (build.component_gaps.get("FP-09-05-01") or "")
assert "FP-09-05-01" in build.partial_ratio
assert any("유공관" in label for label in build.unattached.get("FP-12-10", []))