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

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

292 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 제비율 요율표 화면 자료 — STmate 「원가계산기준」(wM_KAN_RATE) 첫 탭 차례 (PLAN 12장).
⚠ 묶음 제목·산식 표기는 DFM `TWM_KAN_RATE` 첫 탭(☞ 공사원가계산 제잡비율) 차례,
값·구간은 우리 데이터.
⚠ 요율 칸마다 **주소**(`RateOverride.address_of`)와 **기본값(마스터)·지금 값(프로젝트)** 을
함께 실음 — 화면이 「기본값 ↔ 고친 값」을 갈라 보이고 [수 정]에서 그 칸만 고쳐 사유와 함께 돌려줌.
"""
from __future__ import annotations
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)
def _won(amount: Decimal) -> str:
"""원 → 「50억」·「5천만」 표기."""
if amount >= _EOK:
return f"{amount / _EOK:g}억"
return f"{amount / 10_000_000:g}천만"
def amount_label(label: str) -> str:
"""구간 키 → 사람 표기. 모르는 키는 그대로(지어내지 않음)."""
bounds = _bracket_bounds(label)
if bounds is None:
return label
low, high = bounds
if low == 0:
return f"{_won(high)} 미만"
if high == Decimal("Infinity"):
return f"{_won(low)} 이상" + (" " + label.split("_", 3)[-1] if label.count("_") > 2 else "")
return f"{_won(low)}~{_won(high)} 미만"
def duration_label(label: str) -> str:
bounds = _duration_bounds(label)
if bounds is None:
return label
low, high = bounds
if low == 0:
return f"{high}일 이하"
if high >= 10**9:
return f"{low}일 이상"
return f"{low}~{high}일"
def _type(value: str, safety: bool = False) -> str:
return {**WORK_TYPE_LABELS, **(SAFETY_LABELS if safety else {})}.get(value, value)
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]] = {}
for row in rows:
grid.setdefault(tuple(str(row[k]) for k in row_keys), {})[str(row[col_key])] = row
return columns, grid
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]] = []
profit = {row["estimated_price_bracket"]: row for row in v["rate_profit"]["brackets"]}
sections.append(
{
"title": "일반관리비 · 이윤",
"formula": "일반관리비 (재+노+경) × 율 · 이윤 (노+경+일) × 율",
"columns": ["공사규모(추정가격)", "일반관리비(%)", "이윤(%)"],
"rows": [
[
amount_label(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 v["rate_overhead"]["civil_landscape_industrial"]
],
}
)
for variable, title, formula in (
("rate_indirect_labor", "간접노무비", "(직접노무비) × 율(%)"),
("rate_other_expense", "기타경비", "(재료비+노무비) × 율(%)"),
):
columns, grid = _pivot(
v[variable]["brackets"], ("direct_cost_bracket", "duration_bracket"), "work_type"
)
sections.append(
{
"title": title,
"formula": formula,
"columns": ["공사규모(직접공사비)", "공사기간", *(_type(col) for col in columns)],
"rows": [
[
amount_label(size),
duration_label(days),
*(
c.cell(variable, "brackets", cells[col]) if col in cells else ""
for col in columns
),
]
for (size, days), cells in grid.items()
],
}
)
sections.append(
{
"title": "고용보험료",
"formula": "(노무비) × 율",
"columns": ["등급", "추정금액", "요율(%)"],
"rows": [
[
str(row["grade"]),
amount_label(row["estimated_amount_bracket"]),
c.cell("rate_goyong", "brackets", row),
]
for row in v["rate_goyong"]["brackets"]
],
}
)
columns, grid = _pivot(
v["rate_safety_pct"]["brackets"], ("target_amount_bracket",), "work_type"
)
sections.append(
{
"title": "산업안전보건관리비",
"formula": "(재료비+직접노무비+관급자재) × 율 + 기초액",
"columns": [
"대상액",
*(f"{_type(col, safety=True)}(%)" for col in columns),
"기초액(원)",
],
"rows": [
[
amount_label(size),
*(
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()
],
}
)
sections.append(
{
"title": "환 경 보 전 비",
"formula": "(재료비+직접노무비+산출경비) × 율",
"columns": ["구 분", "요율(%)"],
"rows": [
[_type(row["work_type"]), c.cell("rate_environment", "all_work_types", row)]
for row in v["rate_environment"]["all_work_types"]
],
}
)
sections.append(
{
"title": "공사이행보증수수료",
"formula": "[추가금액 + (직접공사비-기준금액) × 율] × 공기(년)",
"columns": ["공사규모(직접공사비)", "수수료 산식"],
"rows": [
[
amount_label(row["direct_cost_bracket"]),
str(row.get("formula", ""))
.replace("direct_cost", "직공비")
.replace("duration_years", "공기(년)"),
]
for row in v["rate_performance_guarantee_fee"]["brackets"]
],
}
)
sections.append(
{
"title": "건설하도급대금지급보증서발급수수료",
"formula": "(직접공사비) × 율",
"columns": ["공사규모", "요율(%)"],
"rows": [
[
amount_label(row["estimated_price_bracket"]),
c.cell("rate_subcontract_payment_guarantee", "brackets", row),
]
for row in v["rate_subcontract_payment_guarantee"]["brackets"]
],
}
)
equipment = v["rate_equipment_payment_guarantee"]
sections.append(
{
"title": "건설기계대여대금 지급보증서 발급금액",
"formula": "(직접공사비) × 율",
"columns": ["구 분", "요율(%)"],
"rows": [
*(
[
_type(row["work_type"]),
c.cell("rate_equipment_payment_guarantee", "general_construction", row),
]
for row in equipment["general_construction"]
),
*(
[
"전문건설 — " + row["work_type"],
c.cell("rate_equipment_payment_guarantee", "specialty_construction", row),
]
for row in equipment["specialty_construction"]
),
],
}
)
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": "퇴직부금비 · 법 정 부 담 금 · 보험료",
"formula": "",
"columns": ["항목", "산식", "요율(%)"],
"rows": [
[
"퇴직공제부금비",
"(직접노무비) × 율",
c.cell("rate_retirement_mutual_aid", None, None),
],
["석면분담금", "(노무비) × 율", c.cell("rate_asbestos_contribution", None, None)],
[
"임금채권부담금",
"(노무비) × 율",
c.cell("rate_wage_claim_contribution", None, None),
],
["산재보험료", "(노무비) × 율", 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)],
],
}
)
return sections