Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
214 lines
9.7 KiB
Python
214 lines
9.7 KiB
Python
"""Z01 기초단가 — 기계 한 표: 기종 한 줄 + 시간당 단가(계산값) · 산출근거(2026-09-15 브레인 계약).
|
||
|
||
기계단가는 취득가 613 · 손료계수 387 · 운전경비(연료·잡재료·조종원 — 건설품셈 8-4) · 노임 · 유가가 계산으로 물림.
|
||
고치는 칸 = 취득가 · 손료계수(이 표) — 노임·유가는 그 표에서 고치고 여기는 **덮개 얹은 값**을 끌어 씀
|
||
계산 칸 = 손료 · 연료 · 조종원 · 시간당 단가 — editable 에서 빼고 locked 에 까닭
|
||
⚠ 셈은 B09 함수를 그대로 부름(`load_machine_catalog` · `load_operating_records` · `hourly_machine_cost`) — 식을 두 벌로 안 둠.
|
||
`hourly_cost_of`(B09) 와 같은 값인지 `test_z01_base_prices.py` 가 대조. 반만 선 값(연료·조종원 빠짐)은 시간당 단가를 안 냄.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, replace
|
||
from decimal import Decimal
|
||
from functools import lru_cache
|
||
from typing import Any
|
||
|
||
from B09_Estimation.B09_Estimation_MachineCost import (
|
||
OPERATOR_WAGE_FORMULA,
|
||
hourly_machine_cost,
|
||
load_machine_catalog,
|
||
)
|
||
from B09_Estimation.B09_Estimation_MachineOperating import FUEL_VARIABLES, load_operating_records
|
||
from Z01_MasterData.Z01_MasterData_Tables import RESOURCES
|
||
|
||
#: B09 셈이 여는 원본 셋 — 바뀌면 캐시를 새로 읽음
|
||
_INPUT_FILES = ("mach_base_2026.json", "pum_const_2026.json", "labor_const_2026-01-01.json")
|
||
EDITABLE = ["price_thousand_krw", "loss_coefficient_per_hour"]
|
||
COMPUTED = ("loss_krw_per_hour", "fuel_krw_per_hour", "operator_krw_per_hour", "hourly_krw")
|
||
#: 조종원 식 글자 — B09 식 문자열(좌→우 차례)을 사람이 읽게만 바꿈
|
||
_WAGE_TEXT = OPERATOR_WAGE_FORMULA.replace("*", " × ").replace("/", " ÷ ")
|
||
_FORMULA = {
|
||
"loss_krw_per_hour": "손료 = 취득가(천원) × 1,000 × 시간당 손료계수 (건설품셈 8-1-6)",
|
||
"fuel_krw_per_hour": "연료 = 주연료(ℓ/hr) × (1 + 잡재료%) × 전국평균 유가 (건설품셈 8-4 · 8-1-7)",
|
||
"operator_krw_per_hour": (
|
||
f"조종원 = 일 노임 × 인원 × {_WAGE_TEXT}"
|
||
" · 원 미만 절사 (건설품셈 8-1-2 · 건협 임금적용요령 4-나)"
|
||
),
|
||
"hourly_krw": "시간당 사용료 = 손료 + 연료 + 조종원",
|
||
}
|
||
_LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜"
|
||
_LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님"
|
||
_LOCKED = {
|
||
"machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음",
|
||
"machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜",
|
||
"specification": "공표 원문 칸 — 원본 갱신으로만 바뀜",
|
||
"economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜",
|
||
"annual_standard_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜",
|
||
"fuel_kind": _LOCKED_OPERATING,
|
||
"fuel_liters_per_hour": _LOCKED_OPERATING,
|
||
"misc_material_percent": _LOCKED_OPERATING,
|
||
"operator_occupation_code": _LOCKED_OPERATING,
|
||
"operator_person_days": _LOCKED_OPERATING,
|
||
"fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 덮개 얹은 전국평균을 끌어 씀",
|
||
"operator_daily_wage_krw": "노임 표에서 고침 — 여기는 덮개 얹은 건설 노임을 끌어 씀",
|
||
"hourly_note": "계산이 왜 안 섰는지 — 밑값이 비면 섬",
|
||
"effective_date": "원본 판 기준일 — 원본 갱신으로만 바뀜",
|
||
"operating_effective_date": "운전경비 원단위가 나온 건설품셈 판 기준일",
|
||
"fuel_price_date": "끌어 쓴 유가 판 기준일 — 유가 표 판",
|
||
"operator_wage_date": "끌어 쓴 노임 판 기준일 — 노임 표 판",
|
||
**dict.fromkeys(COMPUTED, _LOCKED_COMPUTED),
|
||
}
|
||
|
||
|
||
def _num(value: Decimal | int | None) -> int | float | None:
|
||
if value is None:
|
||
return None
|
||
value = Decimal(value)
|
||
return int(value) if value == value.to_integral_value() else float(value)
|
||
|
||
|
||
def _fmt(value: Decimal | int | float) -> str:
|
||
text = f"{Decimal(str(value)):,.4f}".rstrip("0").rstrip(".")
|
||
return text
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Inputs:
|
||
"""기계 셈이 다른 표에서 끌어 오는 값 — 덮개를 얹은 뒤 · 판 기준일과 함께."""
|
||
|
||
wages: dict[str, Decimal | None]
|
||
wage_date: str | None
|
||
oil: dict[str, Decimal]
|
||
oil_dates: dict[str, str | None]
|
||
operating_date: str | None
|
||
|
||
|
||
@lru_cache(maxsize=2)
|
||
def _cached(_mtimes: tuple[int, ...]):
|
||
return load_machine_catalog(), {r.machine_code: r for r in load_operating_records().records}
|
||
|
||
|
||
def _inputs():
|
||
folder = RESOURCES / "data_cost_input_value"
|
||
return _cached(tuple((folder / name).stat().st_mtime_ns for name in _INPUT_FILES))
|
||
|
||
|
||
def base_rows(source_name: str, effective_date: str | None) -> list[dict[str, Any]]:
|
||
"""기종 한 줄 — 원본 칸(카탈로그가 되살린 이름·규격 · 손료계수 포함)과 운전경비 원단위."""
|
||
catalog, records = _inputs()
|
||
rows = []
|
||
for code, m in catalog.machines.items():
|
||
record = records.get(code)
|
||
rows.append(
|
||
{
|
||
"@id": code,
|
||
"@source": source_name,
|
||
"machine_code": code,
|
||
"effective_date": effective_date,
|
||
"machine_name": m.name,
|
||
"specification": m.specification,
|
||
"price_thousand_krw": _num(m.price_thousand_krw),
|
||
"loss_coefficient_per_hour": _num(m.loss_coefficient_per_hour),
|
||
"economic_life_hours": m.economic_life_hours,
|
||
"annual_standard_hours": m.annual_standard_hours,
|
||
"fuel_kind": record.fuel_kind if record else None,
|
||
"fuel_liters_per_hour": _num(record.fuel_liters_per_hour) if record else None,
|
||
"misc_material_percent": _num(record.misc_material_percent) if record else None,
|
||
"operator_occupation_code": record.operator_occupation_code if record else None,
|
||
"operator_person_days": _num(record.operator_person_days) if record else None,
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]:
|
||
"""덮개 얹은 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길."""
|
||
catalog, records = _inputs()
|
||
code = row["@id"]
|
||
coef = row["loss_coefficient_per_hour"]
|
||
record = records.get(code)
|
||
out = {
|
||
**row,
|
||
"operating_effective_date": inputs.operating_date if record is not None else None,
|
||
"fuel_price_krw_per_l": None,
|
||
"fuel_price_date": None,
|
||
"operator_daily_wage_krw": None,
|
||
"operator_wage_date": None,
|
||
**dict.fromkeys(COMPUTED),
|
||
}
|
||
if coef is None:
|
||
out["hourly_note"] = "손료계수 없음 — 시간당 단가 안 섬"
|
||
return out
|
||
spec = replace(
|
||
catalog.machines[code],
|
||
price_thousand_krw=Decimal(str(row["price_thousand_krw"])),
|
||
loss_coefficient_per_hour=Decimal(str(coef)),
|
||
)
|
||
liters = fuel_price = wage = None
|
||
if record is not None:
|
||
liters = record.fuel_liters_per_hour
|
||
if liters is not None:
|
||
variable = FUEL_VARIABLES.get(record.fuel_kind, "")
|
||
fuel_price = inputs.oil.get(variable)
|
||
out["fuel_price_date"] = inputs.oil_dates.get(variable)
|
||
if record.misc_material_percent is not None:
|
||
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
|
||
wage = inputs.wages.get(record.operator_occupation_code)
|
||
out["operator_daily_wage_krw"] = _num(wage)
|
||
out["operator_wage_date"] = inputs.wage_date
|
||
if wage is not None and record.operator_person_days is not None:
|
||
wage = wage * record.operator_person_days # 인원 칸이 비면 B09 처럼 일 노임 그대로
|
||
cost = hourly_machine_cost(
|
||
spec,
|
||
fuel_liters_per_hour=liters if fuel_price is not None else None,
|
||
fuel_price_per_liter=fuel_price,
|
||
operator_daily_wage=wage,
|
||
)
|
||
money = cost.money
|
||
out["fuel_price_krw_per_l"] = _num(fuel_price)
|
||
out["loss_krw_per_hour"] = _num(money.expense)
|
||
formula = {
|
||
"loss_krw_per_hour": (
|
||
f"{_fmt(money.expense)} = {_fmt(spec.price_thousand_krw)}천원 × 1,000 × {spec.loss_coefficient_per_hour}"
|
||
)
|
||
}
|
||
if fuel_price is not None:
|
||
out["fuel_krw_per_hour"] = _num(money.material)
|
||
misc = record.misc_material_percent if record else None
|
||
formula["fuel_krw_per_hour"] = (
|
||
f"{_fmt(money.material)} = {_fmt(record.fuel_liters_per_hour)}ℓ"
|
||
+ (f" × (1 + {_fmt(misc)}%)" if misc is not None else "")
|
||
+ f" × {_fmt(fuel_price)}원/ℓ"
|
||
)
|
||
if wage is not None:
|
||
out["operator_krw_per_hour"] = _num(money.labor)
|
||
persons = record.operator_person_days
|
||
formula["operator_krw_per_hour"] = (
|
||
f"{_fmt(money.labor)} = {_fmt(out['operator_daily_wage_krw'])}원"
|
||
+ (f" × {_fmt(persons)}인" if persons is not None else "")
|
||
+ f" × {_WAGE_TEXT} (원 미만 절사)"
|
||
)
|
||
if cost.gaps:
|
||
out["hourly_note"] = " · ".join(cost.gaps)
|
||
else:
|
||
out["hourly_krw"] = _num(money.total)
|
||
out["hourly_note"] = None
|
||
formula["hourly_krw"] = (
|
||
f"{_fmt(money.total)} = {_fmt(money.expense)} + {_fmt(money.material)} + {_fmt(money.labor)}"
|
||
)
|
||
out["@formula"] = formula
|
||
return out
|
||
|
||
|
||
def spec(columns: list[str]) -> dict[str, Any]:
|
||
return {
|
||
"editable": list(EDITABLE),
|
||
"locked": {
|
||
c: _LOCKED.get(c, "공표 원문 칸 — 원본 갱신으로만 바뀜")
|
||
for c in columns
|
||
if c not in EDITABLE
|
||
},
|
||
"formula": dict(_FORMULA),
|
||
}
|