- D02 정본 칸 plan_base_json = {epsg, starts: [{x, y}], ends: [...]} · 옛 start · end 는 읽을 때 목록으로 감쌈(옮김 없음)
- 목록마다 상한 20 · 저장된 epsg 가 있으면 그대로(점이 늘어 평균 경도가 띠를 넘어도 D03 노선과 안 어긋남)
- 부르는 곳 없던 연도 잇기 코드(chain_years · with_tm · changed · _store) 걷음 — 시험도 지움
- D03 지형 입구 = 시점 · 종점 모두 · 정본 굳힘 = 새 꼴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
6.1 KiB
Python
147 lines
6.1 KiB
Python
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 목록 정본 API
|
|
(요구서 389 3장 1 · 440 1 · PLAN 560-2 · 579-1).
|
|
|
|
- `GET /api/projects/{id}/plan/points` — {epsg, starts: [점], ends: [점]} · 점 = {lon, lat, x, y}
|
|
- `PUT …/plan/points` — {starts: [{lon, lat}], ends: [...]} → TM 셈 → `projects.plan_base_json`
|
|
(`{"epsg", "starts": [{"x", "y"}], "ends": [...]}` · 좌표계 = 저장된 값 ·
|
|
없으면 점 평균 경도의 TM 띠)
|
|
- 시점 · 종점은 짝 없는 따로 목록 · 옛 꼴(`start` · `end` 한 점씩)은 읽을 때 목록으로 감쌈
|
|
(옮김 없음).
|
|
- 칸이 비면(옛 프로젝트) 첫 연도 시점 + 마지막 연도 종점으로 접어 보임 · 연도 행은 D03 [저장] 몫.
|
|
- 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from common_util.common_util_auth import is_forest_service, require_company
|
|
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon, tm_to_lonlat
|
|
from config.config_db import get_db_pool
|
|
|
|
router = APIRouter(prefix="/api/projects", tags=["D02 Plan Base"])
|
|
|
|
MAX_POINTS = 20 # 시점 · 종점 목록마다 상한
|
|
|
|
|
|
class LonLat(BaseModel):
|
|
lon: float = Field(..., ge=120, le=135)
|
|
lat: float = Field(..., ge=30, le=45)
|
|
|
|
|
|
class BaseRequest(BaseModel):
|
|
starts: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
|
|
ends: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
|
|
|
|
|
|
async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, Any]]]:
|
|
"""(예정노선 프로젝트 칸, 연도 → 지금 행)."""
|
|
async with (
|
|
get_db_pool().acquire() as connection,
|
|
connection.cursor(aiomysql.DictCursor) as cursor,
|
|
):
|
|
await cursor.execute(
|
|
"SELECT project_kind, project_year, project_year_end, plan_base_json FROM projects"
|
|
" WHERE id = %s AND deleted_at IS NULL",
|
|
(str(project_id),),
|
|
)
|
|
project = await cursor.fetchone()
|
|
await cursor.execute(
|
|
"SELECT year, start_x, start_y, end_x, end_y, epsg FROM plan_route_years"
|
|
" WHERE project_id = %s",
|
|
(str(project_id),),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
|
if project["project_kind"] != "PLAN" or project["project_year"] is None:
|
|
raise HTTPException(status_code=400, detail="예정노선 프로젝트가 아닙니다.")
|
|
return project, {int(row["year"]): row for row in rows}
|
|
|
|
|
|
def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None:
|
|
if x is None or y is None or epsg is None:
|
|
return None
|
|
lon, lat = tm_to_lonlat(float(x), float(y), int(epsg))
|
|
return {"lon": lon, "lat": lat, "x": float(x), "y": float(y)}
|
|
|
|
|
|
def _stored(project: dict[str, Any]) -> dict[str, Any] | None:
|
|
stored = project.get("plan_base_json")
|
|
return json.loads(stored) if isinstance(stored, str) else stored
|
|
|
|
|
|
def base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
|
|
"""{epsg, starts, ends} — 정본 칸(옛 한 점 꼴은 감쌈) ·
|
|
비면 첫 연도 시점 + 마지막 연도 종점으로 접음."""
|
|
stored = _stored(project)
|
|
if stored:
|
|
epsg = stored.get("epsg")
|
|
starts = stored.get("starts", [stored["start"]] if stored.get("start") else [])
|
|
ends = stored.get("ends", [stored["end"]] if stored.get("end") else [])
|
|
elif rows:
|
|
first, last = rows[min(rows)], rows[max(rows)]
|
|
epsg = first.get("epsg")
|
|
starts = [{"x": first.get("start_x"), "y": first.get("start_y")}]
|
|
ends = [{"x": last.get("end_x"), "y": last.get("end_y")}]
|
|
else:
|
|
epsg, starts, ends = None, [], []
|
|
|
|
def points(items: list[dict[str, Any]]) -> list[dict[str, float]]:
|
|
return [p for p in (_point(i.get("x"), i.get("y"), epsg) for i in items) if p]
|
|
|
|
return {"epsg": epsg, "starts": points(starts), "ends": points(ends)}
|
|
|
|
|
|
async def load_base(project_id: UUID) -> dict[str, Any]:
|
|
"""전체 시점 · 종점 목록 — D03 이 읽는 길."""
|
|
project, rows = await _load(project_id)
|
|
return base_view(project, rows)
|
|
|
|
|
|
def stored_base(payload: BaseRequest, epsg: int | None = None) -> dict[str, Any]:
|
|
"""PUT 몸 → 정본 칸 꼴(TM m) — 좌표계 = `epsg`(저장된 값) · 없으면 점 평균 경도의 TM 띠."""
|
|
given = [*payload.starts, *payload.ends]
|
|
if not given:
|
|
epsg = None
|
|
elif epsg is None:
|
|
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given))
|
|
|
|
def tm(items: list[LonLat]) -> list[dict[str, float]]:
|
|
return [dict(zip(("x", "y"), lonlat_to_tm(p.lon, p.lat, epsg))) for p in items]
|
|
|
|
return {"epsg": epsg, "starts": tm(payload.starts), "ends": tm(payload.ends)}
|
|
|
|
|
|
async def _store_base(project_id: UUID, base: dict[str, Any]) -> None:
|
|
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"UPDATE projects SET plan_base_json = %s WHERE id = %s",
|
|
(json.dumps(base), str(project_id)),
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/plan/points", response_model=None)
|
|
async def get_plan_points(project_id: UUID) -> dict[str, Any]:
|
|
return {"status": "success", **await load_base(project_id)}
|
|
|
|
|
|
@router.put("/{project_id}/plan/points", response_model=None)
|
|
async def put_plan_points(
|
|
project_id: UUID,
|
|
payload: BaseRequest,
|
|
session: dict[str, Any] = Depends(require_company),
|
|
) -> dict[str, Any]:
|
|
if not is_forest_service(session):
|
|
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
|
|
project, _ = await _load(project_id) # 없음 · 예정노선 아님 거절
|
|
epsg = (_stored(project) or {}).get("epsg")
|
|
await _store_base(project_id, stored_base(payload, int(epsg) if epsg else None))
|
|
return {"status": "success", **await load_base(project_id)}
|