산림품셈 10-4 · 건설품셈 8-1-3 은 회당 단가 산출식만 주고 대수·횟수를 정하지 않음(전수 확인). 지어내지 않고 설계 입력으로 닫음. · parse_trips — 비거나 0 이면 없음. 기본값을 두지 않음(1 회로 안 때움) · transport_amount — 회수 × 회당 단가. 회수가 없으면 None(0 원으로 안 채움) · TRIPS_NOTE — 「원문에 공식 없음 · 설계 입력 · 비우면 금액이 안 섬」 사유 · 도는 자리 넷 — 저장(PUT estimation/factors) · 산출 조건 ② · 산출 조건 화면(회당 N원 × M회) · 비면 transport_notes 에 사유가 붙어 못 채운 자리로 뜸 · 화면에 있던 「회수는 여기서 안 정합니다」 안내는 칸으로 바뀌어 걷음 잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1976 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
220 lines
9.8 KiB
Python
220 lines
9.8 KiB
Python
"""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 를 **왕복**으로 본다(건설품셈이 「왕복 수송비」라 못 박으므로).
|
||
|
||
⚠ **거리는 설계 입력이다** — 안 넣으면 **줄이 안 선다**(사토장 운반거리와 같은 자리).
|
||
임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.**
|
||
⚠ **회수(몇 대를 몇 번 나르나)도 설계 입력이다** (2026-09-14 브레인 판정으로 닫음).
|
||
원문 전수 확인 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가 산출식만** 주고
|
||
대수·왕복 횟수를 정하는 공식이 **없다**. 그래서 `transport_trips` 칸을 두고,
|
||
비면 금액을 안 세우고 사유만 남긴다. **회수 × 회당 단가** 곱하기 하나뿐이다.
|
||
"""
|
||
|
||
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 나 이동속도표에서 가져옴"
|
||
"(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄."
|
||
)
|
||
#: 회수 칸 사유 — 화면이 「왜 설계자가 넣나」를 읽는 자리.
|
||
TRIPS_NOTE = (
|
||
"회수(대수 × 왕복)는 **설계 입력**입니다 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가"
|
||
" 산출식만** 주고 대수·횟수를 정하는 공식이 원문에 없습니다(2026-09-14 전수 확인)."
|
||
" 비워 두면 수송비 금액이 서지 않습니다."
|
||
)
|
||
|
||
|
||
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 parse_trips(raw: Any) -> Decimal | None:
|
||
"""설정 칸의 **회수**(대수 × 왕복). 비거나 0 이면 `None` — 금액이 안 선다.
|
||
|
||
⚠ 품셈이 안 정하는 값이라 **우리가 기본값을 두지 않는다**(1 회로 때우지 않음).
|
||
"""
|
||
text = str(raw or "").strip().rstrip("회").strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
value = Decimal(text)
|
||
except (ArithmeticError, ValueError):
|
||
raise ValueError(f"수송 회수를 숫자로 못 읽었습니다: {raw!r}") from None
|
||
return value if value > 0 else None
|
||
|
||
|
||
def transport_amount(unit_price_krw: Decimal, trips: Decimal | None) -> Decimal | None:
|
||
"""수송비 = 회당 단가 × 회수. 회수가 없으면 `None`(0 원으로 안 채운다)."""
|
||
return None if trips is None else unit_price_krw * trips
|
||
|
||
|
||
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
|