From f704bc3bf81369f576867e576160247f537d127a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 03:24:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=9A=94=EC=9C=A8=20=EB=8D=AE?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EA=B8=B0=20=E2=80=94=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EB=8B=A8=EC=9C=84=20=C2=B7=20=EC=B9=B8?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=82=AC=EC=9C=A0=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=E2=86=94=EA=B3=A0=EC=B9=9C=20=EA=B0=92=20?= =?UTF-8?q?=C2=B7=20=E2=86=BA=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - estimation.rate_overrides 에 칸 주소(variable·table·match)·요율·사유·입력 시각을 한 줄씩 — 마스터 요율 파일은 안 건드림 - 엔진이 계산할 때 복사본에만 얹음 · 덮어쓰기 없으면 원본 그대로(골든셋 초록) · 원가계산서 비고에 「요율 덮어쓰기 N건 — 사유」 - 요율표: 칸마다 기본값·지금 값·주소 · [수 정]에서 입력 · 고친 칸 테두리 + (기본 X) · ↺ · 고친 요율 목록에 사유 칸(비면 저장 안 됨) - PUT /estimation/rate-table: 사유 빈 줄·틀린 주소·같은 칸 두 번·음수면 아무것도 안 저장 · 값·사유 같으면 입력 시각 유지 - 판이 바뀌어 주소가 안 맞으면 조용히 버리지 않고 알림 - 시험: 마스터 불변 · 계산에 걸림 · 한 값짜리 · 거름 · 주소 틀리면 멈춤 · 화면 주소 왕복 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_CostSheet.py | 2 + B09_Estimation/B09_Estimation_Engine_Cost.py | 17 +- B09_Estimation/B09_Estimation_RateOverride.py | 122 +++++++ B09_Estimation/B09_Estimation_RateTable.py | 146 +++++--- .../B09_Estimation_Router_CostSheet.py | 87 ++++- .../B09_Estimation_UI_Tab_RateTable.ts | 344 +++++++++++++++--- resources/tester/test_b09_rate_override.py | 119 ++++++ 7 files changed, 743 insertions(+), 94 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_RateOverride.py create mode 100644 resources/tester/test_b09_rate_override.py diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py index 6aa8d2e6..9eaf2d6b 100644 --- a/B09_Estimation/B09_Estimation_CostSheet.py +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -199,6 +199,7 @@ def cost_input( stored: dict[str, Any], waste_krw: Decimal = _ZERO, waste_separate_order: bool = False, + rate_overrides: tuple[dict[str, Any], ...] = (), ) -> CostInput: """내역서 직접비 + 저장값 → 엔진 입력. 저장 안 한 칸은 **엔진 기본값**.""" kwargs: dict[str, Any] = { @@ -215,6 +216,7 @@ def cost_input( direct_expense_krw=direct["expense"], waste_disposal_krw=waste_krw, waste_separate_order=waste_separate_order, + rate_overrides=tuple(rate_overrides), **kwargs, ) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 969255d1..05264420 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -18,6 +18,7 @@ from __future__ import annotations from dataclasses import dataclass, field, replace from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal +from typing import Any from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( CUT_BASES, @@ -27,6 +28,7 @@ from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( profit_cut, vat_base, ) +from B09_Estimation.B09_Estimation_RateOverride import apply_overrides from B09_Estimation.B09_Estimation_Rates import ( RateDataset, flat_rate, @@ -131,6 +133,8 @@ class CostInput: #: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% — #: 율이 안 갈리는 구분은 칸으로 안 세움. retirement_mutual_aid_mode: str = "auto" + #: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율. + rate_overrides: tuple[dict[str, Any], ...] = () #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. @@ -207,8 +211,11 @@ class CostResult: def _load_dataset(data: CostInput) -> RateDataset: if data.rate_file_path: - return load_rate_dataset_from_path(data.rate_file_path) - return load_rate_dataset(data.rate_file_name) + dataset = load_rate_dataset_from_path(data.rate_file_path) + else: + dataset = load_rate_dataset(data.rate_file_name) + # 프로젝트 요율 덮어쓰기 — 복사본에만 얹음(마스터 파일은 그대로). + return apply_overrides(dataset, data.rate_overrides) def _emitter(result: CostResult): @@ -385,6 +392,12 @@ def _calculate_once( ) -> CostResult: """규모 기준액을 못 박고 한 번 계산한다.""" result = CostResult(rate_version=dataset.version_stamp, notes=list(notes)) + if data.rate_overrides: + # 판 지문은 마스터 그대로 — 덮어쓴 칸 수·사유를 따로 남겨 「왜 다른지」가 보이게. + result.notes.append( + f"프로젝트 요율 덮어쓰기 {len(data.rate_overrides)}건 — " + + " · ".join(str(o.get("reason") or "") for o in data.rate_overrides) + ) emit = _emitter(result) if data.enabled_items == DEFAULT_ITEMS: diff --git a/B09_Estimation/B09_Estimation_RateOverride.py b/B09_Estimation/B09_Estimation_RateOverride.py new file mode 100644 index 00000000..90fdc4c7 --- /dev/null +++ b/B09_Estimation/B09_Estimation_RateOverride.py @@ -0,0 +1,122 @@ +"""프로젝트 요율 덮어쓰기 — 발주처별 별도요율 (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 diff --git a/B09_Estimation/B09_Estimation_RateTable.py b/B09_Estimation/B09_Estimation_RateTable.py index 3ae2192b..1923cdd4 100644 --- a/B09_Estimation/B09_Estimation_RateTable.py +++ b/B09_Estimation/B09_Estimation_RateTable.py @@ -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)], ], } ) diff --git a/B09_Estimation/B09_Estimation_Router_CostSheet.py b/B09_Estimation/B09_Estimation_Router_CostSheet.py index ea0a4cfb..2871f66d 100644 --- a/B09_Estimation/B09_Estimation_Router_CostSheet.py +++ b/B09_Estimation/B09_Estimation_Router_CostSheet.py @@ -31,6 +31,7 @@ from B09_Estimation.B09_Estimation_CostSheet import ( status_line, ) from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost +from B09_Estimation.B09_Estimation_RateOverride import apply_overrides, clean_overrides from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset from B09_Estimation.B09_Estimation_RateTable import rate_sections from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at @@ -92,7 +93,8 @@ async def get_cost_sheet(project_id: UUID) -> JSONResponse: } stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {}) waste, separate, waste_note = _waste(bill, quantity_settings(root)) - data = cost_input(direct, stored, waste, separate) + overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ()) + data = cost_input(direct, stored, waste, separate, overrides) try: dataset = load_rate_dataset(data.rate_file_name) result = calculate_cost(data) @@ -122,6 +124,7 @@ async def get_cost_sheet(project_id: UUID) -> JSONResponse: "bill_unconfirmed_count": int(summary.get("unconfirmed_count") or 0), "rate_version": result.rate_version, "notes": result.notes, + "rate_override_count": len(overrides), } ) @@ -149,14 +152,88 @@ async def put_cost_sheet(project_id: UUID, body: dict[str, Any]) -> JSONResponse return JSONResponse(content={"status": "success", "stored": cleaned}) +#: 프로젝트 요율 덮어쓰기 저장 자리 — `estimation` 구획 안 한 칸(마스터 요율 파일은 안 건드림). +OVERRIDES_KEY = "rate_overrides" + + @router.get("/{project_id}/estimation/rate-table") async def get_rate_table(project_id: UUID) -> JSONResponse: - """제비율 요율표 — 지금 판의 요율 데이터를 DFM 첫 탭 묶음 차례로(보기 전용).""" - dataset = load_rate_dataset() + """제비율 요율표 — 마스터 요율에 **이 프로젝트의 덮어쓰기**를 얹어 DFM 첫 탭 차례로. + + 칸마다 기본값(마스터)·지금 값·주소를 실어 화면이 갈라 보이고 고칠 수 있게 한다. + """ + from common_util.common_util_project_settings import estimation_settings + + root = await _root(project_id) + master = load_rate_dataset() + overrides = list((estimation_settings(root) if root else {}).get(OVERRIDES_KEY) or []) + try: + current = apply_overrides(master, overrides) + except RateLookupError as error: + # 판이 바뀌어 주소가 안 맞는 덮어쓰기 — 조용히 버리지 않고 알림(마스터 값으로 보임). + return JSONResponse( + content={ + "status": "success", + "rate_version": master.version_stamp, + "sections": rate_sections(master), + "overrides": overrides, + "override_error": str(error), + } + ) return JSONResponse( content={ "status": "success", - "rate_version": dataset.version_stamp, - "sections": rate_sections(dataset), + "rate_version": master.version_stamp, + "sections": rate_sections(current, master), + "overrides": overrides, + "override_error": "", } ) + + +@router.put("/{project_id}/estimation/rate-table") +async def put_rate_table(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """덮어쓰기 저장 — 목록을 **통째로** 갈아 끼움(빈 목록 = 전부 기본값으로 되돌림). + + ⚠ 한 줄이라도 사유가 비거나 주소가 틀리면 **아무것도 안 저장**하고 까닭을 돌려줌. + """ + from datetime import datetime + + from common_util.common_util_project_settings import estimation_settings, save_section + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + kept, errors = clean_overrides(body.get("overrides"), load_rate_dataset()) + if errors: + return JSONResponse( + status_code=400, + content={"status": "error", "message": " · ".join(errors), "errors": errors}, + ) + previous = { + (o.get("variable"), o.get("table"), str(sorted((o.get("match") or {}).items()))): o + for o in estimation_settings(root).get(OVERRIDES_KEY) or [] + } + now = datetime.now().isoformat(timespec="seconds") + for entry in kept: + # 값·사유가 그대로면 입력 시각을 지킴 — 「언제 고쳤나」가 저장할 때마다 바뀌지 않게. + before = previous.get( + (entry["variable"], entry["table"], str(sorted(entry["match"].items()))) + ) + same = before and (before.get("rate_percent"), before.get("reason")) == ( + entry["rate_percent"], + entry["reason"], + ) + entry["entered_at"] = before.get("entered_at") if same else now + try: + save_section(root, "estimation", {OVERRIDES_KEY: kept}, replace_keys=(OVERRIDES_KEY,)) + except Exception: + logger.exception("B09 요율 덮어쓰기 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "요율 덮어쓰기를 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "overrides": kept}) diff --git a/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts b/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts index 6e85dad8..c6697550 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts @@ -2,13 +2,14 @@ * B09_Estimation_UI_Tab_RateTable.ts * 제비율 요율표 탭 — STmate 「원가계산기준」(wM_KAN_RATE)을 본뜸 (PLAN 12장 · 랩탑 메인). * - * - 좌측 = 도구줄 자리: `수정기준선택 ☞` [기본제비율] · `수 정` (지금은 잠김 — 덮어쓰기 칸 전). + * - 좌측 = 도구줄 자리: `수정기준선택 ☞` [기본제비율] · `수 정` · `취 소` · `저 장`. * - 본문 = 첫 탭 「☞ 공사원가계산 제잡비율」 묶음 차례 · 아래 탭 넷(첫 탭만 켜짐). - * - ⚠ 요율은 서버(`/estimation/rate-table`)가 요율 데이터에서 그대로 냄 — 보기 전용. + * - 요율 덮어쓰기 = **이 프로젝트만** · 칸마다 사유 필수 · 「기본값 ↔ 고친 값」 갈라 보임 · ↺ 되돌리기. + * - ⚠ 값은 서버가 냄 — 화면은 칸 주소와 새 값·사유만 돌려줌. 마스터 요율표는 안 건드림. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; @@ -16,11 +17,30 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +interface RateAddress { + variable: string; + table: string | null; + match: Record; +} + +interface RateCell { + value: number | null; + default: number | null; + overridden: boolean; + address: RateAddress; +} + interface RateSection { title: string; formula: string; columns: string[]; - rows: (string | number)[][]; + rows: (string | number | RateCell)[][]; +} + +interface Override extends RateAddress { + rate_percent: string; + reason: string; + entered_at?: string; } interface RateTableDto { @@ -28,6 +48,17 @@ interface RateTableDto { message?: string; rate_version: { dataset_id: string; effective_date: string; sha256: string }; sections: RateSection[]; + overrides: Override[]; + override_error: string; +} + +/** 고치는 중인 칸 — 저장 전 캐시(지침 5장). */ +interface DraftEntry { + address: RateAddress; + value: string; + reason: string; + label: string; + default: number | null; } /** DFM 아래 탭 — 화면에 보이는 묶음 제목 그대로. */ @@ -46,18 +77,26 @@ function injectStyles(): void { style.textContent = ` .b09rt { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } .b09rt__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09rt__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } .b09rt__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } .b09rt__section { border: 1px solid var(--color-border); padding: 6px 8px; } .b09rt__title { font-weight: 600; font-size: 13px; } .b09rt__formula { font-size: 12px; color: var(--color-text-secondary); margin-left: 6px; font-weight: normal; } .b09rt__table { border-collapse: collapse; font-size: 12px; margin-top: 4px; } .b09rt__table th, .b09rt__table td { border: 1px solid var(--color-border); padding: 2px 8px; } -.b09rt__table td { text-align: right; font-variant-numeric: tabular-nums; } -.b09rt__table td:first-child, .b09rt__table td.b09rt__text { text-align: left; } +.b09rt__table td { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.b09rt__table td.b09rt__text { text-align: left; } +.b09rt__table td.is-changed { outline: 2px solid var(--color-accent, #6c8ebf); outline-offset: -2px; } +.b09rt__default { font-size: 11px; color: var(--color-text-secondary); margin-left: 4px; } +.b09rt__table input { width: 5em; text-align: right; } .b09rt__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; } .b09rt__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; } .b09rt__tab[aria-selected="true"] { font-weight: 600; } .b09rt__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.b09rt__change { display: flex; flex-direction: column; gap: 2px; border-top: 1px dotted var(--color-border); padding-top: 4px; } +.b09rt__change input { width: 100%; } +.b09rt__change input.is-empty { outline: 2px solid var(--color-danger, #d9534f); } +.b09rt__buttons { display: flex; gap: 6px; flex-wrap: wrap; } `; document.head.append(style); } @@ -73,6 +112,18 @@ function el( return node; } +function addressKey(address: RateAddress): string { + const match = Object.keys(address.match) + .sort() + .map((k) => `${k}=${address.match[k]}`) + .join("&"); + return `${address.variable}|${address.table ?? ""}|${match}`; +} + +function isCell(value: unknown): value is RateCell { + return typeof value === "object" && value !== null && "address" in value; +} + async function fetchRates(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`, @@ -83,7 +134,86 @@ async function fetchRates(projectId: string): Promise { return body; } -function drawPanel(ctx: B09TabContext): void { +async function saveOverrides(projectId: string, overrides: Override[]): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ overrides }), + }, + ); + const body = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); +} + +/** 프로젝트별 탭 상태 — 탭을 다시 골라도 [저장] 전 고친 칸이 남음. */ +const states = new Map }>(); + +function stateOf(projectId: string, data: RateTableDto) { + let state = states.get(projectId); + if (!state) { + state = { editing: false, draft: new Map() }; + states.set(projectId, state); + } + if (!state.editing) { + // 편집 중이 아니면 저장본으로 다시 채움 — 저장본이 곧 정본. + state.draft = new Map( + data.overrides.map((o) => [ + addressKey(o), + { + address: { variable: o.variable, table: o.table, match: o.match }, + value: o.rate_percent, + reason: o.reason, + label: "", + default: null, + }, + ]), + ); + } + return state; +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09rt__meta", "프로젝트를 고르세요")); + return; + } + const projectId = ctx.projectId; + const load = (): void => { + ctx.body.replaceChildren(el("div", "b09rt__meta", "요율표 받는 중…")); + fetchRates(projectId) + .then((data) => { + const state = stateOf(projectId, data); + const redraw = (): void => { + ctx.body.replaceChildren(); + ctx.panel.replaceChildren(); + drawBody(ctx, data, state, redraw); + drawPanel(ctx, state, redraw, load); + }; + redraw(); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09rt__meta", + `요율표를 받지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(); +} + +function drawPanel( + ctx: B09TabContext, + state: { editing: boolean; draft: Map }, + redraw: () => void, + reload: () => void, +): void { const box = el("div", "b09rt__panel"); const pick = el("label", "b09rt__panel"); pick.append(el("span", "", "수정기준선택 ☞")); @@ -94,30 +224,119 @@ function drawPanel(ctx: B09TabContext): void { select.append(option); } pick.append(select); - const edit = createButton({ label: "수 정", variant: "ghost", disabled: true }); - edit.title = "요율 수정은 원가계산이 프로젝트 요율을 받는 칸이 선 뒤에 열림"; + box.append(pick); + + const buttons = el("div", "b09rt__buttons"); + if (!state.editing) { + buttons.append( + createButton({ + label: "수 정", + onClick: () => { + state.editing = true; + redraw(); + }, + }), + ); + } else { + buttons.append( + createButton({ + label: "취 소", + variant: "ghost", + onClick: () => { + state.editing = false; + reload(); + }, + }), + createButton({ + label: "저 장", + onClick: async () => { + const entries = [...state.draft.values()]; + if (entries.some((entry) => !entry.reason.trim())) { + showToast("고친 요율마다 사유를 적어야 저장됨", "error"); + redraw(); + return; + } + try { + await saveOverrides( + ctx.projectId as string, + entries.map((entry) => ({ + ...entry.address, + rate_percent: entry.value, + reason: entry.reason.trim(), + })), + ); + state.editing = false; + showToast("요율 덮어쓰기 저장 — 이 프로젝트만", "success"); + reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }, + }), + ); + } + box.append(buttons); box.append( - pick, - edit, - el("span", "b09rt__meta", "요율은 조달청 제비율 판 그대로 — 지금은 보기 전용"), - createButton({ - label: "원가계산서", - variant: "ghost", - onClick: () => ctx.open("cost_sheet"), - }), + el( + "span", + "b09rt__meta", + "고친 요율은 이 프로젝트에만 걸림 — 마스터 요율표(조달청 판)는 그대로 · 칸마다 사유 필수", + ), + ); + + // 고친 칸 목록 — 사유 칸(편집 중에만 고침). + const changes = [...state.draft.values()]; + box.append(el("div", "b09rt__title", `고친 요율 ${changes.length}건`)); + for (const entry of changes) { + const row = el("div", "b09rt__change"); + row.append( + el( + "span", + "", + `${entry.label || entry.address.variable} — ${entry.default ?? "?"} → ${entry.value}`, + ), + ); + if (state.editing) { + const reason = el("input"); + reason.placeholder = "사유(필수) — 예: 발주처 지침 ○○호"; + reason.value = entry.reason; + reason.classList.toggle("is-empty", !entry.reason.trim()); + reason.addEventListener("input", () => { + entry.reason = reason.value; + reason.classList.toggle("is-empty", !reason.value.trim()); + }); + row.append(reason); + } else { + row.append(el("span", "b09rt__meta", `사유: ${entry.reason}`)); + } + box.append(row); + } + box.append( + createButton({ label: "원가계산서", variant: "ghost", onClick: () => ctx.open("cost_sheet") }), ); ctx.panel.append(box); } -function drawBody(ctx: B09TabContext, data: RateTableDto): void { +function drawBody( + ctx: B09TabContext, + data: RateTableDto, + state: { editing: boolean; draft: Map }, + redraw: () => void, +): void { const wrap = el("div", "b09rt"); wrap.append( el( "div", "b09rt__meta", - `원가계산기준 — 요율 판 ${data.rate_version.effective_date} (${data.rate_version.dataset_id})`, + `원가계산기준 — 요율 판 ${data.rate_version.effective_date} (${data.rate_version.dataset_id})` + + (state.editing ? " · 수정 중" : ""), ), ); + if (data.override_error) { + wrap.append( + el("div", "b09rt__warn", `⚠ 저장된 덮어쓰기가 지금 판과 안 맞음 — ${data.override_error}`), + ); + } const scroll = el("div", "b09rt__scroll"); for (const section of data.sections) { const box = el("div", "b09rt__section"); @@ -130,9 +349,20 @@ function drawBody(ctx: B09TabContext, data: RateTableDto): void { table.append(head); for (const row of section.rows) { const tr = el("tr"); - for (const cell of row) { - tr.append(el("td", typeof cell === "number" ? "" : "b09rt__text", String(cell))); - } + const rowLabel = row.filter((c) => typeof c === "string" && c).join(" "); + row.forEach((cell, index) => { + if (!isCell(cell)) { + tr.append(el("td", typeof cell === "number" ? "" : "b09rt__text", String(cell))); + return; + } + const key = addressKey(cell.address); + const entry = state.draft.get(key); + if (entry) { + entry.label = `${section.title} · ${rowLabel} · ${section.columns[index]}`; + entry.default = cell.default; + } + tr.append(rateCell(cell, entry, state, section, rowLabel, index, redraw)); + }); table.append(tr); } box.append(table); @@ -151,28 +381,58 @@ function drawBody(ctx: B09TabContext, data: RateTableDto): void { ctx.body.append(wrap); } -function render(ctx: B09TabContext): void { - injectStyles(); - if (!ctx.projectId) { - ctx.body.append(el("div", "b09rt__meta", "프로젝트를 고르세요")); - return; +/** 요율 칸 — 보기: 값(+기본값) · 편집: 입력 + ↺. 기본값과 같아지면 덮어쓰기에서 빠짐. */ +function rateCell( + cell: RateCell, + entry: DraftEntry | undefined, + state: { editing: boolean; draft: Map }, + section: RateSection, + rowLabel: string, + index: number, + redraw: () => void, +): HTMLTableCellElement { + const td = el("td"); + const key = addressKey(cell.address); + const shown = entry ? entry.value : String(cell.value ?? ""); + const changed = Boolean(entry) && Number(shown) !== cell.default; + td.classList.toggle("is-changed", changed); + if (entry?.reason) td.title = `기본 ${cell.default} · 사유: ${entry.reason}`; + if (!state.editing) { + td.append(document.createTextNode(shown)); + if (changed) td.append(el("span", "b09rt__default", `(기본 ${cell.default})`)); + return td; } - drawPanel(ctx); - ctx.body.append(el("div", "b09rt__meta", "요율표 받는 중…")); - fetchRates(ctx.projectId) - .then((data) => { - ctx.body.replaceChildren(); - drawBody(ctx, data); - }) - .catch((error: unknown) => { - ctx.body.replaceChildren( - el( - "div", - "b09rt__meta", - `요율표를 받지 못함 — ${error instanceof Error ? error.message : ""}`, - ), - ); + const input = el("input"); + input.type = "number"; + input.step = "0.001"; + input.min = "0"; + input.value = shown; + input.addEventListener("change", () => { + if (input.value === "" || Number(input.value) === cell.default) { + state.draft.delete(key); + } else { + state.draft.set(key, { + address: cell.address, + value: input.value, + reason: entry?.reason ?? "", + label: `${section.title} · ${rowLabel} · ${section.columns[index]}`, + default: cell.default, + }); + } + redraw(); + }); + td.append(input); + if (entry) { + const undo = el("button", "", "↺"); + undo.type = "button"; + undo.title = `기본값(${cell.default})으로 — [저 장]하면 덮어쓰기에서 빠짐`; + undo.addEventListener("click", () => { + state.draft.delete(key); + redraw(); }); + td.append(undo); + } + return td; } export const rateTableTab: B09Tab = { diff --git a/resources/tester/test_b09_rate_override.py b/resources/tester/test_b09_rate_override.py new file mode 100644 index 00000000..2a791f31 --- /dev/null +++ b/resources/tester/test_b09_rate_override.py @@ -0,0 +1,119 @@ +"""프로젝트 요율 덮어쓰기 — 프로젝트 단위 · 사유 저장 · 기본값↔고친 값 (PLAN 6장 새 절). + +⚠ 겨누는 것 + ① 마스터 요율판은 **안 바뀜**(덮어쓰기는 복사본에만) + ② 사유 없는 줄·틀린 주소·같은 칸 두 번은 거름 + ③ 덮어쓴 요율이 계산에 실제로 걸림 · 안 쓴 프로젝트는 값이 그대로 + ④ 요율표 칸이 기본값·지금 값·주소를 함께 싣고, 그 주소를 그대로 돌려주면 저장됨 +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 +from B09_Estimation.B09_Estimation_RateOverride import ( # noqa: E402 + apply_overrides, + clean_overrides, +) +from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset # noqa: E402 +from B09_Estimation.B09_Estimation_RateTable import rate_sections # noqa: E402 + +BASE = CostInput( + direct_material_krw=Decimal(123_456_789), + direct_labor_krw=Decimal(234_567_891), + direct_expense_krw=Decimal(45_678_912), + estimated_price_krw=Decimal(300_000_000), # 50억 미만 구간 +) +OVERHEAD = { + "variable": "rate_overhead", + "table": "civil_landscape_industrial", + "match": {"estimated_price_bracket": "lt_5_billion"}, + "rate_percent": "7.5", + "reason": "발주처 지침(시험)", +} + + +def test_마스터는_안_바뀐다() -> None: + master = load_rate_dataset() + before = master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] + changed = apply_overrides(master, [OVERHEAD]) + assert ( + changed.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == 7.5 + ) + assert ( + master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == before + ) + assert ( + load_rate_dataset().variables["rate_overhead"]["civil_landscape_industrial"][0][ + "rate_percent" + ] + == before + ) + + +def test_덮어쓴_요율이_계산에_걸리고_안_쓰면_그대로() -> None: + plain = calculate_cost(BASE) + again = calculate_cost(replace(BASE, rate_overrides=())) + assert plain.totals == again.totals + changed = calculate_cost(replace(BASE, rate_overrides=(OVERHEAD,))) + assert changed.line("general_overhead").rate_percent == Decimal("7.5") + assert changed.amount("general_overhead") < plain.amount("general_overhead") + assert any("요율 덮어쓰기 1건" in note and "발주처 지침" in note for note in changed.notes) + + +def test_한_값짜리_요율도_덮어쓴다() -> None: + sanjae = { + "variable": "rate_sanjae", + "table": None, + "match": {}, + "rate_percent": "3.7", + "reason": "고시 개정", + } + result = calculate_cost(replace(BASE, rate_overrides=(sanjae,))) + assert result.line("industrial_accident_insurance").rate_percent == Decimal("3.7") + + +def test_사유_주소_중복을_거른다() -> None: + master = load_rate_dataset() + kept, errors = clean_overrides( + [ + {**OVERHEAD, "reason": " "}, + {**OVERHEAD, "match": {"estimated_price_bracket": "no_such"}}, + {**OVERHEAD, "rate_percent": "-1"}, + OVERHEAD, + OVERHEAD, + ], + master, + ) + assert len(kept) == 1 and kept[0]["rate_percent"] == "7.5" + assert len(errors) == 4 + assert any("사유" in e for e in errors) and any("두 번" in e for e in errors) + + +def test_주소가_안_맞으면_계산이_멈춘다() -> None: + stale = {**OVERHEAD, "match": {"estimated_price_bracket": "gone"}} + with pytest.raises(RateLookupError): + calculate_cost(replace(BASE, rate_overrides=(stale,))) + + +def test_요율표_칸이_기본값과_주소를_싣고_그대로_돌려주면_저장된다() -> None: + master = load_rate_dataset() + current = apply_overrides(master, [OVERHEAD]) + sections = rate_sections(current, master) + cells = [cell for s in sections for row in s["rows"] for cell in row if isinstance(cell, dict)] + assert cells and all({"value", "default", "overridden", "address"} <= set(c) for c in cells) + changed = [c for c in cells if c["overridden"]] + assert len(changed) == 1 and (changed[0]["value"], changed[0]["default"]) == (7.5, 8.0) + # 화면이 받은 주소 그대로 새 값·사유를 붙여 돌려주면 모두 저장 가능해야 함. + raw = [{**c["address"], "rate_percent": "1", "reason": "왕복"} for c in cells] + kept, errors = clean_overrides(raw, master) + assert not errors and len(kept) == len(cells)