Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1

This commit is contained in:
2026-09-08 05:53:37 +09:00
10 changed files with 919 additions and 712 deletions
+20 -294
View File
@@ -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 {
@@ -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
+13
View File
@@ -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,
@@ -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.550.45」·「0.20.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):
+23 -2
View File
@@ -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]:
+33 -196
View File
@@ -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<base>[^()()]+)[(](?P<inner>[^)]*)[)]")
_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,
)
@@ -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<base>[^()()]+)[(](?P<inner>[^)]*)[)]")
_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}")
@@ -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:
+26 -218
View File
@@ -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",
]
@@ -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,
}