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:
2026-09-22 12:27:45 +09:00
co-authored by Claude Opus 5
parent 185f800d23
commit 8472fc9f40
231 changed files with 1617 additions and 1216 deletions
@@ -0,0 +1,566 @@
"""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", "master_data", "old")
_OUTPUT_SUBPATH = ("resources", "master_data", "old")
#: 품셈 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` 로 드러난다.
#: 「살수차」 — 8-1-2 5호 화물차운전사 줄이 이름으로 적음(원문 또렷 · 물탱크(살수차) 7204 · 2026-09-15 ③).
_OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물", "살수차")
_OPERATOR_ALIAS_TRUCK = "labor_op_truck"
_OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const"
_OPERATOR_ALIAS_GENERAL = "labor_op_general"
#: ⭐ 건설품셈 8-1-2 5호 「운전사의 구분」 — **원문이 이름으로 가르는 기종만** (2026-09-15 브레인).
#: 옛 잠정 규칙이 덤프 15t 을 화물차 · 콤팩터·래머·소형믹서를 건설기계로 세우던 틀림 12(조립에 선 것 2).
#: 거창 실무 「덤프 15Ton 건설기계 · 8·4.5Ton 화물차 · 플레이트 콤팩터 일반기계운전사」 = 원문.
#: ⚠ 원문이 안 가르는 기종(콘크리트 펌프차 · 트랙터(타이어) · 믹서트럭 …)은 **우리가 가르지 않음** — 옛 규칙 + 잠정 사유.
_CLASS_CONSTRUCTION_WORDS = (
"불도저", "굴착기", "로더", "지게차", "스크레이퍼", "기중기", "모터그레이더", "롤러",
"노상안정기", "콘크리트배치플랜트", "콘크리트피니셔", "콘크리트스프레더", "아스팔트믹싱플랜트",
"아스팔트피니셔", "아스팔트살포기", "슬러리실", "골재살포기", "쇄석기", "천공기", "사리채취기",
"노면파쇄기",
) # fmt: skip
_CLASS_TRUCK_WORDS = ("화물트럭", "살수차", "제설차", "노면청소차", "트럭탑재형크레인")
_CLASS_GENERAL_WORDS = (
"양수기",
"윈치",
"벨트컨베이어",
"발전기",
"래머",
"콤팩터",
"콘크리트파쇄기",
)
OPERATOR_PROVISIONAL_NOTE = (
"조종원 직종 잠정 — 건설품셈 8-1-2 5호 「운전사의 구분」 이 이 기종을 이름으로 안 가름"
" (이름에 트럭이 들면 화물차운전사 · 그 밖은 건설기계운전사로 둠)"
)
#: ⚠ **셀 병합으로 줄째 버려지는 표에서 원문 값을 되살린다** (2026-09-15 초류종자살포 ③ 브레인).
#: 8-4-8 기타기계(원문 L3458)는 36기종이 한 칸에 뭉치고 자동세륜기 「-」 하나가 두 기종 몫이라 잡재료 35 ·
#: 조종원 34 개로 짝이 안 맞아 줄째 버려짐. 양끝 맞추기는 우연 일치로 틀린 값이 붙어 못 씀 → 공종이 쓰는
#: 물탱크(살수차) 다섯만 손으로(주연료 칸 · 잡재료 30 · 조종원 1 — 앞 기종 수가 칸마다 같아 자리가 또렷).
#: 버려진 줄 셋 81기종 중 공종이 쓰는 것은 이 다섯뿐(나머지는 막는 공종 0 이라 미룸).
#: 거창 실무 5,500ℓ 호표 「경유 9.3ℓ · 잡품 30% · 화물차운전사 1인」 일치. ⚠ 원천이 바로 실으면 이 표는 지운다.
MERGED_CELL_OPERATING_FIXES: dict[str, str] = {
"7204-0018": "8.2",
"7204-0038": "8.6",
"7204-0055": "9.3",
"7204-0065": "9.4",
"7204-0160": "12.9",
}
#: **운전경비 줄이 원문에 없는 기계** — 손료만으로 층을 세움(부착 장비 길 · 2026-09-15 브레인 판정 ②′).
#: ⚠ 이 목록 밖으로 넓히지 말 것 — 연료가 빠진 채 조용히 싼 값이 섬. 줄마다 원문 없음 · 실무 실증을 적음.
LOSS_ONLY_MACHINES: dict[str, str] = {
"7750-0016": (
"손료만 — 건설품셈 8-4 운전경비표에 취부기(7750) 줄이 없음(원문 없음) · 거창 실무 「취부기(녹생토)"
" 11.94kW」 도 손료 20,295 만 · ⚠ 거창은 디젤엔진 11.19kW 를 따로 세움 — 산림 5-24 · 건설 4-1-3"
" 표 둘 다에 없어 안 넣음"
),
}
_WATER_TANK_MISC_PERCENT = Decimal(30)
_WATER_TANK_OPERATORS = Decimal(1)
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], carry_prefix: str = "") -> list[str]:
"""`0201-0012 0020 0040` → `0201-0012 · 0201-0020 · 0201-0040`.
뒤 코드는 앞 코드의 **앞 네 자리를 이어받는다**. 이어받을 앞자리가 없으면 버린다.
⚠ `carry_prefix` — **앞 표의 마지막 앞자리**. 원문이 페이지에서 잘리면 이어지는 판이
앞자리를 다시 안 적는다(8-4-3 이 `0080 0100 …` 로 시작한다). 그것을 못 이어받아
**운반·하역기계 39 기종의 운전경비가 통째로 버려지고 있었다** — 그 안에 수송비를 내는
**트럭 트랙터 및 평판트레일러(2702)** 가 들어 있다(2026-09-09 밤 실측).
⚠ 이어받은 코드는 **카탈로그에 있는지 확인한 뒤에만** 쓴다(`parse_operating_tables`).
"""
codes: list[str] = []
prefix = carry_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_class(machine_name: str, specification: str = "") -> str | None:
"""8-1-2 5호가 이름·규격으로 가르는 직종 별칭 키 — 원문이 안 가르면 `None`."""
name = re.sub(r"\s", "", machine_name)
found = re.search(r"\d+(?:\.\d+)?", str(specification).replace(",", ""))
size = Decimal(found.group(0)) if found else None
if name.startswith("덤프트럭"):
# 「덤프트럭(12ton이상)」 건설기계 · 「12ton미만의 덤프트럭」 화물차
if size is None:
return None
return _OPERATOR_ALIAS_CONSTRUCTION if size >= 12 else _OPERATOR_ALIAS_TRUCK
if name.startswith("콘크리트믹서") and "트럭" not in name:
# 「콘크리트 믹서(0.55㎥ 이상)」 건설기계 · 「소형믹서」 일반기계
if size is None:
return None
return _OPERATOR_ALIAS_CONSTRUCTION if size >= Decimal("0.55") else _OPERATOR_ALIAS_GENERAL
if name.startswith("공기압축기"):
# 「공기압축기(이동식, 2.83㎥/min 이상)」 건설기계 · 「소형의 공기압축기」 일반기계
if size is None or "이동식" not in name:
return None
return _OPERATOR_ALIAS_CONSTRUCTION if size >= Decimal("2.83") else _OPERATOR_ALIAS_GENERAL
# 괄호 앞 이름의 **끝말**로 — 속에 든 글자로 보면 「크롤러드릴」 이 롤러 · 「타워크레인」 이 기중기로 걸림.
base = re.sub(r"[(].*", "", name)
if any(word in name for word in _CLASS_TRUCK_WORDS): # 「물탱크(살수차)」 — 괄호 속 이름
return _OPERATOR_ALIAS_TRUCK
if base.endswith(_CLASS_GENERAL_WORDS):
return _OPERATOR_ALIAS_GENERAL
if base == "크레인" or base.endswith(_CLASS_CONSTRUCTION_WORDS): # 「기중기(차륜 및 무한궤도)」
return _OPERATOR_ALIAS_CONSTRUCTION
return None
def _operator_code(machine_name: str, aliases: dict[str, str], specification: str = "") -> str:
"""기종 이름으로 운전사 직종을 고른다 — 8-1-2 5호가 가르면 원문, 아니면 **잠정 규칙**.
품셈 8-4 표는 「조종원 인/일」 수만 주고 직종명을 안 준다. 노임표의 `aliases` 가
운전사 직종 셋(`labor_op_const`·`labor_op_truck`·`labor_op_general`)을 들고 있음.
원문이 안 가르는 기종은 트럭 계열만 화물차운전사로, 나머지는 건설기계운전사로 **잠정** 매핑한다.
"""
key = operator_class(machine_name, specification) or (
_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],
known_codes: set[str] | None = None,
) -> OperatingParseResult:
"""품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다.
⚠ `known_codes` — 기종 카탈로그의 코드. **앞 표에서 앞자리를 이어받을 때만** 쓰며,
이어받아 만든 코드가 하나라도 카탈로그에 없으면 **그 줄을 통째로 버린다.**
이어받기는 원문이 페이지에서 잘린 자리를 잇는 것이지 코드를 지어내는 것이 아니다.
"""
result = OperatingParseResult()
carry_prefix = ""
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]))
if not codes and carry_prefix:
carried = expand_codes(_tokens(row[0]), carry_prefix)
# ⚠ 이어받은 코드가 **전부 카탈로그에 있을 때만** 받는다 — 하나라도 없으면
# 앞자리를 잘못 물어 온 것이므로 종전대로 버린다.
if carried and known_codes and all(code in known_codes for code in carried):
codes = carried
if codes:
carry_prefix = codes[-1][:4]
names = _tokens(row[1])
# 규격 칸의 괄호 토큰은 **앞 규격의 딸림값**(「100 (155)」 · 크레인 「15 (0.38)」) —
# 규격 하나로 세면 코드 수와 안 맞아 줄째 버려짐(공기압축기 5205 여섯 · 09-14).
specs = [token for token in _tokens(row[2]) if not token.startswith("(")]
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, machine.specification or record.specification
),
operator_mapping_is_provisional=operator_class(
machine.name, machine.specification or record.specification
)
is None,
)
)
result.records = enriched
return result
def load_operating_records(
pum_file: str = "3_품셈_건설_표전체_2026-01-01.json",
labor_file: str = "1_기초_노임_건설업_대한건설협회_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", {})
catalog = load_machine_catalog()
parsed = parse_operating_tables(pum, aliases, set(catalog.machines))
read = {record.machine_code for record in parsed.records}
for code, liters in MERGED_CELL_OPERATING_FIXES.items():
if code not in read:
parsed.records.append(
OperatingRecord(
machine_code=code,
machine_name="",
specification="",
fuel_liters_per_hour=Decimal(liters),
fuel_kind="경유",
misc_material_percent=_WATER_TANK_MISC_PERCENT,
operator_person_days=_WATER_TANK_OPERATORS,
)
)
return enrich_with_catalog(parsed, 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, "3_품셈_건설_기계운전경비_8-4_2026-01-01.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
#: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」.
#: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음).
REGIONAL_OIL_FILE = "1_기초_유가_시도별_오피넷_2026-09-09.json"
#: 운전경비표 연료 종류 → 유가 판 변수. ⚠ 종류를 안 읽고 경유로 때우면 휘발유 기계가 싸게 섬
#: (2026-09-14 661 뒤 ② — 플레이트 콤팩터·진동기·믹서·래머·커터가 경유값이었음).
FUEL_VARIABLES = {"경유": "oil_diesel", "휘발유": "oil_gasoline"}
def load_regional_fuel_table(
oil_file: str = REGIONAL_OIL_FILE, kind: str = "경유"
) -> tuple[dict[str, dict], dict]:
"""(시도코드 → {이름·값}, 판 신원). 판이 없으면 **빈 표**를 돌려준다.
⚠ 원문에 있는 코드 `00`(전국)은 **지역 선택지에서 뺀다** — 그 자리는 전국평균 판이
맡고, 두 판이 같은 이름으로 서면 어느 값으로 섰는지 못 가린다.
"""
try:
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
except FileNotFoundError:
return {}, {}
diesel = payload["variables"][FUEL_VARIABLES[kind]]
table = {
str(record["sido_code"]): {
"name": str(record["sido_name"]),
"value": Decimal(str(record["value"])),
}
for record in diesel.get("records", [])
if str(record["sido_code"]) != "00"
}
meta = {
"dataset_id": payload.get("dataset_id", ""),
"effective_date": payload.get("effective_date", ""),
"scope": diesel.get("scope", ""),
}
return table, meta
def load_fuel_price(
oil_file: str = "1_기초_유가_전국평균_오피넷_2026-08-14.json",
region: str | None = None,
kind: str = "경유",
) -> tuple[Decimal, dict[str, str]]:
"""경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다.
품셈 8-1-7 5호가 「유류가격은 **해당 지역의 가격**」이라 규정한다.
⚠ **안 주면 전국평균**이다 — 현장 소재지를 임의로 찍지 않는다(프로젝트가 고름).
⚠ 준 지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 그 사실을 신원에 적는다.
"""
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
diesel = payload["variables"][FUEL_VARIABLES[kind]]
meta = {
"dataset_id": payload.get("dataset_id", ""),
"effective_date": payload.get("effective_date", ""),
"scope": diesel.get("scope", ""),
"region": "",
"region_name": "",
}
if not region:
return Decimal(str(diesel["value"])), meta
table, region_meta = load_regional_fuel_table(kind=kind)
picked = table.get(str(region))
if picked is None:
meta["region"] = str(region)
meta["region_missing"] = "그 지역 값이 판에 없어 전국평균으로 섰습니다"
return Decimal(str(diesel["value"])), meta
return picked["value"], {
"dataset_id": region_meta.get("dataset_id", ""),
"effective_date": region_meta.get("effective_date", ""),
"scope": region_meta.get("scope", ""),
"region": str(region),
"region_name": picked["name"],
}
def load_operator_wages(
labor_file: str = "1_기초_노임_건설업_대한건설협회_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
}
#: 노임 신뢰도 기호 — 원문 「임금적용요령」이 직종 옆에 붙여 둔 표시.
#: `*` 조사현장 5개 미만 (적용 시 유의)
#: `**` 미조사 — 유사 직종 단가에 준하여 적용(재경원 회계 45101-45)
#: ⚠ **값이 없는 것과 다르다** — 단가는 서 있되 **표본이 얇다**는 뜻이라 막지 않고 알린다.
#: 지식DB `노임단가_적용 §2-3` 이 「프로그램에서 단가 채택 시 플래그 유지 필요」로 두었고,
#: `원가_입력변수_사전` 도 「해당 단가 사용 시 경고 표시」라 적었는데 노임 층에 없었다
#: (2026-09-09에 이음).
LABOR_RELIABILITY_LABEL = {
"*": "조사현장 5개 미만 — 표본이 얇습니다",
"**": "미조사 직종 — 유사 직종 단가에 준해 적용(재경원 회계 45101-45)",
}
def load_labor_reliability(
labor_file: str = "1_기초_노임_건설업_대한건설협회_2026-01-01.json",
) -> dict[str, str]:
"""직종코드 → 신뢰도 기호(`*`·`**`). 정상 공표 직종은 아예 안 담는다."""
records = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"]["labor_rate"]["records"]
return {
str(r["occupation_code"]): str(r["reliability"])
for r in records
if str(r.get("reliability") or "").strip()
}
def hourly_cost_of(machine_code: str, *, region: str | None = None):
"""기종 하나의 **시간당 사용료 3분할**을 완성해 돌려준다.
재료비 = 주연료 × 유가 + 잡재료(주연료의 %) / 노무비 = 조종원 일당 ÷ 8시간 /
경비 = 손료. `region`(시도코드)을 주면 **그 지역 유가**로 선다(품셈 8-1-7 5호).
"""
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)
wages = load_operator_wages()
liters = record.fuel_liters_per_hour
fuel_price = (
None if liters is None else load_fuel_price(region=region, kind=record.fuel_kind)[0]
)
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,
)