Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-13 22:20:33 +09:00
8 changed files with 496 additions and 0 deletions
@@ -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
@@ -503,6 +505,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