feat(560-2): D02 전체 시점 · 종점 두 점 — projects.plan_base_json 정본 · 옛 연도 행 접어 보임 · 연도 행 화면 걷음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
"""D 예정노선 1단계 기준 지정 — 연도별 시점 · 종점 정본 API (요구 345 · 요구서 3-2 · PLAN 529-3).
|
||||
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 두 점 정본 API (요구서 389 3장 1 · PLAN 560-2).
|
||||
|
||||
- `GET /api/projects/{id}/plan/points` — 사업연도 연도마다 시점 · 종점(경위도 + TM) · 빈 = null
|
||||
- `PUT …/plan/points` — 경위도를 받아 서버가 TM 을 셈 → `plan_route_years` 행
|
||||
- `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` 몫).
|
||||
|
||||
규칙: 직전 연도 종점 = 다음 연도 시점(서버가 맞춤 · 보낸 시점은 첫 연도만 씀) ·
|
||||
범위 밖 · 빈 연도 거절 · 좌표계 = 모든 지점 평균 경도의 TM 띠 한 벌 ·
|
||||
지점이 바뀐 연도는 분석 노선(route_json · fingerprint · confirmed_at) 비움 ·
|
||||
고침 = 산림청만(지정 회사 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
|
||||
|
||||
@@ -38,8 +39,9 @@ class YearPoints(BaseModel):
|
||||
end: LonLat | None = None
|
||||
|
||||
|
||||
class PointsRequest(BaseModel):
|
||||
years: list[YearPoints]
|
||||
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]]:
|
||||
@@ -94,7 +96,7 @@ async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, A
|
||||
connection.cursor(aiomysql.DictCursor) as cursor,
|
||||
):
|
||||
await cursor.execute(
|
||||
"SELECT project_kind, project_year, project_year_end FROM projects"
|
||||
"SELECT project_kind, project_year, project_year_end, plan_base_json FROM projects"
|
||||
" WHERE id = %s AND deleted_at IS NULL",
|
||||
(str(project_id),),
|
||||
)
|
||||
@@ -157,50 +159,68 @@ def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None:
|
||||
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}
|
||||
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 {
|
||||
"status": "success",
|
||||
"year_start": start,
|
||||
"year_end": end,
|
||||
"epsg": epsgs.pop() if len(epsgs) == 1 else None,
|
||||
"years": years,
|
||||
"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]:
|
||||
project, rows = await _load(project_id)
|
||||
return _view(project, rows)
|
||||
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: PointsRequest,
|
||||
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, 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]}
|
||||
await _load(project_id) # 없음 · 예정노선 아님 거절
|
||||
await _store_base(project_id, stored_base(payload))
|
||||
return {"status": "success", **await load_base(project_id)}
|
||||
|
||||
Reference in New Issue
Block a user