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:
@@ -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(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보"
|
||||
|
||||
@@ -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": "직경4~6㎝, 길이120㎝ 기준",
|
||||
"unit": "개",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0120"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-M-3f3d8612",
|
||||
"kind": "material",
|
||||
"name": "브레이드",
|
||||
"spec": "320~400mm",
|
||||
"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", []))
|
||||
Reference in New Issue
Block a user