"""B09 계약 단계 — 당초설계 → 계약내역 (PLAN 12장 「설계 뒤 네 단계」 · 2026-09-14 브레인 배정). 근거: STmate 분석 `27_계약_실행_기성_단계.md` §2 (`wM_Mk_Cont`) · `35_형식과_단계의_공통과_차이.md` §3. · 낙찰률 하나가 아니라 **노무·재료·경비별 단가 적용률** · 옵션 여섯(아래 `OPTIONS` — 화면 표기 그대로) · **행별 적용 제외**(「적용율을 제외할(ex 관급자재대..) 공정을 선택」) · 0% 이면 계약단가(W코드)를 「0」化 — 빈 줄이 아니라 **공내역** 줄 ⚠ **설계를 안 건드린다** — 설계 내역(`/estimation/bill`) 줄을 **복사해** 적용률을 얹는다. 설계 내역·원가계산서·골든셋은 그대로다. ⚠ **값이 맞다가 아니라 구조가 선다까지** — 계약 표본 0건(27번 §9)이라 원 단위로 못 맞춰 봄. 절사는 설계 내역과 같은 규칙(`bill_line` — 성분 단가 원 미만 · 줄 성분마다 절사)을 빌려 씀. """ from __future__ import annotations import copy from dataclasses import replace from decimal import Decimal, InvalidOperation from typing import Any # ⚠ `_Rows` 를 먼저 부르면 순환 import — 설계 내역 본체가 다시 내보내는 자리에서 받음. from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceBookError, PriceKind _ZERO = Decimal(0) _HUNDRED = Decimal(100) #: 저장 자리 — `estimation` 구획 안 한 칸. SETTINGS_KEY = "contract" #: 단가 적용률 — STmate 【 노 】【 재 】【 경 】 차례. RATE_FIELDS: tuple[tuple[str, str], ...] = ( ("labor_pct", "노"), ("material_pct", "재"), ("expense_pct", "경"), ) #: 적용 옵션 — 화면 표기 그대로(27번 §2.2). 뜻이 확인 안 된 것은 칸만 받고 까닭을 적음. OPTIONS: tuple[tuple[str, str], ...] = ( ("apply_to_base_prices", "기초단가(재,노,경,일식)에 단가적용율 적용하기"), ("generate_unit_prices", "적용율 적용된 일위대가/산출근거 생성하기"), ("separate_same_code", "계약내역내 동일코드의 계약단가 개별생성"), ("labor_ratio", "노무비율 적용"), ("tax_free_material", "비과세자재대(원가계산)에 단가적용율 적용하기"), ("zero_makes_empty", '단가적용율이 0% 이면 계약단가(W코드)를 "0"化 (공내역 생성)'), ) #: 계산에 아직 안 쓰는 옵션과 그 까닭 — 조용히 무시하지 않고 화면에 적음. OPTION_NOT_USED = { "labor_ratio": ( "「노무비 비율 별도」의 계산 뜻이 분석 자료에서 확인 안 됨(27번 §2.2) — 칸만 받음" ), "tax_free_material": "비과세자재대 줄이 아직 원가계산서에 없음 — 칸만 받음", } _SHEETS_NEED_BASE = "「기초단가에 적용」을 함께 켜야 계약 일위대가·산출근거가 섬" def _pct(value: Any) -> Decimal | None: try: number = Decimal(str(value)) except (InvalidOperation, ValueError): return None return number if number >= 0 else None def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """저장값과 거른 까닭. 적용률은 0 이상 수 · 비면 100(적용 안 함과 같음).""" errors: list[str] = [] cleaned: dict[str, Any] = {} for key, label in RATE_FIELDS: raw = values.get(key) if raw in (None, ""): cleaned[key] = "100" continue pct = _pct(raw) if pct is None: errors.append(f"【{label}】 적용률이 0 이상 수가 아님 — {raw}") continue cleaned[key] = str(pct) for key, _ in OPTIONS: cleaned[key] = bool(values.get(key)) cleaned["excluded"] = sorted({str(item) for item in values.get("excluded") or [] if item}) return cleaned, errors def _money(value: Any) -> Decimal: return Decimal(str(value)) if value not in (None, "") else _ZERO #: 기초단가 층 — 적용률이 걸리는 카탈로그 종류와 성분(자재 = 재 · 노임 = 노 · 중기 취득가 = 경). _BASE_KIND_RATE = { PriceKind.MATERIAL: "material_pct", PriceKind.LABOR: "labor_pct", PriceKind.MACHINE_BASE: "expense_pct", } def contract_book(book: PriceBook, factors: dict[str, Decimal]) -> PriceBook: """「기초단가(재,노,경,일식)에 단가적용율 적용」 — **복사본**의 채택 단가에 적용률을 곱함. 그 위 층(중기사용료 X · 단가산출 D · 일위대가 B)은 **다시 조립**해 선다 — 설계와 같은 절사 (`PriceBook.resolve`). ⚠ 원본 단가표(캐시 공유)는 안 건드림. 일식(W)은 단가 0 이라 그대로. """ copied = copy.deepcopy(book) for title in copied.titles.values(): key = _BASE_KIND_RATE.get(title.kind) if key is None: continue slot = title.adopted_slot - 1 if 0 <= slot < len(title.slots) and title.slots[slot] is not None: title.slots[slot] = title.slots[slot] * factors[key] return copied #: 계약 호표로 내는 층 — 일위대가(B)와 그 아래 단가산출근거(D). _SHEET_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS) def _add_sheets( contract_build: Any, base_code: str, code: str, sheets: dict[str, dict[str, Any]] ) -> None: """「적용율 적용된 일위대가/산출근거」 — 설계 본표와 **같은 꼴**(`detail_of`)로 W 코드 한 장씩. 그 일위대가가 부르는 산출근거(D)도 따라 냄(W-D-…, 줄끼리 한 장 공유). """ from B09_Estimation.B09_Estimation_UnitPrice_View import detail_of book = contract_build.book stack = [(base_code, code)] while stack: design_code, sheet_code = stack.pop() if sheet_code in sheets: continue sheets[sheet_code] = { **detail_of(contract_build, design_code), "code": sheet_code, "design_code": design_code, } for detail in book.details.get(design_code, []): ref = book.titles.get(detail.ref_code) if ref is not None and ref.kind in _SHEET_KINDS and detail.ref_code != design_code: stack.append((detail.ref_code, f"W-{detail.ref_code}")) def contract_bill( bill_rows: list[dict[str, Any]], settings: dict[str, Any], build: Any = None, ) -> dict[str, Any]: """설계 내역 줄(`BillRow.as_dict`) → 계약내역 줄 · 합계 · 까닭 · 계약 호표. `build` = 설계 일위대가 조립본(`UnitPriceBuild` — 캐시 공유본이라 **안 고침**). 「기초단가에 적용」이 켜진 때만 씀 — 단가표 복사본(`contract_book`)을 다시 조립. ⚠ 0% = 0 원(2026-09-14 브레인 판정) — 「적용 안 함」은 적용제외 체크가 맡음. 「공내역 생성」은 그 0 원 줄에 W 코드 「공내역」 이름을 붙이는 것뿐(끄면 그냥 0 원 줄). 표본 없음 — 확인 대기. """ rates = {key: _pct(settings.get(key, "100")) or _ZERO for key, _ in RATE_FIELDS} zero_empty = bool(settings.get("zero_makes_empty")) separate = bool(settings.get("separate_same_code")) excluded = {str(item) for item in settings.get("excluded") or []} def factor(key: str) -> Decimal: return rates[key] / _HUNDRED book = build.book if build is not None else None scaled_book = ( contract_book(book, {key: factor(key) for key, _ in RATE_FIELDS}) if book is not None and settings.get("apply_to_base_prices") else None ) # 조립본의 곁가지(못 붙은 줄 등)는 그대로 읽고 단가표만 계약 복사본으로 — 얕은 복사. contract_build = replace(build, book=scaled_book) if scaled_book is not None else None sheets: dict[str, dict[str, Any]] = {} rows: list[dict[str, Any]] = [] design = Money3() contract = 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 unit_design = Money3( material=_money(row.get("unit_material_krw")), labor=_money(row.get("unit_labor_krw")), expense=_money(row.get("unit_expense_krw")), ) quantity = row.get("quantity") if quantity in (None, "") or row.get("unit_material_krw") is None: row.update(contract_note="설계 단가가 안 선 줄 — 계약단가도 못 섬", contract_code="") rows.append(row) continue qty = Decimal(str(quantity)) design_line = bill_line(unit_design, qty) design += design_line base_code = str(row.get("price_code") or row.get("code") or "") if str(row.get("item_no")) in excluded: unit, code, note = unit_design, base_code, "적용 제외 — 설계 단가 그대로" else: unit, note = _contract_unit(row, unit_design, factor, book, scaled_book) suffix = f"@{row.get('item_no')}" if separate else "" code = f"W-{base_code}{suffix}" if unit.total == 0 and zero_empty: note = "공내역 — 적용률 0%" if ( contract_build is not None and settings.get("generate_unit_prices") and note.startswith("기초단가 적용") and scaled_book.titles[base_code].kind in _SHEET_KINDS ): _add_sheets(contract_build, base_code, code, sheets) line = bill_line(unit, qty) contract += line row.update( contract_code=code, contract_unit_material_krw=str(unit.material), contract_unit_labor_krw=str(unit.labor), contract_unit_expense_krw=str(unit.expense), contract_unit_price_krw=str(unit.total), contract_material_krw=str(line.material), contract_labor_krw=str(line.labor), contract_expense_krw=str(line.expense), contract_amount_krw=str(line.total), contract_excluded=str(row.get("item_no")) in excluded, contract_note=note, ) rows.append(row) _group_sums(rows) return { "rows": rows, "totals": { "design": _totals(design), "contract": _totals(contract), "ratio_pct": { part: str( (getattr(contract, part) / getattr(design, part) * _HUNDRED).quantize( Decimal("0.001") ) ) if getattr(design, part) else None for part in ("material", "labor", "expense") }, }, "options_not_used": { **{k: v for k, v in OPTION_NOT_USED.items() if settings.get(k)}, **( {"generate_unit_prices": _SHEETS_NEED_BASE} if settings.get("generate_unit_prices") and not settings.get("apply_to_base_prices") else {} ), }, # 「적용율 적용된 일위대가/산출근거 생성」 — 기초단가 적용이 켜진 때만 # (성분 곱셈에는 다시 조립할 호표가 없음). "unit_price_sheets": list(sheets.values()), } def _contract_unit( row: dict[str, Any], unit_design: Money3, factor, book: PriceBook | None, scaled_book: PriceBook | None, ) -> tuple[Money3, str]: """계약 성분 단가와 비고. 기초단가 적용이면 단가표를 다시 조립, 아니면 성분에 곱함. ⚠ 설계 단가가 단가표 조립값과 다르면(할증·수동 단가·구조물도 호표) 기초단가로 못 풂 — 성분 곱셈으로 떨어지고 그 사실을 비고에 적음(조용히 틀린 값을 세우지 않음). """ scaled = Money3( material=unit_design.material * factor("material_pct"), labor=unit_design.labor * factor("labor_pct"), expense=unit_design.expense * factor("expense_pct"), ).floored(Decimal(1)) if scaled_book is None or book is None: return scaled, "" unit, why = reassembled_unit(str(row.get("price_code") or ""), unit_design, book, scaled_book) if unit is None: return scaled, f"기초단가로 못 풂({why}) — 성분에 적용" return unit, "기초단가 적용 — 단가표 다시 조립" def reassembled_unit( code: str, unit_design: Money3, book: PriceBook, new_book: PriceBook ) -> tuple[Money3 | None, str]: """고친 단가표 복사본으로 그 줄 단가를 다시 조립 — 못 풀면 (None, 까닭). ⚠ 설계 단가가 설계 단가표 조립값과 같을 때만 — 다르면(할증·수동 단가·구조물도 호표) 다시 조립한 값이 그 줄 단가가 아님(계약·실행예산 공용). """ if code not in book.titles: return None, "단가표 밖 코드" try: if book.resolve(code).floored(Decimal(1)) != unit_design: return None, "설계 단가가 단가표 조립값과 다름 — 할증·수동 단가" return new_book.resolve(code).floored(Decimal(1)), "" except PriceBookError as error: return None, str(error) def _totals(money: Money3) -> dict[str, str]: return { "material_krw": str(money.material), "labor_krw": str(money.labor), "expense_krw": str(money.expense), "total_krw": str(money.total), } def _group_sums(rows: list[dict[str, Any]], stage: str = "contract") -> None: """묶음 줄 단계 금액(`{stage}_…_krw`) — 아래 줄의 합(설계 내역 `_group_sums` 와 같은 꼴).""" for group in rows: if not group.get("is_group"): continue prefix = f"{group.get('item_no')}." children = [ r for r in rows if not r.get("is_group") and str(r.get("item_no", "")).startswith(prefix) and r.get(f"{stage}_amount_krw") is not None ] for part in ("material", "labor", "expense", "amount"): group[f"{stage}_{part}_krw"] = str( sum((Decimal(r[f"{stage}_{part}_krw"]) for r in children), _ZERO) )