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>
This commit is contained in:
2026-10-27 20:05:44 +09:00
co-authored by Claude Opus 5.5
parent ae24e29d12
commit ba255f2851
11 changed files with 707 additions and 1467 deletions
+5 -232
View File
@@ -1,40 +1,29 @@
"""D 예정노선 2단계 분석 API (요구 345 · PLAN 530-1 · 530-2).
"""D 예정노선 2단계 지형 API (요구 345 · PLAN 530-1).
- `POST /api/projects/{id}/plan/terrain` — 지형 받기(뒤에서) · 끝나면 연도별 `surface_models` 행
- `POST …/plan/analyze` — 연도 구간마다 노선 추천 갈래 넷(뒤에서 · 파생 저장본)
- `GET …/plan/progress` — 두 일의 진행률
- `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선
- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래(+ 꺾임점 고침) → `plan_route_years.route_json` ·
`fingerprint`
- `GET …/plan/progress` — 일의 진행률
지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫). D03 화면에서 끈 지점(캐시 · PLAN 530-3)은
`points`(작업 좌표계 m)로 받아 그 지점으로 받기 · 분석 · 보기 · [저장] 때만 D02 규칙으로 정본에 씀.
계산 자리 = ③ 서버 단독 — [저장]은 노선을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈.
꺾임점 고침(543)은 마디 · 켬끔 · R 만 받아 서버가 선 · 지표를 다시 셈(`D03_PlanAnalysis_Edit`) ·
GET 은 분석 지문이 같은 저장 고침만 돌려줌.
`points`(작업 좌표계 m)로 받아 그 지점으로 받음. 노선 정본(손 그은 선)은 `D03_PlanAnalysis_Network`
(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Depends, HTTPException
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_auth import is_forest_service, require_company
from common_util.common_util_crs import tm_to_lonlat
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_Analyze as analyze
from D03_PlanAnalysis import D03_PlanAnalysis_Edit as plan_edit
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
logger = logging.getLogger(__name__)
@@ -58,21 +47,6 @@ class PointsBody(BaseModel):
points: list[PlanPoint] | None = None
class PlanEditBody(BaseModel):
"""연도 하나의 꺾임점 고침 — 고친 갈래 · 그 분석 지문 · 마디(작업 좌표계 m) · 곡선 켬끔 · R."""
base_mode: str
base_fingerprint: str
nodes: list[tuple[float, float]]
curve_flags: list[bool]
radii: list[float | None]
class ChoiceRequest(PointsBody):
choices: dict[int, str] # 연도 → 갈래
edits: dict[int, PlanEditBody] | 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}
@@ -205,23 +179,6 @@ async def plan_terrain(project_id: UUID, body: PointsBody | None = None) -> dict
return _start_job(project_id, "terrain", work)
@router.post("/{project_id}/plan/analyze", response_model=None)
async def plan_analyze(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]:
"""연도 구간마다 노선 추천 갈래 넷(뒤에서 · 같은 입력이면 저장본)."""
root, project, segments = await _plan_inputs(project_id, body.points if body else None)
criteria = analyze.plan_criteria(project["road_type"], project["design_speed_kph"])
missing = [s["year"] for s in segments if not terrain.models_dir_of(root, s["year"]).is_dir()]
if missing:
raise HTTPException(status_code=409, detail=f"지형을 먼저 받으세요: {missing}년")
async def work(report: Any) -> None:
await asyncio.to_thread(
lambda: analyze.analyze_plan(root, segments, criteria, on_progress=report)
)
return _start_job(project_id, "analyze", 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}
@@ -229,187 +186,3 @@ async def plan_progress(project_id: UUID) -> dict[str, Any]:
"status": "success",
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
}
def _saved_mode(route_json: Any) -> str | None:
try:
value = json.loads(route_json) if isinstance(route_json, str) else route_json
except ValueError:
return None
return value.get("mode") if isinstance(value, dict) else None
def _assemble(
root: Path,
project: dict[str, Any],
segments: list[dict[str, Any]],
choices: dict[int, Any],
edits: dict[int, dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any] | None], list[dict[str, Any]] | None, list[list[float]]]:
"""(연도 저장본 · 이은 누가 · 이은 중심선) — 저장본이 하나라도 없으면 None · 빈 선.
`edits` = 연도 → 고친 노선(`{mode, planned, metrics}`) — 누가 · 선만 고친 길이로(저장본은 엔진
그대로).
"""
results = [analyze.read_segment(root, seg) for seg in segments]
if any(result is None for result in results):
return results, None, []
view = analyze.with_edits(results, edits or {}) # type: ignore[arg-type]
rows = analyze.chain(view, choices, float(project["route_start_m"] or 0.0))
return results, rows, analyze.joined_line(view, rows)
def _saved_edits(
root: Path, segments: list[dict[str, Any]], choices: dict[int, Any]
) -> dict[int, dict[str, Any]]:
"""저장된 꺾임점 고침 — 저장 갈래 그대로 · 분석 지문이 지금과 같은 것만."""
out = {}
for seg in segments:
saved = plan_edit.saved_edit(seg["route_json"])
result = analyze.read_segment(root, seg)
if (
saved
and result
and saved["base_mode"] == choices.get(seg["year"])
and saved["base_fingerprint"] == result.get("fingerprint")
and saved.get("planned")
):
out[seg["year"]] = saved
return out
def _edit_view(saved: dict[str, Any]) -> dict[str, Any]:
return {key: saved[key] for key in (*plan_edit.INPUT_KEYS, "metrics")}
def _query_points(points: str | None) -> list[PlanPoint] | None:
if not points:
return None
try:
return [PlanPoint(**item) for item in json.loads(points)]
except (ValueError, TypeError) as error:
raise HTTPException(status_code=400, detail="points 꼴이 다릅니다.") from error
@router.get("/{project_id}/plan/analysis", response_model=None)
async def plan_analysis(project_id: UUID, points: str | None = None) -> dict[str, Any]:
"""분석 저장본(지점이 지금과 같은 것만) + 고른 갈래(저장값 · 없으면 첫 성공 갈래).
`points` = 끈 지점 JSON(`[{year, start, end}]` · 작업 좌표계 m) — 그 지점의 저장본을 봄.
"""
root, project, segments = await _plan_inputs(project_id, _query_points(points))
choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments}
saved = await asyncio.to_thread(_saved_edits, root, segments, choices)
edits = {year: {"mode": e["base_mode"], **e} for year, e in saved.items()}
results, rows, line = await asyncio.to_thread(
_assemble, root, project, segments, choices, edits
)
return {
"status": "success",
"years": [
{
"year": seg["year"],
"start": seg["start"],
"end": seg["end"],
"analysis": result,
# 저장된 꺾임점 고침(입력 + 그때 지표) — 분석 지문이 같을 때만
"edit": _edit_view(saved[seg["year"]]) if seg["year"] in saved else None,
}
for seg, result in zip(segments, results)
],
"chain": rows,
"line": line,
"start_chainage_m": float(project["route_start_m"] or 0.0),
# 등록 목표 끝 누가(요구서 3-1) — 화면이 이은 끝 누가와 차이를 보임
"end_chainage_target_m": (
float(project["route_end_m"]) if project["route_end_m"] is not None else None
),
}
async def _store_points(project_id: UUID, segments: list[dict[str, Any]]) -> None:
"""끈 지점 → D02 정본 규칙(경위도 · 잇기 · 바뀐 연도만 · 그 연도 노선 비움)."""
epsg = segments[0]["epsg"]
def lonlat(xy: tuple[float, float]) -> base.LonLat:
lon, lat = tm_to_lonlat(xy[0], xy[1], epsg)
return base.LonLat(lon=lon, lat=lat)
project, old = await base._load(project_id)
start = int(project["project_year"])
end = int(project["project_year_end"] or start)
items = [
base.YearPoints(year=s["year"], start=lonlat(s["start"]), end=lonlat(s["end"]))
for s in segments
]
new_epsg, rows = base.with_tm(base.chain_years(start, end, items))
if new_epsg != epsg:
raise HTTPException(
status_code=409,
detail="끈 지점이 다른 좌표계 띠로 넘어갔습니다 — 1단계에서 지정하세요.",
)
writes = [row for row in rows if base.changed(row, old.get(row["year"]), epsg)]
if writes:
await base._store(project_id, epsg, writes, [])
@router.put("/{project_id}/plan/analysis", response_model=None)
async def save_plan_analysis(
project_id: UUID,
request: ChoiceRequest,
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
"""[저장] — 끈 지점(있으면 정본으로) · 연도별 고른 갈래를 서버 저장본으로 다시 이어 씀."""
if (request.points or request.edits) and not is_forest_service(session):
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
root, project, segments = await _plan_inputs(project_id, request.points)
bad = [m for m in request.choices.values() if m not in analyze.PLAN_MODES]
if bad:
raise HTTPException(status_code=400, detail=f"모르는 갈래: {bad}")
choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments}
choices.update(request.choices)
results, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices)
if rows is None:
raise HTTPException(
status_code=409, detail="분석 저장본이 없습니다 — [분석]을 먼저 하세요."
)
lost = [r["year"] for r in rows if choices.get(r["year"]) not in (None, r["mode"])]
if lost:
raise HTTPException(status_code=409, detail=f"고른 갈래에 노선이 없습니다: {lost}년")
edited: dict[int, dict[str, Any]] = {}
if request.edits:
saved = {seg["year"]: plan_edit.saved_edit(seg["route_json"]) for seg in segments}
asked = {year: body.model_dump() for year, body in request.edits.items()}
try:
edited = await asyncio.to_thread(
plan_edit.build_edits, root, results, rows, asked, saved
)
except plan_edit.EditError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
_, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices, edited)
if request.points:
await _store_points(project_id, segments)
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
async with connection.cursor() as cursor:
for result, row in zip(results, rows):
route = edited.get(row["year"]) or result["routes"][row["mode"]]
body = {**row, **{k: route[k] for k in ("planned", "nodes", "curves")}}
if row["year"] in edited: # 꺾임점 고침 — 입력 · 지표 함께(GET 이 돌려줌)
body.update(metrics=route["metrics"], edit=route["edit"])
await cursor.execute(
"UPDATE plan_route_years SET route_json = %s, fingerprint = %s "
"WHERE project_id = %s AND year = %s",
(
json.dumps(body, ensure_ascii=False),
result["fingerprint"],
str(project_id),
row["year"],
),
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return {"status": "success", "chain": rows}