Files
Aislo/B09_Estimation/B09_Estimation_MachineExpenseSheet.py
T
eomsangdonandClaude Opus 5 23f9656e91 feat(B09): 각종 중기경비계산서 — 기종마다 한 장
별표2 (5)(가) 아홉째. 중기목록표가 「얼마」라면 이 장은 「왜 그 값인가」임.
원가계산 → 중기 탭의 목록표 아래에 붙음.

- 기종마다 취득가·내용시간·손료계수(상각·정비·관리) → 시간당 손료 · 주연료와 유가 ·
  조종원 일당과 시간당 환산 · 시간당 사용료 3분할을 차례로 보임.
- 내역에 실제로 선 기종만 냄(카탈로그 613 을 다 뿌리지 않음).
- 부착 장비(브레이커·콤팩터·집게)는 손료만 드는 것이 정상이라 결함으로 안 셈
  — 제 엔진이 없어 연료·조종원이 본체에 듦(건설품셈 제8장 [주]⑤).
- 수송비는 이 장에 안 붙임 — 「회당」으로 서는 별개 공종이라 자리만 가리킴.
- 계산서 합계가 일위대가가 실제로 쓰는 값과 같은지 시험으로 못 박음(두 벌 방지).

⇒ 「설계서 구성」의 중기경비계산서가 반쪽 → 있음으로 바뀜(법이 정한 13 중 8 이 섬).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 23:45:54 +09:00

