"""B09 원가계산 — **기계 수송비** (산림품셈 10-4 중기운반 · 건설품셈 8-1-6의 2). **무엇이 빠져 있었나** — 기계경비는 「기계손료 + 운전경비 + **수송비**」인데(건설품셈 8-1-6의 1), 우리 시간당 사용료 층에는 손료·연료·운전사만 있었다. 수송비를 내는 공종 `FP-10-04` 중기운반은 표가 「미판정」으로 남아 **단가가 아예 안 서고 있었다**(2026-09-09 밤 실측). **원문이 정하는 것** (지어낸 값이 하나도 없다) 산림품셈 10-4 중기운반 (단위: **회당**) 트레일러운반(20TON) t1=20min · t3=20min · t4=0.42min · t2=운반시간 참조 트럭운반(10.5TON) t1=10min · t3=10min · t4=5min · t2=운반시간 참조 ㎝ = t1+t2+t3+t4 · N = 60×0.9/㎝ [주] **자주식 기계는 제외**하며 **인근 기초자치단체와 현장중심점까지의 거리**로 계상한다. 건설품셈 8-1-6의 2 가 — 「가장 가까운 시·도·군·구청 소재지로부터 공사현장까지의 **왕복** 수송비」 · 나 — 자주식 기계의 이동속도표(도로 구분별 km/hr) **우리가 정한 것 둘 — 원문이 안 정해 화면 근거에 그대로 적는다** ㉠ t2(운반시간)의 속도를 **8-1-6의 2 나 이동속도표**에서 가져온다. 그 표는 「자주식 기계가 자주로 이동할 때」의 표이고, 운반 차량의 주행속도로 쓰라는 말은 원문에 없다. 다만 **같은 장에서 같은 도로 구분으로 준 유일한 속도**다. ㉡ t2 를 **왕복**으로 본다(건설품셈이 「왕복 수송비」라 못 박으므로). ⚠ **거리는 설계 입력이다** — 안 넣으면 **줄이 안 선다**(사토장 운반거리와 같은 자리). 임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.** ⚠ **회수(몇 대를 몇 번 나르나)는 여기서 안 정한다** — 단가는 「회당」이고, 회수는 수량 쪽 (설계 입력)이다. 품셈이 대수·회수를 정해 주지 않는다. """ from __future__ import annotations from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle _ZERO = Decimal(0) #: 수송 갈래 — 원문 10-4 가 준 둘. 기종 코드는 카탈로그(건설품셈 8-3) 분류번호. TRANSPORT_VARIANTS: tuple[dict[str, Any], ...] = ( { "key": "트레일러20ton", "label": "트레일러운반(20TON)", "machine_code": "2702-0020", "t1_min": Decimal(20), "t3_min": Decimal(20), "t4_min": Decimal("0.42"), "speed_column": "trailer", }, { "key": "트럭10.5ton", "label": "트럭운반(10.5TON)", "machine_code": "0602-0105", "t1_min": Decimal(10), "t3_min": Decimal(10), "t4_min": Decimal(5), "speed_column": "dump_truck", }, ) #: 이동속도(km/hr) — 건설품셈 8-1-6의 2 나 「자주식 건설기계의 이동속도」. #: ⚠ `None` 은 원문의 「-」다 — **그 도로 구분에 그 차량 값이 없다는 뜻**이라 지어내지 않는다. ROAD_CLASSES: tuple[dict[str, Any], ...] = ( {"key": "expressway_4", "label": "포장도로(고속4차선)", "dump_truck": 60, "trailer": None}, {"key": "expressway_2", "label": "포장도로(고속2차선)", "dump_truck": 50, "trailer": 50}, {"key": "paved", "label": "포장도로", "dump_truck": 40, "trailer": 40}, {"key": "gravel_good", "label": "사리도로(양호)", "dump_truck": 25, "trailer": 20}, {"key": "gravel_poor", "label": "사리도로(불량)", "dump_truck": 10, "trailer": 10}, ) _ROAD_BY_KEY = {row["key"]: row for row in ROAD_CLASSES} #: 회전율 — 원문 `N = 60 × 0.9 / ㎝`. 0.9 는 작업효율이고 60 은 시간→분이다. _CYCLE_EFFICIENCY = Decimal("0.9") _MINUTES_PER_HOUR = Decimal(60) WORK_ITEM_CODE = "FP-10-04" UNIT = "회" BASIS_TEXT = ( "산림품셈 10-4 중기운반(회당) · 건설품셈 8-1-6의 2 — 가장 가까운 시·도·군·구청 소재지에서" " 현장까지 왕복. [주] 자주식 기계는 제외." ) ASSUMPTION_TEXT = ( "⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴" "(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄." ) def parse_distance_km(raw: Any) -> Decimal | None: """설정 칸의 거리. **비면 `None`**(= 수송비 줄이 안 섬).""" text = str(raw or "").strip().rstrip("km").rstrip("㎞").strip() if not text: return None try: value = Decimal(text) except (ArithmeticError, ValueError): raise ValueError(f"수송 거리를 숫자로 못 읽었습니다: {raw!r}") from None if value <= 0: return None return value def road_class(key: str | None) -> dict[str, Any] | None: """도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**""" return _ROAD_BY_KEY.get(str(key or "")) def cycle_minutes(variant: dict[str, Any], distance_km: Decimal, road: dict[str, Any]) -> Decimal: """㎝ = t1 + t2 + t3 + t4 (분). t2 는 **왕복** 운반시간이다.""" speed = road.get(variant["speed_column"]) if not speed: raise ValueError( f"{road['label']} 에는 {variant['label']} 속도가 원문에 없습니다(「-」)" " — 지어내지 않습니다" ) round_trip_hours = distance_km * Decimal(2) / Decimal(str(speed)) t2 = round_trip_hours * _MINUTES_PER_HOUR return variant["t1_min"] + t2 + variant["t3_min"] + variant["t4_min"] def hours_per_trip(variant: dict[str, Any], distance_km: Decimal, road: dict[str, Any]) -> Decimal: """회당 시간(시간). `N = 60 × 0.9 / ㎝` 의 역수다.""" minutes = cycle_minutes(variant, distance_km, road) trips_per_hour = _MINUTES_PER_HOUR * _CYCLE_EFFICIENCY / minutes return Decimal(1) / trips_per_hour def attach_transport( book: PriceBook, names: dict[str, str], *, distance_km: Decimal | None, road_key: str | None, ) -> list[str]: """수송비 일위대가(갈래 둘)를 세운다. 돌려주는 것 — 못 세운 사유 목록. ⚠ **거리·도로 구분이 없으면 한 줄도 안 세운다** — 값이 없는데 금액이 서면 안 된다. """ notes: list[str] = [] if distance_km is None: notes.append( "수송비 — 거리(인근 시·군·구청 소재지 → 현장)가 안 들어와 줄을 안 세웠습니다." " 산출 조건에서 거리를 넣으면 섭니다." ) return notes road = road_class(road_key) if road is None: notes.append("수송비 — 도로 구분을 안 골라 줄을 안 세웠습니다(속도가 도로로 갈립니다).") return notes base_name = names.get(WORK_ITEM_CODE) or "중기운반" for variant in TRANSPORT_VARIANTS: hourly_code = f"X-{variant['machine_code']}" if hourly_code not in book.titles: notes.append(f"수송비 {variant['label']} — 그 차량의 시간당 사용료 층이 없습니다.") continue try: hours = hours_per_trip(variant, distance_km, road) except ValueError as error: notes.append(f"수송비 {variant['label']} — {error}") continue title_code = f"B-{WORK_ITEM_CODE}#{variant['key']}" if title_code in book.titles: continue book.add_title( PriceTitle( code=title_code, kind=PriceKind.UNIT_PRICE, name=f"{base_name} ({variant['label']})", spec=f"{road['label']} · 편도 {distance_km:g}㎞", unit=UNIT, ) ) minutes = cycle_minutes(variant, distance_km, road) book.add_detail( PriceDetail( title_code, hourly_code, hours, note=( f"㎝ = {variant['t1_min']:g} + 왕복 {distance_km * 2:g}㎞" f"÷{road[variant['speed_column']]}㎞/hr + {variant['t3_min']:g}" f" + {variant['t4_min']:g} = {minutes:.2f}분 · N = 60×0.9/㎝" f" ⇒ 회당 {hours:.4f}시간 · {BASIS_TEXT} · {ASSUMPTION_TEXT}" ), ) ) return notes