Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
711 lines
32 KiB
Python
711 lines
32 KiB
Python
"""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_MachineProductivity_Dump import (
|
||
DUMP_PARENT,
|
||
LOADING_EQUIPMENT,
|
||
dump_child_for,
|
||
dump_title_code,
|
||
loading_title_code,
|
||
)
|
||
from B09_Estimation.B09_Estimation_MaterialPrices import manual_count
|
||
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
||
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
|
||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||
UnitPriceBuild,
|
||
_master_edition,
|
||
find_variant_code,
|
||
)
|
||
|
||
|
||
def bill_line(unit: Money3, quantity) -> Money3:
|
||
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
|
||
|
||
근거 STmate 16번 행 금액 1,515건(505줄 × 성분 셋) · 착공내역서 178건 전수 절사.
|
||
⚠ 합을 한 번에 자르면 성분 합과 1~2원 갈림 — 원가계산서 직접비 밑수도 이 자른 성분의 합.
|
||
"""
|
||
return Money3(
|
||
material=round_at(unit.material * quantity, OutputPlace.BOQ_ROW),
|
||
labor=round_at(unit.labor * quantity, OutputPlace.BOQ_ROW),
|
||
expense=round_at(unit.expense * quantity, OutputPlace.BOQ_ROW),
|
||
)
|
||
|
||
|
||
def settle_quantity(row: BillRow) -> Decimal:
|
||
"""수량을 품셈 1-2-2 자리로 **반올림해 확정**하고 그 값을 돌려줌 — 금액은 확정한 수량으로 셈.
|
||
|
||
산림청고시 2025-82호 1-2-2 [주]① 「설계서 수량의 단위와 소수자리 표시는 본 표에 따르며,
|
||
**반올림하여 적용**한다」 ⇒ 수량 먼저 확정 → 금액(`bill_line` 버림). 둘을 섞지 않음(2026-09-14
|
||
브레인 판정). 종전엔 인계 전정밀(0.28181999…96)로 곱해 원 미만 절사에 1원씩 샜음.
|
||
"""
|
||
row.quantity, _ = round_quantity(row.quantity, row.name, row.unit, row.spec)
|
||
return row.quantity
|
||
|
||
|
||
def _sum_groups(rows: list[BillRow]) -> None:
|
||
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
|
||
|
||
⚠ `direct_*`·`body_total_krw` 는 머리글을 빼고 더하므로 두 번 안 셈.
|
||
"""
|
||
for group in rows:
|
||
if not group.is_group:
|
||
continue
|
||
prefix = f"{group.item_no}-"
|
||
children = [
|
||
r for r in rows if not r.is_group and r.item_no.startswith(prefix) and r.amount_krw
|
||
]
|
||
group.material_krw = sum((r.material_krw for r in children), Decimal(0))
|
||
group.labor_krw = sum((r.labor_krw for r in children), Decimal(0))
|
||
group.expense_krw = sum((r.expense_krw for r in children), Decimal(0))
|
||
group.amount_krw = sum((r.amount_krw for r in children), Decimal(0))
|
||
|
||
|
||
def _set_unit(row: BillRow, unit: Money3) -> None:
|
||
"""성분 단가 칸 — 금액을 낸 바로 그 3분할(내역 서식 「노무비·재료비·경비 단가」)."""
|
||
row.unit_material_krw = unit.material
|
||
row.unit_labor_krw = unit.labor
|
||
row.unit_expense_krw = unit.expense
|
||
|
||
|
||
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
|
||
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
|
||
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
|
||
money = scaled if money is None else money + scaled
|
||
row.parts.append((f"B-{code}", amount))
|
||
|
||
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.add_note("quantity", 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
|
||
|
||
money = money.floored(Decimal(1))
|
||
line = bill_line(money, settle_quantity(row))
|
||
_set_unit(row, money)
|
||
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
|
||
row.amount_krw = line.total
|
||
row.material_krw = line.material
|
||
row.labor_krw = line.labor
|
||
row.expense_krw = line.expense
|
||
row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계")
|
||
_mark_manual_materials(row, [code for code, _ in row.parts], unit_prices, result)
|
||
return row
|
||
|
||
|
||
def _structure_price_row(
|
||
item_no: str,
|
||
item: HandoffWorkItem,
|
||
structure_prices: dict[str, dict],
|
||
result: BillResult,
|
||
) -> BillRow:
|
||
"""구조물도 호표 줄(PLAN 6장 ②) — m당 금액은 B08 일위대가 엔진을 **이 내역의 단가표**로 돌린 값.
|
||
|
||
⚠ 일위대가에 막힌 줄이 있으면 **금액을 안 세움** — 절반짜리 단가가 제일 위험(묶음 줄과 같음).
|
||
⚠ 수동 단가로 선 줄은 금액을 세우되 「미확정」으로 셈 — 구조물도 화면과 내역이 같은 값.
|
||
"""
|
||
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import structure_price_code
|
||
|
||
ref = structure_price_code(str(item.work_item_code), item.variant_value)
|
||
row = BillRow(
|
||
item_no=item_no,
|
||
level=1,
|
||
code=item.work_item_code,
|
||
name=item.name,
|
||
spec=item.spec,
|
||
unit=item.unit,
|
||
quantity=item.quantity,
|
||
in_bill=item.in_bill,
|
||
)
|
||
entry = structure_prices.get(ref)
|
||
reason = ""
|
||
if entry is None:
|
||
reason = f"구조물도 일위대가 {ref} 를 못 받음 — 구조물도 탭에서 그 장의 일위대가를 확인"
|
||
elif entry["blocked"]:
|
||
reason = f"구조물도 일위대가 미완 — 막힌 줄 {entry['blocked']}: " + "; ".join(
|
||
entry["reasons"][:3]
|
||
)
|
||
if reason:
|
||
row.add_note("unit_price_krw", reason)
|
||
result.missing.append(
|
||
{
|
||
"name": row.name,
|
||
"code": ref,
|
||
"unit": row.unit,
|
||
"quantity": str(item.quantity),
|
||
"reason": reason,
|
||
}
|
||
)
|
||
return row
|
||
|
||
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
|
||
line = bill_line(entry["money"], settle_quantity(row))
|
||
_set_unit(row, entry["money"])
|
||
row.price_code = ref
|
||
row.parts = list(entry.get("parts") or [])
|
||
row.unconfirmed = int(entry["unconfirmed"] or 0)
|
||
row.unit_price_krw = entry["total"]
|
||
row.amount_krw = line.total
|
||
row.material_krw = line.material
|
||
row.labor_krw = line.labor
|
||
row.expense_krw = line.expense
|
||
row.add_note("unit_price_krw", f"호표 {ref}")
|
||
if entry["unconfirmed"]:
|
||
row.add_note("unit_price_krw", f"⚠ 수동 단가 {entry['unconfirmed']}건 미확정")
|
||
result.unconfirmed.append({"name": row.name, "code": ref, "count": entry["unconfirmed"]})
|
||
return row
|
||
|
||
|
||
def _excluded_row(item: HandoffWorkItem) -> BillRow:
|
||
"""`in_bill=false` 줄. **수량만 보이고 단가·금액을 안 붙인다.**
|
||
|
||
세 갈래가 섞여 온다 — 갈라 적지 않으면 사용자가 할 일을 못 읽는다.
|
||
· **검산용**(보정량계·무대) — 합계 검산에만 쓰는 줄
|
||
· **막힌 줄**(`blocked_kind` 있음) — 입력이나 원단위를 기다림
|
||
· ⚠ **여기서 세지 않는 줄**(`blocked_kind` 없음) — 「다른 표에서 이미 섬」·
|
||
「이 노선엔 없음」. **이것을 할 일 목록에 얹으면 결국 이중계상이 된다.**
|
||
"""
|
||
row = 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,
|
||
)
|
||
# 줄 하나가 통째로 빠지는 사유라 닿는 열이 없다 — 키를 비워 **모든 칸**에 따라붙게 둔다.
|
||
row.add_note(
|
||
"",
|
||
item.blocked_reason or item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.",
|
||
)
|
||
return row
|
||
|
||
|
||
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.add_note("spec", item.spec_class_basis)
|
||
if item.application_ratio_pct is not None:
|
||
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
|
||
row.add_note("quantity", f"반영률 {item.application_ratio_pct}% 적용 후 수량")
|
||
elif item.application_ratio_breakdown:
|
||
# ⚠ **「율 없음」이 아니라 「갈래마다 다름」이다.** 율이 갈리는 줄은 B08 이 `pct` 를
|
||
# 비우고 갈래로만 보낸다. 그 사실을 안 적으면 **값은 맞는데 왜 그 수량인지**를
|
||
# 사람이 못 본다 — 값이 맞아도 그것은 반쪽이다(2026-09-09 두 창 확인).
|
||
parts = ", ".join(
|
||
f"{name} {value}%" for name, value in item.application_ratio_breakdown.items()
|
||
)
|
||
row.add_note("quantity", f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)")
|
||
|
||
if not item.in_bill:
|
||
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
|
||
row.quantity = item.quantity
|
||
row.add_note("", item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.")
|
||
if item.unconfirmed:
|
||
# 치수 없이 기본값으로 선 줄 — 빨간 테두리 + 머리 「미확정 N건 — 금액에 안 들어감」.
|
||
row.unconfirmed = 1
|
||
result.unpriced.append({"name": item.display_name, "reason": item.blocked_reason})
|
||
result.excluded.append(row)
|
||
return row
|
||
|
||
# ⚠⚠ **차단인지 아닌지는 `blocked_kind` 가 정한다 — 문구가 아니다.**
|
||
# 2026-09-09 실측: 배수관 다섯 줄이 `blocked_kind=None · in_bill=True` 인데도
|
||
# **사유가 있다는 것만으로 막혀** 금액이 안 서고 있었다. 그 사유는 차단이 아니라
|
||
# **주의 문구**였다 — 「관종을 안 정해 기본값(파형강관)으로 섰습니다」.
|
||
# ⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말로** 단다.
|
||
if item.blocked_reason and not item.blocked_kind:
|
||
row.add_note("spec", f"ⓘ {item.blocked_reason}")
|
||
|
||
if item.blocked_reason and item.blocked_kind:
|
||
# B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
|
||
# 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
|
||
# 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
|
||
row.add_note(
|
||
"unit_price_krw",
|
||
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}"
|
||
loading = item.haul_equipment == LOADING_EQUIPMENT
|
||
if node.code == DUMP_PARENT and (item.haul_equipment == "dump_truck" or loading):
|
||
# 덤프 운반(10-12) — 잎(토사·암절취·발파암) × 운반거리마다 호표 한 장. 거리 없으면 0원 금지.
|
||
# 적재 짝 줄은 거리 무관 `#적재` 한 장.
|
||
child = dump_child_for(item.variant_value, _master_edition())
|
||
why = ""
|
||
wanted = ""
|
||
if child is None:
|
||
why = (
|
||
f"덤프 운반 갈래 「{item.variant_value or '미지정'}」를 10-12 잎"
|
||
"(토사·암절취·발파암)에 못 맞춤"
|
||
)
|
||
elif loading:
|
||
wanted = loading_title_code(child)
|
||
elif item.haul_distance_m is None:
|
||
why = "입력이 필요합니다 — 운반거리 미입력(유토곡선·사토장 거리가 서면 섬)"
|
||
else:
|
||
wanted = dump_title_code(child, item.haul_distance_m)
|
||
if wanted and wanted not in unit_prices.book.titles:
|
||
why = unit_prices.component_gaps.get(child) or "덤프 운반·적재 일위대가를 못 세웠습니다"
|
||
if why:
|
||
row.add_note("unit_price_krw", why)
|
||
result.missing.append(
|
||
{
|
||
"name": row.name,
|
||
"code": node.code,
|
||
"unit": row.unit,
|
||
"quantity": str(item.quantity),
|
||
"reason": why,
|
||
}
|
||
)
|
||
return row
|
||
price_code = wanted
|
||
if not loading:
|
||
row.spec = f"{row.spec} L={item.haul_distance_m}m".strip()
|
||
if price_code not in unit_prices.book.titles:
|
||
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
|
||
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
|
||
picked = (
|
||
find_variant_code(node.code, item.variant_value, unit_prices)
|
||
if item.variant_value
|
||
else None
|
||
)
|
||
default = unit_prices.default_variants.get(node.code)
|
||
if picked is not None:
|
||
price_code = picked
|
||
variant = str(item.variant_value) # 규격 글로 시작하는 갈래는 한 번만(면고르기)
|
||
row.spec = variant if variant.startswith(row.spec) else f"{row.spec} {variant}"
|
||
elif default is not None:
|
||
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
|
||
price_code = f"{price_code}#{default[0]}"
|
||
row.spec = f"{row.spec} {default[0]}".strip()
|
||
given = (
|
||
f"「{item.variant_value}」은 표에 없는 갈래"
|
||
if item.variant_value
|
||
else "갈래 미지정"
|
||
)
|
||
row.add_note("spec", f"ⓘ {given} — {default[1]}")
|
||
|
||
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}#")
|
||
)
|
||
mode = unit_prices.parent_modes.get(node.code)
|
||
if mode == "sum_steps" and node.code in unit_prices.component_gaps:
|
||
# 단계 합산형 — 부모가 곧 내역 줄인데 **어느 단계가 못 서서** 조립이 안 됐다.
|
||
gap = unit_prices.component_gaps[node.code]
|
||
row.add_note("unit_price_krw", f"단계 합산 단가를 못 세웠습니다 — {gap}")
|
||
reason = f"단계 합산 미완 — {gap}"
|
||
elif children and mode == "sum_steps":
|
||
names = ", ".join(c[2:] for c in children if c.startswith(f"{price_code}#"))
|
||
row.add_note(
|
||
"unit_price_krw",
|
||
f"갈래 「{item.variant_value or '미지정'}」에 맞는 단계 합산 단가가 없습니다"
|
||
f" — 있는 갈래: {names}",
|
||
)
|
||
reason = f"단계 합산 갈래 미일치 — {item.variant_value or '미지정'}"
|
||
elif mode == "choose_one":
|
||
# ⚠ 갈래 고르기형 부모 — **부모에 금액을 붙이지 않는다**(명세 2장). 조용히 0 원이 되지 않게 막는다.
|
||
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
|
||
row.add_note(
|
||
"unit_price_krw",
|
||
"갈래 고르기형 부모 공종이라 잎 하나를 골라야 합니다 — "
|
||
+ (f"후보: {names}" if names else "잎 공종 일위대가도 아직 없습니다"),
|
||
)
|
||
reason = f"갈래 고르기형 부모 — 잎 미선택(후보 {len(children)}건)"
|
||
elif children:
|
||
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
|
||
row.add_note(
|
||
"unit_price_krw",
|
||
f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
|
||
+ (f" / {known_gap_note(node.code)}" if item.variant_value else ""),
|
||
)
|
||
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
|
||
else:
|
||
# ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**.
|
||
# 뭉뚱그리면 사용자가 무엇을 기다려야 하는지 못 읽는다(2026-09-08 산마루측구:
|
||
# 표에 수량 칸이 비어 있고 「철근가공조립(간단)의 30 %」처럼 참조로만 적힌 자리).
|
||
gap = unit_prices.component_gaps.get(node.code)
|
||
if gap:
|
||
row.add_note("unit_price_krw", f"성분이 빠져 단가를 못 세웠습니다 — {gap}")
|
||
reason = f"성분 미확보 — {gap}"
|
||
else:
|
||
row.add_note(
|
||
"unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
|
||
)
|
||
if form_judgment_note(node.code): # 사람이 가른 표 형태 까닭(10-A ⑭)
|
||
row.add_note("unit_price_krw", form_judgment_note(node.code))
|
||
reason = "일위대가 없음"
|
||
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
|
||
diameter_note = pipe_diameter_note(node.code, item.variant_value) if children else ""
|
||
if diameter_note:
|
||
row.add_note("spec", diameter_note)
|
||
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.add_note(
|
||
"unit_price_krw",
|
||
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.add_note(
|
||
"unit_price_krw",
|
||
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.add_note(
|
||
"unit_price_krw",
|
||
f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 "
|
||
"보고 곱했습니다. 확인 필요.",
|
||
)
|
||
|
||
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.add_note(
|
||
"amount_krw",
|
||
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
|
||
|
||
# ⚠ **수량이 미확정 산식 위에 서 있는 줄**은 금액과 함께 그 사실을 싣는다.
|
||
# 금액이 커질수록 더 그렇다 — 지금 구조물터파기가 내역서에서 가장 큰 줄인데
|
||
# 그 밑수가 사용자 확정을 기다리고 있다(계획서 4-12 3단계).
|
||
pending = pending_formula_note(node.code)
|
||
if pending:
|
||
row.add_note("quantity", pending)
|
||
|
||
# ⚠ **원문에는 있는데 단가에 못 실린 몫**도 같은 자리에서 말한다. 금액이 서 있는 줄이라
|
||
# 표시가 없으면 완성된 값으로 읽힌다(규준틀 둘이 인력만으로 492만원이었다).
|
||
gap = known_gap_note(node.code)
|
||
if gap:
|
||
row.add_note("unit_price_krw", gap)
|
||
|
||
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
|
||
if price_code not in result.used_unit_prices:
|
||
result.used_unit_prices.append(price_code)
|
||
row.price_code = price_code
|
||
|
||
unit_money = unit_prices.book.resolve(price_code)
|
||
line = bill_line(unit_money, settle_quantity(row))
|
||
_set_unit(row, unit_money)
|
||
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
|
||
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
|
||
row.amount_krw = line.total
|
||
# 3분할도 자른 값 — ⑤ 직접비 밑수가 곧 성분별로 자른 줄 금액의 합(STmate 16번 원 단위 일치).
|
||
row.material_krw = line.material
|
||
row.labor_krw = line.labor
|
||
row.expense_krw = line.expense
|
||
_mark_manual_materials(row, [price_code], unit_prices, result)
|
||
return row
|
||
|
||
|
||
def _mark_manual_materials(
|
||
row: BillRow, codes: list[str], unit_prices: UnitPriceBuild, result: BillResult
|
||
) -> None:
|
||
"""자재 수동 단가가 닿은 줄 — 금액은 세우되 「미확정 N건」(구조물도 수동 단가와 같은 통로)."""
|
||
manual = getattr(unit_prices, "manual_materials", {})
|
||
count = sum(manual_count(unit_prices.book, code, manual) for code in codes)
|
||
if not count:
|
||
return
|
||
row.unconfirmed += count
|
||
row.add_note("unit_price_krw", f"⚠ 자재 수동 단가 {count}건 미확정")
|
||
result.unconfirmed.append({"name": row.name, "code": codes[0], "count": count})
|
||
|
||
|
||
#: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다.
|
||
#:
|
||
#: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이
|
||
#: **사용자 확정 5차로 닫혔다** — 「비탈 터파기, 지금 이대로」. 값은 안 바뀌었고
|
||
#: 11,263,899원이 **확정된 값**이 됐다.
|
||
#: ⚠ **표를 지우지 않고 비워 둔다** — 같은 성격의 자리가 또 생기면 여기 적으면 된다.
|
||
#: 적을 때는 **왜 대기인지**와 **정해지면 얼마나 움직이는지**를 함께 적을 것.
|
||
_PENDING_FORMULA: dict[str, str] = {}
|
||
|
||
|
||
from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
|
||
form_judgment_note,
|
||
known_gap_note,
|
||
pipe_diameter_note,
|
||
)
|
||
|
||
|
||
def pending_formula_note(code: str | None) -> str:
|
||
"""그 공종의 수량 산식이 확정 대기인가 — 맞으면 실을 문구."""
|
||
if not code:
|
||
return ""
|
||
for prefix, note in _PENDING_FORMULA.items():
|
||
if str(code).startswith(prefix):
|
||
return note
|
||
return ""
|
||
|
||
|
||
def _material_row(
|
||
material: HandoffMaterial, result: BillResult, manual: dict | None = None
|
||
) -> BillRow:
|
||
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
|
||
|
||
`manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림
|
||
(금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`).
|
||
"""
|
||
row = BillRow(
|
||
item_no="",
|
||
level=1,
|
||
code=None,
|
||
name=material.material_name,
|
||
spec=material.spec,
|
||
unit=material.unit,
|
||
quantity=material.total_amount,
|
||
)
|
||
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
|
||
row.add_note("quantity", material.surcharge_note)
|
||
if material.supply_type == SUPPLY_UNKNOWN:
|
||
row.add_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.add_note(
|
||
"unit_price_krw",
|
||
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
|
||
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
|
||
)
|
||
reason = "관급 자재 단가 없음"
|
||
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
|
||
row.add_note(
|
||
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
|
||
)
|
||
return row
|
||
else:
|
||
row.add_note(
|
||
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
|
||
)
|
||
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)
|