브레인 일감. 표 모양 판정이 산림 표에 맞춰져 있어 건설 표를 못 가렸음(건설 미판정 891 · 산림 0).
- `B08_Quantity_Build_WorkItemMaster_FormsConst.py` — **산림 잣대가 미판정으로 떨어질 때만** 도는 건설 층
· 기계 손료표(머리 「분류번호」+「상각 비율」) 122 · 율·계수표(손율·요율·보정계수) · 값 없는 설명·서식표 ·
건설 직종(특급·고급·중급·초급 기술자 · 지적기사 · 비계공 …) · 줄에 단위 칸+값 · 값 자리 계산식 ·
줄 이름이 공식 기호뿐(β·E) · 값이 모두 백분율
· ⚠ 비고 칸의 산출식은 값으로 안 봄 — 인력품 표가 계수로 뒤집히던 자리(9-5-4 수치지도)
· ⚠ 꼬리 단위는 품·자재 단위만 값으로 셈 — `24hr`·`30톤`·`0.166일` 은 규격·기간 표기
· ⚠ **못 가리면 미판정으로 남김**(276) — 지어내 채우지 않음
- 밑수 읽기 넓힘(`_Table.SOURCE_BASIS_RE`) — 건설은 절 제목 끝에 밑수를 적음
· `(용접개소당)`·`(세면기 개당)`·`(절단길이 10m당)`·`(m당 : 관길이기준)`·`(일당)`·`(기당)` 을 읽음
· 단위에 일·기·조·명·틀 더함 · ⚠ 「회」는 넣지 않음(2026-09-17 서브 판정)
· ⚠ `(1ha당, 100본당)` 처럼 **밑수가 둘이면 안 고름** · `공㎥당` 이 `㎥` 로 깎이던 것 고침
· ⚠ **작업조 표에는 「일」 밑수를 안 붙임**(c3da333e — 붙이면 유로폼 호표가 사라짐)
· 건설 밑수 확보 537 → 639 · 미판독 184 → 90 · 산림 확보 251 → 253(원문에 있던 2-2-4 ha당 등)
- 브레인 ①[주] 17 중 3 — `md_notes` 이어짐에 「- 4. 고철공제 : A=…」 꼴(콜론·식) 과 중첩 [주] 를 더함
· 남은 14 는 마스터가 뒤 표의 [주]까지 붙여 둔 자리 — 「애매하면 안 깎는 쪽」으로 까닭만 달아 둠
- 브레인 ②밑수 5 중 3 — `(단위 : ㎥)`(당 없음) · 줄 칸의 `(160ha당)` · 설명 글이 앞에 붙은 `(…, ha당)` 읽음
· F0322·F0323 은 원문 밑수가 표 둘 아래 산출식에만 있어 사람 판정 유지
- ⭐ 금액 불변 — 직접공사비 5,983,724 · 합계 10,124,000 그대로
- `test_form_const.py` 22 — 산림이 건설 층에 안 닿음 · 꼴 여섯 가름 · 못 가리면 미판정 · 밑수 아홉 꼴
- 곁가지(받은 md 손질이 부른 것)
· 8-11 이동식 임목파쇄기 — 랩탑 서브가 8-4 를 줄마다 풀자 **연료 칸이 「-」 인 빈 레코드**가 생겨 산림
8-11 [주]⑤(13.5ℓ·16%) 층이 통째로 빠졌음 → 「연료가 비면 산림 [주]가 정본」으로 두 자리 고침
· 기계 벌 — 중유 기종(`중유487.2`) 을 경유로 적던 것 · 연료가 둘인 칸(`73.7+휘발유54.5`) 을 반쪽만 싣던 것 ·
원문에 없는 기종을 옛 벌에서 이어받던 것 고침(연료 274)
· ⚠ 이어받기는 **원문에 줄이 있는데 연료를 못 읽은 코드**까지 — 못 읽은 줄 + 주연료 칸이 수가 아닌 줄
(양수기 7993-0020 은 원문이 규격을 연료 칸에 밀어 적음 `1.49㎾` · 사람이 맞춘 1.3ℓ 를 지킴)
· 이름표 줄 수·건설 공종 축 셈·열쇠 지문 갱신(까닭을 파일에 적음)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
221 lines
11 KiB
Python
221 lines
11 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 (
|
||
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 ".")
|
||
),
|
||
}
|