feat(B08): 사토장이 서면 사토 운반거리가 측점에서 나옴

사토장이 **측점 위에만** 서므로(사용자 확정 ③) 거리는 「발생점 → 사토장 측점」
누가거리로 그냥 나옴. 가정할 것이 없어짐.

- `_spoil_sites` — 측점 설계에 실려 온 사토장 구간값을 구조물 단위로 접음(다시 안 셈)
- 거리 = 발생점별 물량 가중평균 + `extra_distance_m`(항상 더함)
- 사토장이 없으면 종전대로 설계 입력값 · 그것도 없으면 막힘(임의 거리 안 넣음)
- 사유에 사토장 측점·수용량·**못 담는 몫**을 실어 화면이 드러내게 함
- `distance_basis` 칸 신설 — 받는 쪽이 「측점 기준」과 「설계 입력값」을 갈라 봄

⚠ 사토장에 쌓아도 **유토곡선 사토는 줄지 않음** — 그 흙은 여전히 실어 내야 하는 흙이고
  사토장은 목적지임. 줄이면 운반비가 사라짐. (네 창에 확인 요청함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 11:32:20 +09:00
co-authored by Claude Opus 5
parent 490566eb4d
commit a99204c34d
+86 -3
View File
@@ -87,7 +87,7 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
haul["spoil"] = _spoil_of(plan, settings)
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
table["pipe_lengths"] = [
@@ -163,7 +163,75 @@ _COMPACTED_FACTOR = {
}
def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]:
def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""배치된 사토장(유용토운반작업장) — 측점 설계에 실려 온 구간값을 모은다.
⚠ 여기서 **다시 세지 않는다** — 용량·담긴 양은 B06 이 정한 값이고, 이 함수는 그것을
구조물 단위로 접어 「어디에 얼마나 담기나」만 만든다.
"""
sites: dict[str, dict[str, Any]] = {}
for row in designs:
design = (row or {}).get("design") or {}
key = str(design.get("spoil_fill_structure_id") or "")
if not key or not float(design.get("spoil_fill_area_m2") or 0.0) > 0:
continue
chainage = float(row.get("chainage_m") or 0.0)
site = sites.setdefault(
key,
{
"structure_id": key,
"from_m": chainage,
"to_m": chainage,
"capacity_m3": float(design.get("spoil_fill_capacity_m3") or 0.0),
"placed_m3": float(design.get("spoil_fill_placed_m3") or 0.0),
"unplaced_m3": float(design.get("spoil_fill_unplaced_m3") or 0.0),
"extra_distance_m": design.get("spoil_fill_extra_distance_m"),
},
)
site["from_m"] = min(site["from_m"], chainage)
site["to_m"] = max(site["to_m"], chainage)
for site in sites.values():
site["center_m"] = (site["from_m"] + site["to_m"]) / 2
return sorted(sites.values(), key=lambda item: item["center_m"])
def _site_distance_m(
sites: list[dict[str, Any]], residuals: list[dict[str, Any]]
) -> tuple[float | None, str]:
"""사토장까지의 **가중평균 운반거리**(m)와 근거 문구.
「발생점 → 사토장 측점」 누가거리다(2026-09-09 사용자 확정 ③ — 사토장이 측점 위에만
서므로 가정할 것이 없다). 사토가 여러 자리에 남으면 물량으로 가중평균한다.
⚠ 사토장이 없으면 `None` — 설계 입력(`spoil_site_distance_m`)으로 되돌아간다.
**임의 거리를 넣지 않는다**(그대로 금액이 된다).
"""
if not sites:
return None, ""
work = 0.0
volume = 0.0
for residual in residuals:
if str(residual.get("kind") or "") != "spoil":
continue
amount = float(residual.get("volume_m3") or 0.0) - float(residual.get("natural_m3") or 0.0)
if amount <= 0:
continue
center = (float(residual.get("from_m") or 0.0) + float(residual.get("to_m") or 0.0)) / 2
nearest = min(sites, key=lambda site: abs(site["center_m"] - center))
extra = nearest.get("extra_distance_m")
distance = abs(nearest["center_m"] - center) + float(extra or 0.0)
work += amount * distance
volume += amount
if volume <= 0:
return None, ""
where = " · ".join(f"{site['center_m']:,.1f}m" for site in sites)
return work / volume, f"사토장 측점({where})까지 발생점 기준 가중평균"
def _spoil_of(
plan: dict[str, Any] | None,
settings: dict[str, Any],
sites: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다.
⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤).
@@ -225,10 +293,25 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str
}
if unknown > 0:
note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림")
# 거리 — 사토장이 서 있으면 **그 측점까지의 누가거리**로 나온다. 없으면 설계 입력값.
site_distance, site_basis = _site_distance_m(sites or [], source.get("residuals") or [])
if site_distance is not None:
note_parts.append(site_basis)
placed = sum(float(site.get("placed_m3") or 0.0) for site in sites or [])
unplaced = sum(float(site.get("unplaced_m3") or 0.0) for site in sites or [])
note_parts.append(f"사토장 수용 {placed:,.1f}")
if unplaced > 0:
note_parts.append(f"⚠ 못 담는 {unplaced:,.1f}㎥ 는 밖으로 내야 함")
return {
"volume_m3": round(volume, 3),
"volume_basis": "compacted",
"distance_m": settings.get("spoil_site_distance_m"),
"distance_m": (
round(site_distance, 3)
if site_distance is not None
else settings.get("spoil_site_distance_m")
),
"distance_basis": ("사토장(측점) 기준" if site_distance is not None else "설계 입력값"),
"sites": sites or [],
"note": " · ".join(note_parts),
"by_ground_m3": {key: round(value, 3) for key, value in grounds.items()},
"natural_m3_by_ground": natural_by_ground,