B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
123 lines
5.1 KiB
Python
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
|