· 연료 — `fuel_kind` 를 조립이 안 읽어 휘발유 기계 10종(래머·플레이트 콤팩터·믹서·커터·진동기)이 경유값이었음 → `M-FUEL-휘발유` · 시도 판도 종류대로 · 기계 하나 사용료·중기경비 장도 같은 값 · 진동기 — 건설품셈 8-3 (4611) 두 줄이 한 칸에 뭉쳐 규격·손료계수가 비었음 → 원문값 되살림(전기식 ø45 0.75㎾ 4,935 · 엔진식 ø45 2.6㎾ 5,101 · 판정 없음) · 12-15 「봉상후렉시블(45mm)」 → 엔진식 4611-0350 범위 별칭 — 12-15 는 전기·엔진을 안 적음 · 12-34-1 「3.5HP」 = 2.6㎾ · 운전경비표에 엔진식만(브레인 승인) → 구체콘크리트 갈래가 진동기 1/5.4 hr/㎥ 와 함께 섬 · 661 뒤처리 — 중기경비 장의 `#암석` 이 조합 16%·비암석 계수로 보이던 것을 보정 상각·정비·계와 본 잡품률로 · 마스터 재생성 — 12-15 갈래 키만(구체콘크리트 더함) 휘발유 기계 전수 대조: 재료비 +13~113원/hr · 조립에 선 것은 플레이트 콤팩터(+20원/hr → 9-14-02 +5원)와 진동기 · 나머지 제목 그대로 · 프로젝트 내역 그대로 · 잴 시험 일곱 빨강→초록 · 끄기 다섯 빨강 · 전체 시험 1695 + 330 통과 (시험 파일은 앞선 autopush 가 먼저 쓸어 올린 빨강본 — 이 커밋이 초록으로 맞춤) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
203 lines
9.6 KiB
Python
203 lines
9.6 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 (
|
||
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()
|
||
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 {}
|
||
if variant == "암석":
|
||
# 암석 손료보정(8-1-7 1) — 장도 보정한 상각·정비로 보여야 「계」와 맞음(661 뒤처리).
|
||
from B09_Estimation.B09_Estimation_RockLoss import rock_parts
|
||
|
||
parts = rock_parts(machine_code)
|
||
if parts is not None:
|
||
keys = ("depreciation", "maintenance", "management", "source")
|
||
# 화면 JSON 은 수 — Decimal 을 그대로 실으면 응답이 안 섬
|
||
raw = {
|
||
**raw,
|
||
**{f"{k}_coefficient_1e_minus_7": float(v) for k, v in zip(keys, parts)},
|
||
}
|
||
loss_per_hour = (
|
||
Decimal(str(int(raw["source_coefficient_1e_minus_7"]))) * Decimal("1e-7")
|
||
if variant == "암석" and "source_coefficient_1e_minus_7" in raw
|
||
else machine.loss_coefficient_per_hour
|
||
)
|
||
money = build.book.resolve(code)
|
||
|
||
gaps: list[str] = []
|
||
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES)
|
||
fuel_liters = getattr(record, "fuel_liters_per_hour", None)
|
||
# 연료 종류대로 그 유가(휘발유 기계가 경유값으로 보이던 자리 · 661 뒤 ②).
|
||
fuel_price, fuel_meta = (
|
||
load_fuel_price(kind=record.fuel_kind) if fuel_liters is not None else (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 * loss_per_hour
|
||
if loss_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 ".")
|
||
),
|
||
}
|