Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)],
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
}
|
||||
|
||||
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<K extends keyof HTMLElementTagNameMap>(
|
||||
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<RateTableDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`,
|
||||
@@ -83,7 +134,86 @@ async function fetchRates(projectId: string): Promise<RateTableDto> {
|
||||
return body;
|
||||
}
|
||||
|
||||
function drawPanel(ctx: B09TabContext): void {
|
||||
async function saveOverrides(projectId: string, overrides: Override[]): Promise<void> {
|
||||
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<string, { editing: boolean; draft: Map<string, DraftEntry> }>();
|
||||
|
||||
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<string, DraftEntry> },
|
||||
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<string, DraftEntry> },
|
||||
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<string, DraftEntry> },
|
||||
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 = {
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user