Files
Aislo/resources/tester/test_b09_resource_axis_join.py
T
eomsangdonandClaude Opus 5 c541a6bd2f 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
2026-09-13 17:05:36 +09:00

189 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""자원 축 조인 키 규칙 (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", []))