157 lines
7.1 KiB
Python
157 lines
7.1 KiB
Python
"""유토 **배분(평형선·운반거리·장비)** 만 내주는 창구.
|
|
|
|
왜 따로 있나(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에
|
|
남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul.ts`), 편집이
|
|
멈추면 그 결과를 여기로 보내 배분을 받아 쥔다. 그래서 유토곡선 패널을 펼치는 순간이
|
|
즉시가 된다(미리 받아 뒀으므로).
|
|
|
|
**계산은 한 벌이다** — 화면이 쓰던 TS(`computeHaulPlan`)를 늘 떠 있는 Node 시험 길 `haul.view`
|
|
(`design_calc_node_trial.ts` · 511-C)로 그대로 돌린다(CLAUDE.md 5장). 파이썬으로 옮기면 저장 정본과
|
|
화면 값이 갈린다.
|
|
|
|
정본을 만들지 않는다 — 이 응답은 **표시 전용**이다. 저장 정본은 [저장]·[확정] 뒤
|
|
`recompute_server_side` 가 따로 낸다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Body
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B06_Section.B06_Section_Server_Calc_Prebuild import (
|
|
_mass_haul_context,
|
|
conversion_factors_for,
|
|
haul_inputs_for,
|
|
haul_limits_for,
|
|
)
|
|
from common_util.common_util_calc_runner import run_calc_served
|
|
from common_util.common_util_derived_store import read_derived, write_derived
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import run_with_connection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
|
|
|
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
|
|
_MAX_POINTS = 5000
|
|
# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움
|
|
# (342-1 · 부록 4 R25). 프로세스 메모리 먼저 · 다음 = 프로젝트 `_derived/haul/<열쇠>.json`
|
|
# (재시작 뒤 첫 열기도 셈 없음 · 종류당 최근 2벌).
|
|
_KEPT: OrderedDict[str, Any] = OrderedDict()
|
|
_KEPT_MAX = 32
|
|
_DERIVED_KIND = "haul"
|
|
|
|
|
|
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()
|
|
|
|
|
|
def _keep(key: str, plan: Any) -> None:
|
|
_KEPT[key] = plan
|
|
if len(_KEPT) > _KEPT_MAX:
|
|
_KEPT.popitem(last=False)
|
|
|
|
|
|
def _run_view(result: dict[str, Any], context: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""화면 꼴 배분(그림용 · 잔진동 거름) — 옛 server-calc `haul_plan_for` 갈래와 같은 인자."""
|
|
options = {
|
|
key: context.get(key)
|
|
for key in (
|
|
"collected_stone_deduction_m3",
|
|
"collected_stone_by_ground_m3",
|
|
"collected_stone_ground_unknown_m3",
|
|
"structure_spoil_m3",
|
|
"structure_spoil_points",
|
|
)
|
|
}
|
|
options["conversion"] = context.get("earthwork_conversion")
|
|
options["drawing"] = True
|
|
args = [result, context.get("haul_equipment_limits"), options]
|
|
answer = run_calc_served(["haul.view"], {"args": {"haul.view": args}})
|
|
results = answer.get("results") if isinstance(answer, dict) else None
|
|
return {"haul_plan": results["haul.view"]} if isinstance(results, dict) else None
|
|
|
|
|
|
@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]})
|
|
project_root: Path | None = None
|
|
try:
|
|
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
except Exception: # noqa: BLE001 — 저장소를 못 찾아도 셈은 함(디스크 저장본만 건너뜀)
|
|
logger.warning("유토 배분 저장소 못 찾음: project_id=%s", project_id)
|
|
stored = (
|
|
None
|
|
if project_root is None
|
|
else await asyncio.to_thread(read_derived, project_root, _DERIVED_KIND, key)
|
|
)
|
|
if stored is not None:
|
|
_keep(key, stored["value"])
|
|
return JSONResponse(content={"status": "success", "haul_plan": stored["value"]})
|
|
try:
|
|
output = await asyncio.to_thread(_run_view, result, 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")
|
|
_keep(key, plan)
|
|
try:
|
|
if project_root is not None:
|
|
await asyncio.to_thread(write_derived, project_root, _DERIVED_KIND, key, plan)
|
|
except OSError: # 못 적어도 응답은 그대로 — 다음에 다시 셈
|
|
logger.warning("유토 배분 저장본 쓰기 실패: project_id=%s", project_id, exc_info=True)
|
|
return JSONResponse(content={"status": "success", "haul_plan": plan})
|