Files
Aislo/B09_Estimation/B09_Estimation_MachineCost.py
T
eomsangdonandClaude Opus 5 34d90a14d1 feat(B09): 기계경비(S→X) + 기종 카탈로그 매칭 (PLAN 9-3)
**`B09_Estimation_MachineCost.py` 신설 — 취득가 → 시간당 사용료 두 단계**
- `mach_base_2026.json` 실사: `mach_price` **613 기종**(`machine_code`+`specification`),
  `mach_loss_coef` 387건(시간당 손료계수·내용시간·연간표준시간).
- **시간당 손료 = 취득가 × 손료계수.** 취득가가 **천원 단위**라 환산을 놓치면 1,000배
  틀리는 자리 — 테스트로 박음. 굴착기(무한궤도) 0.7㎥ → **24,554원/hr**
  (STC 2024 관측 23,128 과 같은 자릿수, 취득가 판 차이로 설명됨).
- ⚠ **빠진 성분을 0 으로 안 때움** — `mach_fuel_rate`·`mach_operator_map` 이 **0건**이라
  연료(재료비)·운전사(노무비)를 못 채움. `HourlyMachineCost.gaps` 로 **공백을 알림**.
  실측 비중이 노무 53·재료 20·경비 27 % 라 손료만 쓰면 **4분의 1**이 됨 — 조용히
  총액에 섞이면 안 되는 자리.
- 손료계수 없는 기종 **230건**도 0 으로 안 때우고 멈춤.

**자원 축에 기종 매칭 추가**
- 기종 셀은 괄호 안에 **형식과 규격이 섞여** 있음 — 「굴착기(무한궤도, 0.7㎥)」는
  이름 `굴착기(무한궤도)` + 규격 `0.7`. 형식은 이름의 일부이고 숫자 조각만 규격임.
