"""인계 계약 — **내보내는 키가 계약에 적힌 것과 같은지** (2026-09-07 ㉒ 앞). ⚠ 오늘 이 병이 계약 양쪽에서 하나씩 났다. · 우리 쪽 — 레지스트리 키(`back_len_cm`)와 엔진이 읽는 키가 달라 **저장값이 안 닿음**. · 받는 쪽 — 우리가 보낸 `composite_parts`·`quantity_gross` 등을 **안 읽고 버림**. **「보내는 쪽은 보냈는데 받는 쪽이 안 읽는」 자리는 양쪽 다 조용하다.** ⇒ 키를 늘릴 때 **계약에 안 적고 늘리면 받는 쪽이 영영 모른다.** 이 시험이 그것을 막는다. """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 #: **작업 공종 줄**이 내보내는 칸. 늘릴 때는 계약(8-2)에도 적고 받는 쪽에 알릴 것. WORK_ITEM_KEYS = { "work_item_code", "name", "spec", "unit", "quantity", "quantity_gross", "application_ratio_pct", "application_ratio_breakdown", "quantity_breakdown", "ground_class", "excavation_method", "haul_distance_m", "haul_equipment", "station_from", "station_to", "spec_detail", "composite_parts", "structure_kind", "variant_axis", "variant_value", # ⚠ 갈래 축이 둘 이상인 자리(돌쌓기 = 뒷길이 × 돌 종류) — 2026-09-08 추가. "secondary_axes", "spec_class", "spec_class_basis", "blocked_kind", "blocked_reason", "composite_not_ready", "in_bill", "in_bill_reason", "origin", # 공종 코드가 본 품셈 판 — B09 가 마스터 판과 다르면 값을 안 씀(명세 17장 · 2026-09-13 추가). "pum_edition", # 불변 열쇠(FW-·AX-) — 목차 번호가 개정으로 밀려도 안 바뀜(2026-09-17 8-6 이행 길목 · # 받는 쪽 B09 `parse_handoff` 가 읽음). 코드 없는 줄은 None. "work_item_key", } #: **자재 줄**이 내보내는 칸. ⚠ 여기에 `work_item_code` 가 들어가면 안 된다(축이 다르다). MATERIAL_KEYS = { "material_name", "spec", "unit", "net_amount", "total_amount", "surcharge_pct", "surcharge_note", "supply_type", "install_by", "source_structure", } def _rows() -> dict: unit = build_unit( [ { "structure_id": "w1", "type_id": "retaining_wall", "start_m": 100.0, "end_m": 110.0, "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, } ], {"retaining_wall": "옹벽"}, ) from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material haul = { "rows": [ { "equipment": "dozer", "ground": "토사", "volume_m3": 10.0, "average_distance_m": 40.0, "in_bill": True, } ] } summary = {"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 5.0}]} return build_handoff( summary_table=summary, haul_table=haul, unit_quantity_table=unit, material_table=build_material(unit), ) def test_작업_공종_줄의_칸이_계약과_같을것() -> None: """⚠ 계약에 없는 칸을 늘리면 받는 쪽이 영영 모른다.""" for row in _rows()["work_items"]: extra = set(row) - WORK_ITEM_KEYS assert not extra, f"계약에 없는 칸: {extra}" missing = WORK_ITEM_KEYS - set(row) assert not missing, f"계약에 있는데 안 보내는 칸: {missing}" def test_자재_줄의_칸이_계약과_같을것() -> None: for row in _rows()["materials"]: assert set(row) == MATERIAL_KEYS, set(row) ^ MATERIAL_KEYS def test_자재_줄에_공종코드가_없을것() -> None: """축이 둘이라는 것은 주석이 아니라 계약으로 지킨다.""" assert "work_item_code" not in MATERIAL_KEYS for row in _rows()["materials"]: assert "work_item_code" not in row def test_막힌_까닭이_세_갈래_중_하나일것() -> None: """받는 쪽이 「입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 갈라야 한다.""" from B08_Quantity.B08_Quantity_Engine_Handoff import ( BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, BLOCKED_UNCONFIRMED, BLOCKED_UNIT_DATA_MISSING, ) # ⭐ 넷째 갈래(2026-09-14 브레인 판정) — 치수 없이 기본값으로 선 줄: 수량은 보이되 금액 밖. allowed = { None, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, BLOCKED_FORMULA_MISSING, BLOCKED_UNCONFIRMED, } for row in _rows()["work_items"]: assert row["blocked_kind"] in allowed def test_막힌_줄에는_사유가_반드시_있을것() -> None: """갈래만 있고 사유가 없으면 받는 쪽 화면이 빈다.""" for row in _rows()["work_items"]: if row["blocked_kind"]: assert row["blocked_reason"], row["name"] # ── 갈래 키를 우리가 조립하지 않는다 (2026-09-07 계약 변경) ───────── # # ⚠ 품셈 원문이 물결표를 섞어 쓴다 — 13-06-01·02 는 `∼`(U+223C), 13-06-03 은 `~`(U+FF5E). # 두 창이 각자 키 문자열을 만들면 **글자 하나로 영영 안 맞는다.** # ⇒ 우리는 **축과 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. def _structure_rows() -> list[dict]: out = [] for type_id, options in ( ("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"}), ( "boulder_masonry", {"height_m": 2.0, "length_m": 10.0, "stone_cm": "60~80", "bond": "찰쌓기"}, ), ): unit = build_unit( [ { "structure_id": "x", "type_id": type_id, "start_m": 0.0, "end_m": 10.0, "options": options, } ], {type_id: type_id}, ) out.extend(build_handoff(unit_quantity_table=unit)["work_items"]) return out def test_축과_원본값을_그대로_보낼것() -> None: rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} assert rows["back_len_cm"]["variant_value"] == "45" assert rows["stone_cm"]["variant_value"] == "60~80" def test_갈래_키_문자열을_코드에_붙이지_말것() -> None: """⚠ 이 시험이 계약 변경을 지킨다 — `#55cm이하`·`#직경60㎝이상∼80㎝미만` 을 만들면 깨진다. ⚠ **메/찰은 예외다** — 그것은 키 표기가 아니라 **공종 자체가 갈리는 의미 판정**이라 코드가 `FP-13-06-01`/`-02` 로 통째로 달라진다. """ for row in _structure_rows(): code = str(row["work_item_code"] or "") assert "#" not in code, f"갈래 키를 코드에 붙였다: {code}" assert "㎝" not in code and "cm" not in code def test_원본값을_가공하지_말것() -> None: """물결표·공백을 우리가 손대면 받는 쪽이 원문과 대조를 못 한다.""" rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} raw = rows["stone_cm"]["variant_value"] assert raw == "60~80" # 물결표 종류를 바꾸거나 「㎝」를 붙이지 않는다 def test_돌_종류를_둘째_축으로_보낼것() -> None: """⚠ 품셈 13-4 품은 [주]② 로 **깬돌·깬잡석 전용**이고 돌 종류로 갈리는 표는 13-5 다. 어느 공종으로 볼지는 사용자 확정 대기라 **공종을 바꾸지 않고 원본값만** 실어 보낸다. """ rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} axes = rows["back_len_cm"]["secondary_axes"] or [] assert [item["axis"] for item in axes] == ["stone_kind"]