"""유토 **배분(평형선·운반거리·장비)** 만 내주는 창구. 왜 따로 있나(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 hashlib import json import logging from collections import OrderedDict 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, conversion_factors_for, haul_inputs_for, haul_limits_for, ) 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 # 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움 # (342-1 · 부록 4 R25). ponytail: 프로세스 메모리 · 최근 몇 벌만 — 재시작 뒤 첫 열기는 # 다시 셈(_derived 저장본은 343 틀 뒤). _KEPT: OrderedDict[str, Any] = OrderedDict() _KEPT_MAX = 32 def _plan_key(result: dict[str, Any], context: Any) -> str: body = json.dumps({"result": result, "context": context}, sort_keys=True, default=str) return hashlib.sha1(body.encode("utf-8")).hexdigest() @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": "측점 수가 너무 많습니다."}, ) # 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라 # 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리). haul_inputs = await haul_inputs_for(project_id) # 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다. factors = await conversion_factors_for(project_id) # 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다. limits = await haul_limits_for(project_id) context = _mass_haul_context(haul_inputs, factors, limits) key = _plan_key(result, context) if key in _KEPT: _KEPT.move_to_end(key) return JSONResponse(content={"status": "success", "haul_plan": _KEPT[key]}) try: output = await asyncio.to_thread( run_bundle_json, BUNDLE, _NPM_SCRIPT, {"haul_plan_for": result, "context": context}, ) except Exception: logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) return JSONResponse( status_code=500, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, ) # 묶음 실패(None)는 실패로 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었다(342-1). if not isinstance(output, dict): return JSONResponse( status_code=500, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, ) plan = output.get("haul_plan") _KEPT[key] = plan if len(_KEPT) > _KEPT_MAX: _KEPT.popitem(last=False) return JSONResponse(content={"status": "success", "haul_plan": plan})