Files
Aislo/B09_Estimation/B09_Estimation_BillOfQuantities.py
T
eomsangdonandClaude Opus 5 81286400bd fix(b08,b09): 기본값으로 선 구조물은 줄만 서고 금액 밖 — 「미확정 N건 — 금액에 안 들어감」
- 브레인 판정: 값 없음을 감추면 조용히 빠지거나 조용히 부풂 — 없으면 없다고 보이기
- 관 지점 벽 치수를 등록부 기본값으로 채운 줄 = 미확정: 원단위 합·자재·터파기·되메우기·기초잡석·타설·잔토·채집석에서 뺌
- 인계 막힘 갈래 넷째 값 unconfirmed(계약 칸은 안 늘림) · B09 는 내역 제자리에 빈 금액 + 빨간 테두리 · 머리 뱃지
- 구조물도 그림에 표와 같은 기본값 사유 줄 · 「횡단도와 같은 값」 문구는 틀려 고침(B06 관 벽 높이는 관경 기준)
- 936be972 내역 184,291,506 → 124,424,547 · 미확정 18건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:22:14 +09:00

710 lines
34 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 (
OVERHEAD_TIER_WITHOUT_PIPE,
check_excluded_rows_not_priced,
check_drain_pipe_not_double_counted,
check_included_materials_not_listed,
check_free_haul_not_priced,
check_handoff_boundaries,
check_haul_volume_within_cut,
check_material_surcharge_once,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
from common_util.common_util_pum_edition import edition_waiting_reason
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import (
UnitPriceBuild,
cached_build,
find_variant_code,
)
_ZERO = Decimal(0)
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05")
#: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈.
STRUCTURE_PRICE_PREFIX = "AX-ST-"
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
SUPPLY_UNKNOWN = "unknown"
#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2).
SUPPLY_OWNER = "owner_supplied"
#: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.**
#: ⚠ `blocked_kind` 가 **`None` 인데 `in_bill=False`** 면 **막힌 줄이 아니다** —
#: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다.
#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙).
_NOT_OUR_ROW = "not_our_row"
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
BLOCKED_UNCONFIRMED = "unconfirmed"
_BLOCKED_LABELS = {
_NOT_OUR_ROW: "여기서 세지 않는 줄",
BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감",
"input_missing": "입력이 필요합니다",
"unit_data_missing": "원단위가 없습니다(우리가 만들 것)",
"formula_missing": "전개식이 없습니다(우리가 만들 것)",
}
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 = ""
#: 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()
@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
#: 금액을 낸 단가 코드(`B-…#갈래`) — 단산 번호를 그 코드로 찾음. 안 선 줄은 빈 글.
price_code: str = ""
#: 성분 단가 — 실무 내역서 「노무비·재료비·경비 단가」 칸(화면이 곱하지 않게 서버가 실음).
unit_material_krw: Decimal | None = None
unit_labor_krw: Decimal | None = None
unit_expense_krw: Decimal | None = None
#: 수동 단가로 선 몫 — 화면 빨간 테두리(구조물도 호표 줄).
unconfirmed: int = 0
#: 「단산 N 참조」 — 비고 첫 조각과 같은 글. 번호는 낼 때마다 매김(저장 안 함).
price_basis_label: str = ""
#: 단가표 제목이 아닌 줄(묶음·구조물도 호표)의 **단위당 구성** `(단가 코드, 수량)` — 집계표가 자원까지 풂.
parts: list[tuple[str, Decimal]] = field(default_factory=list)
#: 할증 칸 이름·얹힌 율(사용자) — `B09_Estimation_BillRates`(2차 ④).
rate_key: str = ""
rate: dict[str, str] | None = None
#: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고,
#: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
#: (막힘 사유가 먼저 적힌 반영률 문구를 지웠다, 2026-09-12 데스크탑 보조 조사 ㉮).
#: 그래서 **덮지 않고 쌓는다.** 열을 못 짚는 줄 전체 사유는 키를 빈 글로 둔다.
notes: list[tuple[str, str]] = field(default_factory=list)
@property
def note(self) -> str:
"""화면 「비고」 칸 — 조각을 종전과 **같은 꼴**로 이어 붙인다."""
return " / ".join(text for _, text in self.notes if text)
def add_note(self, column: str, text: str) -> None:
"""사유 한 조각을 **쌓는다**. `column` 은 그 사유가 닿는 열 키(줄 전체면 빈 글)."""
if text:
self.notes.append((column, text))
def as_dict(self) -> dict[str, Any]:
def money(value: Decimal | None) -> str | None:
return None if value is None else str(value)
# 수량 소수자리는 **종목마다 다르다** (품셈 1-2-2). 값은 전정밀로 두고 **찍을 자리만**
# 함께 보낸다 — 자르는 것은 표를 그리는 쪽 몫이다(단수 규칙과 같은 원칙).
shown, digits = (
(None, None)
if self.quantity is None
else round_quantity(self.quantity, self.name, self.unit, self.spec)
)
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),
#: 품셈 1-2-2 종목별 자리로 **반올림한** 표시값. 자리를 모르면 `None` 이고,
#: 그때 화면은 종전대로 찍는다(지어내지 않는다).
"quantity_shown": money(shown) if digits is not None else None,
"quantity_digits": digits,
"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),
"unit_material_krw": money(self.unit_material_krw),
"unit_labor_krw": money(self.unit_labor_krw),
"unit_expense_krw": money(self.unit_expense_krw),
"price_code": self.price_code,
"unconfirmed": self.unconfirmed,
"price_basis_label": self.price_basis_label,
"rate_key": self.rate_key,
"rate": self.rate,
"is_group": self.is_group,
"in_bill": self.in_bill,
"note": self.note,
#: 근거 호버용 — 어느 사유가 **어느 열**에 닿는지까지 실어 보낸다.
#: 화면 「비고」 칸은 위 `note` 그대로라 토글을 끈 사용자도 사유를 그대로 본다.
"notes": [{"column": column, "text": text} for column, text in self.notes],
}
@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)
#: 이 내역서에 쓰인 일위대가 코드 — ③ 단가산출서 번호를 매기는 차례가 된다.
used_unit_prices: list[str] = field(default_factory=list)
#: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다.
material_rows: list[BillRow] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
#: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다.
price_basis: Any = None
#: 일위대가 호표 번호 한 벌 — 내역에 처음 쓰인 차례(저장 안 함).
unit_price_sheet: Any = None
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
material_sheet: Any = None
#: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`.
unconfirmed: list[dict[str, Any]] = field(default_factory=list)
#: 치수 없이 기본값으로 선 구조물 줄 — 제자리에 빈 금액으로 서고 **합계에 안 듦**(`{name, reason}`).
unpriced: list[dict[str, Any]] = 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)
def resource_quantities(self) -> dict[str, Decimal]:
"""집계표에 넘길 `{단가 코드: 수량}` — 내역 줄 차례 그대로(처음 쓰인 차례가 호표 번호)."""
found: dict[str, Decimal] = {}
for row in self.rows:
if row.is_group or row.amount_krw is None or row.quantity is None:
continue
for code, amount in row.parts or [(row.price_code, Decimal(1))]:
if code:
found[code] = found.get(code, _ZERO) + amount * row.quantity
return found
@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 "",
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
@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)
from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_composite_row,
_excluded_row,
_leaf_row,
_material_row,
_structure_price_row,
_sum_groups,
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
)
def build_bill(
payload: dict[str, Any],
*,
build: UnitPriceBuild | None = None,
master: dict[str, Any] | None = None,
structure_prices: dict[str, dict[str, Any]] | None = None,
rates: dict[str, Any] | None = None,
) -> BillResult:
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.
`structure_prices` — 구조물도 호표 금액 `{B-AX-ST-…#키: …}`(B08 `structure_bill_prices`,
**이 `build` 단가표로** 셈). 안 주면 호표 줄은 금액 없이 사유와 함께 섬.
"""
work_items, materials = parse_handoff(payload)
unit_prices = build or cached_build()
master = master or load_work_item_master()
index = _master_index(master)
result = BillResult()
# ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ────────────────────────────────
# 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다.
used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = []
orphans: list[HandoffWorkItem] = []
composites: list[HandoffWorkItem] = []
templated: list[HandoffWorkItem] = []
for item in work_items:
if not item.in_bill and not (item.unconfirmed and item.work_item_code in index):
# (미확정 줄은 제자리 나무로 감 — `_leaf_row` 가 금액 없이 세우고 `unpriced` 에 셈)
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
# **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할
# 줄」로 잘못 읽힌다.
row = _excluded_row(item)
result.excluded.append(row)
if item.blocked_reason:
# ⚠ 「다른 표에서 이미 섬」과 「입력이 필요함」을 **갈라** 싣는다.
kind = item.blocked_kind or _NOT_OUR_ROW
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": f"{_BLOCKED_LABELS.get(kind, '막힘')}{item.blocked_reason}",
"blocked_kind": kind,
}
)
continue
if str(item.work_item_code or "").startswith(STRUCTURE_PRICE_PREFIX):
# 구조물도 호표(PLAN 6장 ②) — 공종 마스터에 없는 자체 확장 코드라 나무에 안 끼움.
templated.append(item)
continue
# 품셈 판이 다른 줄 — 절 번호가 같아도 같은 공종이라는 보장이 없어 값을 안 씀(명세 17장 규칙 4).
waiting = edition_waiting_reason(item.pum_edition, str(master.get("effective_date", "")))
if waiting:
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": waiting,
"blocked_kind": "edition_waiting",
}
)
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. → 마지막 번호
# 인계 줄 → 그 줄로 선 내역 줄. ⚠ 이름으로 되찾지 않음 — 도자운반 토사·리핑암처럼
# 이름이 같은 줄이 둘이면 **첫 줄 단가**가 둘째에도 붙음(2026-09-13 축 C 조사 실결함).
row_of: dict[int, BillRow] = {}
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)
row_of[id(item)] = _leaf_row(item_no, leaf, item, unit_prices, result)
result.rows.append(row_of[id(item)])
# ── 1-2) 묶음 줄 ──────────────────────────────────────────────────────────
for item in composites:
counters[""] = counters.get("", 0) + 1
row_of[id(item)] = _composite_row(str(counters[""]), item, unit_prices, result)
result.rows.append(row_of[id(item)])
# ── 1-3) 구조물도 호표 줄 ─────────────────────────────────────────────────
for item in templated:
counters[""] = counters.get("", 0) + 1
row_of[id(item)] = _structure_price_row(
str(counters[""]), item, structure_prices or {}, result
)
result.rows.append(row_of[id(item)])
# ── 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])
# 이중계상 경계 ①②③(명세 6장) — B08 이 갈 곳 칸으로 판정한 어긴 자리 · 자재 할증 한 번.
check_handoff_boundaries(payload.get("double_count_violations"))
check_material_surcharge_once(materials)
# ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가.
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
],
)
# ㉥ 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 파이프를 자재로 또 세지 않았는가.
# 지금은 제잡비를 늘 아랫단으로 붙여 `overhead_tier` 가 늘 미설치다 — 그래도
# **부르는 자리를 비워 두지 않는다**(「있다」와 「돈다」는 다르다). 설계 조건이
# 인계에 실리는 날 그 값만 바꿔 넣으면 바로 걸린다.
check_drain_pipe_not_double_counted(
overhead_tier=str(payload.get("overhead_tier") or OVERHEAD_TIER_WITHOUT_PIPE),
material_names=[m.material_name for m in materials],
)
# ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡).
# 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다.
# 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다.
haul_rows = [
{
"equipment": item.haul_equipment,
# 그 줄에 실제로 붙은 단가. 안 선 줄은 0 — ㉡ 검사에 넘길 값이다.
"unit_price_krw": getattr(row_of.get(id(item)), "unit_price_krw", None) or _ZERO,
}
for item in work_items
if item.haul_equipment
]
check_free_haul_not_priced(haul_rows=haul_rows)
# ㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가(같은 흙을 두 번 세지 않았는가).
# 절취 = **코드로** 가름(9-3 토사깎기 · 9-4 암절취 · 9-5 발파암 — 인계 대응표의 「흙깎기」 셋).
# ⚠ 종전엔 줄 이름에 「깎기」·「절취」가 드는지로 셌음 — 이름이 바뀌면 검산이 조용히 멈춤.
cut_total = sum(
(
item.quantity
for item in work_items
if str(item.work_item_code or "").startswith(CUT_CODE_PREFIXES)
),
_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,
)
# 자재대 — B08 수량·할증에 단가를 붙인다. 관급은 총원가 밖 별도 표기다.
from B09_Estimation.B09_Estimation_MaterialSheet import build_material_sheet
result.material_sheet = build_material_sheet(
materials,
surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"),
)
# ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13).
from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis
sheet = build_price_basis(result.used_unit_prices, unit_prices)
for row in result.rows:
if row.is_group or row.code is None or row.amount_krw is None:
continue
# 그 줄이 **실제로 쓴** 단가 코드(갈래 `#…` 포함)로 찾음 — 갈래로 선 줄도 번호가 붙게.
label = sheet.label_for(row.price_code or f"B-{row.code}")
if label:
# 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다.
row.notes.insert(0, ("unit_price_krw", label))
row.price_basis_label = label
result.price_basis = sheet
from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet
result.unit_price_sheet = build_unit_price_sheet(
result.rows, unit_prices.book, structure_prices
)
# 할증율·일괄보정(사용자 · 2차 ④) — 줄 금액이 선 뒤, 머리글 합 앞.
from B09_Estimation.B09_Estimation_BillRates import apply_bill_rates
apply_bill_rates(result.rows, rates, bill_line)
_sum_groups(result.rows)
if any(m.surcharge_pct is None for m in materials):
result.notes.append(
"자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. "
"할증은 자재총괄에서 한 번만 붙습니다 (PLAN 8-7 ㉠)."
)
return result
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),
"material_sheet": result.material_sheet.as_dict() if result.material_sheet else None,
"missing": result.missing,
"unconfirmed": result.unconfirmed,
"unconfirmed_count": sum(int(entry["count"]) for entry in result.unconfirmed),
"unpriced": result.unpriced,
"unpriced_count": len(result.unpriced),
"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,
)