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:
@@ -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,
|
||||
}
|
||||
]
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user