Files
Aislo/B09_Estimation/B09_Estimation_RateOverride.py
T
eomsangdonandClaude Opus 5 f704bc3bf8 feat(b09): 요율 덮어쓰기 — 프로젝트 단위 · 칸마다 사유 · 기본값↔고친 값 · ↺ 되돌리기
- estimation.rate_overrides 에 칸 주소(variable·table·match)·요율·사유·입력 시각을 한 줄씩 — 마스터 요율 파일은 안 건드림
- 엔진이 계산할 때 복사본에만 얹음 · 덮어쓰기 없으면 원본 그대로(골든셋 초록) · 원가계산서 비고에 「요율 덮어쓰기 N건 — 사유」
- 요율표: 칸마다 기본값·지금 값·주소 · [수 정]에서 입력 · 고친 칸 테두리 + (기본 X) · ↺ · 고친 요율 목록에 사유 칸(비면 저장 안 됨)
- PUT /estimation/rate-table: 사유 빈 줄·틀린 주소·같은 칸 두 번·음수면 아무것도 안 저장 · 값·사유 같으면 입력 시각 유지
- 판이 바뀌어 주소가 안 맞으면 조용히 버리지 않고 알림
- 시험: 마스터 불변 · 계산에 걸림 · 한 값짜리 · 거름 · 주소 틀리면 멈춤 · 화면 주소 왕복

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:24:33 +09:00

123 lines
5.1 KiB
Python

"""프로젝트 요율 덮어쓰기 — 발주처별 별도요율 (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