"""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.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: 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