- 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
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""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
|