"""이중계상 경계를 축으로 (명세 6장 · 2026-09-13). 지키는 것 — 갈 곳(`destination`) 칸으로 판정하고 B09 내역서가 멈춘다 ① 구조물 터파기·되메우기는 토공집계로만 — 다른 공종 줄이 또 집거나, earthwork 밖으로 가면 오류 ② 배합 성분(시멘트·모래·자갈)이 자재총괄에 뜨면 오류 ③ 할증은 자재총괄 한 번 — 원단위표가 붙였거나 · 자재 합계가 순수량 × (1+율) 과 다르거나 · 할증 포함 재료량을 준 공종에 자재가 일위대가 재료비로 붙으면 오류 """ from __future__ import annotations from decimal import Decimal from types import SimpleNamespace import pytest from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import verify_double_count_boundaries from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table from B09_Estimation.B09_Estimation_BillOfQuantities import HandoffMaterial, build_bill from B09_Estimation.B09_Estimation_Guards import ( DoubleCountError, check_material_surcharge_once, check_materials_before_surcharge, ) from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle def _structure(type_id: str, *components: tuple[str, str, float]) -> dict: return { "type_id": type_id, "name": type_id, "components": [ {"name": name, "unit": "㎥", "amount": amount, "destination": destination} for name, destination, amount in components ], } def test_경계를_지킨_구조물은_어긴_자리가_없다() -> None: table = { "structures": [ _structure( "masonry_wet", ("돌쌓기", "unit_price", 26.1), ("터파기", "earthwork", 12.0), ("되메우기", "earthwork", 4.0), ("채집석", "haul_deduction", 3.0), ("입적", "reference", 9.0), ) ] } assert verify_double_count_boundaries(table, {"rows": []}, load_mapping()) == [] def test_토공으로_가는_성분을_공종_줄이_또_집으면_오류() -> None: mapping = load_mapping() # 옹벽 묶음 조각 「기초잡석」이 토공으로 가는 성분을 집음 — 같은 물량이 두 공종에 붙음 wall = _structure("retaining_wall", ("기초잡석", "earthwork", 0.3)) # 돌쌓기 줄이 세는 성분이 토공으로 감 masonry = _structure("masonry_wet", ("돌쌓기", "earthwork", 26.1)) # 터파기가 자재로 감 — 토공집계로만 가야 함 loose = _structure("masonry_dry", ("터파기", "material", 5.0)) found = verify_double_count_boundaries( {"structures": [wall, masonry, loose]}, {"rows": []}, mapping ) assert any("묶음 조각" in f and "기초잡석" in f for f in found) assert any("구조물 줄" in f and "돌쌓기" in f for f in found) assert any("「터파기」 갈 곳이 material" in f for f in found) def test_배합_성분이_자재총괄에_뜨거나_원단위표가_할증을_붙이면_오류() -> None: found = verify_double_count_boundaries( {"structures": [], "surcharge_applied": True}, {"rows": [{"name": "시멘트"}, {"name": "막자갈"}]}, load_mapping(), ) assert any(f.startswith("② ") and "시멘트" in f for f in found) assert not any("막자갈" in f for f in found) # 뒤채움 재료 — 정확히 같은 이름만 봄 assert any(f.startswith("③ ") for f in found) def test_내역서는_어긴_자리가_있으면_멈춘다() -> None: handoff = build_handoff( summary_table={"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 1.0}]} ) assert handoff["double_count_violations"] == [] handoff["double_count_violations"] = ["① 시험 — 같은 물량이 두 자리에 붙음"] with pytest.raises(DoubleCountError): build_bill(handoff) def _material(total: str, note: str = "", pct: str | None = "10") -> HandoffMaterial: return HandoffMaterial( material_name="고임돌", spec="", unit="㎥", net_amount=Decimal("100"), total_amount=Decimal(total), supply_type="contractor_supplied", surcharge_pct=None if pct is None else Decimal(pct), surcharge_note=note, ) def test_자재_합계는_할증_한_번() -> None: check_material_surcharge_once([_material("110"), _material("100", pct=None)]) check_material_surcharge_once([_material("100", note="품셈에 할증 포함 — 중복 적용 안 함")]) with pytest.raises(DoubleCountError): check_material_surcharge_once([_material("121")]) # 두 번 붙음 def test_할증_포함_재료량_공종에_자재가_재료비로_붙으면_오류() -> None: book = PriceBook() book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="패널", unit="매")) book.add_title(PriceTitle(code="B-FP-12-38-02", kind=PriceKind.UNIT_PRICE, name="사용수량")) book.add_detail(PriceDetail("B-FP-12-38-02", "M-시험", Decimal("0.089"))) # 금액을 세우지 않고도 걸림 — 줄이 붙는 순간이 어긴 자리 with pytest.raises(DoubleCountError): check_materials_before_surcharge(book) def test_양식_호표가_품은_기초잡석을_모음_줄이_또_세면_오류() -> None: """④ 검증 프로젝트 찰쌓기 실화 — 호표가 품은 기초잡석이 인계 줄로도 나가 8.3㎥(실제 3.5㎥).""" table = { "structures": [ {**_structure("masonry_wet", ("기초잡석", "unit_price", 4.8)), "structure_id": "s1"}, {**_structure("masonry_dry", ("기초잡석", "unit_price", 3.5)), "structure_id": "s2"}, ] } priced = [{"structure_ids": ["s1"], "covered": ["기초잡석", "돌쌓기", "모르터"]}] mapping = load_mapping() def rubble(quantity: float) -> list[dict]: return [{"name": "기초잡석", "quantity": quantity, "composite_parts": None}] ok = verify_double_count_boundaries(table, {"rows": []}, mapping, priced, rubble(3.5)) assert not [f for f in ok if f.startswith("④")] doubled = verify_double_count_boundaries(table, {"rows": []}, mapping, priced, rubble(8.3)) assert any(f.startswith("④ 「기초잡석」") and "4.8" in f for f in doubled) # 호표가 없으면 8.3 은 정상 — 판정은 물량 보존이라 빌더의 건너뛰기를 따라 짜지 않음 plain = verify_double_count_boundaries(table, {"rows": []}, mapping, [], rubble(8.3)) assert not [f for f in plain if f.startswith("④")] def test_구조물도_일위대가는_토공으로_가는_줄을_안_넣는다() -> None: template = {"code": "AX-ST-00000001", "unit_price": {"rows": [{"seq": 1, "from_row": 12}]}} sheet = {"rows": [{"no": 12, "unit": "㎥", "unit_amount": 1.2, "destination": "earthwork"}]} book = SimpleNamespace() table = unit_price_table(template, sheet, book, lambda code, value: None) assert table["rows"][0]["reason"].startswith("이중계상") and table["complete"] is False