조합 사용이면 본체 잡재료가 16% 로 줄어 재료비가 달라져 층이 따로 섬(굴착기 0.7 재료비 26,130 → 24,845). 갈래를 안 적어 똑같은 장이 두 번 나온 것처럼 읽혔음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
8.4 KiB
Python
183 lines
8.4 KiB
Python
"""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
|
||
|
||
#: 조합 사용(굴착기+부착장비)일 때의 본체 잡재료비율 — 품셈 제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),
|
||
"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 ".")
|
||
),
|
||
}
|