refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)
B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""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", "master_data", "old")
|
||||
_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, "3_품셈_건설_기계경비기준_8장_2026-01-01.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 (
|
||||
LOSS_ONLY_MACHINES,
|
||||
OPERATOR_PROVISIONAL_NOTE,
|
||||
load_operating_records,
|
||||
load_operator_wages,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_WoodChipping import MACHINE as CHIPPER
|
||||
from B09_Estimation.B09_Estimation_WoodChipping import operating_record as chipper_record
|
||||
|
||||
catalog = load_machine_catalog()
|
||||
operating = {row.machine_code: row for row in load_operating_records().records}
|
||||
# 파쇄기는 8-4 칸이 「-」 라 8-11 [주]⑤ 레코드로 섬(단가와 같은 값) — 없으면 장이 「연료·조종원 없음」 으로 틀리게 뜸.
|
||||
# ⚠ 8-4 에 줄이 생겨도 연료 칸이 「-」 면 값이 없는 것 — 산림 8-11 [주]⑤ 가 정본(2026-09-18).
|
||||
if f"X-{CHIPPER}" in build.book.titles and getattr(operating.get(CHIPPER), "fuel_liters_per_hour", None) is None: # fmt: skip
|
||||
chipper = chipper_record()
|
||||
if chipper is not None:
|
||||
operating[CHIPPER] = chipper
|
||||
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] = []
|
||||
loss_only = LOSS_ONLY_MACHINES.get(machine_code, "") # 8-4 에 줄 없음 · 손료만(②′)
|
||||
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) or bool(loss_only)
|
||||
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,
|
||||
# 8-1-2 5호가 이 기종을 이름으로 안 가르면 잠정 사유(원문이 안 가르는 것을 우리가 안 가름).
|
||||
"operator_note": (
|
||||
OPERATOR_PROVISIONAL_NOTE
|
||||
if wage is not None
|
||||
and getattr(record, "operator_mapping_is_provisional", False)
|
||||
else ""
|
||||
),
|
||||
"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": loss_only or (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 ".")
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user