Files
Aislo/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py
T
eomsangdonandClaude Opus 5 de8f0f15f2 fix(B09): 「붙은 몫 0%」만 적히던 사유에 **무엇이 안 붙었는지**를 함께 적음
「단가가 일부만 섰습니다 — 붙은 몫 0%」 만으로는 어디를 손볼지 알 수 없었음.
구조물터파기가 그 자리였는데, 참조는 이미 풀렸고 실제로 막는 것은 깨기(대형브레이커)·
치즐이었음.

- 사유에 `component_gaps`·`unattached` 를 이어 붙임
  「붙은 몫 0% · 깨기, 치즐소모량(본/hr) 줄이 아직 안 붙었습니다 (계수 참조는 풀렸습니다).」

검증: pytest 288 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 07:50:39 +09:00

405 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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` 줄. **수량만 보이고 단가·금액을 안 붙인다.**
세 갈래가 섞여 온다 — 갈라 적지 않으면 사용자가 할 일을 못 읽는다.
· **검산용**(보정량계·무대) — 합계 검산에만 쓰는 줄
· **막힌 줄**(`blocked_kind` 있음) — 입력이나 원단위를 기다림
· ⚠ **여기서 세지 않는 줄**(`blocked_kind` 없음) — 「다른 표에서 이미 섬」·
「이 노선엔 없음」. **이것을 할 일 목록에 얹으면 결국 이중계상이 된다.**
"""
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.blocked_reason
or 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.spec_class_basis:
# 갈래 판정 근거는 **B08 문구를 그대로** 쓴다(두 벌로 짜지 않는다).
row.note = " / ".join(part for part in (row.note, item.spec_class_basis) if part)
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:
# ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**.
# 뭉뚱그리면 사용자가 무엇을 기다려야 하는지 못 읽는다(2026-09-08 산마루측구:
# 표에 수량 칸이 비어 있고 「철근가공조립(간단)의 30 %」처럼 참조로만 적힌 자리).
gap = unit_prices.component_gaps.get(node.code)
if gap:
row.note = f"성분이 빠져 단가를 못 세웠습니다 — {gap}"
reason = f"성분 미확보 — {gap}"
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 으로 때우는 것과 같은 사고다.
# 무엇이 없어서 못 붙었는지까지 적는다 — 「붙은 몫 0%」만으로는 어디를 손볼지 모른다.
why = unit_prices.component_gaps.get(node.code) or ""
missing_rows = unit_prices.unattached.get(node.code) or []
if not why and missing_rows:
why = f"{', '.join(missing_rows[:3])} 줄이 아직 안 붙었습니다"
row.note = (
f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + "."
)
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": f"단가 일부만 섬(붙은 몫 {covered}%)" + (f" — {why}" if why else ""),
}
)
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
)
if title.unit and row.unit and not _same_unit(title.unit, row.unit):
# ⚠⚠ **단위가 다르면 곱하지 않는다** (2026-09-08 실측으로 드러난 자리).
# 돌쌓기(찰)이 B08 에서 **연장 10 m** 로 오는데 품셈 일위대가는 **㎡당**이라,
# 52,938.9원/㎡ × 10 m = 529,389원이 조용히 서 있었다. 면적으로 세면 26.101㎡ ×
# 52,938.9 = 1,381,753원이라 **2.6 배 적은 금액**이 내역서에 든 셈이다.
# 어느 쪽이 맞는지는 우리가 정할 일이 아니다 — **B08 이 면적을 보내거나 묶음
# 조각으로 보내야** 풀린다. 그때까지 **금액을 만들지 않고 드러낸다.**
row.note = (
f"단위가 안 맞습니다 — 수량은 {row.unit}, 단가는 {title.unit}당입니다. "
"곱하면 금액이 틀리므로 비워 둡니다."
)
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": f"단위 불일치 — 수량 {row.unit} vs 단가 {title.unit}당",
"blocked_kind": "unit_mismatch",
}
)
return row
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 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
#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다.
_UNIT_ALIASES = {
"㎥": "m3",
"m³": "m3",
"M3": "m3",
"루베": "m3",
"㎡": "m2",
"m²": "m2",
"M2": "m2",
"㎏": "kg",
"KG": "kg",
"톤": "ton",
"TON": "ton",
"t": "ton",
"개소": "개",
"EA": "개",
"ea": "개",
"인": "인",
"인/일": "인",
}
def _same_unit(left: str, right: str) -> bool:
"""두 단위가 같은가. **표기 차이만 흡수하고, 환산은 하지 않는다** — m 과 ㎡ 는 다르다."""
def key(text: str) -> str:
tight = "".join(str(text or "").split())
return _UNIT_ALIASES.get(tight, tight)
return key(left) == key(right)