"""B09 실행예산 단계 — 설계 내역 → 실행예산 (PLAN 12장 「설계 뒤 네 단계」 · 2026-09-14 배정). 근거: STmate 분석 `27_계약_실행_기성_단계.md` §4 (`wM_Boq_ExecX` 「시간/단위당 중기실행단가 계산 및 입력」) · `35_형식과_단계의_공통과_차이.md` §3 · 원자료 `ui_form_catalog.txt` 65~67행. · 설계 중기사용료와 **별도로** 「1단위당 중기사용료(원/단위)」 · 「1단위 = 시간」 · 최초단가(노무비·재료비·경비) → 실행단가 · 절사 1원 ~ 100,000원 미만 · 차액 보정 : 기본보정 / 노무비 / 재료비 / 경비 · 최초수량 → 실행수량 ⚠ **설계·계약을 안 건드린다** — 설계 단가표를 **복사해** 중기(X) 호표만 실행단가로 갈아 끼우고 그 위 일위대가를 다시 조립한다. 설계 내역·원가계산서·골든셋은 그대로다. ⚠ **값이 맞다가 아니라 구조가 선다까지** — 실행예산 표본 0건(27번 §9 · 35번 「중기 화면 구조 확인 · 전체 실행예산 및 실제 값 미확인」). 아래 둘은 **구조로 읽은 것**이라 확인 대기: ① 실행단가 성분 = 시간당 실행단가 × 설계 성분 비율(최초단가 → 실행단가 한 줄에서 읽음) ② 절사는 시간당 성분마다 · 차액 = 절사한 시간당 합계 − 성분 합 """ from __future__ import annotations import copy from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line from B09_Estimation.B09_Estimation_Contract import _group_sums, _money, _totals, reassembled_unit from B09_Estimation.B09_Estimation_PriceBook import ( DEFAULT_ADOPTED_SLOT, PRICE_SLOT_COUNT, Money3, PriceBook, PriceBookError, PriceDetail, PriceKind, PriceTitle, ) _ZERO = Decimal(0) #: 저장 자리 — `estimation` 구획 안 한 칸. SETTINGS_KEY = "execution" #: 절사 기준 — `wCm_Jeol` 표기 차례(원 미만 절사 단위). CUT_UNITS: tuple[str, ...] = ("1", "10", "100", "1000", "10000", "100000") #: 차액 보정 — `wCm_OPT` 표기 차례. CORRECTIONS: tuple[tuple[str, str], ...] = ( ("basic", "기본보정"), ("labor", "노무비"), ("material", "재료비"), ("expense", "경비"), ) #: 고를 때 무엇이 다른지 — 화면에 그대로 적음(2026-09-14 브레인 지시). #: ⚠ 「기본보정」은 표본이 오면 고칠 자리 — 지금 뜻은 지어낸 것이 아니라 「안 몰고 남김」. CORRECTION_HINTS = { "basic": "차액을 어느 비목에도 안 몰고 남김(뜻 확인 대기)", "labor": "차액을 노무비에 더함", "material": "차액을 재료비에 더함", "expense": "차액을 경비에 더함", } #: 뜻이 확인 안 된 보정 — 조용히 한쪽에 몰지 않고 차액을 그대로 보임. CORRECTION_NOT_KNOWN = { "basic": "「기본보정」의 뜻이 분석 자료에서 확인 안 됨(27번 §4) — 차액을 안 몰고 그대로 보임", } #: 실행단가를 받치는 기초단가 줄 — 성분마다 한 줄(자재 = 재료 · 노임 = 노무 · 중기 취득가 = 경비). _PART_KIND = ( ("material", PriceKind.MATERIAL, "M"), ("labor", PriceKind.LABOR, "L"), ("expense", PriceKind.MACHINE_BASE, "S"), ) _PART_LABEL = {"material": "재료비", "labor": "노무비", "expense": "경비"} def _number(value: Any) -> Decimal | None: try: number = Decimal(str(value)) except (InvalidOperation, ValueError): return None return number if number.is_finite() else None def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """저장값과 거른 까닭. 사용료가 빈 중기는 설계 사용료 그대로 · 수량이 빈 줄은 설계 수량.""" errors: list[str] = [] machines: dict[str, dict[str, str]] = {} for code, entry in (values.get("machines") or {}).items(): entry = entry or {} if entry.get("unit_price_krw") in (None, ""): continue price = _number(entry.get("unit_price_krw")) hours = _number(entry.get("hours_per_unit")) if price is None or price < 0: errors.append( f"{code} 1단위당 중기사용료가 0 이상 수가 아님 — {entry.get('unit_price_krw')}" ) continue if hours is None or hours <= 0: errors.append( f"{code} 「1단위 = 시간」이 0 보다 큰 수가 아님 — {entry.get('hours_per_unit')}" ) continue cut = str(entry.get("cut_unit_krw") or "1") correction = str(entry.get("correction") or "basic") machines[str(code)] = { "unit_price_krw": str(price), "hours_per_unit": str(hours), "cut_unit_krw": cut if cut in CUT_UNITS else "1", "correction": correction if correction in dict(CORRECTIONS) else "basic", } quantities: dict[str, str] = {} for item_no, raw in (values.get("quantities") or {}).items(): if raw in (None, ""): continue qty = _number(raw) if qty is None or qty < 0: errors.append(f"{item_no} 실행수량이 0 이상 수가 아님 — {raw}") continue quantities[str(item_no)] = str(qty) return {"machines": machines, "quantities": quantities}, errors def execution_hourly( design: Money3, unit_price: Decimal, hours: Decimal, cut: Decimal, correction: str ) -> tuple[Money3 | None, Decimal, str]: """시간당 실행단가(성분) · 차액 · 까닭. 못 가르면 (None, 0, 까닭). 시간당 실행단가 = 1단위당 중기사용료 ÷ 1단위 시간 → 설계 성분 비율로 가름 → 성분마다 절사 → 차액(절사한 합계 − 성분 합)을 고른 비목에 더함. 「기본보정」은 뜻 미확인이라 차액을 남김. """ if design.total <= 0: return None, _ZERO, "설계 중기사용료 성분 합이 0 — 비율로 못 가름" per_hour = unit_price / hours parts = { part: (per_hour * getattr(design, part) / design.total / cut).to_integral_value( rounding=ROUND_FLOOR ) * cut for part, _, _ in _PART_KIND } target = (per_hour / cut).to_integral_value(rounding=ROUND_FLOOR) * cut diff = target - sum(parts.values(), _ZERO) note = "" if diff and correction in parts: parts[correction] += diff note = f"차액 {diff}원 → {dict(CORRECTIONS)[correction]}" elif diff: note = f"차액 {diff}원 남음 — {CORRECTION_NOT_KNOWN['basic']}" return Money3(**parts), diff, note def execution_book(book: PriceBook, hourly: dict[str, Money3]) -> PriceBook: """중기(X) 호표를 실행단가로 갈아 끼운 **복사본** — 원본 단가표(캐시 공유)는 안 건드림. X 상세 줄을 비우고 성분마다 기초단가 한 줄(수량 1)을 달아 그 위 일위대가가 설계와 같은 규칙(`PriceBook.resolve`)으로 다시 조립되게 함. """ copied = copy.deepcopy(book) for code, money in hourly.items(): copied.details[code] = [] for part, kind, prefix in _PART_KIND: slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT slots[DEFAULT_ADOPTED_SLOT - 1] = getattr(money, part) ref = f"{prefix}-실행-{code}" copied.titles[ref] = PriceTitle( code=ref, kind=kind, name=f"중기실행단가 {_PART_LABEL[part]}", slots=slots ) copied.add_detail(PriceDetail(code, ref, Decimal(1), note="실행예산 중기실행단가")) return copied def machines_under(book: PriceBook, codes: list[str]) -> dict[str, set[str]]: """내역 단가코드 → 그 아래 중기(X) 코드들 — X 안으로는 안 내려감.""" found: dict[str, set[str]] = {} def walk(code: str, seen: tuple[str, ...]) -> set[str]: if code in found: return found[code] title = book.titles.get(code) if title is None or code in seen: return set() if title.kind is PriceKind.MACHINE_HOURLY: return {code} result: set[str] = set() for detail in book.details.get(code, []): if detail.ref_code != code: result |= walk(detail.ref_code, (*seen, code)) found[code] = result return result return {code: walk(code, ()) for code in codes} def execution_bill( bill_rows: list[dict[str, Any]], settings: dict[str, Any], build: Any = None, ) -> dict[str, Any]: """설계 내역 줄 → 실행예산 줄 · 중기 실행단가 표 · 합계. `build` = 설계 일위대가 조립본(캐시 공유본이라 **안 고침**) — 없으면 중기 표 없이 수량만. """ entries = settings.get("machines") or {} quantities = settings.get("quantities") or {} book = build.book if build is not None else None codes = [str(row.get("price_code") or "") for row in bill_rows if not row.get("is_group")] under = machines_under(book, codes) if book is not None else {} machines: list[dict[str, Any]] = [] hourly: dict[str, Money3] = {} for code in sorted(set().union(*under.values())): title = book.titles[code] item: dict[str, Any] = {"code": code, "name": title.name, "spec": title.spec} try: design = book.resolve(code) except PriceBookError as error: machines.append({**item, "note": f"설계 중기사용료가 안 섬({error})"}) continue item.update(design=_totals(design), **entries.get(code, {})) entry = entries.get(code) if entry: money, diff, note = execution_hourly( design, Decimal(entry["unit_price_krw"]), Decimal(entry["hours_per_unit"]), Decimal(entry["cut_unit_krw"]), entry["correction"], ) item.update(note=note, diff_krw=str(diff)) if money is not None: hourly[code] = money item["execution"] = _totals(money) machines.append(item) exec_book = execution_book(book, hourly) if hourly else None rows: list[dict[str, Any]] = [] design_sum = Money3() exec_sum = Money3() for source in bill_rows: row = dict(source) if row.get("is_group") or not row.get("in_bill", True): rows.append(row) continue quantity = row.get("quantity") if quantity in (None, "") or row.get("unit_material_krw") is None: row.update(execution_note="설계 단가가 안 선 줄 — 실행단가도 못 섬") rows.append(row) continue unit_design = Money3( material=_money(row.get("unit_material_krw")), labor=_money(row.get("unit_labor_krw")), expense=_money(row.get("unit_expense_krw")), ) design_sum += bill_line(unit_design, Decimal(str(quantity))) code = str(row.get("price_code") or "") unit, note = unit_design, "" changed = under.get(code, set()) & set(hourly) if exec_book is not None and changed: rebuilt, why = reassembled_unit(code, unit_design, book, exec_book) if rebuilt is None: note = f"중기 실행단가를 못 얹음({why}) — 설계 단가 그대로" else: unit, note = rebuilt, f"중기 실행단가 적용 — {', '.join(sorted(changed))}" item_no = str(row.get("item_no")) qty = Decimal(quantities.get(item_no, str(quantity))) line = bill_line(unit, qty) exec_sum += line row.update( execution_quantity=str(qty), execution_quantity_changed=item_no in quantities, execution_unit_material_krw=str(unit.material), execution_unit_labor_krw=str(unit.labor), execution_unit_expense_krw=str(unit.expense), execution_unit_price_krw=str(unit.total), execution_material_krw=str(line.material), execution_labor_krw=str(line.labor), execution_expense_krw=str(line.expense), execution_amount_krw=str(line.total), execution_note=note, ) rows.append(row) _group_sums(rows, "execution") return { "rows": rows, "machines": machines, "totals": {"design": _totals(design_sum), "execution": _totals(exec_sum)}, }