- **규격이 옆 칸에 있는 표**가 많아(`['굴착기 (무한궤도)','굴착기(무한궤도,0.2㎥)','hr',…])
  뒷 칸에서 규격 후보를 찾아 고름. 규격 없이 매칭 성공으로 치지 않는 규칙은 그대로.
- 산출 갱신 — 매칭 86 → **91행**(노무 86 + 기종 5), 미매칭 274 → **269건**.

자체검증 — 신규 7건 포함 `pytest tmp/tests/ -q` **104 passed** · ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:43:19 +09:00

186 lines
7.3 KiB
Python

"""B09 원가계산 — 기계경비 (PLAN 9-3 의 `S` → `X` 두 단계).
실무·교본이 같은 두 단계다 (신규 문서 5장 라-1 예제도 같은 모양):
S 취득가(천원) ──(손료계수)──► X 시간당 중기사용료
= 손료(경비) + 연료(재료) + 운전사(노무)
이 모듈이 내는 것은 **시간당 사용료 한 시간분**이고, 3분할(재료·노무·경비)로 낸다.
`B09_Estimation_PriceBook` 의 `MACHINE_BASE`(S) · `MACHINE_HOURLY`(X) 층에 그대로 앉는다.
데이터 (`resources/data_cost_input_value/mach_base_2026.json`)
- `mach_price` **613 기종** — `machine_code` + `specification` 으로 규격이 갈린다.
- `mach_loss_coef` **387건** — 시간당 손료계수·내용시간·연간표준시간.
- ⚠ `mach_fuel_rate` **0건** · `mach_operator_map` **0건** — **비어 있다.**
연료소모량과 기종별 운전사 직종은 품셈 본문에 있고, 아직 뽑히지 않았다.
⚠ **그래서 이 모듈은 손료(경비)까지만 채우고, 연료·운전사는 「공백」으로 표시한다.**
0 으로 때우면 **시간당 사용료가 절반 이하로 나오고 그대로 총액에 섞인다** — 실측
비중이 노무 53 % · 재료 20 % · 경비 27 % 라 손료만으로는 4분의 1 남짓이다.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import Money3
_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
_THOUSAND = Decimal(1000)
class MachineCostError(LookupError):
"""기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다."""
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_json(file_name: str) -> dict[str, Any]:
with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h:
return json.load(h)
@dataclass(frozen=True)
class MachineSpec:
"""기종 한 줄 — 규격까지 붙어야 한 대가 정해진다."""
machine_code: str
name: str
specification: str
price_thousand_krw: Decimal
loss_coefficient_per_hour: Decimal | None = None
economic_life_hours: int | None = None
annual_standard_hours: int | None = None
@property
def display_name(self) -> str:
return f"{self.name} {self.specification}".strip()
@dataclass
class MachineCatalog:
"""613 기종. **이름만으로는 못 고른다** — 규격이 있어야 한 대가 정해진다."""
machines: dict[str, MachineSpec] = field(default_factory=dict)
def by_name(self, name: str) -> list[MachineSpec]:
return [m for m in self.machines.values() if m.name == name]
def resolve(self, name: str, specification: str) -> MachineSpec | None:
"""이름 + 규격으로 한 대를 고른다. 규격이 없으면 **고르지 않는다**."""
found = self.by_name(name)
if not found:
return None
if len(found) == 1 and not specification:
return found[0]
narrowed = [m for m in found if m.specification == specification]
return narrowed[0] if len(narrowed) == 1 else None
def get(self, machine_code: str) -> MachineSpec:
try:
return self.machines[machine_code]
except KeyError as exc:
raise MachineCostError(f"기종 카탈로그에 없는 코드입니다: {machine_code}") from exc
def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatalog:
"""취득가 613건에 손료계수 387건을 붙여 카탈로그 한 벌을 만든다."""
variables = _read_json(file_name)["variables"]
coefficients = {
r["machine_code"]: r for r in variables.get("mach_loss_coef", {}).get("records", [])
}
catalog = MachineCatalog()
for row in variables.get("mach_price", {}).get("records", []):
code = row["machine_code"]
coefficient = coefficients.get(code, {})
catalog.machines[code] = MachineSpec(
machine_code=code,
name=row["machine_name"],
specification=str(row.get("specification", "")),
price_thousand_krw=Decimal(str(row["price_thousand_krw"])),
loss_coefficient_per_hour=(
Decimal(str(coefficient["loss_coefficient_per_hour"]))
if "loss_coefficient_per_hour" in coefficient
else None
),
economic_life_hours=coefficient.get("economic_life_hours"),
annual_standard_hours=coefficient.get("annual_standard_hours"),
)
return catalog
def hourly_loss_cost(machine: MachineSpec) -> Decimal:
"""시간당 손료 = 취득가 × 손료계수.
취득가가 **천원 단위**라 원으로 환산한다 — 이 단위를 놓치면 1,000배 틀린다.
"""
if machine.loss_coefficient_per_hour is None:
raise MachineCostError(
f"{machine.display_name}: 손료계수가 없습니다 (취득가만 있는 기종 226건 중 하나)"
)
return machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour
@dataclass
class HourlyMachineCost:
"""시간당 중기사용료 — 3분할과 **채우지 못한 성분 목록**을 함께 낸다."""
machine: MachineSpec
money: Money3
gaps: list[str] = field(default_factory=list)
@property
def is_complete(self) -> bool:
return not self.gaps
def hourly_machine_cost(
machine: MachineSpec,
*,
fuel_liters_per_hour: Decimal | None = None,
fuel_price_per_liter: Decimal | None = None,
operator_daily_wage: Decimal | None = None,
operator_hours_per_day: int = 8,
) -> HourlyMachineCost:
"""시간당 사용료 한 시간분.
손료(경비)는 카탈로그로 바로 나온다. **연료(재료)·운전사(노무)는 값을 주지 않으면
0 으로 때우지 않고 `gaps` 에 적어 돌려준다** — 빠진 채로 총액에 섞이는 것이
이 자리에서 제일 위험하다.
"""
gaps: list[str] = []
expense = hourly_loss_cost(machine)
material = Decimal(0)
if fuel_liters_per_hour is None or fuel_price_per_liter is None:
# TODO(미결 PLAN 9-6): `mach_fuel_rate` 0건 — 연료소모량이 품셈 본문에만 있다.
gaps.append("연료소모량(L/hr) 미확보 — 재료비 성분 비어 있음")
else:
material = fuel_liters_per_hour * fuel_price_per_liter
labor = Decimal(0)
if operator_daily_wage is None:
# TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다.
gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음")
else:
labor = operator_daily_wage / Decimal(operator_hours_per_day)
return HourlyMachineCost(
machine=machine,
money=Money3(material=material, labor=labor, expense=expense),
gaps=gaps,
)
def catalog_gaps(catalog: MachineCatalog) -> dict[str, int]:
"""카탈로그 자체의 공백 — 몇 기종이 손료계수를 못 가졌나."""
missing = [m for m in catalog.machines.values() if m.loss_coefficient_per_hour is None]
return {"machines": len(catalog.machines), "without_loss_coefficient": len(missing)}