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
This commit is contained in:
2026-09-14 03:24:33 +09:00
co-authored by Claude Opus 5
parent 267bad1dfd
commit f704bc3bf8
7 changed files with 743 additions and 94 deletions
+101 -45
View File
@@ -1,8 +1,9 @@
"""B09 제비율 요율표 화면 자료 — STmate 「원가계산기준」(wM_KAN_RATE) 첫 탭 차례 (PLAN 12장).
⚠ 요율 데이터(`rates_<연도>.json`)를 **읽어 보이기만** 한다 — 묶음 제목·산식 표기는 DFM
`TWM_KAN_RATE` 첫 탭(☞ 공사원가계산 제잡비율) 차례를 따르고, 값·구간은 우리 데이터 그대로.
⚠ 수정은 아직 없음 — 엔진이 프로젝트 덮어쓰기 요율을 받는 칸이 서야 열 수 있음.
⚠ 묶음 제목·산식 표기는 DFM `TWM_KAN_RATE` 첫 탭(☞ 공사원가계산 제잡비율) 차례,
값·구간은 우리 데이터.
⚠ 요율 칸마다 **주소**(`RateOverride.address_of`)와 **기본값(마스터)·지금 값(프로젝트)** 을
함께 실음 — 화면이 「기본값 ↔ 고친 값」을 갈라 보이고 [수 정]에서 그 칸만 고쳐 사유와 함께 돌려줌.
"""
from __future__ import annotations
@@ -11,6 +12,7 @@ from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_CostSheet import SAFETY_LABELS, WORK_TYPE_LABELS
from B09_Estimation.B09_Estimation_RateOverride import address_of
from B09_Estimation.B09_Estimation_Rates import RateDataset, _bracket_bounds, _duration_bounds
_EOK = Decimal(100_000_000)
@@ -52,7 +54,35 @@ def _type(value: str, safety: bool = False) -> str:
return {**WORK_TYPE_LABELS, **(SAFETY_LABELS if safety else {})}.get(value, value)
def _pivot(rows: list[dict[str, Any]], row_keys: tuple[str, ...], col_key: str, safety=False):
class _Cells:
"""요율 칸 만들기 — 지금 판(덮어쓴 복사본)과 마스터 판을 같은 주소로 맞대 봄."""
def __init__(self, current: RateDataset, master: RateDataset) -> None:
self.current, self.master = current.variables, master.variables
def cell(self, variable: str, table: str | None, row: dict[str, Any] | None) -> dict[str, Any]:
address = address_of(variable, table, row)
value = self._rate(self.current, address)
default = self._rate(self.master, address)
return {
"value": value,
"default": default,
"overridden": value != default,
"address": address,
}
@staticmethod
def _rate(variables: dict[str, Any], address: dict[str, Any]) -> Any:
variable = variables[address["variable"]]
if not address["table"]:
return variable.get("rate_percent")
for row in variable[address["table"]]:
if all(str(row.get(k)) == str(v) for k, v in address["match"].items()):
return row.get("rate_percent")
return None
def _pivot(rows: list[dict[str, Any]], row_keys: tuple[str, ...], col_key: str):
"""세로 줄을 (구간…) × 공종 표로 편다 — DFM 표 모양(행 구간 · 열 공종)."""
columns = list(dict.fromkeys(str(row[col_key]) for row in rows))
grid: dict[tuple[str, ...], dict[str, Any]] = {}
@@ -61,15 +91,16 @@ def _pivot(rows: list[dict[str, Any]], row_keys: tuple[str, ...], col_key: str,
return columns, grid
def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
"""묶음 목록 `{title, formula, columns, rows}` — DFM 첫 탭 위→아래 차례."""
def rate_sections(dataset: RateDataset, master: RateDataset | None = None) -> list[dict[str, Any]]:
"""묶음 목록 `{title, formula, columns, rows}` — DFM 첫 탭 위→아래 차례.
`rows` 의 요율 칸은 `{value, default, overridden, address}` · 이름 칸은 글자.
"""
v = dataset.variables
c = _Cells(dataset, master or dataset)
sections: list[dict[str, Any]] = []
overhead = v["rate_overhead"]["civil_landscape_industrial"]
profit = {
row["estimated_price_bracket"]: row["rate_percent"] for row in v["rate_profit"]["brackets"]
}
profit = {row["estimated_price_bracket"]: row for row in v["rate_profit"]["brackets"]}
sections.append(
{
"title": "일반관리비 · 이윤",
@@ -78,10 +109,12 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
"rows": [
[
amount_label(row["estimated_price_bracket"]),
row["rate_percent"],
profit.get(row["estimated_price_bracket"], ""),
c.cell("rate_overhead", "civil_landscape_industrial", row),
c.cell("rate_profit", "brackets", profit[row["estimated_price_bracket"]])
if row["estimated_price_bracket"] in profit
else "",
]
for row in overhead
for row in v["rate_overhead"]["civil_landscape_industrial"]
],
}
)
@@ -96,12 +129,15 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
{
"title": title,
"formula": formula,
"columns": ["공사규모(직접공사비)", "공사기간", *(_type(c) for c in columns)],
"columns": ["공사규모(직접공사비)", "공사기간", *(_type(col) for col in columns)],
"rows": [
[
amount_label(size),
duration_label(days),
*(cells[c]["rate_percent"] if c in cells else "" for c in columns),
*(
c.cell(variable, "brackets", cells[col]) if col in cells else ""
for col in columns
),
]
for (size, days), cells in grid.items()
],
@@ -113,7 +149,11 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
"formula": "(노무비) × 율",
"columns": ["등급", "추정금액", "요율(%)"],
"rows": [
[row["grade"], amount_label(row["estimated_amount_bracket"]), row["rate_percent"]]
[
str(row["grade"]),
amount_label(row["estimated_amount_bracket"]),
c.cell("rate_goyong", "brackets", row),
]
for row in v["rate_goyong"]["brackets"]
],
}
@@ -125,18 +165,27 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
{
"title": "산업안전보건관리비",
"formula": "(재료비+직접노무비+관급자재) × 율 + 기초액",
"columns": ["대상액", *(f"{_type(c, safety=True)}(%)" for c in columns), "기초액(원)"],
"columns": [
"대상액",
*(f"{_type(col, safety=True)}(%)" for col in columns),
"기초액(원)",
],
"rows": [
[
amount_label(size),
*(cells[c]["rate_percent"] if c in cells else "" for c in columns),
next(
(
cells[c].get("base_amount_krw")
for c in columns
if cells.get(c, {}).get("base_amount_krw")
),
"",
*(
c.cell("rate_safety_pct", "brackets", cells[col]) if col in cells else ""
for col in columns
),
str(
next(
(
cells[col].get("base_amount_krw")
for col in columns
if cells.get(col, {}).get("base_amount_krw")
),
"",
)
),
]
for (size,), cells in grid.items()
@@ -149,7 +198,7 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
"formula": "(재료비+직접노무비+산출경비) × 율",
"columns": ["구 분", "요율(%)"],
"rows": [
[_type(row["work_type"]), row["rate_percent"]]
[_type(row["work_type"]), c.cell("rate_environment", "all_work_types", row)]
for row in v["rate_environment"]["all_work_types"]
],
}
@@ -176,7 +225,10 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
"formula": "(직접공사비) × 율",
"columns": ["공사규모", "요율(%)"],
"rows": [
[amount_label(row["estimated_price_bracket"]), row["rate_percent"]]
[
amount_label(row["estimated_price_bracket"]),
c.cell("rate_subcontract_payment_guarantee", "brackets", row),
]
for row in v["rate_subcontract_payment_guarantee"]["brackets"]
],
}
@@ -189,24 +241,24 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
"columns": ["구 분", "요율(%)"],
"rows": [
*(
[_type(row["work_type"]), row["rate_percent"]]
[
_type(row["work_type"]),
c.cell("rate_equipment_payment_guarantee", "general_construction", row),
]
for row in equipment["general_construction"]
),
*(
["전문건설 — " + row["work_type"], row["rate_percent"]]
[
"전문건설 — " + row["work_type"],
c.cell("rate_equipment_payment_guarantee", "specialty_construction", row),
]
for row in equipment["specialty_construction"]
),
],
}
)
pension = next(
(
row["rate_percent"]
for row in v["rate_pension"]["annual_rates"]
if row["year"] == int(str(dataset.version_stamp.get("effective_date", "0"))[:4] or 0)
),
"",
)
year = int(str(dataset.effective_date)[:4] or 0)
pension_row = next((r for r in v["rate_pension"]["annual_rates"] if r["year"] == year), None)
sections.append(
{
"title": "퇴직부금비 · 법 정 부 담 금 · 보험료",
@@ -216,19 +268,23 @@ def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]:
[
"퇴직공제부금비",
"(직접노무비) × 율",
v["rate_retirement_mutual_aid"]["rate_percent"],
c.cell("rate_retirement_mutual_aid", None, None),
],
["석면분담금", "(노무비) × 율", v["rate_asbestos_contribution"]["rate_percent"]],
["석면분담금", "(노무비) × 율", c.cell("rate_asbestos_contribution", None, None)],
[
"임금채권부담금",
"(노무비) × 율",
v["rate_wage_claim_contribution"]["rate_percent"],
c.cell("rate_wage_claim_contribution", None, None),
],
["산재보험료", "(노무비) × 율", v["rate_sanjae"]["rate_percent"]],
["건강보험료", "(직접노무비) × 율", v["rate_health"]["rate_percent"]],
["장기요양보험료", "(건강보험료) × 율", v["rate_care"]["rate_percent"]],
["연금보험료", "(직접노무비) × 율", pension],
["부가가치세", "(공급가액) × 율", v["rate_vat"]["rate_percent"]],
["산재보험료", "(노무비) × 율", c.cell("rate_sanjae", None, None)],
["건강보험료", "(직접노무비) × 율", c.cell("rate_health", None, None)],
["장기요양보험료", "(건강보험료) × 율", c.cell("rate_care", None, None)],
[
"연금보험료",
"(직접노무비) × 율",
c.cell("rate_pension", "annual_rates", pension_row) if pension_row else "",
],
["부가가치세", "(공급가액) × 율", c.cell("rate_vat", None, None)],
],
}
)