"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 두 점 정본 API (요구서 389 3장 1 · PLAN 560-2). - `GET /api/projects/{id}/plan/points` — {epsg, start, end} · 점 = {lon, lat, x, y} · 빈 = null - `PUT …/plan/points` — {start: {lon, lat}, end: {lon, lat}} → TM 셈 → `projects.plan_base_json` (`{"epsg", "start": {"x", "y"}, "end": {"x", "y"}}` · 좌표계 = 두 점 평균 경도의 TM 띠) - 칸이 비면(옛 프로젝트) 첫 연도 시점 + 마지막 연도 종점으로 접어 보임 · 연도 행은 D03 [저장] 몫. - 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫). 연도 잇기 꼴(`chain_years` · `with_tm` · `changed` · `_store`)은 D03 [저장]이 씀. """ 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"]) SAME_M = 0.001 # 1 mm 안이면 같은 지점 — 되저장으로 분석 노선을 지우지 않음 class LonLat(BaseModel): lon: float = Field(..., ge=120, le=135) lat: float = Field(..., ge=30, le=45) class YearPoints(BaseModel): year: int start: LonLat | None = None end: LonLat | None = None class BaseRequest(BaseModel): start: LonLat | None = None end: LonLat | None = None def chain_years(year_start: int, year_end: int, items: list[YearPoints]) -> list[dict[str, Any]]: """연도 범위를 잇기 — [{year, start, end}] 경위도 · 시점 = 직전 종점(첫 연도만 보낸 값).""" by_year: dict[int, YearPoints] = {} for item in items: if not year_start <= item.year <= year_end: raise HTTPException( status_code=400, detail=f"{item.year}년은 사업연도({year_start}~{year_end}) 밖입니다.", ) if item.year in by_year: raise HTTPException(status_code=400, detail=f"{item.year}년이 두 번 왔습니다.") by_year[item.year] = item rows: list[dict[str, Any]] = [] previous: LonLat | None = None for year in range(year_start, year_end + 1): item = by_year.get(year) start = previous or (item.start if item else None) end = item.end if item else None if start is None or end is None: side = "시점" if start is None else "종점" raise HTTPException(status_code=400, detail=f"{year}년 {side}이 비었습니다.") rows.append({"year": year, "start": start, "end": end}) previous = end return rows def with_tm(rows: list[dict[str, Any]]) -> tuple[int, list[dict[str, Any]]]: """(epsg, 행마다 start_x · start_y · end_x · end_y 더함).""" lons = [p.lon for row in rows for p in (row["start"], row["end"])] epsg = tm_epsg_for_lon(sum(lons) / len(lons)) out = [] for row in rows: sx, sy = lonlat_to_tm(row["start"].lon, row["start"].lat, epsg) ex, ey = lonlat_to_tm(row["end"].lon, row["end"].lat, epsg) out.append({"year": row["year"], "start_x": sx, "start_y": sy, "end_x": ex, "end_y": ey}) return epsg, out def changed(new: dict[str, Any], old: dict[str, Any] | None, epsg: int) -> bool: if not old or old.get("epsg") is None or int(old["epsg"]) != epsg: return True keys = ("start_x", "start_y", "end_x", "end_y") return any(old[k] is None or abs(float(old[k]) - new[k]) > SAME_M for k in keys) 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} async def _store( project_id: UUID, epsg: int, writes: list[dict[str, Any]], drops: list[int] ) -> None: """바뀐 연도만 넣고(분석 노선 비움) · 범위 밖 연도 행은 지움 — 한 묶음.""" async with get_db_pool().acquire() as connection: await connection.begin() try: async with connection.cursor() as cursor: for row in writes: await cursor.execute( "INSERT INTO plan_route_years" " (project_id, year, start_x, start_y, end_x, end_y, epsg)" " VALUES (%s, %s, %s, %s, %s, %s, %s)" " ON DUPLICATE KEY UPDATE start_x = VALUES(start_x)," " start_y = VALUES(start_y), end_x = VALUES(end_x), end_y = VALUES(end_y)," " epsg = VALUES(epsg), route_json = NULL, fingerprint = NULL," " confirmed_at = NULL", ( str(project_id), row["year"], row["start_x"], row["start_y"], row["end_x"], row["end_y"], epsg, ), ) for year in drops: await cursor.execute( "DELETE FROM plan_route_years WHERE project_id = %s AND year = %s", (str(project_id), year), ) await connection.commit() except Exception: await connection.rollback() raise 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 base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]: """{epsg, start, end} — 정본 칸 · 비면 첫 연도 시점 + 마지막 연도 종점으로 접음.""" stored = project.get("plan_base_json") if isinstance(stored, str): stored = json.loads(stored) if stored: epsg, start, end = stored.get("epsg"), stored.get("start"), stored.get("end") elif rows: first, last = rows[min(rows)], rows[max(rows)] epsg = first.get("epsg") start = {"x": first.get("start_x"), "y": first.get("start_y")} end = {"x": last.get("end_x"), "y": last.get("end_y")} else: epsg, start, end = None, None, None return { "epsg": epsg, "start": _point(start["x"], start["y"], epsg) if start else None, "end": _point(end["x"], end["y"], epsg) if end else None, } 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) -> dict[str, Any]: """PUT 몸 → 정본 칸 꼴(TM m).""" given = [p for p in (payload.start, payload.end) if p] epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given)) if given else None def tm(p: LonLat | None) -> dict[str, float] | None: if p is None or epsg is None: return None x, y = lonlat_to_tm(p.lon, p.lat, epsg) return {"x": x, "y": y} return {"epsg": epsg, "start": tm(payload.start), "end": tm(payload.end)} 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="예정노선 지점은 산림청만 고칠 수 있습니다.") await _load(project_id) # 없음 · 예정노선 아님 거절 await _store_base(project_id, stored_base(payload)) return {"status": "success", **await load_base(project_id)}