Files
Aislo/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py
T
eomsangdonandClaude Opus 5 607e7c0cd8 fix(b09): 묶음 줄 금액이 줄 수량만큼 부풀던 것 — 조각 수량은 이미 합계
까닭 — B08 인계의 조각 수량은 「합계」인데(그쪽 시험이 지킴: BOX 동바리 3.92×10 ·
포장 97.66㎥) B09 가 Σ(조각 단가 × 조각 수량) 을 1단위 단가로 보고 줄 수량을 또 곱했음.
평떼 718.14㎡ 가 720배(42억)로 섰음(2026-09-15 랩탑 서브 실측).

고침 — 계약은 그대로 두고 읽는 쪽을 고침. 합계를 그대로 금액으로 쓰고 단가는 나눠서 보임.
재·노·경 성분 셋도 같이. 계약을 코드 가까이 못박음 — 두 자리(composite_parts 칸 · _composite_row).

 시험도 고침 — 종전이 줄 수량 1 로만 재서 못 잡았음. _line(parts, quantity) 로 열고
수량 2·10·718.14 로 잼 · 성분 셋도 따로. 빨강 4 확인한 뒤 고쳤음.

⚠ 지금 검증 프로젝트엔 값이 선 묶음 줄이 0 이라 금액 변화는 안 드러남 —
  BOX·포장·집수정·녹화가 서면 그때 제 값으로 섬.

전체 시험 2136 통과 · 28 건너뜀 · 1 xfail ·
1 실패는 내 변경과 무관한 노임 신뢰도 시험(아래 보고).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-15 05:33:34 +09:00

171 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 원가계산 — ④ 예산내역서 **인계 읽기**(자료형 · 파싱).
`B09_Estimation_BillOfQuantities` 가 700줄에 닿아 **그대로 옮겨 온 자리**다
(2026-09-15 · 순수 분리 — 글자 한 자 안 고쳤다). 조판·계층은 그쪽에 그대로 있다.
⚠ **모르는 칸을 채우지 않는다** — B08 이 안 보낸 값은 `None`·빈 문자열로 남긴다.
⚠ 쓰던 자리가 안 바뀌도록 본 모듈이 다시 내보낸다 —
`from ...BillOfQuantities import HandoffWorkItem` 가 그대로 돈다.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
_ZERO = Decimal(0)
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
SUPPLY_UNKNOWN = "unknown"
#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2).
SUPPLY_OWNER = "owner_supplied"
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
BLOCKED_UNCONFIRMED = "unconfirmed"
class BillError(ValueError):
"""내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다."""
@dataclass(frozen=True)
class HandoffWorkItem:
"""B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다."""
work_item_code: str | None
name: str
spec: str
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 = ""
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
application_ratio_pct: Decimal | None = None
#: 반영률 적용 **전** 수량. 산출근거에만 쓴다.
quantity_gross: Decimal | None = None
#: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다.
application_ratio_breakdown: dict | None = None
quantity_breakdown: dict | None = None
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
#: ⭐ **조각 수량은 「합계」다 — 1단위당이 아니다.** B08 과의 계약이고 그쪽 시험이 지킨다
#: (BOX 동바리 3.92 × 연장 10m · 포장 97.66㎥). 그러므로 `Σ(조각 단가 × 조각 수량)` 이
#: **그 줄 전체 금액**이며 **줄 수량을 다시 곱하면 안 된다**.
#: ⚠ 이 줄이 안 적혀 있어서 금액이 배로 부풀었다 — 평떼 718.14㎡ 가 720배(42억).
#: 2026-09-15 랩탑 서브 실측 · 고친 자리는 `_Rows._composite_row`.
composite_parts: tuple = ()
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
composite_not_ready: tuple = ()
structure_kind: str = ""
#: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다).
blocked_reason: str = ""
#: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) /
#: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다.
blocked_kind: str = ""
#: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의).
variant_axis: str = ""
variant_value: str = ""
#: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구.
#: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다.
spec_class: str = ""
spec_class_basis: str = ""
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
pum_edition: str = ""
@property
def display_name(self) -> str:
return f"{self.name} {self.spec}".strip()
@property
def unconfirmed(self) -> bool:
"""B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에
**빈 금액 + 빨간 테두리**로 서고 합계에 안 듦 · 머리에 「미확정 N건 — 금액에 안 들어감」."""
return self.blocked_kind == BLOCKED_UNCONFIRMED
@dataclass(frozen=True)
class HandoffMaterial:
"""B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정)."""
material_name: str
spec: str
unit: str
net_amount: Decimal
total_amount: Decimal
supply_type: str
surcharge_pct: Decimal | None = None
surcharge_note: str = ""
install_by: str | None = None
source_structure: tuple[str, ...] = ()
@property
def display_name(self) -> str:
return f"{self.material_name} {self.spec}".strip()
def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
if value is None or value == "":
return default
return Decimal(str(value))
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
if "work_items" not in payload or "materials" not in payload:
raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.")
work_items = [
HandoffWorkItem(
work_item_code=row.get("work_item_code"),
name=row.get("name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
quantity=_decimal(row.get("quantity")) or _ZERO,
in_bill=bool(row.get("in_bill", True)),
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),
quantity_gross=_decimal(row.get("quantity_gross"), None),
application_ratio_breakdown=row.get("application_ratio_breakdown"),
quantity_breakdown=row.get("quantity_breakdown"),
composite_parts=tuple(row.get("composite_parts") or ()),
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
structure_kind=row.get("structure_kind") or "",
blocked_reason=row.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or "",
variant_axis=row.get("variant_axis") or "",
variant_value=str(row.get("variant_value") or ""),
spec_class=row.get("spec_class") or "",
spec_class_basis=row.get("spec_class_basis") or "",
pum_edition=str(row.get("pum_edition") or ""),
)
for row in payload["work_items"]
]
materials = [
HandoffMaterial(
material_name=row.get("material_name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
net_amount=_decimal(row.get("net_amount")) or _ZERO,
total_amount=_decimal(row.get("total_amount")) or _ZERO,
supply_type=row.get("supply_type") or SUPPLY_UNKNOWN,
surcharge_pct=_decimal(row.get("surcharge_pct"), None),
surcharge_note=row.get("surcharge_note") or "",
install_by=row.get("install_by"),
source_structure=tuple(row.get("source_structure") or ()),
)
for row in payload["materials"]
]
return work_items, materials