Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_Router.py
T
eomsangdonandClaude Opus 5.5 ba255f2851 feat(562-2): D03 노선 정본 GET · PUT network — 손 그은 선 · 갈래 · 마크 · 조각 → 조각 행 · 연도 행 · 사업연도 끝 · 옛 추천 엔진 · 꺾임점 R 걷음
- 새 D03_PlanAnalysis_Network.py — 서버가 marks 로 조각 경계 · 길이 · 측점 다시 셈(높이 저장 안 함) · 옛 연도 노선은 읽을 때 한 선으로 · 업체 지정 조각 지움 409 · 산림청만
- 새 db_management/021_plan_network.sql — plan_networks · plan_route_pieces · plan_route_assignees.piece_id(DB 반영함)
- main.py 라우터 한 벌 · Router.py 의 POST analyze · GET/PUT analysis 덩이 지움(지형 · 진행 길 그대로)
- 지움: D03_PlanAnalysis_Analyze.py · D03_PlanAnalysis_Edit.py · config PLAN_ANALYZE_WORKERS
- 시험: test_530_2_plan_analyze = 노선 정본 시험으로 다시 씀(옛 엔진 시험은 엔진과 함께 걷음) · test_530_2_plan_router = 분석 API 시험 지우고 끈 지점 규칙만 · test_530_3_plan_logic = Analyze 짝 시험 지움 · test_543_plan_edit 지움(꺾임점 R 고침 걷음 — 요구 389 11 직선만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:05:44 +09:00

189 lines
7.8 KiB
Python

"""D 예정노선 2단계 지형 API (요구 345 · PLAN 530-1).
- `POST /api/projects/{id}/plan/terrain` — 지형 받기(뒤에서) · 끝나면 연도별 `surface_models` 행
- `GET …/plan/progress` — 일의 진행률
지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫). D03 화면에서 끈 지점(캐시 · PLAN 530-3)은
`points`(작업 좌표계 m)로 받아 그 지점으로 받음. 노선 정본(손 그은 선)은 `D03_PlanAnalysis_Network`
(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D02_PlanBase import D02_PlanBase_Router as base
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["D03 Plan Analysis"])
# 진행 — (프로젝트, 일) → {running, percent, message, error} · 프로세스 메모리
_JOBS: dict[tuple[str, str], dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
KINDS = ("terrain", "analyze")
class PlanPoint(BaseModel):
"""끈 지점(작업 좌표계 m) — 연도 하나의 시점 · 종점."""
year: int
start: tuple[float, float]
end: tuple[float, float]
class PointsBody(BaseModel):
points: list[PlanPoint] | None = None
def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[dict[str, Any]]:
"""연도 구간 지점을 끈 지점으로 — 연도 같음 · 직전 종점 = 다음 시점(1 mm) 아니면 거절."""
by_year = {p.year: p for p in points}
if set(by_year) != {seg["year"] for seg in segments}:
raise HTTPException(status_code=400, detail="끈 지점의 연도가 사업연도와 다릅니다.")
out = [
{
**seg,
"start": tuple(by_year[seg["year"]].start),
"end": tuple(by_year[seg["year"]].end),
"saved": (seg["start"], seg["end"]), # 저장 지점 — 그 분석본은 쌓임 거르기에서 남김
}
for seg in segments
]
for prev, seg in zip(out, out[1:]):
if max(abs(a - b) for a, b in zip(prev["end"], seg["start"])) > base.SAME_M:
raise HTTPException(
status_code=400, detail=f"{seg['year']}년 시점이 직전 종점과 다릅니다."
)
return out
async def _plan_inputs(
project_id: UUID, points: list[PlanPoint] | None = None
) -> tuple[Path, dict[str, Any], list[dict[str, Any]]]:
"""(실경로, 프로젝트 칸, 연도 구간) — 빈 연도 · 좌표계 섞임 거절 · `points` = 끈 지점."""
async with get_db_pool().acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT road_type, design_speed_kph, route_start_m, route_end_m FROM projects"
" WHERE id = %s",
(str(project_id),),
)
project = await cursor.fetchone()
await cursor.execute(
"SELECT year, start_x, start_y, end_x, end_y, epsg, route_json, fingerprint "
"FROM plan_route_years WHERE project_id = %s ORDER BY year",
(str(project_id),),
)
rows = await cursor.fetchall()
if not stored or not project:
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
if not rows:
raise HTTPException(
status_code=400, detail="연도별 지점이 없습니다 — 1단계에서 지정하세요."
)
for row in rows:
if any(row[k] is None for k in ("start_x", "start_y", "end_x", "end_y", "epsg")):
raise HTTPException(status_code=400, detail=f"{row['year']}년 지점이 비었습니다.")
if len({int(row["epsg"]) for row in rows}) != 1:
raise HTTPException(status_code=400, detail="연도마다 좌표계가 다릅니다.")
segments = [
{
"year": int(row["year"]),
"start": (float(row["start_x"]), float(row["start_y"])),
"end": (float(row["end_x"]), float(row["end_y"])),
"epsg": int(row["epsg"]),
"route_json": row["route_json"],
}
for row in rows
]
if points:
segments = _override(segments, points)
return Path(resolve_stored_project_path(stored)), project, segments
def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
key = (str(project_id), kind)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None}
_JOBS[key] = job
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
async def run() -> None:
try:
await work(report)
job.update(percent=100)
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("D %s 실패: project_id=%s", kind, project_id)
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
task = asyncio.create_task(run(), name=f"D {kind} {project_id}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted", **job}
async def _save_models(project_id: UUID, years: dict[int, list[dict[str, Any]]], epsg: int) -> None:
"""연도별 지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로)."""
from B04_PreProcess.B04_PreProcess_Repository import _existing_models, _upsert_surface_model
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
existing = await _existing_models(connection, project_id)
for models in years.values():
for model in models:
await _upsert_surface_model(
connection,
existing,
project_id=project_id,
model=model,
source_file_id=None,
processed_cloud_id=None,
crs_epsg=epsg,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
@router.post("/{project_id}/plan/terrain", response_model=None)
async def plan_terrain(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]:
"""지점 기준 수치지형도 · 위성 · 연도별 DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`."""
root, _, segments = await _plan_inputs(project_id, body.points if body else None)
epsg = segments[0]["epsg"]
async def work(report: Any) -> None:
got = await asyncio.to_thread(terrain.ensure_plan_terrain, root, segments, epsg, report)
await _save_models(project_id, got["years"], epsg)
report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매")
return _start_job(project_id, "terrain", work)
@router.get("/{project_id}/plan/progress", response_model=None)
async def plan_progress(project_id: UUID) -> dict[str, Any]:
idle = {"running": False, "percent": 0, "message": None, "error": None}
return {
"status": "success",
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
}