까닭 — 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
171 lines
7.9 KiB
Python
171 lines
7.9 KiB
Python
"""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
|