"""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) 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 ".") ), }