"""공종 불변 열쇠 **길목** — PLAN_공종축 8-6 이행(2026-09-17 브레인 · 코덱스 조사). 잣대 = **금액 불변.** 열쇠를 더하는 일이지 값을 바꾸는 일이 아님 — 같은 인계로 세운 내역서가 열쇠가 있든(새 인계) 없든(옛 인계) **한 원도** 안 달라야 함. 길목은 경계 두 곳뿐(B08 인계 출구 · B09 인계 입구) · `work_item_code` 는 목차 번호로 그대로 남음. """ from __future__ import annotations import copy import json import re from functools import lru_cache from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill from B09_Estimation.B09_Estimation_BillOfQuantities_Input import parse_handoff from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices from common_util.common_util_work_item_key import MASTER_DIR, attach_keys, work_item_key MAPPING = MASTER_DIR.parent / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json" def _strip_keys(value): """옛 인계 — 길목이 더한 칸을 뗌.""" if isinstance(value, dict): return { k: _strip_keys(v) for k, v in value.items() if k not in ("work_item_key", "unkeyed_work_item_codes") } if isinstance(value, list): return [_strip_keys(v) for v in value] return value @lru_cache(maxsize=1) def _handoff() -> dict: """토공·운반·구조물·자재가 다 선 인계 — 금액이 실제로 붙는 줄이 여럿.""" unit = build_unit_table( [ { "structure_id": "s1", "type_id": "masonry_wet", "start_m": 35.0, "end_m": 45.0, "options": {"height_m": 1.5, "length_m": 10.0}, } ] ) rows = [ {"group": "흙깎기", "item": "토사", "spec": "", "unit": "㎥", "amount": 1234.5}, {"group": "측구터파기", "item": "토사", "spec": "", "unit": "㎥", "amount": 77.0}, {"group": "층따기", "item": "", "spec": "", "unit": "㎥", "amount": 12.0}, ] haul = { "rows": [ { "equipment": "dump_truck", "ground": "토사", "volume_m3": 500.0, "average_distance_m": 1200.0, "in_bill": True, } ] } return build_handoff( summary_table={"rows": rows}, haul_table=haul, unit_quantity_table=unit, material_table=build_material_table(unit), ) def test_목차_코드는_불변_열쇠로_난수_코드는_그대로() -> None: forest = json.loads( (MASTER_DIR / "work_item_master_2026-01-01.json").read_text(encoding="utf-8") ) for item in forest["work_items"]: assert work_item_key(item["work_item_code"], forest["pum_edition"]) == item["work_item_key"] assert work_item_key("FP-09-03-02") == work_item_key("FP-09-03-02", "2026-01-01") assert re.fullmatch(r"CW-\d{5}", work_item_key("CP-01-03-02-01") or "") assert work_item_key("AX-WK-c0842a0d") == "AX-WK-c0842a0d" # 난수 8자리 — 이미 불변 assert work_item_key("AX-ST-0123abcd") == "AX-ST-0123abcd" assert work_item_key("FP-09-03-02", "1999-01-01") is None # 판이 다르면 안 줌 assert work_item_key("FP-99-99") is None and work_item_key(None) is None def test_매핑의_코드가_다_열쇠를_받음() -> None: """B08 이 인계에 싣는 코드의 샘 — 하나라도 못 받으면 그 줄은 열쇠 없이 감.""" text = MAPPING.read_text(encoding="utf-8") codes = set(re.findall(r'"((?:FP|CP|AX-WK|AX-ST)-[0-9A-Za-z-]+)"', text)) assert len(codes) > 50 assert [c for c in sorted(codes) if work_item_key(c, "2026-01-01") is None] == [] def test_B08_인계_출구에서_열쇠가_붙고_목차_코드는_그대로() -> None: handoff = _handoff() coded = [row for row in handoff["work_items"] if row.get("work_item_code")] assert len(coded) >= 5 for row in coded: assert row["work_item_key"] == work_item_key(row["work_item_code"], row["pum_edition"]) assert row["work_item_code"].startswith(("FP-", "AX-")) # 사람이 보는 번호는 남음 assert handoff["unkeyed_work_item_codes"] == [] assert all("work_item_key" not in row for row in handoff["materials"]) # 자재는 공종 축 아님 def test_못_찾은_코드는_지어내지_않고_목록() -> None: rows = [ {"work_item_code": "FP-99-99"}, {"work_item_code": None}, {"work_item_code": "FP-09-18"}, ] assert attach_keys(rows) == ["FP-99-99"] assert rows[0]["work_item_key"] is None and rows[1]["work_item_key"] is None assert rows[2]["work_item_key"].startswith("FW-") def test_B09_입구는_옛_인계에도_같은_열쇠() -> None: new, _ = parse_handoff(_handoff()) old, _ = parse_handoff(_strip_keys(copy.deepcopy(_handoff()))) assert [w.work_item_key for w in old] == [w.work_item_key for w in new] assert any(w.work_item_key.startswith("FW-") for w in new) def test_금액_불변_열쇠가_있든_없든_내역서가_한_원도_안_다름() -> None: """⭐ 이번 일감의 잣대(브레인) — 새 인계(열쇠 있음)와 옛 인계(열쇠 없음)로 세운 내역서가 같음.""" build = build_unit_prices() new = build_bill(copy.deepcopy(_handoff()), build=build) old = build_bill(_strip_keys(copy.deepcopy(_handoff())), build=build) assert bill_summary(new) == bill_summary(old) assert [row.as_dict() for row in new.rows] == [row.as_dict() for row in old.rows] assert [row.as_dict() for row in new.material_rows] == [ row.as_dict() for row in old.material_rows ] priced = [row for row in new.rows if not row.is_group and row.amount_krw] assert len(priced) >= 3 and new.body_total_krw > 0 # 금액이 실제로 선 줄로 잼 #: ⭐ 이행 **전** 코드(4bfbf98f^ · 길목 없음)로 위 `_handoff()` 를 세워 뜬 금액 — 2026-09-17 랩탑 메인. #: 이행 뒤 코드도 내역서 줄 18·성분·원가계산서 지문까지 한 글자 같았음(옛 두 파일을 따로 얹어 맞댐). #: ⚠ 단가 자료(노임·기계·자재·요율)가 바뀌면 이 수도 바뀜 — 그땐 길목 탓이 아닌지 먼저 볼 것 #: (열쇠 있든 없든 같은지 = 위 시험) · 길목 탓이면 고칠 것은 코드, 아니면 다시 뜸. PRE_MIGRATION_KRW = { "material_cost": "1158206", "labor_cost": "4400597", "expense": "1991430", "direct_construction_cost": "5983724", "net_construction_cost": "7550233", "total_cost": "9203637", "grand_total": "10124000", } def test_금액_불변_이행_전_코드로_뜬_원가계산서와_한_원도_같음() -> None: from B09_Estimation.B09_Estimation_BillOfQuantities import cost_input_from_bill from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost bill = build_bill(copy.deepcopy(_handoff()), build=build_unit_prices()) totals = calculate_cost(cost_input_from_bill(bill)).totals assert {key: str(totals[key]) for key in PRE_MIGRATION_KRW} == PRE_MIGRATION_KRW