174 lines
7.9 KiB
Python
Raw 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 — **각종 중기경비계산서** (별표2 (5)(가) 아홉째 · 건설품셈 8-1-6).
**무엇인가** — 기종마다 **한 장**으로 「이 기계 한 시간이 왜 이 값인가」를 보이는 표다.
기계경비 = 기계손료 + 운전경비 + 수송비 (건설품셈 8-1-6의 1)
├ 손료 = 취득가격(천원) × 1,000 × 시간당 손료계수 (상각·정비·관리 계수의 합)
├ 운전경비 = 주연료 × 유가(+잡재료 %) + 조종원 일당 ÷ 8 × 제수당 계수
└ 수송비 = **기종에 붙지 않는다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이라
이 장에서는 **어디서 서는지만** 가리킨다.
⚠ **중기목록표와 다르다.** 목록표는 「기종별 시간당 사용료 얼마」 한 줄이고, 이 장은
**그 값이 나온 과정**이다. 별표2 가 둘을 따로 적지 않았지만 실무 서식은 계산 과정을
기종마다 한 장으로 남긴다.
⚠ **못 채운 성분은 0 으로 안 때운다** — 연료·조종원이 없으면 그 사실을 줄에 남긴다
(`HourlyMachineCost.gaps` 와 같은 규칙).
"""
from __future__ import annotations
import json
import os
from decimal import Decimal
from functools import lru_cache
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_HOURS_PER_DAY,
load_machine_catalog,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
_THOUSAND = Decimal(1000)
_ZERO = Decimal(0)
#: 부착 장비 — **제 엔진이 없어** 연료·조종원이 본체에 든다(품셈 제8장 [주]⑤).
#: ⚠ 그 셋을 「성분이 빔」으로 세면 **정상인 줄을 결함으로 읽는다** — 손료만 있는 것이 맞다.
_ATTACHMENT_PREFIXES = ("0103-", "0230-", "0240-", "7206-")
ATTACHMENT_NOTE = (
"부착 장비라 손료만 듭니다 — 제 엔진이 없어 연료·조종원이 본체(굴착기·불도저)에"
" 들어갑니다(건설품셈 제8장 [주]⑤)."
)
TRANSPORT_NOTE = (
"수송비는 이 장에 안 붙습니다 — 산림품셈 10-4 가 「회당」으로 세는 별개 공종이라"
" 「산출 조건」에서 거리를 넣으면 중기운반 줄로 섭니다(건설품셈 8-1-6의 2)."
)
LOSS_NOTE = (
"손료 = 취득가격(천원) × 1,000 × 시간당 손료계수. 계수는 상각비·정비비·관리비"
" 계수의 합이며 원문이 10⁻⁷ 단위로 줍니다(건설품셈 8-1-5·8-1-6)."
)
OPERATOR_NOTE = (
"조종원 노임 = 일당 ÷ 8시간 × 16/12 × 25/20. 공표 노임이 기본급여액뿐이라"
" 제수당·상여금·퇴직급여충당금을 따로 계상합니다."
)
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@lru_cache(maxsize=1)
def _loss_records() -> dict[str, dict[str, Any]]:
"""기종코드 → 손료계수 원문 줄(상각·정비·관리 계수까지)."""
path = os.path.join(_project_root(), *_CATALOG_SUBPATH, "mach_base_2026.json")
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
records = payload["variables"]["mach_loss_coef"]["records"]
return {str(row["machine_code"]): row for row in records}
def _money(value: Decimal | None) -> str | None:
return None if value is None else str(value)
def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"""**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다."""
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
from B09_Estimation.B09_Estimation_MachineOperating import (
load_operating_records,
load_operator_wages,
)
catalog = load_machine_catalog()
operating = {row.machine_code: row for row in load_operating_records().records}
wages = load_operator_wages()
fuel_price, fuel_meta = load_fuel_price()
loss = _loss_records()
sheets: list[dict[str, Any]] = []
for code, title in sorted(build.book.titles.items()):
if title.kind is not PriceKind.MACHINE_HOURLY:
continue
machine_code = code[2:].split("#")[0]
machine = catalog.machines.get(machine_code)
if machine is None:
continue
record = operating.get(machine_code)
raw = loss.get(machine_code) or {}
money = build.book.resolve(code)
gaps: list[str] = []
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES)
fuel_liters = getattr(record, "fuel_liters_per_hour", None)
misc_percent = getattr(record, "misc_material_percent", None)
occupation = getattr(record, "operator_occupation_code", "") or ""
wage = wages.get(occupation)
if not attachment:
if fuel_liters is None:
gaps.append("주연료 소요량(L/hr)이 없습니다 — 재료비 성분이 비어 있습니다")
if wage is None:
gaps.append("조종원 직종·일당이 없습니다 — 노무비 성분이 비어 있습니다")
if machine.loss_coefficient_per_hour is None:
gaps.append("손료계수가 없습니다 — 취득가만 있는 기종입니다")
sheets.append(
{
"code": code,
"machine_code": machine_code,
"name": machine.name,
"spec": machine.specification,
# ① 손료
"price_thousand_krw": _money(machine.price_thousand_krw),
"economic_life_hours": raw.get("economic_life_hours"),
"annual_standard_hours": raw.get("annual_standard_hours"),
"depreciation_coefficient": raw.get("depreciation_coefficient_1e_minus_7"),
"maintenance_coefficient": raw.get("maintenance_coefficient_1e_minus_7"),
"management_coefficient": raw.get("management_coefficient_1e_minus_7"),
"loss_coefficient": raw.get("source_coefficient_1e_minus_7"),
"loss_krw_per_hour": _money(
machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour
if machine.loss_coefficient_per_hour is not None
else None
),
# ② 운전경비
"fuel_liters_per_hour": _money(fuel_liters),
"fuel_price_per_liter": _money(fuel_price),
"fuel_scope": fuel_meta.get("region_name") or "전국 공시가",
"misc_material_percent": _money(misc_percent),
"operator_code": occupation,
"operator_daily_wage": _money(wage),
"operator_krw_per_hour": _money(
(wage / Decimal(OPERATOR_HOURS_PER_DAY)) * OPERATOR_ALLOWANCE_FACTOR
if wage is not None
else None
),
# ③ 시간당 사용료 — 조립된 값(이 장의 결론)
"material_krw": _money(money.material),
"labor_krw": _money(money.labor),
"expense_krw": _money(money.expense),
"total_krw": _money(money.total),
"attachment": attachment,
"attachment_note": ATTACHMENT_NOTE if attachment else "",
"gaps": gaps,
}
)
return sheets
def machine_expense_report(build: Any) -> dict[str, Any]:
"""중기경비계산서 한 벌 — 장 목록 + 근거 문구."""
sheets = machine_expense_sheets(build)
incomplete = [sheet["name"] for sheet in sheets if sheet["gaps"]]
return {
"sheets": sheets,
"notes": [LOSS_NOTE, OPERATOR_NOTE, TRANSPORT_NOTE, ATTACHMENT_NOTE],
"summary": (
f"내역에 선 기종 {len(sheets)} 종의 계산 과정입니다"
+ (f" — 성분이 빈 것 {len(incomplete)} 종." if incomplete else ".")
),
}