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
This commit is contained in:
2026-09-13 22:18:00 +09:00
co-authored by Claude Opus 5
parent fd4f00ab7e
commit 938eef94bd
7 changed files with 311 additions and 0 deletions
@@ -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()
@@ -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
@@ -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 "")
@@ -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],
+62
View File
@@ -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 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를
#: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**.
@@ -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
@@ -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