Files
Aislo/B09_Estimation/B09_Estimation_MachineExpenseSheet.py
eomsangdonandClaude Opus 5 7ea2598263 fix(b09): 휘발유 기계에 경유값이 붙던 것 — 운전경비표 연료 종류대로 유가 · 콘크리트 진동기(4611) 원문값 되살려 집수정 구체콘크리트 갈래를 세움
· 연료 — `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
2026-09-14 22:40:49 +09:00

203 lines
9.6 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()
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 ".")
),
}