diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py
index d8410b1f..4a5f7329 100644
--- a/B09_Estimation/B09_Estimation_BillOfQuantities.py
+++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py
@@ -24,7 +24,9 @@ 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_haul_volume_within_cut,
@@ -304,6 +306,15 @@ def _number_of(path: tuple[int, ...]) -> str:
return "-".join(str(n) for n in path)
+from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
+ _composite_row,
+ _excluded_row,
+ _haul_price_of,
+ _leaf_row,
+ _material_row,
+)
+
+
def build_bill(
payload: dict[str, Any],
*,
@@ -415,6 +426,15 @@ def build_bill(
],
)
+ # ㉥ 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 파이프를 자재로 또 세지 않았는가.
+ # 지금은 제잡비를 늘 아랫단으로 붙여 `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 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다.
@@ -468,300 +488,6 @@ def build_bill(
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
-
- if item.blocked_reason:
- # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
- # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
- # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
- row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}"
- result.missing.append(
- {
- "name": row.name,
- "code": node.code,
- "unit": row.unit,
- "quantity": str(item.quantity),
- "reason": row.note,
- "blocked_kind": item.blocked_kind,
- }
- )
- return row
-
- price_code = f"B-{node.code}"
- if item.variant_value and price_code not in unit_prices.book.titles:
- # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
- # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
- picked = find_variant_code(node.code, item.variant_value, unit_prices)
- if picked is not None:
- price_code = picked
- row.spec = f"{row.spec} {item.variant_value}".strip()
-
- 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
- )
-
- # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
- if price_code not in result.used_unit_prices:
- result.used_unit_prices.append(price_code)
-
- 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
- # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
- # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
- # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
- if material.supply_type == SUPPLY_OWNER:
- row.note = (
- row.note
- or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
- "관급자재대(총원가 밖 별도 표기)로 갑니다."
- )
- reason = "관급 자재 단가 없음"
- else:
- row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
- reason = "사급 자재 단가 없음(미결 No.18)"
-
- result.missing.append(
- {
- "name": material.display_name,
- "unit": material.unit,
- "quantity": str(material.total_amount),
- "reason": reason,
- "supply_type": material.supply_type,
- }
- )
- return row
-
-
def bill_summary(result: BillResult) -> dict[str, Any]:
"""화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다."""
return {
diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py
new file mode 100644
index 00000000..46a19f4c
--- /dev/null
+++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py
@@ -0,0 +1,322 @@
+"""B09 원가계산 — ④ 예산내역서 **줄 만들기** (`B09_Estimation_BillOfQuantities` 보조).
+
+가르는 금은 「표 한 장을 짜는가 / 줄 하나를 만드는가」다. 700줄 제한(CLAUDE.md 4장)에
+걸려 나눴고, 부르는 쪽은 종전대로 조판 모듈에서 가져다 쓴다.
+
+⚠ **여기 있는 줄 만들기는 하나같이 「못 세우면 금액을 비운다」**로 끝난다 —
+0 으로 때우면 총액이 그럴듯해지고 무엇이 빠졌는지 안 보인다.
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+
+from B09_Estimation.B09_Estimation_BillOfQuantities import (
+ SUPPLY_OWNER,
+ SUPPLY_UNKNOWN,
+ BillResult,
+ BillRow,
+ HandoffMaterial,
+ HandoffWorkItem,
+ _BLOCKED_LABELS,
+ _MasterNode,
+ _decimal,
+)
+from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
+from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code
+
+_ZERO = Decimal(0)
+
+
+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
+
+ if item.blocked_reason:
+ # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
+ # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
+ # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
+ row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}"
+ result.missing.append(
+ {
+ "name": row.name,
+ "code": node.code,
+ "unit": row.unit,
+ "quantity": str(item.quantity),
+ "reason": row.note,
+ "blocked_kind": item.blocked_kind,
+ }
+ )
+ return row
+
+ price_code = f"B-{node.code}"
+ if item.variant_value and price_code not in unit_prices.book.titles:
+ # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
+ # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
+ picked = find_variant_code(node.code, item.variant_value, unit_prices)
+ if picked is not None:
+ price_code = picked
+ row.spec = f"{row.spec} {item.variant_value}".strip()
+
+ 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
+ )
+
+ # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
+ if price_code not in result.used_unit_prices:
+ result.used_unit_prices.append(price_code)
+
+ 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
+ # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
+ # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
+ # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
+ if material.supply_type == SUPPLY_OWNER:
+ row.note = (
+ row.note
+ or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
+ "관급자재대(총원가 밖 별도 표기)로 갑니다."
+ )
+ reason = "관급 자재 단가 없음"
+ else:
+ row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
+ reason = "사급 자재 단가 없음(미결 No.18)"
+
+ result.missing.append(
+ {
+ "name": material.display_name,
+ "unit": material.unit,
+ "quantity": str(material.total_amount),
+ "reason": reason,
+ "supply_type": material.supply_type,
+ }
+ )
+ return row
diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py
index 800ceac2..0bf0fdc7 100644
--- a/B09_Estimation/B09_Estimation_Guards.py
+++ b/B09_Estimation/B09_Estimation_Guards.py
@@ -92,6 +92,13 @@ def check_haul_volume_within_cut(
)
+#: ⚠ **아직 부르는 자리가 없다** (2026-09-08 메인 창 교차검토에서 확인).
+#: 배합 분해(콘크리트 → 시멘트·모래·자갈)가 우리 일위대가에 아직 안 서 있어
+#: 넘길 값이 없다. **「있다」와 「돈다」는 다르다** — 조건이 생기는 곳을 아래에 적어 둔다.
+#:
+#: **부를 자리** — `B09_Estimation_UnitPrice.build_unit_prices()` 에서 콘크리트 계열
+#: (`FP-12-01*`)에 시멘트·모래·자갈 줄이 붙기 시작하면, 그 공종의 시멘트 총량과
+#: 콘크리트 체적을 넘겨 부를 것. 지금 그 표는 「㎥」까지만 내고 분해가 없다.
def check_mix_decomposed_once(
*,
cement_total_kg: Decimal,
@@ -218,6 +225,12 @@ OVERHEAD_TIER_WITHOUT_PIPE = "without_pipe"
DRAIN_PIPE_NAMES = ("물빼기파이프", "물빼기 파이프", "물구멍", "배수공")
+#: ⚠ **아직 부르는 자리가 없다** — 제잡비를 늘 **아랫단**(파이프 미설치)으로만 붙여
+#: 조건이 안 온다. 값이 틀린 것은 아니고, **윗단을 쓰게 되는 날 막을 것이 없는** 상태다.
+#:
+#: **부를 자리** — `B09_Estimation_BillOfQuantities.build_bill()` 의 자재 검사 옆.
+#: 제잡비 윗단을 고르는 설계 조건(물빼기 파이프 설치 여부)이 인계에 실리면,
+#: 그 조건과 자재 이름 목록을 넘겨 부를 것. 지금은 그 칸 자체가 없다.
def check_drain_pipe_not_double_counted(
*,
overhead_tier: str,
diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py
index cd3fff20..e5a6ea05 100644
--- a/B09_Estimation/B09_Estimation_MachineProductivity.py
+++ b/B09_Estimation/B09_Estimation_MachineProductivity.py
@@ -36,6 +36,7 @@ from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
+from B09_Estimation.B09_Estimation_ResourceAxis import RANGE_DASHES
_ZERO = Decimal(0)
_SECONDS_PER_HOUR = Decimal(3600)
@@ -64,7 +65,7 @@ _RE_PARENS = re.compile(r"[((]([^))]*)[))]")
_RE_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
_RE_FRACTION = re.compile(r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)$")
#: 범위 표기 — 「0.55∼0.45」·「0.2~0.8」. **확정값이 아니다.**
-_RE_RANGE = re.compile(r"\d+(?:\.\d+)?\s*[∼~~-]\s*\d+(?:\.\d+)?")
+_RE_RANGE = re.compile(rf"\d+(?:\.\d+)?\s*[{RANGE_DASHES}]\s*\d+(?:\.\d+)?")
class ProductivityError(ValueError):
diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py
index d73ee436..ad91cc60 100644
--- a/B09_Estimation/B09_Estimation_PriceBook.py
+++ b/B09_Estimation/B09_Estimation_PriceBook.py
@@ -194,12 +194,30 @@ class PriceBook:
raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다")
total = Money3()
+ # 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다.
+ # 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋.
+ direct_labor = Decimal(0)
for row in rows:
# ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로
# 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다).
if row.percent_of_labor is not None:
# 제잡비 — **노무비 합계**의 %가 **경비**로 붙는다(품셈 13-6-1 [주]③).
- total = total + Money3(expense=total.labor * row.percent_of_labor / Decimal(100))
+ #
+ # ⚠ **밑수는 사람 품(직접노무비)이다** — 기계 줄 안의 조종원 노임은
+ # 안 센다. 근거 셋(2026-09-08 원문 대조, 두 창 합의):
+ # ① 산림품셈 13-6-2 [주]③ — 「제잡비는 콘크리트 버켓 손료, 다짐기계
+ # 손료 비용이며 **노무비의 합계액**에 위 표의 비율을 곱한 금액을
+ # 상한으로 하여 계상한다」
+ # ② 건설품셈 제8장 — 「잡재료 등 손료 : **직접노무비**에 다음 표의
+ # 비율을 곱한 것을 상한으로 한다」 (같은 이름의 규정)
+ # ③ 같은 장 — 기계를 넣을 때는 「잡재료비 = **노무비, 기계손료 및
+ # 운전경비의 합** × 잡재료비율」이라 **따로 적음** ⇒ 넓은 쪽이면
+ # 명시하는 서식인데 13-6-2 는 그냥 「노무비」다.
+ # 뜻으로도 그렇다 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람
+ # 품에 비례해 얹는 자리인데, 그 표엔 굴착기가 이미 본 자원으로 서 있다.
+ # ⚠ 잠정 — 조종원 노임을 넣으면 찰쌓기 60~80 기준 2,098.46 → 2,565.90
+ # (약 +22 %). 사용자 확정 대기(PLAN 9-6).
+ total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100))
continue
child = self.resolve(row.ref_code, (*_seen, code))
@@ -207,7 +225,10 @@ class PriceBook:
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
total = total + total.scaled(row.percent_of_parent / Decimal(100))
continue
- total = total + child.scaled(row.quantity)
+ scaled = child.scaled(row.quantity)
+ if self.titles[row.ref_code].kind is PriceKind.LABOR:
+ direct_labor = direct_labor + scaled.labor
+ total = total + scaled
return total
def unmatched_codes(self) -> list[str]:
diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py
index f4d0e8d9..630d651b 100644
--- a/B09_Estimation/B09_Estimation_ResourceAxis.py
+++ b/B09_Estimation/B09_Estimation_ResourceAxis.py
@@ -145,7 +145,13 @@ class ResourceCatalog:
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
-_RE_RANGE_CELL = re.compile(r"^\d+(?:\.\d+)?\s*[∼~〜\-–]\s*\d+(?:\.\d+)?$")
+#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
+#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
+#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
+#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
+RANGE_DASHES = "∼~〜~-–‐"
+
+_RE_RANGE_CELL = re.compile(rf"^\d+(?:\.\d+)?\s*[{RANGE_DASHES}]\s*\d+(?:\.\d+)?$")
_RE_HANGUL = re.compile(r"[가-힣]")
@@ -192,201 +198,6 @@ def _normalize(text: str) -> str:
return re.sub(r"\s+", "", str(text or "")).strip()
-def load_labor_catalog(file_name: str = "labor_const_2026-01-01.json") -> ResourceCatalog:
- """노임 카탈로그 132직종 + `aliases`. 코드는 `occupation_code`."""
- payload = _read_json(*_CATALOG_SUBPATH, file_name)
- variables = payload["variables"]
- records = variables["labor_rate"]["records"]
- entries = [
- CatalogEntry(code=str(r["occupation_code"]), name=r["occupation_name"], kind="labor")
- for r in records
- ]
- return ResourceCatalog(entries=entries, aliases=dict(variables.get("aliases", {})))
-
-
-def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list[CatalogEntry]:
- """기종 카탈로그 613건을 매칭용 항목으로 편다.
-
- ⚠ **규격이 매칭의 일부**다 — 「굴착기(무한궤도)」만 23 규격이라 이름만으로는
- 한 대가 안 정해진다(지시 4번). `CatalogEntry.spec` 에 규격을 실어 둔다.
- """
- from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
-
- catalog = load_machine_catalog(file_name)
- return [
- CatalogEntry(code=m.machine_code, name=m.name, kind="machine", spec=m.specification)
- for m in catalog.machines.values()
- ]
-
-
-def load_material_catalog_entries(
- file_name: str = "mat_price_public_2026-08-14.json",
-) -> list[CatalogEntry]:
- """관급 자재 6,999건을 매칭용 항목으로 편다.
-
- ⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323).
- **규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다.
- """
- from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog
-
- catalog = load_material_catalog(file_name)
- return [
- CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification)
- for m in catalog.items.values()
- ]
-
-
-def load_combined_catalog() -> ResourceCatalog:
- """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다."""
- labor = load_labor_catalog()
- return ResourceCatalog(
- entries=[
- *labor.entries,
- *load_machine_catalog_entries(),
- *load_material_catalog_entries(),
- ],
- aliases=labor.aliases,
- )
-
-
-def load_work_item_master(
- file_name: str = "work_item_master_2026-01-01.json",
-) -> dict[str, Any]:
- """메인 창 산출물 — **읽기 전용**."""
- return _read_json(*_MASTER_SUBPATH, file_name)
-
-
-def split_name_and_spec(cell: str) -> tuple[str, str]:
- """셀 문자열에서 이름과 규격을 가른다.
-
- 「유압식백호우 (무한궤도,0.7㎥)」 → (`유압식백호우`, `무한궤도,0.7㎥`)
- 「덤프트럭 15톤」 → (`덤프트럭`, `15톤`)
- """
- text = str(cell or "").strip()
- match = _RE_SPEC.search(text)
- if not match:
- return text, ""
- spec = (match.group(1) or match.group(2) or "").strip()
- name = (text[: match.start()] + text[match.end() :]).strip(" ,()()")
- return name, spec
-
-
-#: 기종 이름의 괄호 안은 **규격과 형식이 섞여** 있다 —
-#: 「굴착기(무한궤도, 0.7㎥)」 는 이름 `굴착기(무한궤도)` + 규격 `0.7` 이다.
-#: 형식(무한궤도·타이어)은 이름의 일부이고, 숫자가 든 조각만 규격이다.
-_RE_MACHINE = re.compile(r"^(?P[^()()]+)[((](?P[^))]*)[))]")
-_RE_SIZE_TOKEN = re.compile(r"\d+(?:\.\d+)?")
-
-
-def parse_machine_cell(cell: str) -> tuple[str, str]:
- """기종 셀을 카탈로그 이름과 규격으로 가른다.
-
- 「굴착기(무한궤도, 0.7㎥)」 → (`굴착기(무한궤도)`, `0.7`)
- 「굴착기 (무한궤도)」 → (`굴착기(무한궤도)`, ``) ← 규격은 옆 칸에 있다
- 괄호가 없으면 원문 그대로 돌려준다.
- """
- text = _normalize(cell)
- match = _RE_MACHINE.match(text)
- if not match:
- return text, ""
- base = match.group("base")
- parts = [p for p in re.split(r"[,·/]", match.group("inner")) if p]
- form_parts = [p for p in parts if not _RE_SIZE_TOKEN.search(p)]
- size_parts = [p for p in parts if _RE_SIZE_TOKEN.search(p)]
- name = f"{base}({','.join(form_parts)})" if form_parts else base
- spec = ""
- if size_parts:
- found = _RE_SIZE_TOKEN.search(size_parts[0])
- spec = found.group(0) if found else ""
- return name, spec
-
-
-def spec_candidates(cells: list[str]) -> list[str]:
- """규격이 옆 칸에 있는 표가 많다 — 뒷 칸들에서 규격 후보를 모은다.
-
- 실측 배치: `['굴착기+부착용집게', '0.2㎥', 'hr', '2.71', …]` ·
- `['굴착기 (무한궤도)', '굴착기(무한궤도,0.2㎥)', 'hr', '0.80', …]`
- """
- found: list[str] = []
- for cell in cells:
- text = _normalize(cell)
- if not text or len(text) > 30:
- continue
- _, spec = parse_machine_cell(text)
- if spec:
- found.append(spec)
- continue
- token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³"))
- if token:
- found.append(token.group(0))
- return found
-
-
-def parse_amount(cell: str) -> Decimal | None:
- """숫자 셀만 값으로 본다. 숫자가 아니면 None — 억지로 읽지 않는다."""
- text = _normalize(cell)
- if not _RE_NUMBER.match(text):
- return None
- try:
- return Decimal(text.replace(",", ""))
- except InvalidOperation: # pragma: no cover - 정규식이 먼저 거른다
- return None
-
-
-#: 「0.2 × 30%」 꼴 — 값과 배분율이 **한 칸에** 적힌 표기(기초잡석 12-25).
-#: ⚠ 이 값을 읽었으면 **딱지의 배분율을 또 곱하면 안 된다** — 같은 30 % 가 두 번 곱해진다.
-_RE_RATIO_EXPRESSION = re.compile(r"^(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*%$")
-
-
-def parse_amount_expression(cell: str) -> Decimal | None:
- """「0.2 × 30%」를 0.06 으로 읽는다. 그 밖의 식은 **읽지 않는다**.
-
- 식을 넓게 읽으려 들면 「5인/km」처럼 **기준이 다른 값**까지 삼킨다. 여기서 보는 것은
- 「값 × 비율%」 한 모양뿐이다.
- """
- found = _RE_RATIO_EXPRESSION.match(_normalize(cell))
- if found is None:
- return None
- return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100)
-
-
-#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값.
-#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」
-#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]).
-#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기).
-#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다.
-_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[((](\d+(?:\.\d+)?)[))]$")
-
-
-def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None:
- """「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`."""
- text = _normalize(cell)
- found = _RE_ALTERNATIVE.match(text)
- if found:
- return Decimal(found.group(1)), Decimal(found.group(2))
- plain = parse_amount(text)
- return None if plain is None else (plain, None)
-
-
-def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal:
- """표 형태에 맞춰 소요량으로 환산한다.
-
- ⚠ **여기가 20배 틀리는 자리다.**
- - `productivity`(생산량형, 예 「㎥/1인/1일」) → **1 ÷ 값**
- - `requirement`(소요량형, 예 「100㎥당 인부 x인」) → **값 ÷ basis_quantity**
- """
- if pum_form == "productivity":
- if raw == 0:
- raise ResourceAxisError("생산량이 0 이라 소요량으로 뒤집을 수 없습니다")
- return Decimal(1) / raw
- if pum_form == "requirement":
- divisor = basis_quantity if basis_quantity else Decimal(1)
- if divisor == 0:
- raise ResourceAxisError("기준 수량이 0 입니다")
- return raw / divisor
- raise ResourceAxisError(f"자원 축을 붙일 수 없는 표 형태입니다: {pum_form}")
-
-
@dataclass
class ResourceRow:
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
@@ -721,6 +532,14 @@ def match_table(
#: **보통인부 0.23인이 통째로 빠지고 있었다**(그 공종의 자원 줄이 0 개였다).
#: 괄호 안이 **숫자·%·소수점뿐일 때만** 떼어 낸다 — 「보통인부(인)」 같은 단위 표기는
#: 떼면 안 되므로 넓게 잡지 않는다.
+#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값.
+#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」
+#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]).
+#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기).
+#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다.
+_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[((](\d+(?:\.\d+)?)[))]$")
+
+
_RE_RATIO_SUFFIX = re.compile(r"[((][\d.\s]*%?[))]$")
@@ -827,3 +646,21 @@ def write_resource_axis(
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
return {"resource_axis": axis_path, "unmatched": unmatched_path}
+
+
+# 카탈로그 적재·셀 파싱은 700줄 제한으로 `_Sources` 파일로 옮겼다.
+# 가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다.
+from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402
+ convert_amount,
+ load_combined_catalog,
+ load_labor_catalog,
+ load_machine_catalog_entries,
+ load_material_catalog_entries,
+ load_work_item_master,
+ parse_amount,
+ parse_amount_expression,
+ parse_amount_pair,
+ parse_machine_cell,
+ spec_candidates,
+ split_name_and_spec,
+)
diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py
new file mode 100644
index 00000000..69f3c23f
--- /dev/null
+++ b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py
@@ -0,0 +1,220 @@
+"""B09 원가계산 — 자원 축 **자료 적재·셀 파싱** (`B09_Estimation_ResourceAxis` 보조).
+
+가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다. 700줄 제한(CLAUDE.md
+4장)에 걸려 나눴고, 부르는 쪽은 종전대로 `B09_Estimation_ResourceAxis` 에서 가져다 쓴다.
+
+⚠ **셀을 억지로 읽지 않는다** — 범위(「0.55∼0.45」)·참조(「육상과동일」)·반복부호(「〃」)는
+확정값이 아니므로 `None` 을 돌려주고, 그 사실이 위쪽에서 드러난다.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from B09_Estimation.B09_Estimation_ResourceAxis import (
+ CatalogEntry,
+ RANGE_DASHES,
+ ResourceAxisError,
+ ResourceCatalog,
+ _normalize,
+ _project_root,
+ _read_json,
+ _RE_NUMBER,
+ _RE_RANGE_CELL,
+ _RE_SPEC,
+ _RE_ALTERNATIVE,
+)
+
+_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
+_MASTER_SUBPATH = ("resources", "data_work_item_master")
+
+
+def load_labor_catalog(file_name: str = "labor_const_2026-01-01.json") -> ResourceCatalog:
+ """노임 카탈로그 132직종 + `aliases`. 코드는 `occupation_code`."""
+ payload = _read_json(*_CATALOG_SUBPATH, file_name)
+ variables = payload["variables"]
+ records = variables["labor_rate"]["records"]
+ entries = [
+ CatalogEntry(code=str(r["occupation_code"]), name=r["occupation_name"], kind="labor")
+ for r in records
+ ]
+ return ResourceCatalog(entries=entries, aliases=dict(variables.get("aliases", {})))
+
+
+def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list[CatalogEntry]:
+ """기종 카탈로그 613건을 매칭용 항목으로 편다.
+
+ ⚠ **규격이 매칭의 일부**다 — 「굴착기(무한궤도)」만 23 규격이라 이름만으로는
+ 한 대가 안 정해진다(지시 4번). `CatalogEntry.spec` 에 규격을 실어 둔다.
+ """
+ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
+
+ catalog = load_machine_catalog(file_name)
+ return [
+ CatalogEntry(code=m.machine_code, name=m.name, kind="machine", spec=m.specification)
+ for m in catalog.machines.values()
+ ]
+
+
+def load_material_catalog_entries(
+ file_name: str = "mat_price_public_2026-08-14.json",
+) -> list[CatalogEntry]:
+ """관급 자재 6,999건을 매칭용 항목으로 편다.
+
+ ⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323).
+ **규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다.
+ """
+ from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog
+
+ catalog = load_material_catalog(file_name)
+ return [
+ CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification)
+ for m in catalog.items.values()
+ ]
+
+
+def load_combined_catalog() -> ResourceCatalog:
+ """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다."""
+ labor = load_labor_catalog()
+ return ResourceCatalog(
+ entries=[
+ *labor.entries,
+ *load_machine_catalog_entries(),
+ *load_material_catalog_entries(),
+ ],
+ aliases=labor.aliases,
+ )
+
+
+def load_work_item_master(
+ file_name: str = "work_item_master_2026-01-01.json",
+) -> dict[str, Any]:
+ """메인 창 산출물 — **읽기 전용**."""
+ return _read_json(*_MASTER_SUBPATH, file_name)
+
+
+def split_name_and_spec(cell: str) -> tuple[str, str]:
+ """셀 문자열에서 이름과 규격을 가른다.
+
+ 「유압식백호우 (무한궤도,0.7㎥)」 → (`유압식백호우`, `무한궤도,0.7㎥`)
+ 「덤프트럭 15톤」 → (`덤프트럭`, `15톤`)
+ """
+ text = str(cell or "").strip()
+ match = _RE_SPEC.search(text)
+ if not match:
+ return text, ""
+ spec = (match.group(1) or match.group(2) or "").strip()
+ name = (text[: match.start()] + text[match.end() :]).strip(" ,()()")
+ return name, spec
+
+
+#: 기종 이름의 괄호 안은 **규격과 형식이 섞여** 있다 —
+#: 「굴착기(무한궤도, 0.7㎥)」 는 이름 `굴착기(무한궤도)` + 규격 `0.7` 이다.
+#: 형식(무한궤도·타이어)은 이름의 일부이고, 숫자가 든 조각만 규격이다.
+_RE_MACHINE = re.compile(r"^(?P[^()()]+)[((](?P[^))]*)[))]")
+_RE_SIZE_TOKEN = re.compile(r"\d+(?:\.\d+)?")
+
+
+def parse_machine_cell(cell: str) -> tuple[str, str]:
+ """기종 셀을 카탈로그 이름과 규격으로 가른다.
+
+ 「굴착기(무한궤도, 0.7㎥)」 → (`굴착기(무한궤도)`, `0.7`)
+ 「굴착기 (무한궤도)」 → (`굴착기(무한궤도)`, ``) ← 규격은 옆 칸에 있다
+ 괄호가 없으면 원문 그대로 돌려준다.
+ """
+ text = _normalize(cell)
+ match = _RE_MACHINE.match(text)
+ if not match:
+ return text, ""
+ base = match.group("base")
+ parts = [p for p in re.split(r"[,·/]", match.group("inner")) if p]
+ form_parts = [p for p in parts if not _RE_SIZE_TOKEN.search(p)]
+ size_parts = [p for p in parts if _RE_SIZE_TOKEN.search(p)]
+ name = f"{base}({','.join(form_parts)})" if form_parts else base
+ spec = ""
+ if size_parts:
+ found = _RE_SIZE_TOKEN.search(size_parts[0])
+ spec = found.group(0) if found else ""
+ return name, spec
+
+
+def spec_candidates(cells: list[str]) -> list[str]:
+ """규격이 옆 칸에 있는 표가 많다 — 뒷 칸들에서 규격 후보를 모은다.
+
+ 실측 배치: `['굴착기+부착용집게', '0.2㎥', 'hr', '2.71', …]` ·
+ `['굴착기 (무한궤도)', '굴착기(무한궤도,0.2㎥)', 'hr', '0.80', …]`
+ """
+ found: list[str] = []
+ for cell in cells:
+ text = _normalize(cell)
+ if not text or len(text) > 30:
+ continue
+ _, spec = parse_machine_cell(text)
+ if spec:
+ found.append(spec)
+ continue
+ token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³"))
+ if token:
+ found.append(token.group(0))
+ return found
+
+
+def parse_amount(cell: str) -> Decimal | None:
+ """숫자 셀만 값으로 본다. 숫자가 아니면 None — 억지로 읽지 않는다."""
+ text = _normalize(cell)
+ if not _RE_NUMBER.match(text):
+ return None
+ try:
+ return Decimal(text.replace(",", ""))
+ except InvalidOperation: # pragma: no cover - 정규식이 먼저 거른다
+ return None
+
+
+#: 「0.2 × 30%」 꼴 — 값과 배분율이 **한 칸에** 적힌 표기(기초잡석 12-25).
+#: ⚠ 이 값을 읽었으면 **딱지의 배분율을 또 곱하면 안 된다** — 같은 30 % 가 두 번 곱해진다.
+_RE_RATIO_EXPRESSION = re.compile(r"^(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*%$")
+
+
+def parse_amount_expression(cell: str) -> Decimal | None:
+ """「0.2 × 30%」를 0.06 으로 읽는다. 그 밖의 식은 **읽지 않는다**.
+
+ 식을 넓게 읽으려 들면 「5인/km」처럼 **기준이 다른 값**까지 삼킨다. 여기서 보는 것은
+ 「값 × 비율%」 한 모양뿐이다.
+ """
+ found = _RE_RATIO_EXPRESSION.match(_normalize(cell))
+ if found is None:
+ return None
+ return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100)
+
+
+def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None:
+ """「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`."""
+ text = _normalize(cell)
+ found = _RE_ALTERNATIVE.match(text)
+ if found:
+ return Decimal(found.group(1)), Decimal(found.group(2))
+ plain = parse_amount(text)
+ return None if plain is None else (plain, None)
+
+
+def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal:
+ """표 형태에 맞춰 소요량으로 환산한다.
+
+ ⚠ **여기가 20배 틀리는 자리다.**
+ - `productivity`(생산량형, 예 「㎥/1인/1일」) → **1 ÷ 값**
+ - `requirement`(소요량형, 예 「100㎥당 인부 x인」) → **값 ÷ basis_quantity**
+ """
+ if pum_form == "productivity":
+ if raw == 0:
+ raise ResourceAxisError("생산량이 0 이라 소요량으로 뒤집을 수 없습니다")
+ return Decimal(1) / raw
+ if pum_form == "requirement":
+ divisor = basis_quantity if basis_quantity else Decimal(1)
+ if divisor == 0:
+ raise ResourceAxisError("기준 수량이 0 입니다")
+ return raw / divisor
+ raise ResourceAxisError(f"자원 축을 붙일 수 없는 표 형태입니다: {pum_form}")
diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py
index 38bcfb67..417b70c1 100644
--- a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py
+++ b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py
@@ -24,6 +24,7 @@ from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
+ RANGE_DASHES,
AxisResult,
ResourceCatalog,
ResourceRow,
@@ -253,7 +254,7 @@ def _resolve_packed(catalog: ResourceCatalog, name: str, specs: list[str] | None
_TRACK_PREFERENCE = ("무한궤도",)
-_RE_SPEC_RANGE = re.compile(r"^(\d+(?:\.\d+)?)[∼~~-](\d+(?:\.\d+)?)$")
+_RE_SPEC_RANGE = re.compile(rf"^(\d+(?:\.\d+)?)[{RANGE_DASHES}](\d+(?:\.\d+)?)$")
def _spec_matches(catalog_spec: str, wanted: str) -> bool:
diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py
index f60b44ac..19976501 100644
--- a/B09_Estimation/B09_Estimation_UnitPrice.py
+++ b/B09_Estimation/B09_Estimation_UnitPrice.py
@@ -42,6 +42,7 @@ from B09_Estimation.B09_Estimation_PriceBook import (
PriceTitle,
)
from B09_Estimation.B09_Estimation_ResourceAxis import (
+ RANGE_DASHES,
AxisResult,
build_resource_axis,
load_combined_catalog,
@@ -229,7 +230,9 @@ def load_basis_missing(
#: 13-6-1·2 는 ∼, 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다.
#: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다
#: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다).
-_TILDE_CHARS = "∼~〜~"
+#: 물결표 목록은 `B09_Estimation_ResourceAxis.RANGE_DASHES` 한 곳에서 온다 —
+#: 붙임표(`-`·`–`)는 갈래 이름에 안 쓰이므로 물결표만 골라 쓴다.
+_TILDE_CHARS = "".join(ch for ch in RANGE_DASHES if ch not in "-–‐")
def normalize_variant_key(text: str) -> str:
@@ -539,223 +542,6 @@ def cached_build() -> UnitPriceBuild:
return build_unit_prices()
-def _plain(text: str) -> str:
- """화면용 평문 — 마크다운 강조 표시를 벗긴다."""
- return _RE_EMPHASIS.sub(lambda match: match.group(1), text)
-
-
-def _status_notes() -> list[str]:
- """화면에 낼 「지금 무엇이 안 선 상태인가」.
-
- 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가
- 거의 없다는 것이 이 자리의 진짜 공백이다.
- """
- from B09_Estimation.B09_Estimation_MaterialCatalog import (
- catalog_summary,
- load_material_catalog,
- )
-
- summary = catalog_summary(load_material_catalog())
- # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다.
- return [
- _plain(note)
- for note in [
- f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — "
- "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 "
- "철근·레미콘·아스콘은 원천에서 빠져 있습니다.",
- "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — "
- "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 "
- "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)",
- (
- f"관급 자재 **설치 주체가 미지정**"
- f"({summary['owner_supplied_install_unspecified']:,}건)이라 "
- "안전관리비 대상액에 자동으로 넣지 않습니다."
- ),
- ]
- ]
-
-
-def build_summary(build: UnitPriceBuild) -> dict:
- """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다."""
- kinds: dict[str, int] = {}
- for title in build.book.titles.values():
- kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1
- # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다.
- # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다
- # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07).
- totals = sorted(
- build.book.resolve(code).total
- for code, title in build.book.titles.items()
- if title.kind is PriceKind.UNIT_PRICE
- )
- stats: dict[str, str] = {}
- low: list[dict[str, str]] = []
- if totals:
- stats = {
- "min": _money_text(totals[0]),
- "median": _money_text(totals[len(totals) // 2]),
- "max": _money_text(totals[-1]),
- }
- # ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라
- # 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다.
- low = [
- {"code": code, "name": title.name, "total": _money_text(money)}
- for code, title in build.book.titles.items()
- if title.kind is PriceKind.UNIT_PRICE
- and code[2:].split("#")[0] not in build.partial_ratio
- and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
- ]
-
- # 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다.
- high = [
- {"code": code, "name": title.name, "total": _money_text(money)}
- for code, title in build.book.titles.items()
- if title.kind is PriceKind.UNIT_PRICE
- and not title.unit
- and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW
- ]
-
- return {
- "titles": len(build.book.titles),
- "unit_price_totals": stats,
- # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
- "suspiciously_low": low,
- # 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다.
- "blocked_items": len(build.partial_ratio),
- # 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
- "unknown_basis_high": high,
- "unknown_basis": sum(
- 1
- for code, title in build.book.titles.items()
- if title.kind is PriceKind.UNIT_PRICE and not title.unit
- ),
- "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
- "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
- "skipped_work_items": len(build.skipped),
- "incomplete_machines": len(build.incomplete_machines),
- "kinds": kinds,
- # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결).
- "notes": _status_notes(),
- }
-
-
-def _money_text(value: Decimal) -> str:
- """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2).
-
- 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다
- (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다).
- """
- return str(round_at(value, OutputPlace.UNIT_PRICE_ROW))
-
-
-def list_unit_prices(build: UnitPriceBuild) -> list[dict]:
- """목록표 — 「무엇이 있나」 한 줄씩."""
- rows: list[dict] = []
- for code, title in sorted(build.book.titles.items()):
- if title.kind is not PriceKind.UNIT_PRICE:
- continue
- money = build.book.resolve(code)
- rows.append(
- {
- "code": code,
- "name": title.name,
- "spec": title.spec,
- "unit": title.unit,
- "material": _money_text(money.material),
- "labor": _money_text(money.labor),
- "expense": _money_text(money.expense),
- "total": _money_text(money.total),
- }
- )
- return rows
-
-
-def detail_of(build: UnitPriceBuild, code: str) -> dict:
- """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
- title = build.book.title(code)
- money = build.book.resolve(code)
- rows: list[dict] = []
- for detail in build.book.details.get(code, []):
- if detail.percent_of_labor is not None:
- # 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야
- # 화면 합계와 실제 단가가 어긋나지 않는다.
- labor_so_far = sum((Decimal(str(r["labor"])) for r in rows), _ZERO)
- amount = labor_so_far * detail.percent_of_labor / Decimal(100)
- rows.append(
- {
- "code": detail.ref_code,
- "name": "제잡비",
- "spec": f"노무비의 {detail.percent_of_labor}%",
- "unit": "%",
- "quantity": str(detail.percent_of_labor),
- "material": "0",
- "labor": "0",
- "expense": str(amount),
- "total": _money_text(amount),
- "source": "품셈 [주]",
- "drillable": False,
- "note": detail.note,
- }
- )
- continue
-
- child = build.book.title(detail.ref_code)
- unit_money = build.book.resolve(detail.ref_code)
- line = unit_money.scaled(detail.quantity)
- rows.append(
- {
- "ref_code": detail.ref_code,
- "name": child.name,
- "spec": child.spec,
- "unit": child.unit,
- "source_index": SOURCE_INDEX.get(child.kind, 0),
- "source_label": SOURCE_LABEL.get(child.kind, ""),
- "drillable": child.kind in DRILLABLE_KINDS,
- "quantity": str(detail.quantity),
- "unit_material": _money_text(unit_money.material),
- "unit_labor": _money_text(unit_money.labor),
- "unit_expense": _money_text(unit_money.expense),
- "unit_total": _money_text(unit_money.total),
- "material": _money_text(line.material),
- "labor": _money_text(line.labor),
- "expense": _money_text(line.expense),
- # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다.
- # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다.
- "total": str(
- round_at(line.material, OutputPlace.UNIT_PRICE_ROW)
- + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW)
- + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW)
- ),
- "note": detail.note,
- }
- )
- # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」
- # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다.
- summed = {
- key: sum((Decimal(r[key]) for r in rows), Decimal(0))
- for key in ("material", "labor", "expense", "total")
- }
- # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
- # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
- check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
- return {
- "code": code,
- "name": title.name,
- "spec": title.spec,
- "unit": title.unit,
- "kind": title.kind.value,
- "material": str(summed["material"]),
- "labor": str(summed["labor"]),
- "expense": str(summed["expense"]),
- "total": str(summed["total"]),
- # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
- "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
- # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
- "precise_total": _money_text(money.total),
- "rows": rows,
- }
-
-
@dataclass
class DirectCostBreakdown:
"""⑤ 공사원가계산서가 받는 **직접비 3분할**.
@@ -830,3 +616,25 @@ def cost_input_from_quantities(
),
breakdown,
)
+
+
+# 화면용 조회(요약·목록·본표)는 700줄 제한으로 `_View` 파일로 옮겼다.
+# **부르는 쪽이 어디서 오는지 신경 쓰지 않게** 여기서 다시 내보낸다.
+from B09_Estimation.B09_Estimation_UnitPrice_View import ( # noqa: E402
+ build_summary,
+ detail_of,
+ list_unit_prices,
+)
+
+__all__ = [
+ "UnitPriceBuild",
+ "build_unit_prices",
+ "cached_build",
+ "build_summary",
+ "detail_of",
+ "list_unit_prices",
+ "direct_cost_from_quantities",
+ "cost_input_from_quantities",
+ "find_variant_code",
+ "normalize_variant_key",
+]
diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py
new file mode 100644
index 00000000..14c57ed6
--- /dev/null
+++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py
@@ -0,0 +1,258 @@
+"""B09 원가계산 — 일위대가 **화면용 조회** (요약·목록·본표).
+
+조립(`B09_Estimation_UnitPrice`)과 **보여주기**를 갈라 둔 파일이다. 700줄 제한(CLAUDE.md
+4장)에 걸려 나눴고, 가르는 금은 「값을 만드는가 / 만든 값을 화면 모양으로 옮기는가」다.
+
+⚠ 단수 처리는 **여기서** 한다 — 계산 함수 안에서 자르지 않는다
+(`B09_Estimation_Rounding` 머리말). 일위대가 금액란은 0.1원 버림이다.
+"""
+
+from __future__ import annotations
+
+import re
+from decimal import Decimal
+
+from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
+from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog
+from B09_Estimation.B09_Estimation_PriceBook import PriceKind
+from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
+from B09_Estimation.B09_Estimation_UnitPrice import (
+ DRILLABLE_KINDS,
+ SOURCE_INDEX,
+ SOURCE_LABEL,
+ SUSPICIOUSLY_HIGH_KRW,
+ SUSPICIOUSLY_LOW_KRW,
+ UnitPriceBuild,
+)
+from B09_Estimation.B09_Estimation_Guards import check_column_sums
+
+_ZERO = Decimal(0)
+_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
+
+
+def _plain(text: str) -> str:
+ """화면용 평문 — 마크다운 강조 표시를 벗긴다."""
+ return _RE_EMPHASIS.sub(lambda match: match.group(1), text)
+
+
+def _status_notes() -> list[str]:
+ """화면에 낼 「지금 무엇이 안 선 상태인가」.
+
+ 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가
+ 거의 없다는 것이 이 자리의 진짜 공백이다.
+ """
+ from B09_Estimation.B09_Estimation_MaterialCatalog import (
+ catalog_summary,
+ load_material_catalog,
+ )
+
+ summary = catalog_summary(load_material_catalog())
+ # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다.
+ return [
+ _plain(note)
+ for note in [
+ f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — "
+ "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 "
+ "철근·레미콘·아스콘은 원천에서 빠져 있습니다.",
+ "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — "
+ "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 "
+ "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)",
+ (
+ f"관급 자재 **설치 주체가 미지정**"
+ f"({summary['owner_supplied_install_unspecified']:,}건)이라 "
+ "안전관리비 대상액에 자동으로 넣지 않습니다."
+ ),
+ ]
+ ]
+
+
+def build_summary(build: UnitPriceBuild) -> dict:
+ """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다."""
+ kinds: dict[str, int] = {}
+ for title in build.book.titles.values():
+ kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1
+ # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다.
+ # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다
+ # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07).
+ totals = sorted(
+ build.book.resolve(code).total
+ for code, title in build.book.titles.items()
+ if title.kind is PriceKind.UNIT_PRICE
+ )
+ stats: dict[str, str] = {}
+ low: list[dict[str, str]] = []
+ if totals:
+ stats = {
+ "min": _money_text(totals[0]),
+ "median": _money_text(totals[len(totals) // 2]),
+ "max": _money_text(totals[-1]),
+ }
+ # ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라
+ # 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다.
+ low = [
+ {"code": code, "name": title.name, "total": _money_text(money)}
+ for code, title in build.book.titles.items()
+ if title.kind is PriceKind.UNIT_PRICE
+ and code[2:].split("#")[0] not in build.partial_ratio
+ and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
+ ]
+
+ # 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다.
+ high = [
+ {"code": code, "name": title.name, "total": _money_text(money)}
+ for code, title in build.book.titles.items()
+ if title.kind is PriceKind.UNIT_PRICE
+ and not title.unit
+ and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW
+ ]
+
+ return {
+ "titles": len(build.book.titles),
+ "unit_price_totals": stats,
+ # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
+ "suspiciously_low": low,
+ # 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다.
+ "blocked_items": len(build.partial_ratio),
+ # 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
+ "unknown_basis_high": high,
+ "unknown_basis": sum(
+ 1
+ for code, title in build.book.titles.items()
+ if title.kind is PriceKind.UNIT_PRICE and not title.unit
+ ),
+ "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
+ "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
+ "skipped_work_items": len(build.skipped),
+ "incomplete_machines": len(build.incomplete_machines),
+ "kinds": kinds,
+ # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결).
+ "notes": _status_notes(),
+ }
+
+
+def _money_text(value: Decimal) -> str:
+ """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2).
+
+ 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다
+ (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다).
+ """
+ return str(round_at(value, OutputPlace.UNIT_PRICE_ROW))
+
+
+def list_unit_prices(build: UnitPriceBuild) -> list[dict]:
+ """목록표 — 「무엇이 있나」 한 줄씩."""
+ rows: list[dict] = []
+ for code, title in sorted(build.book.titles.items()):
+ if title.kind is not PriceKind.UNIT_PRICE:
+ continue
+ money = build.book.resolve(code)
+ rows.append(
+ {
+ "code": code,
+ "name": title.name,
+ "spec": title.spec,
+ "unit": title.unit,
+ "material": _money_text(money.material),
+ "labor": _money_text(money.labor),
+ "expense": _money_text(money.expense),
+ "total": _money_text(money.total),
+ }
+ )
+ return rows
+
+
+def detail_of(build: UnitPriceBuild, code: str) -> dict:
+ """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
+ title = build.book.title(code)
+ money = build.book.resolve(code)
+ rows: list[dict] = []
+ for detail in build.book.details.get(code, []):
+ if detail.percent_of_labor is not None:
+ # 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야
+ # 화면 합계와 실제 단가가 어긋나지 않는다.
+ # 밑수는 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다
+ # (근거 인용 셋은 `PriceBook.resolve` 의 같은 자리 주석).
+ labor_so_far = sum(
+ (
+ Decimal(str(row_item["labor"]))
+ for row_item in rows
+ if row_item.get("kind") == PriceKind.LABOR.value
+ ),
+ _ZERO,
+ )
+ amount = labor_so_far * detail.percent_of_labor / Decimal(100)
+ rows.append(
+ {
+ "code": detail.ref_code,
+ "name": "제잡비",
+ "spec": f"노무비의 {detail.percent_of_labor}%",
+ "unit": "%",
+ "quantity": str(detail.percent_of_labor),
+ "material": "0",
+ "labor": "0",
+ "expense": str(amount),
+ "total": _money_text(amount),
+ "source": "품셈 [주]",
+ "drillable": False,
+ "note": detail.note,
+ }
+ )
+ continue
+
+ child = build.book.title(detail.ref_code)
+ unit_money = build.book.resolve(detail.ref_code)
+ line = unit_money.scaled(detail.quantity)
+ rows.append(
+ {
+ "ref_code": detail.ref_code,
+ "name": child.name,
+ "spec": child.spec,
+ "unit": child.unit,
+ # 제잡비 밑수를 가릴 때 쓴다 — 사람 품(`labor`)만 센다.
+ "kind": child.kind.value,
+ "source_index": SOURCE_INDEX.get(child.kind, 0),
+ "source_label": SOURCE_LABEL.get(child.kind, ""),
+ "drillable": child.kind in DRILLABLE_KINDS,
+ "quantity": str(detail.quantity),
+ "unit_material": _money_text(unit_money.material),
+ "unit_labor": _money_text(unit_money.labor),
+ "unit_expense": _money_text(unit_money.expense),
+ "unit_total": _money_text(unit_money.total),
+ "material": _money_text(line.material),
+ "labor": _money_text(line.labor),
+ "expense": _money_text(line.expense),
+ # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다.
+ # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다.
+ "total": str(
+ round_at(line.material, OutputPlace.UNIT_PRICE_ROW)
+ + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW)
+ + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW)
+ ),
+ "note": detail.note,
+ }
+ )
+ # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」
+ # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다.
+ summed = {
+ key: sum((Decimal(r[key]) for r in rows), Decimal(0))
+ for key in ("material", "labor", "expense", "total")
+ }
+ # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
+ # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
+ check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
+ return {
+ "code": code,
+ "name": title.name,
+ "spec": title.spec,
+ "unit": title.unit,
+ "kind": title.kind.value,
+ "material": str(summed["material"]),
+ "labor": str(summed["labor"]),
+ "expense": str(summed["expense"]),
+ "total": str(summed["total"]),
+ # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
+ "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
+ # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
+ "precise_total": _money_text(money.total),
+ "rows": rows,
+ }