Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
207 lines
8.7 KiB
Python
207 lines
8.7 KiB
Python
"""B09 원가계산 — 자재대 표 (PLAN 8-7 「자재대·관급자재대(금액)는 B09」).
|
|
|
|
**무엇인가** — B08 자재총괄이 낸 **수량·할증**에 **단가**를 붙여 금액을 내는 표다.
|
|
수량은 B08 것이 정본이고 여기서 다시 세지 않는다.
|
|
|
|
**관급과 사급은 자리가 다르다** (PLAN 8-2 · 9-1)
|
|
- **사급** — 도급 재료비. 내역서 안에 들어간다.
|
|
- **관급** — **총원가 밖 별도 표기** + 조달수수료. ⑤ 공사원가계산서의
|
|
「관급자재대」와 같은 값이라 그쪽과 이어야 한다.
|
|
- **`unknown`** — 관급·사급이 안 갈린 것. **어느 쪽에도 안 넣는다** — 넣는 순간
|
|
총액이 틀리고, 어느 쪽으로 넣었는지 나중에 못 가린다.
|
|
|
|
⚠ **할증은 여기서 한 번만** (PLAN 8-7 ㉠). B08 이 `total_amount` 에 이미 할증을
|
|
반영해 보내면 그 값을 쓰고, 여기서 또 곱하지 않는다. `surcharge_status` 가
|
|
`rate_unavailable` 이면 **할증 전 값**임을 표에 드러낸다.
|
|
|
|
⚠ **단가가 없으면 금액을 만들지 않는다.** 사급 물가지가 미결(No.18)이라 지금은
|
|
대부분이 그 자리다 — 0 으로 때우면 자재비가 통째로 사라진 채 총액이 그럴듯해진다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_MaterialCatalog import (
|
|
SUPPLY_CONTRACTOR,
|
|
SUPPLY_OWNER,
|
|
MaterialCatalog,
|
|
load_material_catalog,
|
|
)
|
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
|
|
|
_ZERO = Decimal(0)
|
|
|
|
#: 관급·사급이 안 갈린 값. B08 이 실제로 보낸다.
|
|
SUPPLY_UNKNOWN = "unknown"
|
|
|
|
#: 할증 깃발 — B08 과 맞춘 세 갈래(2026-09-08).
|
|
SURCHARGE_APPLIED = "applied"
|
|
SURCHARGE_NOT_APPLIED = "not_applied"
|
|
SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable"
|
|
|
|
|
|
@dataclass
|
|
class MaterialSheetRow:
|
|
"""자재대 한 줄. 금액이 `None` 이면 **단가를 못 세운 것**이지 0 이 아니다."""
|
|
|
|
name: str
|
|
spec: str
|
|
unit: str
|
|
net_amount: Decimal
|
|
total_amount: Decimal
|
|
supply_type: str
|
|
unit_price_krw: Decimal | None = None
|
|
amount_krw: Decimal | None = None
|
|
surcharge_pct: Decimal | None = None
|
|
source_structure: tuple[str, ...] = ()
|
|
note: str = ""
|
|
#: 「자재 단가」 탭 키(이름 규격) · 수동 단가로 섰나(= 미확정).
|
|
key: str = ""
|
|
manual: bool = False
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
def money(value: Decimal | None) -> str | None:
|
|
return None if value is None else str(value)
|
|
|
|
return {
|
|
"name": self.name,
|
|
"spec": self.spec,
|
|
"unit": self.unit,
|
|
"net_amount": str(self.net_amount),
|
|
"total_amount": str(self.total_amount),
|
|
"supply_type": self.supply_type,
|
|
"unit_price_krw": money(self.unit_price_krw),
|
|
"amount_krw": money(self.amount_krw),
|
|
"surcharge_pct": money(self.surcharge_pct),
|
|
"source_structure": list(self.source_structure),
|
|
"note": self.note,
|
|
"key": self.key,
|
|
"manual": self.manual,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class MaterialSheet:
|
|
"""자재대 한 벌 — 사급·관급·미정 셋으로 갈린다."""
|
|
|
|
contractor_rows: list[MaterialSheetRow] = field(default_factory=list)
|
|
owner_rows: list[MaterialSheetRow] = field(default_factory=list)
|
|
unknown_rows: list[MaterialSheetRow] = field(default_factory=list)
|
|
#: 단가를 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다.**
|
|
missing: list[dict[str, str]] = field(default_factory=list)
|
|
notes: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def contractor_total_krw(self) -> Decimal:
|
|
"""사급 자재비 합계 — 도급 재료비로 들어간다."""
|
|
return sum((row.amount_krw or _ZERO for row in self.contractor_rows), _ZERO)
|
|
|
|
@property
|
|
def owner_total_krw(self) -> Decimal:
|
|
"""관급자재대 — **총원가 밖 별도 표기**. ⑤ 의 관급자재대와 같은 값이어야 한다."""
|
|
return sum((row.amount_krw or _ZERO for row in self.owner_rows), _ZERO)
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"contractor": [row.as_dict() for row in self.contractor_rows],
|
|
"owner": [row.as_dict() for row in self.owner_rows],
|
|
"unknown": [row.as_dict() for row in self.unknown_rows],
|
|
"contractor_total_krw": str(self.contractor_total_krw),
|
|
# 관급자재대는 **천원 올림** 자리다(단수처리 규칙).
|
|
"owner_total_krw": str(
|
|
round_at(self.owner_total_krw, OutputPlace.OWNER_MATERIAL_TOTAL)
|
|
),
|
|
"missing": self.missing,
|
|
"notes": self.notes,
|
|
}
|
|
|
|
|
|
def build_material_sheet(
|
|
materials: list,
|
|
*,
|
|
surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE,
|
|
catalog: MaterialCatalog | None = None,
|
|
manual: dict[str, dict[str, str]] | None = None,
|
|
) -> MaterialSheet:
|
|
"""자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.
|
|
|
|
`manual` — 「자재 단가」 탭 수동 단가(`MaterialPrices.normalize`, 키 「이름 규격」).
|
|
⚠ **사급 단가는 수동 단가만**(2026-09-14 PLAN 1장 Ⓐ-2) — 나라장터는 관급 단가라 사급에 쓰면
|
|
도급 재료비가 관급 값으로 조용히 섬. 관급 줄은 종전대로 나라장터(총원가 밖).
|
|
"""
|
|
book = catalog or load_material_catalog()
|
|
sheet = MaterialSheet()
|
|
|
|
if surcharge_status == SURCHARGE_RATE_UNAVAILABLE:
|
|
sheet.notes.append(
|
|
"할증률이 아직 없어 **할증 전 수량**입니다 — 할증은 자재총괄에서 한 번만 "
|
|
"붙습니다 (PLAN 8-7 ㉠)."
|
|
)
|
|
|
|
for material in materials:
|
|
row = MaterialSheetRow(
|
|
name=getattr(material, "material_name", ""),
|
|
spec=getattr(material, "spec", ""),
|
|
unit=getattr(material, "unit", ""),
|
|
net_amount=getattr(material, "net_amount", _ZERO),
|
|
total_amount=getattr(material, "total_amount", _ZERO),
|
|
supply_type=getattr(material, "supply_type", SUPPLY_UNKNOWN),
|
|
surcharge_pct=getattr(material, "surcharge_pct", None),
|
|
source_structure=tuple(getattr(material, "source_structure", ()) or ()),
|
|
)
|
|
row.key = f"{row.name} {row.spec}".strip()
|
|
|
|
if row.supply_type == SUPPLY_UNKNOWN:
|
|
# ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다.
|
|
row.note = "관급·사급이 안 갈렸습니다 — 어느 쪽 합계에도 넣지 않습니다."
|
|
sheet.unknown_rows.append(row)
|
|
sheet.missing.append(
|
|
{"name": row.name, "unit": row.unit, "reason": "공급 구분 미정(unknown)"}
|
|
)
|
|
continue
|
|
|
|
entry = (manual or {}).get(row.key) if row.supply_type == SUPPLY_CONTRACTOR else None
|
|
found = book.resolve(row.name, row.spec) if row.supply_type == SUPPLY_OWNER else None
|
|
price = Decimal(entry["price_krw"]) if entry else found.price_krw if found else None
|
|
if price is None:
|
|
row.note = (
|
|
"사급 자재 단가 없음 — 「자재 단가」 탭에서 수동 입력(유료 물가지 미결 No.18)"
|
|
if row.supply_type == SUPPLY_CONTRACTOR
|
|
else "관급 자재 단가 없음 — 나라장터 목록에 이 품목이 없음"
|
|
)
|
|
sheet.missing.append(
|
|
{"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"}
|
|
)
|
|
else:
|
|
row.unit_price_krw = price
|
|
# 자재대 줄도 **내역서 본체와 같은 절사** 자리다.
|
|
row.amount_krw = round_at(price * row.total_amount, OutputPlace.BOQ_ROW)
|
|
if entry:
|
|
row.manual = True
|
|
row.note = " · ".join(
|
|
part
|
|
for part in (
|
|
"⚠ 수동 단가(미확정)",
|
|
entry.get("source"),
|
|
entry.get("entered_at"),
|
|
)
|
|
if part
|
|
)
|
|
|
|
if row.supply_type == SUPPLY_OWNER:
|
|
sheet.owner_rows.append(row)
|
|
elif row.supply_type == SUPPLY_CONTRACTOR:
|
|
sheet.contractor_rows.append(row)
|
|
else:
|
|
sheet.unknown_rows.append(row)
|
|
|
|
if sheet.owner_rows:
|
|
sheet.notes.append(
|
|
"관급자재대는 **총원가 밖 별도 표기**입니다 — ⑤ 공사원가계산서의 "
|
|
"관급자재대와 같은 값이어야 합니다."
|
|
)
|
|
return sheet
|