feat(B09): 밑수 반영 재생성 + 밑수 미확보 줄 차단 + ㉡ 무대 가드 가동

B08 이 밑수(「10㎡당」 같은 기준 수량)를 본문에서 찾아 181표를 채움.
그 위에서 자원 축·일위대가를 다시 냄

- **떼채취 평떼 1,032,408 → 10,324원/㎡** — 100 배 부풀어 있던 것이 교정됨.
  「100매당일 것」이라던 의심이 실제로 `100㎡당`이었음
- 기준 미상 122 → 40, 100만원 초과 10 → 2 건. 중앙값 73,989 → 48,961
- 파생물 지문이 새 마스터(`fe454c56…`)로 따라 바뀜 — 낡음 감지 장치가
  처음으로 실제 갱신에서 동작 확인됨

밑수 미확보 표(`basis_missing` 144건 → 26 공종)
- 1 단위당으로 단정하면 곱셈이 10배·100배 틀리므로 **곱하지 않음**.
  내역서에 「밑수를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 4-4. 가지정리」로 뜸

㉡ 무대 가드 가동 (B08 이 운반을 실물로 내기 시작)
- 인계에서 `haul_distance_m`·`haul_equipment` 를 받아 조판에서 실제로 호출
- 무대(20 m 이내)는 줄로 서되 금액 없음. 운반토량 합이 총 절취량을 넘는지도 검사
- 짝 시험 — 무대에 단가가 붙으면 멈추고, **암 운반·덤프 줄은 안 걸림**

검증: pytest 170 통과(신규 3)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 01:11:32 +09:00
co-authored by Claude Opus 5
parent 52467b1c87
commit d78f59ba5c
3 changed files with 293 additions and 199 deletions
@@ -23,7 +23,11 @@ from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Guards import check_excluded_rows_not_priced
from B09_Estimation.B09_Estimation_Guards import (
check_excluded_rows_not_priced,
check_free_haul_not_priced,
check_haul_volume_within_cut,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
@@ -49,6 +53,9 @@ class HandoffWorkItem:
unit: str
quantity: Decimal
in_bill: bool
#: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다.
haul_distance_m: Decimal | None = None
haul_equipment: str | None = None
in_bill_reason: str = ""
origin: str = ""
ground_class: str = ""
@@ -181,6 +188,8 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
in_bill_reason=row.get("in_bill_reason") or "",
origin=row.get("origin") or "",
ground_class=row.get("ground_class") or "",
haul_distance_m=_decimal(row.get("haul_distance_m"), None),
haul_equipment=row.get("haul_equipment"),
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
)
@@ -327,6 +336,31 @@ def build_bill(
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
# ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡).
# 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다.
# 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다.
haul_rows = [
{
"equipment": item.haul_equipment,
"unit_price_krw": _haul_price_of(item, result),
}
for item in work_items
if item.haul_equipment
]
check_free_haul_not_priced(haul_rows=haul_rows)
# ㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가(같은 흙을 두 번 세지 않았는가).
cut_total = sum(
(item.quantity for item in work_items if "깎기" in item.name or "절취" in item.name),
_ZERO,
)
haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO)
if cut_total > 0 and haul_total > 0:
check_haul_volume_within_cut(
haul_volume_total_m3=haul_total,
total_cut_volume_m3=cut_total,
)
if any(m.surcharge_pct is None for m in materials):
result.notes.append(
"자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. "
@@ -335,6 +369,14 @@ def build_bill(
return result
def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal:
"""그 운반 줄에 실제로 붙은 단가. 안 붙었으면 0 — ㉡ 검사에 넘길 값이다."""
for row in result.rows:
if row.name == item.name and row.unit_price_krw is not None:
return row.unit_price_krw
return _ZERO
def _excluded_row(item: HandoffWorkItem) -> BillRow:
"""검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**"""
return BillRow(
@@ -413,6 +455,22 @@ def _leaf_row(
)
return row
missing_basis = unit_prices.basis_missing.get(node.code)
if missing_basis:
# ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배
# 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.**
row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}"
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": "밑수 미확보 — 곱하면 10배·100배 틀림",
}
)
return row
covered = unit_prices.partial_ratio.get(node.code)
if covered is not None:
# ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만
@@ -83,6 +83,9 @@ class UnitPriceBuild:
cycle_factors: dict[str, CycleFactors] = field(default_factory=dict)
#: 공종 하나가 낳은 규격 갈래들 — 「무근구조물」·「철근구조물」·「소형구조물」.
variants: dict[str, list[str]] = field(default_factory=dict)
#: 밑수(「10㎡당」)를 못 찾은 표를 쓰는 공종 — **곱하면 안 되는 줄**이다.
#: 1 단위당으로 단정하면 곱셈이 10배·100배 틀린다(B08 `basis_missing` 목록).
basis_missing: dict[str, str] = field(default_factory=dict)
#: 계수를 못 세운 표 — **무엇이 없는지**를 들고 있는다.
factor_gaps: dict[str, FactorGap] = field(default_factory=dict)
@@ -196,6 +199,31 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
return incomplete
@lru_cache(maxsize=1)
def load_basis_missing(
file_name: str = "basis_missing_2026-01-01.json",
) -> dict[str, str]:
"""B08 이 낸 **밑수 못 찾은 표** 목록 — `{표 번호: 절 이름}`.
「10㎡당」 같은 기준을 원문에서 못 찾은 표다. 1 단위당으로 단정하면 곱셈이
10배·100배 틀리므로(떼채취가 실제로 100배였다) 그 표를 쓰는 공종은
**금액을 안 만든다**.
"""
import json
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(root, "resources", "data_work_item_master", file_name)
if not os.path.exists(path):
return {}
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
return {
str(item.get("pum_table_id")): str(item.get("section", ""))
for item in payload.get("items", [])
}
def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -209,6 +237,7 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
build = UnitPriceBuild()
missing_basis = load_basis_missing()
wages = load_operator_wages()
_add_labor_titles(build.book, wages)
@@ -237,6 +266,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
build.skipped.append(work_item_code)
continue
# ⚠ 밑수를 못 찾은 표를 쓰면 **곱하면 안 되는 줄**로 표시한다.
for row in rows:
section = missing_basis.get(str(row.pum_table_id))
if section:
build.basis_missing[work_item_code] = section
break
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
base_name = names.get(work_item_code) or work_item_code
build.book.add_title(
File diff suppressed because it is too large Load Diff