Files
Aislo/B09_Estimation/B09_Estimation_MachineExpenseSheet.py
eomsangdonandClaude Opus 5 50c3fb65f5 fix(b09): 중기 호표 잡품을 주연료비 × 율 가산 행으로·성분 소계 절사 — 골든셋 중기 143 호표 전수
- 잡품을 연료 수량에 접지 않고 주연료비의 % 가산 행으로(STmate 잡품 단가 칸 = 연료 금액)
- 시간당 중기사용료(X) 호표 — 줄 금액 0.1원 미만 · 성분 소계 원 미만 절사(명세 7장, 봉화 0.7㎥ 96,843)
- 조종원 시간당 노임 자르는 자리를 프로젝트 설정 operator_wage_digits 로(기본 0, 화면은 뒤)
- 골든셋 ④ 벌 — 실무 6건 중기사용료 시트 143 호표를 우리 단가표 층으로 되풀어 전수 일치

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 23:29:16 +09:00

183 lines
8.5 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 — **각종 중기경비계산서** (별표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 (
hourly_operator_wage,
load_machine_catalog,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
#: 조합 사용(굴착기+부착장비)일 때의 본체 잡재료비율 — 품셈 제8장 [주]⑤.
COMBINED_MISC_PERCENT = 16
_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, _, variant = code[2:].partition("#")
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,
# ⚠ 같은 기종이 **두 장**일 수 있다 — 조합 사용이면 잡재료가 16% 로 줄어
# 재료비가 달라지므로 층이 따로 선다(품셈 제8장 [주]⑤). 갈래를 안 적으면
# 똑같은 장이 두 번 나온 것처럼 읽힌다.
"variant": variant,
# ① 손료
"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": (
str(COMBINED_MISC_PERCENT) if variant else _money(misc_percent)
),
"operator_code": occupation,
"operator_daily_wage": _money(wage),
# 식 좌→우 순차 + 원 미만 절사(명세 7장) — 계수로 접으면 1원 틀림.
"operator_krw_per_hour": _money(
hourly_operator_wage(wage, digits=getattr(build, "operator_wage_digits", 0))
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 ".")
),
}