feat(B05,B06): 유토 배분을 서버로 옮기고 브라우저는 미리 받아 쓰게 함

배분·운반거리 산식(_balance 523 + _settle 235 = 758줄)을 브라우저 번들에서 빼기 위한
배선. 계산은 여전히 한 벌 — 서버가 같은 TS 를 Node 로 돈다(CLAUDE.md 5장).

- POST /projects/{id}/sections/{route_id}/haul-plan 신설
  (B06_Section_Router_HaulPlan.py). 브라우저가 낸 누가토량 결과를 받아 배분만 돌려줌.
  화면이 쓰는 꼴 그대로 내보내 그리기 코드를 안 건드림. 표시 전용 — 정본은 저장 때 따로.
- B05_Profile_Api_HaulPlan.ts 신설 — 편집이 멈추면(400ms) 조용히 받아 두는 선반입기.
  늦게 온 응답은 버림(최신 요청만 채택). 못 받아도 곡선 자체는 그대로 보임.
- common_util_mass_haul.massHaulPayload 가 배분을 값으로 물던 것을 끊음(extra 인자).
  이 커플링 때문에 화면에서 안 불러도 번들에 남았음.
- 저장 경로는 곡선도 배분도 안 만듦 — 서버가 정본을 내므로 balloon 위치만 보냄.
- 죽은 파일 B06_Section_UI_Section_View_MassHaul.ts(236줄) 삭제.

자체검증(공용 브라우저, 용화 route 149) — 엔드포인트 200 · 196ms.
서버 배분과 브라우저 computeHaulPlan 결과가 JSON 문자열까지 동일(blocks 1 · steps 2 ·
spoil/borrow/hauled/transferred/fill_total 전부 일치). 시험 400 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:47:22 +09:00
co-authored by Claude Opus 5
parent 1abb456ef9
commit c0bfcba34b
9 changed files with 228 additions and 276 deletions
@@ -0,0 +1,75 @@
"""유토 **배분(평형선·운반거리·장비)** 만 내주는 창구.
왜 따로 있나(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에
남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul.ts`), 편집이
멈추면 그 결과를 여기로 보내 배분을 받아 쥔다. 그래서 유토곡선 패널을 펼치는 순간이
즉시가 된다(미리 받아 뒀으므로).
**계산은 한 벌이다** — 화면이 쓰던 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`)으로
그대로 돌린다(CLAUDE.md 5장). 파이썬으로 옮기면 저장 정본과 화면 값이 갈린다.
정본을 만들지 않는다 — 이 응답은 **표시 전용**이다. 저장 정본은 [저장]·[확정] 뒤
`recompute_server_side` 가 따로 낸다.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE, _mass_haul_context
from common_util.common_util_node_bundle import run_bundle_json
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
_NPM_SCRIPT = "build:server-calc"
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
_MAX_POINTS = 5000
@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None)
async def compute_haul_plan(
project_id: UUID,
route_id: int,
payload: dict[str, Any] = Body(...),
) -> JSONResponse:
"""브라우저가 낸 누가토량 결과를 받아 **배분만** 돌려준다.
입력은 `common_util_mass_haul.computeMassHaul` 의 결과 한 벌이다(`points` 포함).
출력은 `{"haul_plan": {...} | null}` — 화면이 그대로 그린다.
"""
result = payload.get("result")
points = result.get("points") if isinstance(result, dict) else None
if not isinstance(points, list) or not points:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "누가토량 결과가 비어 있습니다."},
)
if len(points) > _MAX_POINTS:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "측점 수가 너무 많습니다."},
)
try:
output = await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"haul_plan_for": result, "context": _mass_haul_context()},
)
except Exception:
logger.exception(
"유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
)
plan = output.get("haul_plan") if isinstance(output, dict) else None
return JSONResponse(content={"status": "success", "haul_plan": plan})