fix(B08): 사토 운반 줄 신설 — 유토곡선이 낸 사토를 아무도 실어 내지 않았음

- 운반 줄이 띠(bands)·이동(transfers)에서만 만들어져 사토는 잔량(residuals)으로 남아
  어디에도 안 실렸음. 그래서 구조물 잔토 126.63㎥ 를 얹어도 덤프가 안 늘었고,
  채집석 공제도 사토를 줄이는 값이라 끝까지 금액에 안 나타났음
- 실무에는 서는 줄(울진 대흥 1공구 토적집계 D32 사토 1,281㎥)
- 운반표에 `spoil`(물량·거리·근거)을 실어 인계가 「사토 운반」 한 줄을 세움
- 물량은 유토곡선 결과 그대로 — 공제·가산이 끝난 값이라 여기서 또 만지지 않음.
  자연방토는 실어 내지 않으므로 뺌
- 거리는 설계 입력 `spoil_site_distance_m` 신설 — 안 정했으면 막고 사유
  (품셈이 정하는 값이 아니라 임의 거리가 그대로 금액이 됨)
- 지반 갈래는 유토곡선이 안분하지 않아 갈래 없이 한 줄, 그 사실을 근거에
- tmp/tests/test_b08_spoil_haul.py 신설(6건)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 23:05:15 +09:00
co-authored by Claude Opus 5
parent cf2c894c0b
commit 759343e384
4 changed files with 123 additions and 1 deletions
@@ -43,6 +43,7 @@ from __future__ import annotations
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_BasisUnit import verify_unit_matches_basis
from B08_Quantity.B08_Quantity_Engine_Handoff_Spoil import spoil_haul_rows
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import structure_earthwork_rows
# ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게
@@ -121,6 +122,9 @@ def build_handoff(
rows, misses = _haul_rows(haul_table, table)
work_items.extend(rows)
unmatched.extend(misses)
# 사토를 실어 내는 줄 — 유토곡선이 사토를 내는데 **운반 줄이 없었다**(2026-09-08).
# 띠·이동에서만 운반이 만들어져 사토 잔량이 어디에도 안 실렸다.
work_items.extend(spoil_haul_rows(haul_table, table))
if unit_quantity_table:
rows, misses = _structure_rows(unit_quantity_table, table)
work_items.extend(rows)
@@ -0,0 +1,81 @@
"""사토 — 실어 내는 줄 (2026-09-08).
⚠⚠ **유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 `blocks[].bands`
와 `transfers` 에서만 만들어지는데, 사토는 `residuals(kind="spoil")` 로 남아 **어느 쪽에도
없다.** 그래서 구조물 잔토를 사토에 얹어도(126.63㎥) **덤프 물량이 하나도 안 늘었다** —
채집석 공제도 사토를 줄이는 값이라 **끝까지 금액에 안 나타났다**.
실무에는 서는 줄이다 — 울진 대흥 1공구 토적집계 `D32 사토 1,281㎥`.
⚠ **거리는 품셈이 정하지 않는다.** 사토장까지 거리는 설계 입력(`spoil_site_distance_m`)이고,
안 정했으면 **막고 사유를 낸다** — 임의 거리를 넣으면 그대로 금액이 된다.
⚠ **지반 갈래를 지어내지 않는다.** 유토곡선이 사토 잔량에 `ea/rr/br` 안분을 안 한다
(실어 내는 흙이 어느 지반에서 나왔는지 모름). 갈래 없이 한 줄로 세우고 그 사실을 적는다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_MISSING,
ORIGIN_HAUL,
WorkItemMapping,
)
SPOIL_NAME = "사토 운반"
SPOIL_EQUIPMENT = "dump_truck"
DISTANCE_MISSING = (
"사토장까지 운반거리가 저장에 없어 값이 서지 않음 — 품셈이 정하는 값이 아니라 설계 입력임"
"(임의 거리를 넣으면 그대로 금액이 됨)"
)
GROUND_UNKNOWN = "⚠ 사토의 지반 갈래는 유토곡선이 안분하지 않아 갈래 없이 한 줄로 섬"
def spoil_haul_rows(
haul_table: dict[str, Any] | None, mapping: WorkItemMapping
) -> list[dict[str, Any]]:
"""사토를 실어 내는 줄 하나. 사토가 없으면 줄도 없다."""
spoil = (haul_table or {}).get("spoil") or {}
volume = float(spoil.get("volume_m3") or 0.0)
if volume <= 0:
return []
distance = spoil.get("distance_m")
entry = mapping.for_haul(SPOIL_EQUIPMENT) or {}
code = entry.get("work_item_code")
blocked = distance is None or float(distance) <= 0
reason = DISTANCE_MISSING if blocked else ""
note = spoil.get("note") or ""
return [
{
"work_item_code": code,
"name": SPOIL_NAME,
"spec": f"{float(distance):g}m" if not blocked else "",
"unit": "",
"quantity": volume,
"quantity_gross": spoil.get("volume_gross_m3"),
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None if blocked else float(distance),
"haul_equipment": SPOIL_EQUIPMENT,
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": " · ".join(part for part in (note, GROUND_UNKNOWN) if part),
"composite_parts": None,
"structure_kind": None,
"blocked_kind": BLOCKED_INPUT_MISSING if blocked else None,
"blocked_reason": reason,
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
"composite_not_ready": None,
"in_bill": not blocked and code is not None,
"in_bill_reason": reason,
"origin": ORIGIN_HAUL,
}
]
+35 -1
View File
@@ -83,6 +83,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
settings, project_root = await _project_settings(project_id)
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
haul["spoil"] = _spoil_of(plan, settings)
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
table["pipe_lengths"] = [
@@ -150,6 +154,34 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
return JSONResponse(content=table)
def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]:
"""사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다.
⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤).
여기서 또 만지면 두 번 셈이 된다.
⚠ 자연방토(`natural_spoil_m3`)는 실어 내지 않는 몫이라 **뺀다**.
"""
haul_plan = (plan or {}).get("haul_plan") if isinstance(plan, dict) else None
source = haul_plan if isinstance(haul_plan, dict) else (plan or {})
total = float(source.get("spoil_m3") or 0.0)
natural = float(source.get("natural_spoil_m3") or 0.0)
volume = max(total - natural, 0.0)
note_parts = [f"사토 {total:,.2f}"]
if natural > 0:
note_parts.append(f"자연방토 {natural:,.2f}㎥ 뺀 값")
added = source.get("structure_spoil_added_m3")
if added:
note_parts.append(f"구조물 잔토 {float(added):,.2f}㎥ 얹힌 뒤")
deducted = source.get("collected_stone_deducted_m3")
if deducted:
note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤")
return {
"volume_m3": round(volume, 3),
"distance_m": settings.get("spoil_site_distance_m"),
"note": " · ".join(note_parts),
}
async def _route_structures(project_id: UUID) -> list[dict[str, Any]]:
"""배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록."""
try:
@@ -219,11 +251,13 @@ class QuantitySettingsBody(BaseModel):
ancillary_counts: dict[str, float] | None = None
# 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①).
bench_cut_depth_m: float | None = None
# 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다.
spoil_site_distance_m: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다.
NULLABLE_SETTING_KEYS = ("topsoil_thickness_m", "bench_cut_depth_m")
NULLABLE_SETTING_KEYS = ("topsoil_thickness_m", "bench_cut_depth_m", "spoil_site_distance_m")
@router.put("/{project_id}/quantity/settings")
@@ -115,6 +115,9 @@ def default_settings() -> dict[str, Any]:
# ⚠ 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
# 교본이 「층따기 높이·폭은 **설계도서에 명시**」라 해 설계 입력이다.
"bench_cut_depth_m": None,
# 사토장까지 운반거리(m) — 설계 입력이다. 품셈은 거리를 정하지 않는다.
# ⚠ `None` 은 「안 정함」이고, 그러면 사토 운반 줄이 **막힌 채로** 선다.
"spoil_site_distance_m": None,
"dataset_versions": {},
},
"estimation": {