diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 090e4b1a..1f642343 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -208,6 +208,14 @@ def build_handoff( result["ratio_math_warnings"] = verify_ratio_math(result) result["material_code_warnings"] = verify_no_code_on_materials(result) result["bill_flag_warnings"] = verify_bill_flags(result) + # ⚠ 이중계상 경계(명세 6장) — 갈 곳 칸으로 판정한 어긴 자리. B09 내역서가 여기서 멈춘다. + from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( + verify_double_count_boundaries, + ) + + result["double_count_violations"] = verify_double_count_boundaries( + unit_quantity_table, material_table, table + ) # ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08). result["basis_unit_warnings"] = verify_unit_matches_basis( work_items, extra=table.declared_units() diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py new file mode 100644 index 00000000..8a287933 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py @@ -0,0 +1,94 @@ +"""B08 → B09 인계 — **이중계상 경계를 갈 곳(`destination`) 칸으로 판정** (명세 6장 · 2026-09-13). + +주석 경고뿐이던 규칙 셋을 수로 걸리는 검사로 세운다. 원문은 옛 계획서 8-7절(㉠·㉢)과 명세 6장. + + ① 구조물 터파기·되메우기는 **토공집계로만** — 갈 곳이 `earthwork` 인 성분을 다른 공종 줄이 + 또 집으면 같은 물량이 두 공종 코드에 붙음. 터파기·되메우기가 `earthwork` 밖으로 가도 오류 + ② 배합 성분(시멘트·모래·자갈)은 **B09 일위대가만 쪼갬** — 자재총괄에 뜨면 오류(㉢) + ③ 자재 할증은 **자재총괄 한 곳** — 원단위표가 이미 할증을 붙였으면 오류(㉠). + 자재총괄 합계의 할증 한 번·일위대가 재료비의 할증 전은 B09 가드가 금액 자리에서 봄 + +⚠ 새 얼개를 만들지 않는다 — 성분마다 붙은 `destination` 과, 인계 줄이 성분을 + **이름으로 집는 자리**(구조물 줄 `billing_component` · 묶음 조각 `from_components` · + 콘크리트 타설 · 기초잡석)만 대조한다. +⚠ 여기는 목록만 낸다. 멈추는 것은 B09 `build_bill`(`DoubleCountError`) — 가드가 모인 한 자리. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping +from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import PLACING_TARGET_NAMES +from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import RUBBLE_GROUP +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import verify_single_surcharge +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS + +#: 토공집계로만 가야 하는 성분 — 전개·양식·관측 원단위가 같은 이름을 쓴다. +EARTHWORK_ONLY = ("터파기", "되메우기") +#: 공종 줄이 값으로 매기면 안 되는 갈 곳 — 이미 다른 자리가 세거나(토공·사토 공제) 참고값이다. +NOT_BILLED_BY_WORK_ITEM = { + "earthwork": "토공집계", + "haul_deduction": "유토곡선 사토 공제", + "reference": "참고 줄", +} + + +def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[str, set[str]]]: + """이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다.""" + type_id = str(structure.get("type_id") or "") + entry = mapping.for_structure(type_id) or {} + composite = mapping.composite_for(type_id) + found: list[tuple[str, set[str]]] = [] + if entry.get("billing_component"): + found.append(("구조물 줄", {str(entry["billing_component"])})) + if composite and not entry.get("work_item_code"): + for part in composite.get("parts") or []: + if isinstance(part, dict): + label = f"묶음 조각 「{part.get('name') or part.get('code')}」" + found.append((label, {str(name) for name in part.get("from_components") or []})) + if not composite: + # 묶음 구조물은 타설·기초잡석 줄이 건너뛴다(조각이 이미 셈) — 빌더와 같은 조건. + found.append(("콘크리트 타설 줄", set(PLACING_TARGET_NAMES))) + found.append(("기초잡석 줄", {RUBBLE_GROUP})) + return found + + +def verify_double_count_boundaries( + unit_quantity_table: dict[str, Any] | None, + material_table: dict[str, Any] | None, + mapping: WorkItemMapping, +) -> list[str]: + """어긴 자리를 사람 말로 — 비면 경계가 지켜진 것.""" + found: list[str] = [] + for structure in (unit_quantity_table or {}).get("structures") or []: + label = str(structure.get("name") or structure.get("type_id") or "구조물") + destinations: dict[str, set[str]] = {} + for component in structure.get("components") or []: + if float(component.get("amount") or 0.0) <= 0: + continue + name = str(component.get("name") or "").strip() + destination = str(component.get("destination") or "") + destinations.setdefault(name, set()).add(destination) + if name in EARTHWORK_ONLY and destination != "earthwork": + found.append( + f"① {label} 「{name}」 갈 곳이 {destination or '(없음)'} — " + "구조물 터파기·되메우기는 토공집계(earthwork)로만 감" + ) + for where, names in _pickers(structure, mapping): + for name in sorted(names): + for destination in sorted( + destinations.get(name, set()) & set(NOT_BILLED_BY_WORK_ITEM) + ): + found.append( + f"① {label} 「{name}」은 {NOT_BILLED_BY_WORK_ITEM[destination]}" + f"({destination})로 가는데 {where}이 또 셈 — 같은 물량이 두 자리에 붙음" + ) + for row in (material_table or {}).get("rows") or []: + name = str(row.get("name") or "").strip() + if name in MIX_COMPONENTS: + found.append( + f"② 자재총괄에 배합 성분 「{name}」 — 시멘트·모래·자갈은 B09 일위대가만 쪼갬(㉢)" + ) + found.extend(f"③ {warning} (㉠)" for warning in verify_single_surcharge(unit_quantity_table)) + return found diff --git a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py index 40d383ab..bf0cf43b 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py @@ -181,6 +181,15 @@ def _assemble( "reason": "", } source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None + # 이중계상 경계 ①(명세 6장) — 토공집계·사토 공제·참고로 가는 줄을 일위대가에 또 넣지 않음. + from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import NOT_BILLED_BY_WORK_ITEM + + owner = NOT_BILLED_BY_WORK_ITEM.get(str((source or {}).get("destination") or "")) + if owner: + out["reason"] = f"이중계상 — 원단위 줄 {row['from_row']} 은 {owner}로 가는 줄" + blocked += 1 + rows.append(out) + continue if source is not None and (source.get("skipped") or source.get("error")): # 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등). out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "") diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 0c9e6578..e28ed4a4 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -29,7 +29,9 @@ from B09_Estimation.B09_Estimation_Guards import ( check_drain_pipe_not_double_counted, check_included_materials_not_listed, check_free_haul_not_priced, + check_handoff_boundaries, check_haul_volume_within_cut, + check_material_surcharge_once, ) from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity @@ -472,6 +474,10 @@ def build_bill( # ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ────────────────────── check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded]) + # 이중계상 경계 ①②③(명세 6장) — B08 이 갈 곳 칸으로 판정한 어긴 자리 · 자재 할증 한 번. + check_handoff_boundaries(payload.get("double_count_violations")) + check_material_surcharge_once(materials) + # ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가. check_included_materials_not_listed( work_item_codes=[row.code or "" for row in result.rows], diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py index 997a726c..14f30c2a 100644 --- a/B09_Estimation/B09_Estimation_Guards.py +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -20,6 +20,7 @@ from __future__ import annotations from decimal import Decimal +from typing import Any _TOLERANCE = Decimal("0.5") @@ -219,6 +220,67 @@ def check_excluded_rows_not_priced( ) +def check_handoff_boundaries(violations: list[str] | None) -> None: + """이중계상 경계 ①②③(명세 6장) — B08 인계가 갈 곳 칸으로 판정해 보낸 어긴 자리. + + 판정은 성분 칸을 가진 B08 한 곳에서 한다(`B08_Quantity_Engine_Handoff_Boundaries`). + 여기서는 **멈추기만** 한다 — 목록이 차 있는데 내역서를 세우면 같은 물량이 두 번 금액이 된다. + """ + if violations: + raise DoubleCountError("이중계상 경계를 어겼습니다 — " + " / ".join(violations)) + + +#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면 +#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 넷, 코드는 마스터가 붙인 자리. +SURCHARGE_INCLUDED_ITEMS: dict[str, str] = { + "FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)", + "FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」", + "FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」", + "AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」", +} + + +def check_materials_before_surcharge(book: Any) -> None: + """③ 일위대가 재료비는 할증 전 — 할증 포함 재료량을 준 공종에 자재 줄이 붙으면 멈춘다. + + ⚠ 지금은 그 재료들이 자재 단가 층이 없어 **안 붙은 줄**로만 보인다 — 층이 서는 날 여기서 걸린다. + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceKind + + for title_code, details in book.details.items(): + code = title_code[2:].split("#", 1)[0] if title_code.startswith("B-") else "" + if code not in SURCHARGE_INCLUDED_ITEMS: + continue + for detail in details: + ref = book.titles.get(detail.ref_code) + if ref is not None and ref.kind is PriceKind.MATERIAL: + raise DoubleCountError( + f"{title_code}: 자재 「{ref.name}」이 일위대가 재료비로 붙었습니다 — " + f"{SURCHARGE_INCLUDED_ITEMS[code]}. 일위대가 재료비는 할증 전 값이고 " + "할증은 자재총괄 한 곳뿐입니다 (PLAN 8-7 ㉠)." + ) + + +def check_material_surcharge_once(materials: list[Any]) -> None: + """③ 자재 줄 합계 = 순수량 × (1 + 할증률) **한 번** — 율이 없거나 품셈 포함이면 순수량 그대로. + + 내역서 자재대는 `total_amount` 를 수량으로 쓴다. 인계 도중 어디서든 할증이 한 번 더 붙으면 + 그 값이 그대로 금액이 되므로, **금액을 만드는 자리**에서 순수량과 율로 되짚는다. + """ + for material in materials: + net = Decimal(str(material.net_amount)) + included = "할증 포함" in str(material.surcharge_note or "") + rate = None if included else material.surcharge_pct + expected = net if rate is None else net * (Decimal(1) + Decimal(str(rate)) / Decimal(100)) + total = Decimal(str(material.total_amount)) + if abs(total - expected) > max(Decimal("1e-6"), abs(expected) * Decimal("1e-9")): + raise DoubleCountError( + f"자재 「{material.material_name}」: 합계 {total} 가 순수량 {net} × " + f"(1+{rate or 0}%) = {expected} 와 다릅니다 — 할증이 두 번 붙었거나 빠졌습니다 " + "(PLAN 8-7 ㉠ 할증은 자재총괄 한 번)." + ) + + #: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다. #: 품셈 13-6-2 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를 #: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**. diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 24029293..5832ff7e 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -950,6 +950,10 @@ def build_unit_prices( build.book, [code for code in build.book.titles if code.startswith("B-")] ) build.labor_reliability = _labor_reliability_in_use(build.book) + # ③ 할증 포함 재료량을 준 공종에 자재가 재료비로 붙지 않았는가(명세 6장 · ㉠). + from B09_Estimation.B09_Estimation_Guards import check_materials_before_surcharge + + check_materials_before_surcharge(build.book) return build diff --git a/resources/tester/test_double_count_boundaries.py b/resources/tester/test_double_count_boundaries.py new file mode 100644 index 00000000..d66da883 --- /dev/null +++ b/resources/tester/test_double_count_boundaries.py @@ -0,0 +1,128 @@ +"""이중계상 경계를 축으로 (명세 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