Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
200 lines
9.6 KiB
Python
200 lines
9.6 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
|
||
|
||
from common_util.common_util_work_item_key import work_item_code_of, work_item_key
|
||
from common_util.common_util_work_item_link import choose
|
||
|
||
#: 연결고리 표 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 **안 고른** 줄(금액에 안 듦).
|
||
BLOCKED_LINK_UNDECIDED = "link_undecided"
|
||
|
||
_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 = ""
|
||
#: 불변 열쇠(FW-·AX-) — 목차 번호(`work_item_code`)가 개정으로 밀려도 안 바뀜(8-6 이행 길목).
|
||
#: ⚠ 금액 셈은 아직 `work_item_code` 로 함 — 이 칸은 더하기만(630곳 안 고침).
|
||
work_item_key: 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 _linked(row: dict[str, Any]) -> dict[str, Any]:
|
||
"""열쇠 길목 + **연결고리 표** — 산림·건설 어느 품을 쓸지 표가 정함(고시 총칙 7 · 표 `policy`).
|
||
|
||
둘 다 있는 공종이면 표가 적은 우선 품셈 열쇠·목차 코드로 바꿈 · 미판정이면 막음(사유 그대로).
|
||
⚠ 이미 B08 이 막아 보낸 줄은 그 까닭을 덮지 않음.
|
||
"""
|
||
edition = row.get("pum_edition") or None
|
||
key = row.get("work_item_key") or work_item_key(row.get("work_item_code"), edition)
|
||
choice = choose(key)
|
||
out = {"work_item_key": key or "", "work_item_code": row.get("work_item_code")}
|
||
if choice.blocked and not row.get("blocked_reason"):
|
||
out.update(blocked_reason=choice.blocked, blocked_kind=BLOCKED_LINK_UNDECIDED)
|
||
elif choice.key and choice.key != key:
|
||
out.update(work_item_key=choice.key, work_item_code=work_item_code_of(choice.key, edition))
|
||
return out
|
||
|
||
|
||
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=linked["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 linked.get("blocked_reason") or "",
|
||
blocked_kind=row.get("blocked_kind") or linked.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 ""),
|
||
# 옛 인계(열쇠 없음)도 같은 길목으로 채움 · 연결고리 표가 고른 열쇠
|
||
work_item_key=linked["work_item_key"],
|
||
)
|
||
for row in payload["work_items"]
|
||
for linked in (_linked(row),)
|
||
]
|
||
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
|