fix(b09): 조종원 직종을 8-1-2 5호 원문대로 — 덤프 12t 이상 건설기계 · 콤팩터·래머·소형믹서 일반기계(틀림 12 · 조립에 선 것 2) · 원문이 안 가르는 기종은 잠정 사유 · 파쇄기 중기경비 장 거짓 공백 걷음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-15 04:48:36 +09:00
co-authored by Claude Opus 5
parent 329bdc5c90
commit 2269266910
4 changed files with 156 additions and 6 deletions
@@ -82,12 +82,20 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
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 [주]⑤ 레코드로 섬(단가와 같은 값) — 없으면 장이 「연료·조종원 없음」 으로 틀리게 뜸.
if f"X-{CHIPPER}" in build.book.titles and CHIPPER not in operating:
chipper = chipper_record()
if chipper is not None:
operating[CHIPPER] = chipper
wages = load_operator_wages()
loss = _loss_records()
@@ -170,6 +178,13 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
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(
@@ -47,6 +47,32 @@ _RE_FUEL_WITH_KIND = re.compile(r"^(휘발유|중유|경유)?(\d+(?:\.\d+)?)$")
_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 초류종자살포 ③ 브레인).
@@ -181,14 +207,45 @@ def _parse_person_days(token: str) -> Decimal | None:
return Decimal(text) if _RE_DECIMAL.match(text) else None
def _operator_code(machine_name: str, aliases: dict[str, str]) -> str:
"""기종 이름으로 운전사 직종을 고른다 — **잠정 규칙**.
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`)을 들고 있
트럭 계열만 화물차운전사로, 나머지는 건설기계운전사로 **잠정** 매핑한다.
운전사 직종 셋(`labor_op_const`·`labor_op_truck`·`labor_op_general`)을 들고 있음.
원문이 안 가르는 기종은 트럭 계열만 화물차운전사로, 나머지는 건설기계운전사로 **잠정** 매핑한다.
"""
key = (
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
@@ -288,7 +345,13 @@ def enrich_with_catalog(
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),
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
@@ -30,6 +30,7 @@ export interface MachineExpenseDto {
fuel_scope: string;
misc_material_percent: string | null;
operator_code: string;
operator_note: string;
operator_daily_wage: string | null;
operator_krw_per_hour: string | null;
material_krw: string | null;
@@ -110,6 +111,7 @@ function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLEl
);
}
if (sheet.attachment_note) box.append(note(sheet.attachment_note));
if (sheet.operator_note) box.append(note(sheet.operator_note));
for (const gap of sheet.gaps) box.append(note(`${gap}`));
return box;
}
@@ -0,0 +1,70 @@
"""조종원 직종 — 건설품셈 8-1-2 5호 「운전사의 구분」 (2026-09-15 브레인 · 잰 틀림 12 · 조립에 선 것 2).
원문: 건설기계운전사 「…덤프트럭(12ton이상) … 콘크리트 믹서(0.55㎥ 이상) … 공기압축기(이동식, 2.83㎥/min 이상)」
화물차운전사 「12ton미만의 덤프트럭, 화물트럭, 살수차 …」
일반기계운전사 「소형의 공기압축기, 양수기, 소형믹서, 윈치 … 발전기, 래머, 콤팩터 …」
⚠ 옛 잠정 규칙(이름에 트럭 들면 화물차 · 그 밖 전부 건설기계) — 덤프 15t 이 화물차 · 콤팩터·래머·소형믹서가 건설기계로 섰음.
거창 실무 기계경비 「덤프트럭 15Ton 건설기계운전사 · 8Ton·4.5Ton 화물차운전사 · 플레이트 콤팩터 일반기계운전사」 = 원문.
원문이 이름으로 안 가르는 기종(콘크리트 펌프차 · 트랙터(타이어) · 콘크리트 믹서트럭 …)은 **잠정 그대로 + 사유**.
"""
from __future__ import annotations
from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets
from B09_Estimation.B09_Estimation_MachineOperating import (
_CATALOG_SUBPATH,
_read_json,
load_operating_records,
)
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
ALIASES = _read_json(*_CATALOG_SUBPATH, "labor_const_2026-01-01.json")["variables"]["aliases"]
CONST, TRUCK, GENERAL = (
ALIASES[k] for k in ("labor_op_const", "labor_op_truck", "labor_op_general")
)
def _records():
return {r.machine_code: r for r in load_operating_records().records}
def test_원문이_가르는_직종() -> None:
records = _records()
expected = {
"0602-0150": CONST, # 덤프트럭 15t — 12ton 이상
"0602-0320": CONST,
"0602-0045": TRUCK, # 덤프트럭 4.5t — 12ton 미만
"7204-0055": TRUCK, # 살수차
"1730-0015": GENERAL, # 플레이트 콤팩터
"1630-0080": GENERAL, # 래머
"4205-0045": GENERAL, # 콘크리트 믹서 0.45㎥ — 소형믹서(0.55 미만)
"0201-0070": CONST, # 굴착기
"5205-0035": CONST, # 공기압축기(이동식) 3.5 — 2.83 이상
}
for code, occupation in expected.items():
record = records[code]
assert record.operator_occupation_code == occupation, record
assert record.operator_mapping_is_provisional is False, record
def test_원문이_안_가르는_기종은_잠정_그대로() -> None:
records = _records()
for code in ("2602-0015", "4304-0060"): # 트랙터(타이어) · 콘크리트 믹서트럭
assert records[code].operator_mapping_is_provisional is True, records[code]
assert records["2602-0015"].operator_occupation_code == CONST # 옛 규칙 그대로
assert records["4304-0060"].operator_occupation_code == TRUCK
def test_조립에_선_둘이_원문_직종으로_섬() -> None:
build = cached_build(dump_haul_m=("164.23",))
dump = {r.ref_code for r in build.book.details["X-0602-0150"]}
compactor = {r.ref_code for r in build.book.details["X-1730-0015"]}
assert f"{CONST}#시간" in dump, dump
assert f"{GENERAL}#시간" in compactor, compactor
def test_잠정_직종은_중기경비_장에_사유() -> None:
sheets = {s["machine_code"]: s for s in machine_expense_sheets(cached_build())}
assert sheets["0201-0070"]["operator_note"] == "" # 굴착기 — 원문이 가름
note = sheets["7205-0125"]["operator_note"] # 이동식 임목파쇄기 — 원문 목록에 없음
assert "잠정" in note and "8-1-2" in note, note