feat(B09): 기계 수송비를 세움 — 기계경비의 셋째 몫이 통째로 빠져 있었음

기계경비 = 기계손료 + 운전경비 + 수송비인데 수송비가 없었음. 수송비를 내는 공종
FP-10-04 중기운반은 표가 미판정이라 단가가 아예 안 섰음.

- 산림품셈 10-4 사이클 그대로 — 트레일러(20TON) t1=20·t3=20·t4=0.42 /
  트럭(10.5TON) t1=10·t3=10·t4=5 · ㎝=t1+t2+t3+t4 · N=60×0.9/㎝, 단위는 회당.
- 거리·도로 구분은 설계 입력 — 비면 줄이 안 서고 사유만 남음(임의 거리 금지).
- 원문이 「-」로 둔 칸(트레일러·고속4차선)은 지어내지 않고 사유로 냄.
- ⚠ 우리가 정한 둘을 화면 근거에 적음 — 속도를 8-1-6의 2 나 이동속도표에서 가져온 것,
  운반시간을 왕복으로 본 것.
- 곁다리: 운전경비 표가 페이지에서 잘려 앞자리를 못 이어받아 39 기종이 통째로
  버려지고 있었음(그 안에 수송 차량 2702 가 있었음). 이어받되 카탈로그에 있는
  코드일 때만 받게 함 — 운전경비 줄 92 → 158.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:23:06 +09:00
