Files
Aislo/resources/tester/test_double_count_boundaries.py
T
eomsangdonandClaude Opus 5 938eef94bd feat(axis): 이중계상 경계를 갈 곳 칸으로 판정 — 어기면 내역서가 멈춤
① 구조물 터파기·되메우기는 토공집계로만 — 토공·사토 공제·참고로 가는 성분을 구조물 줄·묶음 조각·타설·기초잡석이 또 집으면 오류.
② 시멘트·모래·자갈이 자재총괄에 뜨면 오류. ③ 원단위표가 할증을 붙였거나, 자재 합계가 순수량 × (1+율) 과 다르거나,
할증 포함 재료량을 준 공종에 자재가 일위대가 재료비로 붙으면 오류.
판정은 B08 인계(double_count_violations), 멈춤은 B09 build_bill 한 자리. 배정 프로젝트 실측 어긴 자리 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:18:00 +09:00

129 lines
5.8 KiB
Python

"""이중계상 경계를 축으로 (명세 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:
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