- 자재 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
189 lines
7.5 KiB
Python
189 lines
7.5 KiB
Python
"""자원 축 조인 키 규칙 (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", []))
|