"""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 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 _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 = { "apply_to_base_prices": ( "기초단가(자재·노임·중기) 수준 적용은 다음 차례 — 지금은 일위대가 성분에 적용" ), "generate_unit_prices": "계약 일위대가·산출근거 표 생성은 다음 차례", "labor_ratio": ( "「노무비 비율 별도」의 계산 뜻이 분석 자료에서 확인 안 됨(27번 §2.2) — 칸만 받음" ), "tax_free_material": "비과세자재대 줄이 아직 원가계산서에 없음 — 칸만 받음", } 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 def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) -> dict[str, Any]: """설계 내역 줄(`BillRow.as_dict`) → 계약내역 줄 · 합계 · 까닭. ⚠ 0% 처리(판정 대기): 「공내역 생성」을 켜면 그 성분이 0 원(세 성분 모두 0 이면 공내역 줄). 끄면 0% 를 「적용 안 함」으로 봄 — STmate 가 따로 옵션을 둔 까닭으로 읽은 것(표본 없음). """ 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: pct = rates[key] if pct == 0 and not zero_empty: return Decimal(1) # 0% = 적용 안 함(공내역 옵션 꺼짐) return pct / _HUNDRED 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 = 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)) suffix = f"@{row.get('item_no')}" if separate else "" code = f"W-{base_code}{suffix}" note = "공내역 — 적용률 0%" if unit.total == 0 and zero_empty else "" 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)}, } 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]]) -> None: """묶음 줄 계약 금액 — 아래 줄의 합(설계 내역 `_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("contract_amount_krw") is not None ] for part in ("material", "labor", "expense", "amount"): group[f"contract_{part}_krw"] = str( sum((Decimal(r[f"contract_{part}_krw"]) for r in children), _ZERO) )