feat(B09): 운전경비(연료·잡재료·조종원)를 품셈 8-4 에서 뽑아 시간당 사용료 완성
**빈 두 성분을 채움** — `mach_fuel_rate`·`mach_operator_map` 0건이라 시간당 중기사용료의 재료비·노무비가 비어 있던 자리(손료만 있으면 실제의 4분의 1). 건설품셈 **8-4 운전경비 산정** 표에서 뽑아 **92 기종** 확보. ⚠ **표가 열 단위로 뭉쳐 옴** — PDF 추출 탓에 `분류번호` 한 칸에 코드 31개, `주연료` 한 칸에 값 31개가 공백으로 이어 붙어 있음. **위치로 짝짓되 개수가 안 맞으면 그 줄을 통째로 버림**(10건 버림, 목록으로 남김). 어긋난 채 짝지으면 **다른 기종의 연료값이 조용히 붙는** 자리라 억지로 안 맞춤. - 코드는 앞자리를 이어받음 — `0201-0012 0020 0040` → `0201-0020`·`0201-0040`. - ⚠ **이름 칸은 못 씀** — 표 전체 기종명이 한 덩어리로 옴. **코드로 613 기종 카탈로그에서 이름·규격을 가져옴**(카탈로그에 없는 코드는 버리고 목록에). **검증(굴착기 무한궤도 0.7㎥)** — 주연료 11.6 ℓ/hr · 잡재료 22 % · 조종원 1인/일. 시간당 = 재료 26,130(연료 21,418 + 잡재료 4,712) + 노무 35,412 + 경비 24,554 = **86,096**. STC 2024 관측 96,829 과 **11 % 차** — 손료·재료는 근사하고 **노무가 갈림** (관측 55,700/hr = 445,600/일로 어느 직종보다 높음). **맞추려 손대지 않음** — 관측값은 대조용이고 우리는 법대로 감. ⚠ 조종원 직종 확인 항목으로 올림. - **잠정 표시** — 조종원 **직종명이 품셈 8-4 에 없어** `aliases` 기반 규칙(트럭 계열 = 화물차운전사, 그 밖 = 건설기계운전사)으로 매김. 결과에 `operator_mapping_is_provisional` 로 드러남. TODO(미결 9-6). - **유가는 전국평균 잠정**, 지역 파라미터 자리(`region`)만 뚫어 둠 (품셈 8-1-7 5호). - **파생 파일로 냄** — `resources/data_cost_machine_operating/`, 기준자료 안 건드림. `derived_from` 에 `mach_base` 판의 세 쪽을 적어 낡으면 드러나게 함. 자체검증 — 신규 6건 포함 `pytest tmp/tests/ -q` **110 passed** · ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
"""B09 원가계산 — 운전경비(연료·잡재료·조종원)를 품셈 8-4 에서 뽑는다.
|
||||
|
||||
`mach_base_2026.json` 의 `mach_fuel_rate`·`mach_operator_map` 이 **0건**이라 시간당
|
||||
중기사용료의 **재료비·노무비 성분이 비어 있었다**(PLAN 9-3). 그 값은 건설품셈
|
||||
**8-4 운전경비 산정** 표에 있다.
|
||||
|
||||
**파생 파일로 낸다** — 기준자료(`resources/data_cost_input_value/`)를 고치지 않는다.
|
||||
그 파일은 `_manifest.json` 지문으로 재현성을 거는 자리이고 원천 xlsx 에서 다시
|
||||
생성되는 물건이라, 값을 채워 넣으면 ① 이미 그 판으로 계산한 스냅샷과 대조가 깨지고
|
||||
② 다음 재생성 때 날아가고 ③ 주인이 겹친다.
|
||||
⚠ **길게 보면 기준자료 원본이 채워지는 것이 맞다** — `mach_fuel_rate` 가 그 파일 안에
|
||||
**빈 변수로 선언**돼 있다는 것이 「원래 거기 들어갈 값」이라는 뜻이다. 원천 재생성 몫이라
|
||||
미결로 올려 둔다.
|
||||
|
||||
⚠ **표가 열 단위로 뭉쳐 있다.** PDF 에서 뽑히며 한 열이 한 칸에 공백으로 이어 붙었다 —
|
||||
`분류번호` 칸 하나에 코드 31개, `주연료` 칸 하나에 값 31개가 들어 있다. 그래서
|
||||
**위치로 짝짓고, 짝이 안 맞으면 그 표를 통째로 버린다.** 어긋난 채로 짝지으면 다른
|
||||
기종의 연료값이 조용히 붙는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
|
||||
_OUTPUT_SUBPATH = ("resources", "data_cost_machine_operating")
|
||||
|
||||
#: 품셈 8-4 운전경비 표의 머리글 — 이 여섯이 다 있어야 그 표로 본다.
|
||||
_REQUIRED_HEADERS = ("분류번호", "기계명", "규격", "주연료", "잡재료", "조종원")
|
||||
|
||||
_RE_CODE_FULL = re.compile(r"^(\d{4})-(\d{4})$")
|
||||
_RE_CODE_TAIL = re.compile(r"^\d{4}$")
|
||||
_RE_DECIMAL = re.compile(r"^\d+(?:\.\d+)?$")
|
||||
|
||||
#: 연료 종류가 값 앞에 붙는 경우 — 「휘발유0.7」·「중유487.2」.
|
||||
_RE_FUEL_WITH_KIND = re.compile(r"^(휘발유|중유|경유)?(\d+(?:\.\d+)?)$")
|
||||
|
||||
#: 조종원 직종 — 품셈 표는 「인/일」 수만 주고 직종명을 안 준다.
|
||||
#: TODO(미결 PLAN 9-6): 기종별 직종이 품셈 다른 장에 있다. 아래는 `aliases` 기반 **잠정**이며
|
||||
#: 결과에 `operator_mapping_is_provisional: true` 로 드러난다.
|
||||
_OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물")
|
||||
_OPERATOR_ALIAS_TRUCK = "labor_op_truck"
|
||||
_OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const"
|
||||
|
||||
|
||||
class OperatingCostError(ValueError):
|
||||
"""운전경비 표를 못 읽은 경우. 어긋난 채로 짝짓지 않는다."""
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _read_json(*parts: str) -> dict[str, Any]:
|
||||
with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatingRecord:
|
||||
"""기종 하나의 운전경비 원단위."""
|
||||
|
||||
machine_code: str
|
||||
machine_name: str
|
||||
specification: str
|
||||
fuel_liters_per_hour: Decimal | None
|
||||
fuel_kind: str
|
||||
misc_material_percent: Decimal | None
|
||||
operator_person_days: Decimal | None
|
||||
operator_occupation_code: str = ""
|
||||
operator_mapping_is_provisional: bool = True
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"machine_code": self.machine_code,
|
||||
"machine_name": self.machine_name,
|
||||
"specification": self.specification,
|
||||
"fuel_liters_per_hour": (
|
||||
None if self.fuel_liters_per_hour is None else str(self.fuel_liters_per_hour)
|
||||
),
|
||||
"fuel_kind": self.fuel_kind,
|
||||
"misc_material_percent": (
|
||||
None if self.misc_material_percent is None else str(self.misc_material_percent)
|
||||
),
|
||||
"operator_person_days": (
|
||||
None if self.operator_person_days is None else str(self.operator_person_days)
|
||||
),
|
||||
"operator_occupation_code": self.operator_occupation_code,
|
||||
"operator_mapping_is_provisional": self.operator_mapping_is_provisional,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatingParseResult:
|
||||
records: list[OperatingRecord] = field(default_factory=list)
|
||||
#: 짝이 안 맞아 버린 표 — 조용히 넘기지 않고 센다.
|
||||
dropped_tables: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _tokens(cell: str) -> list[str]:
|
||||
return [t for t in re.split(r"\s+", str(cell or "").strip()) if t]
|
||||
|
||||
|
||||
def expand_codes(tokens: list[str]) -> list[str]:
|
||||
"""`0201-0012 0020 0040` → `0201-0012 · 0201-0020 · 0201-0040`.
|
||||
|
||||
뒤 코드는 앞 코드의 **앞 네 자리를 이어받는다**. 이어받을 앞자리가 없으면 버린다.
|
||||
"""
|
||||
codes: list[str] = []
|
||||
prefix = ""
|
||||
for token in tokens:
|
||||
full = _RE_CODE_FULL.match(token)
|
||||
if full:
|
||||
prefix = full.group(1)
|
||||
codes.append(token)
|
||||
continue
|
||||
if _RE_CODE_TAIL.match(token) and prefix:
|
||||
codes.append(f"{prefix}-{token}")
|
||||
continue
|
||||
return [] # 코드 열이 아닌 표
|
||||
return codes
|
||||
|
||||
|
||||
def _parse_fuel(token: str) -> tuple[Decimal | None, str]:
|
||||
"""「11.6」·「휘발유0.7」·「-」 를 값과 연료 종류로 가른다."""
|
||||
text = token.strip()
|
||||
if text in ("-", "–", ""):
|
||||
return None, ""
|
||||
match = _RE_FUEL_WITH_KIND.match(text)
|
||||
if not match:
|
||||
return None, ""
|
||||
kind = match.group(1) or "경유"
|
||||
return Decimal(match.group(2)), kind
|
||||
|
||||
|
||||
def _parse_percent(token: str) -> Decimal | None:
|
||||
text = token.strip().rstrip("%")
|
||||
return Decimal(text) if _RE_DECIMAL.match(text) else None
|
||||
|
||||
|
||||
def _parse_person_days(token: str) -> Decimal | None:
|
||||
text = token.strip()
|
||||
return Decimal(text) if _RE_DECIMAL.match(text) else None
|
||||
|
||||
|
||||
def _operator_code(machine_name: str, aliases: dict[str, str]) -> str:
|
||||
"""기종 이름으로 운전사 직종을 고른다 — **잠정 규칙**.
|
||||
|
||||
품셈 8-4 표는 「조종원 인/일」 수만 주고 직종명을 안 준다. 노임표의 `aliases` 가
|
||||
운전사 직종 셋(`labor_op_const`·`labor_op_truck`·`labor_op_general`)을 들고 있어
|
||||
트럭 계열만 화물차운전사로, 나머지는 건설기계운전사로 **잠정** 매핑한다.
|
||||
"""
|
||||
key = (
|
||||
_OPERATOR_ALIAS_TRUCK
|
||||
if any(word in machine_name for word in _OPERATOR_TRUCK_WORDS)
|
||||
else _OPERATOR_ALIAS_CONSTRUCTION
|
||||
)
|
||||
return aliases.get(key, "")
|
||||
|
||||
|
||||
def parse_operating_tables(
|
||||
pum: dict[str, Any],
|
||||
aliases: dict[str, str],
|
||||
) -> OperatingParseResult:
|
||||
"""품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다."""
|
||||
result = OperatingParseResult()
|
||||
for table in pum.get("tables", []):
|
||||
headers = table.get("headers") or []
|
||||
joined = " ".join(headers)
|
||||
if not all(word in joined for word in _REQUIRED_HEADERS):
|
||||
continue
|
||||
|
||||
for row in table.get("rows") or []:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
codes = expand_codes(_tokens(row[0]))
|
||||
names = _tokens(row[1])
|
||||
specs = _tokens(row[2])
|
||||
fuels = _tokens(row[3])
|
||||
miscs = _tokens(row[4])
|
||||
operators = _tokens(row[5])
|
||||
|
||||
# ⚠ 위치로 짝짓는다 — 개수가 안 맞으면 그 줄을 통째로 버린다.
|
||||
if not codes or not (len(codes) == len(specs) == len(fuels) == len(operators)):
|
||||
result.dropped_tables.append(f"{table.get('section', '')[:40]} (개수 불일치)")
|
||||
continue
|
||||
|
||||
# ⚠ 이름 칸은 **표 전체의 기종명이 한 덩어리로** 들어온다
|
||||
# (「불도저(무한궤도)불도저(타이어)습지불도저굴착기(무한궤도)…」).
|
||||
# 코드별로 못 가르므로 **이름은 여기서 안 쓴다** — 613 기종 카탈로그에서
|
||||
# `machine_code` 로 찾아 붙인다(`enrich_with_catalog`).
|
||||
_ = names
|
||||
for index, code in enumerate(codes):
|
||||
fuel, kind = _parse_fuel(fuels[index])
|
||||
misc = _parse_percent(miscs[index]) if index < len(miscs) else None
|
||||
result.records.append(
|
||||
OperatingRecord(
|
||||
machine_code=code,
|
||||
machine_name="", # 카탈로그에서 채운다
|
||||
specification=specs[index],
|
||||
fuel_liters_per_hour=fuel,
|
||||
fuel_kind=kind,
|
||||
misc_material_percent=misc,
|
||||
operator_person_days=_parse_person_days(operators[index]),
|
||||
operator_occupation_code="", # 이름을 안 뒤 정한다
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def enrich_with_catalog(
|
||||
result: OperatingParseResult,
|
||||
catalog: Any,
|
||||
aliases: dict[str, str],
|
||||
) -> OperatingParseResult:
|
||||
"""코드로 613 기종 카탈로그에서 **이름·규격을 가져와** 채운다.
|
||||
|
||||
품셈 표의 이름 칸은 통째로 뭉쳐 와 못 쓴다. 코드가 정본이다.
|
||||
카탈로그에 없는 코드는 **버리고 목록에 남긴다** — 이름 없는 기종은 단가가 못 붙는다.
|
||||
"""
|
||||
enriched: list[OperatingRecord] = []
|
||||
for record in result.records:
|
||||
machine = catalog.machines.get(record.machine_code)
|
||||
if machine is None:
|
||||
result.dropped_tables.append(f"{record.machine_code} (기종 카탈로그에 없음)")
|
||||
continue
|
||||
enriched.append(
|
||||
OperatingRecord(
|
||||
machine_code=record.machine_code,
|
||||
machine_name=machine.name,
|
||||
specification=machine.specification or record.specification,
|
||||
fuel_liters_per_hour=record.fuel_liters_per_hour,
|
||||
fuel_kind=record.fuel_kind,
|
||||
misc_material_percent=record.misc_material_percent,
|
||||
operator_person_days=record.operator_person_days,
|
||||
operator_occupation_code=_operator_code(machine.name, aliases),
|
||||
)
|
||||
)
|
||||
result.records = enriched
|
||||
return result
|
||||
|
||||
|
||||
def load_operating_records(
|
||||
pum_file: str = "pum_const_2026.json",
|
||||
labor_file: str = "labor_const_2026-01-01.json",
|
||||
) -> OperatingParseResult:
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
|
||||
pum = _read_json(*_CATALOG_SUBPATH, pum_file)["variables"]["pum"]
|
||||
aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {})
|
||||
parsed = parse_operating_tables(pum, aliases)
|
||||
return enrich_with_catalog(parsed, load_machine_catalog(), aliases)
|
||||
|
||||
|
||||
def write_operating_records(
|
||||
result: OperatingParseResult,
|
||||
*,
|
||||
source_dataset_version: dict[str, str],
|
||||
output_dir: str | None = None,
|
||||
) -> str:
|
||||
"""파생 파일로 낸다. **어느 기준자료 판에서 파생됐는지**를 함께 적는다."""
|
||||
directory = output_dir or os.path.join(_project_root(), *_OUTPUT_SUBPATH)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
path = os.path.join(directory, "machine_operating_2026.json")
|
||||
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "machine_operating_derived",
|
||||
"derived_from": source_dataset_version,
|
||||
"note": (
|
||||
"건설품셈 8-4 운전경비 산정에서 뽑은 파생본. 기준자료가 아니다 — "
|
||||
"`mach_fuel_rate`·`mach_operator_map` 이 채워지면 이 파일은 걷어낸다."
|
||||
),
|
||||
"policy": {
|
||||
"operator_mapping_is_provisional": True,
|
||||
"operator_mapping_rule": "트럭 계열 = 화물차운전사, 그 밖 = 건설기계운전사 (잠정)",
|
||||
},
|
||||
"stats": {"records": len(result.records), "dropped": len(result.dropped_tables)},
|
||||
"records": [r.as_dict() for r in result.records],
|
||||
"dropped_tables": result.dropped_tables,
|
||||
}
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
return path
|
||||
|
||||
|
||||
def load_fuel_price(oil_file: str = "oil_2026-08-14.json") -> tuple[Decimal, dict[str, str]]:
|
||||
"""경유 단가와 그 판의 신원.
|
||||
|
||||
⚠ **전국평균이다.** 품셈 8-1-7 5호는 「유류가격은 **해당 지역의 가격**」이라
|
||||
규정하므로 나중에 현장 소재지 값으로 갈아끼울 자리다 (TODO 미결 PLAN 9-6).
|
||||
지역 파라미터 자리만 뚫어 두고 지금은 전국평균을 잠정으로 쓴다.
|
||||
"""
|
||||
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
|
||||
diesel = payload["variables"]["oil_diesel"]
|
||||
return Decimal(str(diesel["value"])), {
|
||||
"dataset_id": payload.get("dataset_id", ""),
|
||||
"effective_date": payload.get("effective_date", ""),
|
||||
"scope": diesel.get("scope", ""),
|
||||
}
|
||||
|
||||
|
||||
def load_operator_wages(labor_file: str = "labor_const_2026-01-01.json") -> dict[str, Decimal]:
|
||||
"""직종코드 → 일당."""
|
||||
records = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"]["labor_rate"]["records"]
|
||||
return {
|
||||
str(r["occupation_code"]): Decimal(str(r["daily_wage_krw"]))
|
||||
for r in records
|
||||
if "daily_wage_krw" in r
|
||||
}
|
||||
|
||||
|
||||
def hourly_cost_of(machine_code: str, *, region: str | None = None):
|
||||
"""기종 하나의 **시간당 사용료 3분할**을 완성해 돌려준다.
|
||||
|
||||
재료비 = 주연료 × 유가 + 잡재료(주연료의 %) / 노무비 = 조종원 일당 ÷ 8시간 /
|
||||
경비 = 손료. `region` 은 유가 지역값 자리 — 지금은 전국평균만 있어 무시된다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_MachineCost import hourly_machine_cost, load_machine_catalog
|
||||
|
||||
catalog = load_machine_catalog()
|
||||
machine = catalog.get(machine_code)
|
||||
records = {r.machine_code: r for r in load_operating_records().records}
|
||||
record = records.get(machine_code)
|
||||
if record is None:
|
||||
return hourly_machine_cost(machine)
|
||||
|
||||
fuel_price, _ = load_fuel_price()
|
||||
wages = load_operator_wages()
|
||||
|
||||
liters = record.fuel_liters_per_hour
|
||||
if liters is not None and record.misc_material_percent is not None:
|
||||
# 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분).
|
||||
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
|
||||
|
||||
wage = wages.get(record.operator_occupation_code)
|
||||
if record.operator_person_days is not None and wage is not None:
|
||||
wage = wage * record.operator_person_days
|
||||
|
||||
return hourly_machine_cost(
|
||||
machine,
|
||||
fuel_liters_per_hour=liters,
|
||||
fuel_price_per_liter=fuel_price if liters is not None else None,
|
||||
operator_daily_wage=wage,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user