"""B09 원가계산 — 내역 **할증율·일괄보정** (PLAN 12장 2차 ④ · STmate `wBoqRate`·`wM_Rate`). 사용자가 고친 값(`estimation.edits.bill_rates`)을 내역 줄 금액에 얹는다 — 프로젝트 단위. 칸 "<단가 코드 또는 명칭>|<규격>" = 그 줄 하나(선택 항목만 — 고른 줄마다 한 칸) "*" = 내역서 전체 값 {"all": "10"} 재·노·경 같은 비율(%) {"material": "5", "labor": "0", "expense": "3"} 비목별(%) + "rounding": {"material": {"mode": "floor"|"round", "digits": 0~4}, …} 비목별 절사/반올림 · 소수 자리 차례 줄 칸이 있으면 줄 칸, 없으면 "*" ⚠ 성분 단가 × (1 + 율) → 고른 자리로 절사/반올림(안 고르면 원 미만 절사 — 호표 성분 소계 규칙) → 줄 금액은 종전대로 성분마다 절사(명세 7장 · 내역 줄 자리 규칙은 안 바꿈). ⚠ 할증은 **금액만** 움직임 — 수량·자원 집계표 수량은 그대로. ⚠ 되돌리기 — 칸을 지우면 계산값으로. 여러 줄을 한꺼번에 바꾸므로 화면이 「할증 전부 되돌리기」도 줌. """ from __future__ import annotations from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation from typing import Any from B09_Estimation.B09_Estimation_PriceBook import Money3 ALL_ROWS = "*" PARTS = ("material", "labor", "expense") _LABELS = {"material": "재", "labor": "노", "expense": "경"} _MODES = {"floor": ROUND_FLOOR, "round": ROUND_HALF_UP} _DEFAULT_ROUNDING = {"mode": "floor", "digits": 0} def rate_key(row: Any) -> str: """줄 칸 이름 — 단가 코드(없으면 명칭) + 규격. 내역이 다시 서도 같은 줄이면 같은 이름.""" return f"{row.price_code or row.name}|{row.spec}" def parse_rate(value: Any) -> tuple[dict[str, Decimal], dict[str, dict[str, Any]]] | None: """저장본 한 칸 → (성분별 율 %, 성분별 자리). 모양이 틀리면 `None`.""" if not isinstance(value, dict): return None try: if "all" in value: rates = dict.fromkeys(PARTS, Decimal(str(value["all"]))) else: rates = {part: Decimal(str(value.get(part) or 0)) for part in PARTS} except (InvalidOperation, ValueError): return None if any(not rate.is_finite() or rate <= -100 or rate > 1000 for rate in rates.values()): return None raw = value.get("rounding") or {} if isinstance(raw, dict) and "mode" in raw: raw = dict.fromkeys(PARTS, raw) rounding: dict[str, dict[str, Any]] = {} for part in PARTS: chosen = raw.get(part) if isinstance(raw, dict) else None chosen = chosen if isinstance(chosen, dict) else _DEFAULT_ROUNDING mode, digits = str(chosen.get("mode") or "floor"), chosen.get("digits", 0) if mode not in _MODES or not str(digits).isdigit() or not 0 <= int(digits) <= 4: return None rounding[part] = {"mode": mode, "digits": int(digits)} return rates, rounding def _round(value: Decimal, rule: dict[str, Any]) -> Decimal: return value.quantize(Decimal(1).scaleb(-rule["digits"]), rounding=_MODES[rule["mode"]]) def apply_bill_rates(rows: list[Any], raw: dict[str, Any] | None, bill_line: Any) -> None: """금액이 선 내역 줄에 율을 얹음 — `bill_line` 은 줄 금액 규칙(순환 import 를 피해 받음).""" for row in rows: row.rate_key = rate_key(row) if not raw: return for row in rows: if row.is_group or row.amount_krw is None or row.unit_material_krw is None: continue source = row.rate_key if row.rate_key in raw else ALL_ROWS if ALL_ROWS in raw else None parsed = parse_rate(raw.get(source)) if source else None if not parsed: continue rates, rounding = parsed before = { "material": row.unit_material_krw, "labor": row.unit_labor_krw, "expense": row.unit_expense_krw, } unit = Money3( **{ part: _round(before[part] * (1 + rates[part] / 100), rounding[part]) for part in PARTS } ) line = bill_line(unit, row.quantity) row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = ( unit.material, unit.labor, unit.expense, ) row.unit_price_krw = unit.total row.amount_krw = line.total row.material_krw, row.labor_krw, row.expense_krw = line.material, line.labor, line.expense same = len(set(rates.values())) == 1 text = ( f"{rates['material']}%" if same else " · ".join(f"{_LABELS[part]} {rates[part]}%" for part in PARTS) ) row.rate = { "source": source, **{part: str(rates[part]) for part in PARTS}, "was_unit_price_krw": str(sum(before.values(), Decimal(0))), } row.add_note( "unit_price_krw", f"할증 {text} (사용자{' · 내역서 전체' if source == ALL_ROWS else ''})", ) def validate_rate(value: Any) -> dict[str, Any]: """저장 전 검사 — 받을 수 있는 모양(글)으로. 틀리면 `ValueError`.""" parsed = parse_rate(value) if parsed is None: raise ValueError( "할증율은 -100 보다 크고 1000 이하인 수(%) · 자리는 절사/반올림 × 소수 0~4 여야 합니다" ) rates, rounding = parsed body: dict[str, Any] = ( {"all": str(rates["material"])} if isinstance(value, dict) and "all" in value else {part: str(rates[part]) for part in PARTS} ) body["rounding"] = rounding return body