Files
Aislo/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py

200 lines
9.6 KiB
Python
Raw Permalink 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
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