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:
@@ -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]
|
||||
|
||||
|
||||
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`.
|
||||
|
||||
뒤 코드는 앞 코드의 **앞 네 자리를 이어받는다**. 이어받을 앞자리가 없으면 버린다.
|
||||
|
||||
⚠ `carry_prefix` — **앞 표의 마지막 앞자리**. 원문이 페이지에서 잘리면 이어지는 판이
|
||||
앞자리를 다시 안 적는다(8-4-3 이 `0080 0100 …` 로 시작한다). 그것을 못 이어받아
|
||||
**운반·하역기계 39 기종의 운전경비가 통째로 버려지고 있었다** — 그 안에 수송비를 내는
|
||||
**트럭 트랙터 및 평판트레일러(2702)** 가 들어 있다(2026-09-09 밤 실측).
|
||||
⚠ 이어받은 코드는 **카탈로그에 있는지 확인한 뒤에만** 쓴다(`parse_operating_tables`).
|
||||
"""
|
||||
codes: list[str] = []
|
||||
prefix = ""
|
||||
prefix = carry_prefix
|
||||
for token in tokens:
|
||||
full = _RE_CODE_FULL.match(token)
|
||||
if full:
|
||||
@@ -166,9 +172,16 @@ def _operator_code(machine_name: str, aliases: dict[str, str]) -> str:
|
||||
def parse_operating_tables(
|
||||
pum: dict[str, Any],
|
||||
aliases: dict[str, str],
|
||||
known_codes: set[str] | None = None,
|
||||
) -> OperatingParseResult:
|
||||
"""품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다."""
|
||||
"""품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다.
|
||||
|
||||
⚠ `known_codes` — 기종 카탈로그의 코드. **앞 표에서 앞자리를 이어받을 때만** 쓰며,
|
||||
이어받아 만든 코드가 하나라도 카탈로그에 없으면 **그 줄을 통째로 버린다.**
|
||||
이어받기는 원문이 페이지에서 잘린 자리를 잇는 것이지 코드를 지어내는 것이 아니다.
|
||||
"""
|
||||
result = OperatingParseResult()
|
||||
carry_prefix = ""
|
||||
for table in pum.get("tables", []):
|
||||
headers = table.get("headers") or []
|
||||
joined = " ".join(headers)
|
||||
@@ -179,6 +192,14 @@ def parse_operating_tables(
|
||||
if len(row) < 6:
|
||||
continue
|
||||
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])
|
||||
specs = _tokens(row[2])
|
||||
fuels = _tokens(row[3])
|
||||
@@ -253,8 +274,9 @@ def load_operating_records(
|
||||
|
||||
pum = _read_json(*_CATALOG_SUBPATH, pum_file)["variables"]["pum"]
|
||||
aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {})
|
||||
parsed = parse_operating_tables(pum, aliases)
|
||||
return enrich_with_catalog(parsed, load_machine_catalog(), aliases)
|
||||
catalog = load_machine_catalog()
|
||||
parsed = parse_operating_tables(pum, aliases, set(catalog.machines))
|
||||
return enrich_with_catalog(parsed, catalog, aliases)
|
||||
|
||||
|
||||
def write_operating_records(
|
||||
|
||||
@@ -249,11 +249,14 @@ async def _build_for(project_id: UUID):
|
||||
)
|
||||
# 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1).
|
||||
# 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」).
|
||||
# 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]).
|
||||
return cached_build(
|
||||
ranges,
|
||||
machines,
|
||||
str(settings.get("misc_material_percent") 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,
|
||||
)
|
||||
|
||||
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 이 정상이다.
|
||||
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(
|
||||
1
|
||||
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": [
|
||||
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
|
||||
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
|
||||
@@ -446,6 +479,9 @@ class FactorChoiceBody(BaseModel):
|
||||
misc_material_percent: 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")
|
||||
@@ -499,6 +535,25 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
|
||||
},
|
||||
)
|
||||
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:
|
||||
save_section(root, "estimation", values, replace_keys=tuple(values))
|
||||
return JSONResponse(content={"status": "success", **values})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 선다(산림품셈 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 {
|
||||
status: string;
|
||||
ranges: RangeFactorRow[];
|
||||
machines: MachineChoiceRow[];
|
||||
misc_material?: MiscMaterialRow;
|
||||
transport?: TransportRow;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
@@ -560,6 +571,8 @@ export async function saveFactorChoices(
|
||||
machine_choices?: Record<string, string>;
|
||||
misc_material_percent?: string;
|
||||
fuel_region?: string;
|
||||
transport_distance_km?: string;
|
||||
transport_road?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
@@ -726,5 +739,48 @@ export function drawFactorChoices(
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_Transport import parse_distance_km
|
||||
|
||||
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
|
||||
_ZERO = Decimal(0)
|
||||
@@ -92,6 +93,8 @@ class UnitPriceBuild:
|
||||
skipped: 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 산마루측구).
|
||||
@@ -542,6 +545,8 @@ def build_unit_prices(
|
||||
machine_picks: dict[str, str] | None = None,
|
||||
misc_material_percent: Decimal | None = None,
|
||||
fuel_region: str | None = None,
|
||||
transport_distance_km: Decimal | None = None,
|
||||
transport_road: str | None = None,
|
||||
) -> UnitPriceBuild:
|
||||
"""자원 축을 일위대가(`B`)로 조립한다.
|
||||
|
||||
@@ -555,6 +560,9 @@ def build_unit_prices(
|
||||
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다.
|
||||
|
||||
⚠ `fuel_region` — 유가 시도코드(품셈 8-1-7 5호). **안 주면 전국평균**이다.
|
||||
|
||||
⚠ `transport_*` — 기계 수송비(산림품셈 10-4 · 건설품셈 8-1-6의 2). **거리·도로 구분이
|
||||
없으면 그 줄이 안 선다** — 임의 거리로 금액을 세우지 않는다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import (
|
||||
chosen_values,
|
||||
@@ -658,6 +666,11 @@ def build_unit_prices(
|
||||
# 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다.
|
||||
machine_codes |= formula_machine_codes(master)
|
||||
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)
|
||||
|
||||
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
|
||||
@@ -852,6 +865,16 @@ def build_unit_prices(
|
||||
covered += machine_share
|
||||
if covered < Decimal(100):
|
||||
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% 층으로 바꿔 단다
|
||||
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
||||
build.combined_swapped = _apply_combined_misc_rate(
|
||||
@@ -982,6 +1005,8 @@ def cached_build(
|
||||
machine_picks: tuple[tuple[str, str], ...] = (),
|
||||
misc_material_percent: str = "",
|
||||
fuel_region: str = "",
|
||||
transport_distance_km: str = "",
|
||||
transport_road: str = "",
|
||||
) -> UnitPriceBuild:
|
||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
||||
|
||||
@@ -1005,6 +1030,8 @@ def cached_build(
|
||||
machine_picks=machine_choices(settings),
|
||||
misc_material_percent=parse_misc_material_percent(misc_material_percent),
|
||||
fuel_region=fuel_region or None,
|
||||
transport_distance_km=parse_distance_km(transport_distance_km),
|
||||
transport_road=transport_road or None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user