diff --git a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py index 4f399ade..c214ca43 100644 --- a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py +++ b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py @@ -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( diff --git a/B09_Estimation/B09_Estimation_MachineOperating.py b/B09_Estimation/B09_Estimation_MachineOperating.py index 4f5f3e63..6fe7802e 100644 --- a/B09_Estimation/B09_Estimation_MachineOperating.py +++ b/B09_Estimation/B09_Estimation_MachineOperating.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_UI_MachineExpense.ts b/B09_Estimation/B09_Estimation_UI_MachineExpense.ts index cce33926..ea964667 100644 --- a/B09_Estimation/B09_Estimation_UI_MachineExpense.ts +++ b/B09_Estimation/B09_Estimation_UI_MachineExpense.ts @@ -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; } diff --git a/resources/tester/test_b09_operator_class.py b/resources/tester/test_b09_operator_class.py new file mode 100644 index 00000000..e68a7e7c --- /dev/null +++ b/resources/tester/test_b09_operator_class.py @@ -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