"""자원 축 조인 키 규칙 (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", []))