feat(529-3): D02 지점 정본 API · 경위도 ↔ TM 점 변환

- common_util_crs: 경도로 TM 띠(5185 · 5186 · 5187) · lonlat_to_tm · tm_to_lonlat
- D02_PlanBase_Router: GET · PUT /api/projects/{id}/plan/points — 잇기(직전 종점 = 다음 시점) · 범위 밖 · 빈 연도 거절 · 산림청만 고침 · 바뀐 연도만 분석 노선 비움
- 지킴 시험 test_529_3_plan_points

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 19:46:43 +09:00
co-authored by Claude Opus 5.5
parent 02bb502ad9
commit 76a399026c
4 changed files with 384 additions and 1 deletions
+206
View File
@@ -0,0 +1,206 @@
"""D 예정노선 1단계 기준 지정 — 연도별 시점 · 종점 정본 API (요구 345 · 요구서 3-2 · PLAN 529-3).
- `GET /api/projects/{id}/plan/points` — 사업연도 연도마다 시점 · 종점(경위도 + TM) · 빈 = null
- `PUT …/plan/points` — 경위도를 받아 서버가 TM 을 셈 → `plan_route_years` 행
규칙: 직전 연도 종점 = 다음 연도 시점(서버가 맞춤 · 보낸 시점은 첫 연도만 씀) ·
범위 밖 · 빈 연도 거절 · 좌표계 = 모든 지점 평균 경도의 TM 띠 한 벌 ·
지점이 바뀐 연도는 분석 노선(route_json · fingerprint · confirmed_at) 비움 ·
고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
"""
from __future__ import annotations
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 PointsRequest(BaseModel):
years: list[YearPoints]
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 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 _view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
start = int(project["project_year"])
end = int(project["project_year_end"] or start)
years = []
for year in range(start, end + 1):
row = rows.get(year) or {}
years.append(
{
"year": year,
"start": _point(row.get("start_x"), row.get("start_y"), row.get("epsg")),
"end": _point(row.get("end_x"), row.get("end_y"), row.get("epsg")),
}
)
epsgs = {int(r["epsg"]) for r in rows.values() if r.get("epsg") is not None}
return {
"status": "success",
"year_start": start,
"year_end": end,
"epsg": epsgs.pop() if len(epsgs) == 1 else None,
"years": years,
}
@router.get("/{project_id}/plan/points", response_model=None)
async def get_plan_points(project_id: UUID) -> dict[str, Any]:
project, rows = await _load(project_id)
return _view(project, rows)
@router.put("/{project_id}/plan/points", response_model=None)
async def put_plan_points(
project_id: UUID,
payload: PointsRequest,
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
if not is_forest_service(session):
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
project, old = await _load(project_id)
start = int(project["project_year"])
end = int(project["project_year_end"] or start)
epsg, rows = with_tm(chain_years(start, end, payload.years))
writes = [row for row in rows if changed(row, old.get(row["year"]), epsg)]
drops = [year for year in old if not start <= year <= end]
if writes or drops:
await _store(project_id, epsg, writes, drops)
_, now = await _load(project_id)
return {**_view(project, now), "changed_years": [row["year"] for row in writes]}