인계 계약 시험 (같은 병 재발 방지) - **B08 이 보내는 칸을 내가 실제로 읽는지** 시험으로 마주 걺. 새 칸이 오면 깨짐. 안 읽는 칸은 까닭을 함께 적게 함(측점·spec_detail 등) - 값이 자료형까지 실제로 닿는지도 짝으로 봄 — 이름만 맞고 값이 안 실리면 같은 병 ㉦ 가드 신설 (품셈 13-6 [주]① 「고임돌 및 채움 콘크리트 등은 품에 포함」) - 큰돌쌓기 줄이 서 있는데 **그 구조물이 낳은** 고임돌·채움콘크리트가 자재로도 서면 멈춤 - ⚠ 13-6 한정 — 돌쌓기 13-4 의 고임돌은 정상이라 안 걸림. 짝 시험 2건 - 내 일위대가는 그 둘을 안 세우고 있음을 확인(품셈 표에 없음) 수량 표시 - 내역서 수량을 **소수 2자리**로 보임. 계산은 전정밀 그대로 - 「표시는 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다」를 표 아래 상시 표시 (실무 서식 자릿수는 기준 문서에 없어 2자리는 잠정) 검증: pytest 195 통과(신규 5), tsc 0건, 화면 실측 — 15,726.93 · 92.45 로 뜸 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
703 lines
29 KiB
Python
703 lines
29 KiB
Python
"""B09 원가계산 — ④ 예산내역서 조판 (PLAN 9-5 「B08 출력을 붙이는 자리」).
|
|
|
|
**하는 일** — B08 인계 응답(공종 수량 + 자재)을 받아 **계층이 선 내역서 한 장**으로
|
|
접는다. 수량은 B08 것을 그대로 쓰고(다시 세지 않는다), 단가는 ③ 일위대가에서 가져오며,
|
|
금액은 이 자리에서 `수량 × 단가` 로 만든다.
|
|
|
|
**계층은 코드에 안 박는다** (PLAN 9-3 · STmate `BOQ11` 해부 결과). 공종 마스터가 이미
|
|
`parent_code` · `level` · `sort_order`(256 간격)를 들고 있으므로, 쓰인 공종의 **조상만
|
|
남긴 가지치기 나무**를 세우고 거기서 ITEM NO. 를 매긴다. 깊이를 코드 글자수로 세지 않는다 —
|
|
`FP-09-03-02` 가 3층이라는 보장이 없다.
|
|
|
|
**빈칸을 지어내지 않는다** — 단가가 없는 줄, 관급/사급이 안 갈린 자재는 금액을 0 으로
|
|
때우지 않고 `missing` 에 이름째 남긴다. 화면이 그것을 그대로 보인다.
|
|
|
|
⚠ **`in_bill=false` 줄에는 단가를 붙이지 않는다** (PLAN 8-7 ㉡ 와 같은 성격).
|
|
보정량계·무대 같은 검산용 줄이라 금액을 매기면 같은 것을 두 번 세게 된다. 주석으로
|
|
막지 않고 `check_excluded_rows_not_priced()` 가 수치로 멈춘다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_Guards import (
|
|
check_excluded_rows_not_priced,
|
|
check_included_materials_not_listed,
|
|
check_free_haul_not_priced,
|
|
check_haul_volume_within_cut,
|
|
)
|
|
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
|
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
|
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
|
|
|
_ZERO = Decimal(0)
|
|
|
|
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
|
|
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
|
|
SUPPLY_UNKNOWN = "unknown"
|
|
|
|
|
|
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
|
|
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
|
|
composite_parts: tuple = ()
|
|
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
|
|
composite_not_ready: tuple = ()
|
|
structure_kind: str = ""
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
return f"{self.name} {self.spec}".strip()
|
|
|
|
|
|
@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()
|
|
|
|
|
|
@dataclass
|
|
class BillRow:
|
|
"""내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다."""
|
|
|
|
item_no: str
|
|
level: int
|
|
code: str | None
|
|
name: str
|
|
spec: str = ""
|
|
unit: str = ""
|
|
quantity: Decimal | None = None
|
|
unit_price_krw: Decimal | None = None
|
|
amount_krw: Decimal | None = None
|
|
#: 3분할 — ⑤ 로 넘길 때 **뭉치지 않고** 성분 그대로 간다(PLAN 8-9 규칙 2).
|
|
material_krw: Decimal = _ZERO
|
|
labor_krw: Decimal = _ZERO
|
|
expense_krw: Decimal = _ZERO
|
|
is_group: bool = False
|
|
in_bill: bool = True
|
|
note: str = ""
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
def money(value: Decimal | None) -> str | None:
|
|
return None if value is None else str(value)
|
|
|
|
return {
|
|
"item_no": self.item_no,
|
|
"level": self.level,
|
|
"code": self.code,
|
|
"name": self.name,
|
|
"spec": self.spec,
|
|
"unit": self.unit,
|
|
"quantity": money(self.quantity),
|
|
"unit_price_krw": money(self.unit_price_krw),
|
|
"amount_krw": money(self.amount_krw),
|
|
"material_krw": str(self.material_krw),
|
|
"labor_krw": str(self.labor_krw),
|
|
"expense_krw": str(self.expense_krw),
|
|
"is_group": self.is_group,
|
|
"in_bill": self.in_bill,
|
|
"note": self.note,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class BillResult:
|
|
"""④ 예산내역서 한 장."""
|
|
|
|
rows: list[BillRow] = field(default_factory=list)
|
|
#: 금액을 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다**.
|
|
missing: list[dict[str, str]] = field(default_factory=list)
|
|
#: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다.
|
|
excluded: list[BillRow] = field(default_factory=list)
|
|
#: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다.
|
|
material_rows: list[BillRow] = field(default_factory=list)
|
|
notes: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def direct_material_krw(self) -> Decimal:
|
|
return sum((r.material_krw for r in self.rows if not r.is_group), _ZERO)
|
|
|
|
@property
|
|
def direct_labor_krw(self) -> Decimal:
|
|
return sum((r.labor_krw for r in self.rows if not r.is_group), _ZERO)
|
|
|
|
@property
|
|
def direct_expense_krw(self) -> Decimal:
|
|
return sum((r.expense_krw for r in self.rows if not r.is_group), _ZERO)
|
|
|
|
@property
|
|
def body_total_krw(self) -> Decimal:
|
|
"""내역서 **본체** 합계 — 줄마다 절사한 금액의 합.
|
|
|
|
⚠ 집계표(반올림) 합계와 원 단위로 어긋나는 것이 정상이다
|
|
(`B09_Estimation_Rounding.SUMMARY_MISMATCH_NOTE`).
|
|
"""
|
|
return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO)
|
|
|
|
|
|
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 "",
|
|
)
|
|
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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _MasterNode:
|
|
code: str
|
|
name: str
|
|
level: int
|
|
parent_code: str | None
|
|
sort_order: int
|
|
|
|
|
|
def _master_index(master: dict[str, Any]) -> dict[str, _MasterNode]:
|
|
return {
|
|
node["work_item_code"]: _MasterNode(
|
|
code=node["work_item_code"],
|
|
name=node.get("name", ""),
|
|
level=int(node.get("level", 1)),
|
|
parent_code=node.get("parent_code"),
|
|
sort_order=int(node.get("sort_order", 0)),
|
|
)
|
|
for node in master.get("work_items", [])
|
|
if node.get("work_item_code")
|
|
}
|
|
|
|
|
|
def _ancestor_chain(code: str, index: dict[str, _MasterNode]) -> list[_MasterNode]:
|
|
"""뿌리 → 자기 순서의 조상 사슬. **코드 글자수로 깊이를 세지 않는다.**"""
|
|
chain: list[_MasterNode] = []
|
|
seen: set[str] = set()
|
|
cursor: str | None = code
|
|
while cursor and cursor in index and cursor not in seen:
|
|
seen.add(cursor)
|
|
node = index[cursor]
|
|
chain.append(node)
|
|
cursor = node.parent_code
|
|
chain.reverse()
|
|
return chain
|
|
|
|
|
|
def _number_of(path: tuple[int, ...]) -> str:
|
|
"""ITEM NO. — 자리마다 1 부터. 「1」·「1-2」·「1-2-3」 모양."""
|
|
return "-".join(str(n) for n in path)
|
|
|
|
|
|
def build_bill(
|
|
payload: dict[str, Any],
|
|
*,
|
|
build: UnitPriceBuild | None = None,
|
|
master: dict[str, Any] | None = None,
|
|
) -> BillResult:
|
|
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다."""
|
|
work_items, materials = parse_handoff(payload)
|
|
unit_prices = build or cached_build()
|
|
index = _master_index(master or load_work_item_master())
|
|
result = BillResult()
|
|
|
|
# ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ────────────────────────────────
|
|
# 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다.
|
|
used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = []
|
|
orphans: list[HandoffWorkItem] = []
|
|
composites: list[HandoffWorkItem] = []
|
|
for item in work_items:
|
|
if not item.in_bill:
|
|
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
|
|
# **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할
|
|
# 줄」로 잘못 읽힌다.
|
|
result.excluded.append(_excluded_row(item))
|
|
continue
|
|
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
|
|
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
|
|
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
|
|
composites.append(item)
|
|
continue
|
|
if not item.work_item_code or item.work_item_code not in index:
|
|
orphans.append(item)
|
|
continue
|
|
used.append(((), item, _ancestor_chain(item.work_item_code, index)))
|
|
|
|
def sort_key(entry: tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]) -> tuple:
|
|
return tuple(node.sort_order for node in entry[2])
|
|
|
|
used.sort(key=sort_key)
|
|
|
|
emitted: dict[str, str] = {} # 코드 → ITEM NO.
|
|
counters: dict[str, int] = {} # 부모 ITEM NO. → 마지막 번호
|
|
|
|
def next_number(parent_no: str) -> str:
|
|
counters[parent_no] = counters.get(parent_no, 0) + 1
|
|
return f"{parent_no}-{counters[parent_no]}" if parent_no else str(counters[parent_no])
|
|
|
|
for _, item, chain in used:
|
|
parent_no = ""
|
|
# 조상 줄(머리글)을 먼저 세운다 — 이미 선 것은 다시 안 세운다.
|
|
for node in chain[:-1]:
|
|
if node.code in emitted:
|
|
parent_no = emitted[node.code]
|
|
continue
|
|
parent_no = next_number(parent_no)
|
|
emitted[node.code] = parent_no
|
|
result.rows.append(
|
|
BillRow(
|
|
item_no=parent_no,
|
|
level=node.level,
|
|
code=node.code,
|
|
name=node.name,
|
|
is_group=True,
|
|
)
|
|
)
|
|
leaf = chain[-1]
|
|
# ⚠ **잎 줄은 번호를 재사용하지 않는다.** 같은 공종코드가 지반·규격만 달리해
|
|
# 두 번 올 수 있고(2026-09-08 실물: 도자운반 토사/리핑암 두 줄), 그때 번호를
|
|
# 물려주면 ITEM NO. 가 겹쳐 어느 줄인지 못 가린다. 머리글만 물려준다.
|
|
item_no = next_number(parent_no)
|
|
emitted.setdefault(leaf.code, item_no)
|
|
result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result))
|
|
|
|
# ── 1-2) 묶음 줄 ──────────────────────────────────────────────────────────
|
|
for item in composites:
|
|
counters[""] = counters.get("", 0) + 1
|
|
result.rows.append(_composite_row(str(counters[""]), item, unit_prices, result))
|
|
|
|
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
|
|
for item in orphans:
|
|
# 구조물 줄은 사유가 다르다 — 품셈에 그 공종이 없어 **전개식(원단위)** 이 있어야
|
|
# 조각으로 설 수 있다. 「코드가 없다」로만 적으면 무엇을 해야 하는지 안 보인다.
|
|
reason = (
|
|
"구조물 전개식(원단위)이 없어 조각을 못 세웠습니다 — B08 원단위 필요."
|
|
if item.origin == "structure"
|
|
else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다."
|
|
)
|
|
result.missing.append(
|
|
{
|
|
"name": item.display_name,
|
|
"unit": item.unit,
|
|
"quantity": str(item.quantity),
|
|
"reason": reason,
|
|
}
|
|
)
|
|
|
|
# ── 3) 자재 벌 ────────────────────────────────────────────────────────────
|
|
for material in materials:
|
|
result.material_rows.append(_material_row(material, result))
|
|
|
|
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
|
|
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
|
|
|
|
# ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가.
|
|
check_included_materials_not_listed(
|
|
work_item_codes=[row.code or "" for row in result.rows],
|
|
materials=[
|
|
{"material_name": m.material_name, "source_structure": list(m.source_structure)}
|
|
for m in materials
|
|
],
|
|
)
|
|
|
|
# ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡).
|
|
# 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다.
|
|
# 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다.
|
|
haul_rows = [
|
|
{
|
|
"equipment": item.haul_equipment,
|
|
"unit_price_krw": _haul_price_of(item, result),
|
|
}
|
|
for item in work_items
|
|
if item.haul_equipment
|
|
]
|
|
check_free_haul_not_priced(haul_rows=haul_rows)
|
|
|
|
# ㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가(같은 흙을 두 번 세지 않았는가).
|
|
cut_total = sum(
|
|
(item.quantity for item in work_items if "깎기" in item.name or "절취" in item.name),
|
|
_ZERO,
|
|
)
|
|
haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO)
|
|
if cut_total > 0 and haul_total > 0:
|
|
check_haul_volume_within_cut(
|
|
haul_volume_total_m3=haul_total,
|
|
total_cut_volume_m3=cut_total,
|
|
)
|
|
|
|
if any(m.surcharge_pct is None for m in materials):
|
|
result.notes.append(
|
|
"자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. "
|
|
"할증은 자재총괄에서 한 번만 붙습니다 (PLAN 8-7 ㉠)."
|
|
)
|
|
return result
|
|
|
|
|
|
def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal:
|
|
"""그 운반 줄에 실제로 붙은 단가. 안 붙었으면 0 — ㉡ 검사에 넘길 값이다."""
|
|
for row in result.rows:
|
|
if row.name == item.name and row.unit_price_krw is not None:
|
|
return row.unit_price_krw
|
|
return _ZERO
|
|
|
|
|
|
def _composite_row(
|
|
item_no: str,
|
|
item: HandoffWorkItem,
|
|
unit_prices: UnitPriceBuild,
|
|
result: BillResult,
|
|
) -> BillRow:
|
|
"""묶음 줄 — 조각들의 `단가 × 조각수량` 을 더해 **1단위 단가**를 만든다.
|
|
|
|
옹벽처럼 품셈에 그 공종이 없는 것은 **조각을 합친 것이 곧 그 줄의 단가**다
|
|
(PLAN 9-3 「제목 + 상세」 한 쌍). 조각이 하나라도 비면 **금액을 만들지 않는다** —
|
|
절반짜리 단가가 서는 것이 가장 위험하다.
|
|
"""
|
|
row = BillRow(
|
|
item_no=item_no,
|
|
level=1,
|
|
code=None,
|
|
name=item.name,
|
|
spec=item.spec,
|
|
unit=item.unit,
|
|
quantity=item.quantity,
|
|
in_bill=item.in_bill,
|
|
)
|
|
missing_parts: list[str] = []
|
|
money = None
|
|
for part in item.composite_parts:
|
|
code = str(part.get("code") or "")
|
|
amount = _decimal(part.get("quantity"), None)
|
|
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
|
|
missing_parts.append(code or str(part.get("name") or "이름 없음"))
|
|
continue
|
|
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount)
|
|
money = scaled if money is None else money + scaled
|
|
|
|
reasons: list[str] = []
|
|
for pending in item.composite_not_ready:
|
|
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
|
|
if isinstance(pending, str):
|
|
missing_parts.append(pending)
|
|
continue
|
|
label = str(pending.get("code") or pending.get("name") or "이름 없음")
|
|
why = str(pending.get("reason") or pending.get("note") or "")
|
|
missing_parts.append(label)
|
|
if why:
|
|
reasons.append(f"{label}: {why}")
|
|
|
|
if missing_parts or money is None:
|
|
detail_text = "; ".join(reasons[:3]) or ", ".join(missing_parts[:4])
|
|
row.note = f"묶음 조각이 덜 찼습니다 — {detail_text}"
|
|
result.missing.append(
|
|
{
|
|
"name": row.name,
|
|
"unit": row.unit,
|
|
"quantity": str(item.quantity),
|
|
"reason": (
|
|
f"묶음 조각 미확보 ({len(missing_parts)}건)"
|
|
+ (f" — {reasons[0]}" if reasons else "")
|
|
),
|
|
}
|
|
)
|
|
return row
|
|
|
|
line = money.scaled(item.quantity)
|
|
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
|
|
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
|
|
row.material_krw = line.material
|
|
row.labor_krw = line.labor
|
|
row.expense_krw = line.expense
|
|
row.note = f"묶음 {len(item.composite_parts)}조각 합계"
|
|
return row
|
|
|
|
|
|
def _excluded_row(item: HandoffWorkItem) -> BillRow:
|
|
"""검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**"""
|
|
return BillRow(
|
|
item_no="",
|
|
level=1,
|
|
code=item.work_item_code,
|
|
name=item.name,
|
|
spec=item.spec,
|
|
unit=item.unit,
|
|
quantity=item.quantity,
|
|
in_bill=False,
|
|
note=item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.",
|
|
)
|
|
|
|
|
|
def _leaf_row(
|
|
item_no: str,
|
|
node: _MasterNode,
|
|
item: HandoffWorkItem,
|
|
unit_prices: UnitPriceBuild,
|
|
result: BillResult,
|
|
) -> BillRow:
|
|
"""세부 공종 한 줄. 단가가 없으면 **금액을 비우고** `missing` 에 남긴다."""
|
|
row = BillRow(
|
|
item_no=item_no,
|
|
level=node.level,
|
|
code=node.code,
|
|
name=item.name or node.name,
|
|
spec=item.spec,
|
|
unit=item.unit,
|
|
quantity=item.quantity,
|
|
in_bill=item.in_bill,
|
|
)
|
|
if item.application_ratio_pct is not None:
|
|
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
|
|
row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량"
|
|
|
|
if not item.in_bill:
|
|
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
|
|
row.quantity = item.quantity
|
|
row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다."
|
|
result.excluded.append(row)
|
|
return row
|
|
|
|
price_code = f"B-{node.code}"
|
|
if price_code not in unit_prices.book.titles:
|
|
# 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다
|
|
# (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도).
|
|
# 한 층 아래 공종 + **규격 갈래**(`#무근구조물`) 둘 다 후보로 본다.
|
|
children = sorted(
|
|
code
|
|
for code in unit_prices.book.titles
|
|
if (
|
|
code.startswith(f"{price_code}-")
|
|
and code.count("-") == price_code.count("-") + 1
|
|
and "#" not in code
|
|
)
|
|
or code.startswith(f"{price_code}#")
|
|
)
|
|
if children:
|
|
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
|
|
row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
|
|
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
|
|
else:
|
|
row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
|
|
reason = "일위대가 없음"
|
|
result.missing.append(
|
|
{
|
|
"name": row.name,
|
|
"code": node.code,
|
|
"unit": row.unit,
|
|
"quantity": str(item.quantity),
|
|
"reason": reason,
|
|
"candidates": ", ".join(children),
|
|
}
|
|
)
|
|
return row
|
|
|
|
missing_basis = unit_prices.basis_missing.get(node.code)
|
|
if missing_basis:
|
|
# ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배
|
|
# 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.**
|
|
row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}"
|
|
result.missing.append(
|
|
{
|
|
"name": row.name,
|
|
"code": node.code,
|
|
"unit": row.unit,
|
|
"quantity": str(item.quantity),
|
|
"reason": "밑수 미확보 — 곱하면 10배·100배 틀림",
|
|
}
|
|
)
|
|
return row
|
|
|
|
covered = unit_prices.partial_ratio.get(node.code)
|
|
if covered is not None:
|
|
# ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만
|
|
# 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다.
|
|
row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)."
|
|
result.missing.append(
|
|
{
|
|
"name": row.name,
|
|
"code": node.code,
|
|
"unit": row.unit,
|
|
"quantity": str(item.quantity),
|
|
"reason": f"단가 일부만 섬(붙은 몫 {covered}%)",
|
|
}
|
|
)
|
|
return row
|
|
|
|
title = unit_prices.book.title(price_code)
|
|
if not title.unit:
|
|
# ⚠ 품셈 표가 기준 단위를 안 준 단가다 — 「10㎡당」 같은 묶음 기준일 수 있다.
|
|
# 값을 막지는 않되(막으면 대부분이 멈춘다) **모르는 채 곱했다는 사실을 적는다**.
|
|
# 비고를 **덮지 않고 잇는다** — 반영률 문구가 먼저 적혀 있을 수 있다.
|
|
row.note = " / ".join(
|
|
part
|
|
for part in (
|
|
row.note,
|
|
f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 "
|
|
"보고 곱했습니다. 확인 필요.",
|
|
)
|
|
if part
|
|
)
|
|
|
|
unit_money = unit_prices.book.resolve(price_code)
|
|
line = unit_money.scaled(item.quantity)
|
|
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
|
|
# 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상.
|
|
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
|
|
# 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다.
|
|
row.material_krw = line.material
|
|
row.labor_krw = line.labor
|
|
row.expense_krw = line.expense
|
|
return row
|
|
|
|
|
|
def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
|
|
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**."""
|
|
row = BillRow(
|
|
item_no="",
|
|
level=1,
|
|
code=None,
|
|
name=material.material_name,
|
|
spec=material.spec,
|
|
unit=material.unit,
|
|
quantity=material.total_amount,
|
|
note=material.surcharge_note,
|
|
)
|
|
if material.supply_type == SUPPLY_UNKNOWN:
|
|
row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
|
|
result.missing.append(
|
|
{
|
|
"name": material.display_name,
|
|
"unit": material.unit,
|
|
"quantity": str(material.total_amount),
|
|
"reason": "공급 구분 미정(unknown)",
|
|
}
|
|
)
|
|
return row
|
|
# 사급 자재 단가는 아직 원천이 없다(미결 No.18) — 여기서도 지어내지 않는다.
|
|
row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
|
|
result.missing.append(
|
|
{
|
|
"name": material.display_name,
|
|
"unit": material.unit,
|
|
"quantity": str(material.total_amount),
|
|
"reason": "자재 단가 없음(미결 No.18)",
|
|
}
|
|
)
|
|
return row
|
|
|
|
|
|
def bill_summary(result: BillResult) -> dict[str, Any]:
|
|
"""화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다."""
|
|
return {
|
|
"rows": len(result.rows),
|
|
"detail_rows": sum(1 for r in result.rows if not r.is_group),
|
|
"group_rows": sum(1 for r in result.rows if r.is_group),
|
|
"excluded_rows": len(result.excluded),
|
|
"material_rows": len(result.material_rows),
|
|
"missing": result.missing,
|
|
"body_total_krw": str(result.body_total_krw),
|
|
"direct_material_krw": str(result.direct_material_krw),
|
|
"direct_labor_krw": str(result.direct_labor_krw),
|
|
"direct_expense_krw": str(result.direct_expense_krw),
|
|
"notes": result.notes,
|
|
}
|
|
|
|
|
|
def cost_input_from_bill(result: BillResult, **cost_input_kwargs):
|
|
"""④ 내역서 합계를 ⑤ 원가계산서 입력으로 접어 넣는다.
|
|
|
|
**뭉치지 않는다** — 재료·노무·경비 성분이 그대로 간다(PLAN 8-9 규칙 2).
|
|
⑤ 표에 찍히는 자리이므로 **자원 집계표 규칙(반올림)** 으로 자른다.
|
|
"""
|
|
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput
|
|
|
|
summary = OutputPlace.RESOURCE_SUMMARY
|
|
return CostInput(
|
|
direct_material_krw=round_at(result.direct_material_krw, summary),
|
|
direct_labor_krw=round_at(result.direct_labor_krw, summary),
|
|
direct_expense_krw=round_at(result.direct_expense_krw, summary),
|
|
**cost_input_kwargs,
|
|
)
|