co-authored by Claude Opus 5
parent 08c0ec3134
commit fdc30ecf17
6 changed files with 473 additions and 6 deletions
@@ -106,13 +106,19 @@ def _tokens(cell: str) -> list[str]:
return [t for t in re.split(r"\s+", str(cell or "").strip()) if t] return [t for t in re.split(r"\s+", str(cell or "").strip()) if t]
def expand_codes(tokens: list[str]) -> list[str]: def expand_codes(tokens: list[str], carry_prefix: str = "") -> list[str]:
"""`0201-0012 0020 0040` → `0201-0012 · 0201-0020 · 0201-0040`. """`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] = [] codes: list[str] = []
prefix = "" prefix = carry_prefix
for token in tokens: for token in tokens:
full = _RE_CODE_FULL.match(token) full = _RE_CODE_FULL.match(token)
if full: if full:
@@ -166,9 +172,16 @@ def _operator_code(machine_name: str, aliases: dict[str, str]) -> str:
def parse_operating_tables( def parse_operating_tables(
pum: dict[str, Any], pum: dict[str, Any],
aliases: dict[str, str], aliases: dict[str, str],
known_codes: set[str] | None = None,
) -> OperatingParseResult: ) -> OperatingParseResult:
"""품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다.""" """품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다.
⚠ `known_codes` — 기종 카탈로그의 코드. **앞 표에서 앞자리를 이어받을 때만** 쓰며,
이어받아 만든 코드가 하나라도 카탈로그에 없으면 **그 줄을 통째로 버린다.**
이어받기는 원문이 페이지에서 잘린 자리를 잇는 것이지 코드를 지어내는 것이 아니다.
"""
result = OperatingParseResult() result = OperatingParseResult()
carry_prefix = ""
for table in pum.get("tables", []): for table in pum.get("tables", []):
headers = table.get("headers") or [] headers = table.get("headers") or []
joined = " ".join(headers) joined = " ".join(headers)
@@ -179,6 +192,14 @@ def parse_operating_tables(
if len(row) < 6: if len(row) < 6:
continue continue
codes = expand_codes(_tokens(row[0])) 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]) names = _tokens(row[1])
specs = _tokens(row[2]) specs = _tokens(row[2])
fuels = _tokens(row[3]) fuels = _tokens(row[3])
@@ -253,8 +274,9 @@ def load_operating_records(
pum = _read_json(*_CATALOG_SUBPATH, pum_file)["variables"]["pum"] pum = _read_json(*_CATALOG_SUBPATH, pum_file)["variables"]["pum"]
aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {}) aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {})
parsed = parse_operating_tables(pum, aliases) catalog = load_machine_catalog()
return enrich_with_catalog(parsed, load_machine_catalog(), aliases) parsed = parse_operating_tables(pum, aliases, set(catalog.machines))
return enrich_with_catalog(parsed, catalog, aliases)
def write_operating_records( def write_operating_records(
+56 -1
View File
@@ -249,11 +249,14 @@ async def _build_for(project_id: UUID):
) )
# 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1). # 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1).
# 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」). # 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」).
# 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]).
return cached_build( return cached_build(
ranges, ranges,
machines, machines,
str(settings.get("misc_material_percent") or ""), str(settings.get("misc_material_percent") or ""),
str(settings.get("fuel_region") or ""), str(settings.get("fuel_region") or ""),
str(settings.get("transport_distance_km") or ""),
str(settings.get("transport_road") or ""),
) )
@@ -389,9 +392,22 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
MISC_MATERIAL_MIN_PERCENT, MISC_MATERIAL_MIN_PERCENT,
) )
from B09_Estimation.B09_Estimation_Transport import ASSUMPTION_TEXT as TRANSPORT_ASSUMPTION
from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS
from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
book = (await _build_for(project_id)).book prices = await _build_for(project_id)
book = prices.book
transport_notes = list(prices.transport_notes)
transport_prices = {}
for variant in TRANSPORT_VARIANTS:
code = f"B-{TRANSPORT_CODE}#{variant['key']}"
if code in book.titles:
transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}"
with_material = sum( with_material = sum(
1 1
for unit_code, unit_title in book.titles.items() for unit_code, unit_title in book.titles.items()
@@ -423,6 +439,23 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
" 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다." " 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다."
), ),
}, },
"transport": {
"distance_km": str(settings.get("transport_distance_km") or ""),
"road": str(settings.get("transport_road") or ""),
"roads": [
{"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS
],
"variants": [
{
"key": variant["key"],
"label": variant["label"],
"unit_price_krw": transport_prices.get(variant["key"], ""),
}
for variant in TRANSPORT_VARIANTS
],
"basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION],
"notes": transport_notes,
},
"notes": [ "notes": [
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.", "고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.", "바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
@@ -446,6 +479,9 @@ class FactorChoiceBody(BaseModel):
misc_material_percent: str | None = None misc_material_percent: str | None = None
#: 유가 지역(시도코드) — **빈 문자열이면 전국평균**으로 돌아간다. #: 유가 지역(시도코드) — **빈 문자열이면 전국평균**으로 돌아간다.
fuel_region: str | None = None fuel_region: str | None = None
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
transport_distance_km: str | None = None
transport_road: str | None = None
@router.put("/{project_id}/estimation/factors") @router.put("/{project_id}/estimation/factors")
@@ -499,6 +535,25 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
}, },
) )
values["fuel_region"] = region values["fuel_region"] = region
if body.transport_distance_km is not None:
from B09_Estimation.B09_Estimation_Transport import parse_distance_km
try:
distance = parse_distance_km(body.transport_distance_km)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
values["transport_distance_km"] = "" if distance is None else str(distance)
if body.transport_road is not None:
from B09_Estimation.B09_Estimation_Transport import road_class
road = str(body.transport_road).strip()
if road and road_class(road) is None:
# ⚠ 원문 표에 없는 도로 구분을 받아 두면 속도를 못 골라 조용히 안 선다.
return JSONResponse(
status_code=400,
content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
)
values["transport_road"] = road
try: try:
save_section(root, "estimation", values, replace_keys=tuple(values)) save_section(root, "estimation", values, replace_keys=tuple(values))
return JSONResponse(content={"status": "success", **values}) return JSONResponse(content={"status": "success", **values})
+191
View File
@@ -0,0 +1,191 @@
"""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
@@ -536,11 +536,22 @@ export interface MiscMaterialRow {
base_note: string; base_note: string;
} }
/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 선다(산림품셈 10-4). */
export interface TransportRow {
distance_km: string;
road: string;
roads: Array<{ key: string; label: string }>;
variants: Array<{ key: string; label: string; unit_price_krw: string }>;
basis: string[];
notes: string[];
}
export interface FactorChoicesDto { export interface FactorChoicesDto {
status: string; status: string;
ranges: RangeFactorRow[]; ranges: RangeFactorRow[];
machines: MachineChoiceRow[]; machines: MachineChoiceRow[];
misc_material?: MiscMaterialRow; misc_material?: MiscMaterialRow;
transport?: TransportRow;
notes: string[]; notes: string[];
} }
@@ -560,6 +571,8 @@ export async function saveFactorChoices(
machine_choices?: Record<string, string>; machine_choices?: Record<string, string>;
misc_material_percent?: string; misc_material_percent?: string;
fuel_region?: string; fuel_region?: string;
transport_distance_km?: string;
transport_road?: string;
}, },
): Promise<void> { ): Promise<void> {
const response = await fetch( const response = await fetch(
@@ -726,5 +739,48 @@ export function drawFactorChoices(
for (const line of misc.basis) body.append(note(line)); for (const line of misc.basis) body.append(note(line));
} }
const transport = data.transport;
if (transport) {
body.append(
percentBox(
"기계 수송 거리 (인근 시·군·구청 → 현장, 편도 ㎞)",
transport.distance_km,
"비움",
(text) => {
void saveFactorChoices(projectId, { transport_distance_km: text })
.then(reload)
.catch((error: Error) => body.append(note(`${error.message}`)));
},
),
);
const roadOptions: FactorOption[] = [
{ key: "", label: "안 고름" },
...transport.roads.map((road) => ({ key: road.key, label: road.label })),
];
body.append(
picker("수송 도로 구분", roadOptions, transport.road, (key) => {
void saveFactorChoices(projectId, { transport_road: key })
.then(reload)
.catch((error: Error) => body.append(note(`${error.message}`)));
}),
);
for (const variant of transport.variants) {
body.append(
note(
variant.unit_price_krw
? `${variant.label} — 회당 ${variant.unit_price_krw}`
: `${variant.label} — 아직 안 섬`,
),
);
}
for (const line of transport.notes) body.append(note(`${line}`));
for (const line of transport.basis) body.append(note(line));
body.append(
note(
"⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.",
),
);
}
for (const line of data.notes) body.append(note(line)); for (const line of data.notes) body.append(note(line));
} }
@@ -64,6 +64,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import (
) )
from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_Transport import parse_distance_km
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
_ZERO = Decimal(0) _ZERO = Decimal(0)
@@ -92,6 +93,8 @@ class UnitPriceBuild:
skipped: list[str] = field(default_factory=list) skipped: list[str] = field(default_factory=list)
#: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보).
incomplete_machines: list[str] = field(default_factory=list) incomplete_machines: list[str] = field(default_factory=list)
#: 수송비를 왜 못 세웠나 — 거리·도로 구분이 없으면 여기에 사유가 남는다(빈칸으로 안 둔다).
transport_notes: list[str] = field(default_factory=list)
#: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구. #: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구.
#: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져 #: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져
#: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구). #: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구).
@@ -542,6 +545,8 @@ def build_unit_prices(
machine_picks: dict[str, str] | None = None, machine_picks: dict[str, str] | None = None,
misc_material_percent: Decimal | None = None, misc_material_percent: Decimal | None = None,
fuel_region: str | None = None, fuel_region: str | None = None,
transport_distance_km: Decimal | None = None,
transport_road: str | None = None,
) -> UnitPriceBuild: ) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다. """자원 축을 일위대가(`B`)로 조립한다.
@@ -555,6 +560,9 @@ def build_unit_prices(
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다. 사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다.
⚠ `fuel_region` — 유가 시도코드(품셈 8-1-7 5호). **안 주면 전국평균**이다. ⚠ `fuel_region` — 유가 시도코드(품셈 8-1-7 5호). **안 주면 전국평균**이다.
⚠ `transport_*` — 기계 수송비(산림품셈 10-4 · 건설품셈 8-1-6의 2). **거리·도로 구분이
없으면 그 줄이 안 선다** — 임의 거리로 금액을 세우지 않는다.
""" """
from B09_Estimation.B09_Estimation_FactorChoices import ( from B09_Estimation.B09_Estimation_FactorChoices import (
chosen_values, chosen_values,
@@ -658,6 +666,11 @@ def build_unit_prices(
# 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다.
machine_codes |= formula_machine_codes(master) machine_codes |= formula_machine_codes(master)
machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows} machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows}
# 수송 차량(트레일러·트럭)은 어느 공종의 자원 줄도 아니다 — **거리가 들어왔을 때만** 세운다.
if transport_distance_km is not None:
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region) build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
@@ -852,6 +865,16 @@ def build_unit_prices(
covered += machine_share covered += machine_share
if covered < Decimal(100): if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered build.partial_ratio[work_item_code] = covered
# 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다.
from B09_Estimation.B09_Estimation_Transport import attach_transport
build.transport_notes = attach_transport(
build.book,
names,
distance_km=transport_distance_km,
road_key=transport_road,
)
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate( build.combined_swapped = _apply_combined_misc_rate(
@@ -982,6 +1005,8 @@ def cached_build(
machine_picks: tuple[tuple[str, str], ...] = (), machine_picks: tuple[tuple[str, str], ...] = (),
misc_material_percent: str = "", misc_material_percent: str = "",
fuel_region: str = "", fuel_region: str = "",
transport_distance_km: str = "",
transport_road: str = "",
) -> UnitPriceBuild: ) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -1005,6 +1030,8 @@ def cached_build(
machine_picks=machine_choices(settings), machine_picks=machine_choices(settings),
misc_material_percent=parse_misc_material_percent(misc_material_percent), misc_material_percent=parse_misc_material_percent(misc_material_percent),
fuel_region=fuel_region or None, fuel_region=fuel_region or None,
transport_distance_km=parse_distance_km(transport_distance_km),
transport_road=transport_road or None,
) )
+116
View File
@@ -0,0 +1,116 @@
"""기계 수송비 — 기계경비의 셋째 몫 (2026-09-09 밤).
기계경비 = 기계손료 + 운전경비 + **수송비**(건설품셈 8-1-6 1)인데 수송비가 통째로 빠져
있었다. 수송비를 내는 공종 `FP-10-04` 중기운반은 표가 미판정이라 **단가가 서고** 있었다.
겨누는 여섯
**거리가 없으면 줄도 선다** 임의 거리로 금액이 조용히 서면
도로 구분도 마찬가지 속도가 도로로 갈림
사이클이 원문 그대로 트레일러 t1=20·t3=20·t4=0.42 / 트럭 t1=10·t3=10·t4=5
운반시간은 **왕복** · 회전율 N = 60×0.9/
원문이 - (트레일러 · 고속4차선) **지어내지 않고 사유로 **
차량들의 운전경비가 실제로 읽혀 있음 앞서 표가 페이지에서 잘려 통째로 버려졌음
"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402
expand_codes,
load_operating_records,
)
from B09_Estimation.B09_Estimation_Transport import ( # noqa: E402
TRANSPORT_VARIANTS,
cycle_minutes,
hours_per_trip,
parse_distance_km,
road_class,
)
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices # noqa: E402
_TRAILER = next(v for v in TRANSPORT_VARIANTS if v["key"] == "트레일러20ton")
_TRUCK = next(v for v in TRANSPORT_VARIANTS if v["key"] == "트럭10.5ton")
def test_거리가_없으면_한_줄도_안_선다() -> None:
build = build_unit_prices()
assert not [code for code in build.book.titles if code.startswith("B-FP-10-04")]
assert any("거리" in note for note in build.transport_notes)
def test_도로를_안_고르면_안_선다() -> None:
build = build_unit_prices(transport_distance_km=Decimal(12))
assert not [code for code in build.book.titles if code.startswith("B-FP-10-04")]
assert any("도로" in note for note in build.transport_notes)
def test_사이클이_원문_그대로다() -> None:
"""③④ 트레일러 12㎞ 사리도로(양호) — 20 + (24÷20)×60 + 20 + 0.42 = 112.42분."""
road = road_class("gravel_good")
assert road is not None
minutes = cycle_minutes(_TRAILER, Decimal(12), road)
assert minutes == Decimal(20) + Decimal("72") + Decimal(20) + Decimal("0.42")
# N = 60×0.9/㎝ 의 역수가 회당 시간이다.
hours = hours_per_trip(_TRAILER, Decimal(12), road)
assert hours == minutes / (Decimal(60) * Decimal("0.9"))
def test_트럭은_트럭_사이클을_쓴다() -> None:
road = road_class("gravel_good")
assert road is not None
minutes = cycle_minutes(_TRUCK, Decimal(12), road)
# 트럭은 사리도로(양호) 25㎞/hr — 10 + (24÷25)×60 + 10 + 5
assert minutes == Decimal(10) + Decimal("57.6") + Decimal(10) + Decimal(5)
def test_원문이_비운_칸은_지어내지_않는다() -> None:
"""⑤ 트레일러는 고속4차선 값이 원문에 「-」다."""
road = road_class("expressway_4")
assert road is not None and road["trailer"] is None
with pytest.raises(ValueError) as caught:
cycle_minutes(_TRAILER, Decimal(12), road)
assert "원문에 없습니다" in str(caught.value)
def test_거리_칸은_비우면_없음이다() -> None:
assert parse_distance_km("") is None
assert parse_distance_km(" ") is None
assert parse_distance_km("0") is None
assert parse_distance_km("12.5") == Decimal("12.5")
with pytest.raises(ValueError):
parse_distance_km("멀다")
def test_거리를_넣으면_회당_단가가_선다() -> None:
build = build_unit_prices(transport_distance_km=Decimal(12), transport_road="gravel_good")
assert build.transport_notes == []
for variant in TRANSPORT_VARIANTS:
code = f"B-FP-10-04#{variant['key']}"
title = build.book.titles[code]
assert title.unit == ""
money = build.book.resolve(code)
assert money.total > 0
# 손료(경비)·연료(재료)·운전사(노무) 셋이 다 들어야 수송비다.
assert money.material > 0 and money.labor > 0 and money.expense > 0
def test_수송_차량_운전경비가_읽힌다() -> None:
"""⑥ 표가 페이지에서 잘려 앞자리를 못 이어받아 39 기종이 통째로 버려지고 있었다."""
codes = {record.machine_code for record in load_operating_records().records}
assert "2702-0020" in codes # 트럭 트랙터 및 평판트레일러 20ton
assert "0602-0105" in codes # 덤프트럭 10.5ton
def test_앞자리_이어받기는_이어받을_것이_있을_때만() -> None:
"""⚠ 넓히면 없는 코드를 지어낸다 — 이어받을 앞자리가 없으면 그대로 버린다."""
assert expand_codes(["0080", "0100"]) == []
assert expand_codes(["0080", "0100"], "2101") == ["2101-0080", "2101-0100"]
assert expand_codes(["2101-0010", "0015"]) == ["2101-0010", "2101-0015"]