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:
@@ -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
Reference in New Issue
Block a user