"""프로젝트 요율 덮어쓰기 — 발주처별 별도요율 (PLAN 6장 새 절 · 2026-09-14 브레인 판정). ⚠ **프로젝트 단위로만** — 마스터 요율 파일(`rates_<연도>.json`)은 안 고친다. 고치면 남의 프로젝트가 흔들린다. 프로젝트 설정 `estimation.rate_overrides` 에 칸마다 한 줄씩 두고, 계산할 때 **복사본에** 얹는다. ⚠ **사유 없는 덮어쓰기는 안 받음** — 「이 값이 왜 다르지」가 나중에 풀려야 한다. 한 줄 모양 {"variable": "rate_overhead", "table": "civil_landscape_industrial", "match": {"estimated_price_bracket": "lt_5_billion"}, "rate_percent": "7.5", "reason": "발주처 지침 …", "entered_at": "2026-09-14T…"} `table` 이 없으면 변수 자체의 `rate_percent`(산재·건강 따위 한 값짜리). """ from __future__ import annotations import copy from dataclasses import replace from decimal import Decimal, InvalidOperation from typing import Any from B09_Estimation.B09_Estimation_Rates import RateDataset, RateLookupError #: 줄을 가르는 칸 — 이 칸들로 같은 표 안의 줄 하나를 집는다(요율·기초액·식은 가르는 칸 아님). IDENTITY_FIELDS = ( "estimated_price_bracket", "direct_cost_bracket", "duration_bracket", "target_amount_bracket", "estimated_amount_bracket", "work_type", "grade", "year", ) def address_of(variable: str, table: str | None, row: dict[str, Any] | None) -> dict[str, Any]: """요율 칸 하나의 주소 — 화면이 들고 다니다 저장 때 그대로 돌려줌.""" match = {key: row[key] for key in IDENTITY_FIELDS if row and key in row} return {"variable": variable, "table": table, "match": match} def _target(variables: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: """덮어쓸 dict(줄 또는 변수) — 못 찾거나 둘 이상이면 멈춤(조용히 딴 칸을 고치지 않음).""" variable = variables.get(str(override.get("variable") or "")) if not isinstance(variable, dict): raise RateLookupError(f"요율 덮어쓰기: 없는 항목 — {override.get('variable')}") table = override.get("table") if not table: if "rate_percent" not in variable: raise RateLookupError(f"요율 덮어쓰기: 한 값짜리가 아님 — {override.get('variable')}") return variable rows = variable.get(str(table)) if not isinstance(rows, list): raise RateLookupError(f"요율 덮어쓰기: 없는 표 — {override.get('variable')}.{table}") match = override.get("match") or {} hits = [ row for row in rows if isinstance(row, dict) and "rate_percent" in row and all(str(row.get(key)) == str(value) for key, value in match.items()) ] if len(hits) != 1: raise RateLookupError( f"요율 덮어쓰기: 줄을 하나로 못 집음({len(hits)}건) —" f" {override.get('variable')}.{table} {match}" ) return hits[0] def apply_overrides(dataset: RateDataset, overrides: Any) -> RateDataset: """복사본에 덮어쓴 요율판. 덮어쓰기가 없으면 원본 그대로(캐시 공유).""" if not overrides: return dataset variables = copy.deepcopy(dataset.variables) for override in overrides: _target(variables, override)["rate_percent"] = float(Decimal(str(override["rate_percent"]))) return replace(dataset, variables=variables) def clean_overrides(raw: Any, dataset: RateDataset) -> tuple[list[dict[str, Any]], list[str]]: """저장할 덮어쓰기와 거른 까닭. 사유가 비거나·주소가 틀리거나·음수면 버림.""" kept: list[dict[str, Any]] = [] errors: list[str] = [] seen: set[str] = set() for item in raw if isinstance(raw, list) else []: if not isinstance(item, dict): continue label = f"{item.get('variable')}.{item.get('table') or ''} {item.get('match') or {}}" reason = str(item.get("reason") or "").strip() if not reason: errors.append(f"사유가 비어 있음 — {label}") continue try: value = Decimal(str(item.get("rate_percent"))) except (InvalidOperation, ValueError): errors.append(f"요율이 수가 아님 — {label}") continue if value < 0: errors.append(f"음수 요율 — {label}") continue entry = { "variable": str(item.get("variable") or ""), "table": item.get("table") or None, "match": dict(item.get("match") or {}), "rate_percent": str(value), "reason": reason, "entered_at": str(item.get("entered_at") or ""), } try: _target(copy.deepcopy(dataset.variables), entry) except RateLookupError as error: errors.append(str(error)) continue key = f"{entry['variable']}|{entry['table']}|{sorted(entry['match'].items())}" if key in seen: errors.append(f"같은 칸을 두 번 고침 — {label}") continue seen.add(key) kept.append(entry) return kept, errors