diff --git a/D02_PlanBase/D02_PlanBase_Router.py b/D02_PlanBase/D02_PlanBase_Router.py index 79d888bf0..5c8c750e4 100644 --- a/D02_PlanBase/D02_PlanBase_Router.py +++ b/D02_PlanBase/D02_PlanBase_Router.py @@ -1,12 +1,14 @@ -"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 두 점 정본 API (요구서 389 3장 1 · PLAN 560-2). +"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 목록 정본 API +(요구서 389 3장 1 · 440 1 · PLAN 560-2 · 579-1). -- `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 띠) +- `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` 몫). - -연도 잇기 꼴(`chain_years` · `with_tm` · `changed` · `_store`)은 D03 [저장]이 씀. """ from __future__ import annotations @@ -25,7 +27,7 @@ from config.config_db import get_db_pool router = APIRouter(prefix="/api/projects", tags=["D02 Plan Base"]) -SAME_M = 0.001 # 1 mm 안이면 같은 지점 — 되저장으로 분석 노선을 지우지 않음 +MAX_POINTS = 20 # 시점 · 종점 목록마다 상한 class LonLat(BaseModel): @@ -33,60 +35,9 @@ class LonLat(BaseModel): 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) + 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]]]: @@ -114,44 +65,6 @@ async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, A 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 @@ -159,45 +72,51 @@ def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None: 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} — 정본 칸 · 비면 첫 연도 시점 + 마지막 연도 종점으로 접음.""" +def _stored(project: dict[str, Any]) -> dict[str, Any] | None: stored = project.get("plan_base_json") - if isinstance(stored, str): - stored = json.loads(stored) + 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, start, end = stored.get("epsg"), stored.get("start"), stored.get("end") + 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") - start = {"x": first.get("start_x"), "y": first.get("start_y")} - end = {"x": last.get("end_x"), "y": last.get("end_y")} + starts = [{"x": first.get("start_x"), "y": first.get("start_y")}] + ends = [{"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, - } + 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 이 읽는 길.""" + """전체 시점 · 종점 목록 — 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 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(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} + 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, "start": tm(payload.start), "end": tm(payload.end)} + return {"epsg": epsg, "starts": tm(payload.starts), "ends": tm(payload.ends)} async def _store_base(project_id: UUID, base: dict[str, Any]) -> None: @@ -221,6 +140,7 @@ async def put_plan_points( ) -> 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)) + 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)} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py index cce67c4a2..e43374286 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py @@ -391,7 +391,7 @@ async def _freeze_base(connection: Any, project_id: UUID, base: dict[str, Any]) """옛 연도 행을 접은 시점 · 종점 → D02 정본 칸(비었을 때만).""" stored = { "epsg": base["epsg"], - **{key: {"x": base[key]["x"], "y": base[key]["y"]} for key in ("start", "end")}, + **{key: [{"x": p["x"], "y": p["y"]} for p in base[key]] for key in ("starts", "ends")}, } async with connection.cursor() as cursor: await cursor.execute( @@ -426,7 +426,7 @@ async def put_plan_network( raise HTTPException(status_code=400, detail=str(error)) from error await connection.begin() try: - if project["plan_base_json"] is None and base["start"] and base["end"]: + if project["plan_base_json"] is None and base["starts"] and base["ends"]: await _freeze_base(connection, project_id, base) await _store(connection, project_id, network, derived) await connection.commit() diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 1de51d747..654d1a691 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -47,9 +47,9 @@ class TerrainBody(BaseModel): async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]: - """(실경로, 좌표계, [전체 시점, 전체 종점]) — D02 정본(빈 칸이면 옛 연도 행 접음).""" + """(실경로, 좌표계, [전체 시점들 · 종점들]) — D02 정본(빈 칸이면 옛 연도 행 접음).""" plan = await base.load_base(project_id) - if not plan["start"] or not plan["end"]: + if not plan["starts"] or not plan["ends"]: raise HTTPException( status_code=400, detail="전체 시점 · 종점이 없습니다 — 1단계에서 지정하세요." ) @@ -57,7 +57,7 @@ async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, flo stored = await get_project_storage_relative_path(connection, project_id) if not stored: raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") - ends = [(float(p["x"]), float(p["y"])) for p in (plan["start"], plan["end"])] + ends = [(float(p["x"]), float(p["y"])) for p in (*plan["starts"], *plan["ends"])] return Path(resolve_stored_project_path(stored)), int(plan["epsg"]), ends diff --git a/resources/tester/test_529_3_plan_points.py b/resources/tester/test_529_3_plan_points.py index 892df2b10..4de07be15 100644 --- a/resources/tester/test_529_3_plan_points.py +++ b/resources/tester/test_529_3_plan_points.py @@ -1,5 +1,5 @@ -"""D02 기준 지정(PLAN 529-3 · 560-2) — 경위도 ↔ TM 변환 · 전체 두 점 정본 API · 옛 연도 행 접기 -· 연도 잇기(D03 [저장]이 씀). +"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1) — 경위도 ↔ TM 변환 · 시점 · 종점 목록 정본 API · +옛 한 점 꼴 감쌈 · 옛 연도 행 접기 · 저장된 좌표계 그대로. ./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q """ @@ -7,7 +7,7 @@ import json import pytest -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import require_company @@ -80,32 +80,53 @@ def api(monkeypatch): return client -def test_put_two_points(api) -> None: - got = api.put(URL, json={"start": A, "end": C}) +def test_put_lists(api) -> None: + got = api.put(URL, json={"starts": [A, B], "ends": [C]}) assert got.status_code == 200 data = got.json() - assert data["epsg"] == 5186 - assert (data["start"]["x"], data["start"]["y"]) == pytest.approx( + assert data["epsg"] == 5186 and len(data["starts"]) == 2 and len(data["ends"]) == 1 + assert (data["starts"][0]["x"], data["starts"][0]["y"]) == pytest.approx( lonlat_to_tm(127.10, 37.50, 5186) ) - assert abs(data["end"]["lon"] - C["lon"]) < 1e-8 and abs(data["end"]["lat"] - C["lat"]) < 1e-8 + end = data["ends"][0] + assert abs(end["lon"] - C["lon"]) < 1e-8 and abs(end["lat"] - C["lat"]) < 1e-8 stored = json.loads(api.state["project"]["plan_base_json"]) - assert set(stored) == {"epsg", "start", "end"} and set(stored["start"]) == {"x", "y"} + assert set(stored) == {"epsg", "starts", "ends"} and set(stored["starts"][1]) == {"x", "y"} assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦 -def test_put_one_point_and_empty(api) -> None: - data = api.put(URL, json={"start": A}).json() - assert data["start"] and data["end"] is None and data["epsg"] == 5186 +def test_put_partial_and_empty(api) -> None: + data = api.put(URL, json={"starts": [A]}).json() + assert len(data["starts"]) == 1 and data["ends"] == [] and data["epsg"] == 5186 data = api.put(URL, json={}).json() - assert data == {"status": "success", "epsg": None, "start": None, "end": None} + assert data == {"status": "success", "epsg": None, "starts": [], "ends": []} + assert api.put(URL, json={"starts": [A] * 21}).status_code == 422 # 상한 20 + + +def test_put_keeps_stored_epsg(api) -> None: + api.put(URL, json={"starts": [A], "ends": [C]}) + far = {"lon": 128.9, "lat": 37.5} # 평균이 128° 를 넘어도 저장된 5186 그대로 + data = api.put(URL, json={"starts": [A], "ends": [far, far]}).json() + assert data["epsg"] == 5186 + + +def test_get_wraps_old_single_points(api) -> None: + a, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, C)) + old = {"epsg": 5186, "start": {"x": a[0], "y": a[1]}, "end": {"x": c[0], "y": c[1]}} + api.state["project"]["plan_base_json"] = json.dumps(old) + data = api.get(URL).json() + assert [p["x"] for p in data["starts"]] == pytest.approx([a[0]]) + assert [p["x"] for p in data["ends"]] == pytest.approx([c[0]]) + old["end"] = None + api.state["project"]["plan_base_json"] = json.dumps(old) + assert api.get(URL).json()["ends"] == [] def test_put_forest_only(api) -> None: api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1} - assert api.put(URL, json={"start": A, "end": B}).status_code == 403 + assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 403 api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None} - assert api.put(URL, json={"start": A, "end": B}).status_code == 200 + assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 200 def test_get_folds_old_year_rows(api) -> None: @@ -119,19 +140,8 @@ def test_get_folds_old_year_rows(api) -> None: } data = api.get(URL).json() assert data["epsg"] == epsg - assert (data["start"]["x"], data["end"]["x"]) == pytest.approx((a[0], c[0])) + assert (data["starts"][0]["x"], data["ends"][0]["x"]) == pytest.approx((a[0], c[0])) def test_get_empty_new_project(api) -> None: - assert api.get(URL).json() == {"status": "success", "epsg": None, "start": None, "end": None} - - -def test_chain_years_for_d03() -> None: - items = [ - router_mod.YearPoints(year=2026, start=A, end=B), - router_mod.YearPoints(year=2027, start=A, end=C), - ] - rows = router_mod.chain_years(2026, 2027, items) - assert rows[1]["start"].lon == B["lon"] # 시점 = 직전 종점 - with pytest.raises(HTTPException): - router_mod.chain_years(2026, 2027, items[:1]) + assert api.get(URL).json() == {"status": "success", "epsg": None, "starts": [], "ends": []} diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index d42ac5832..4fc5ff5fa 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -184,7 +184,7 @@ class _Db: def client(monkeypatch): db = _Db() monkeypatch.setattr(net, "get_db_pool", lambda: db) - base = {"epsg": 5186, "start": {"x": 0.0, "y": 0.0}, "end": {"x": 90.0, "y": 180.0}} + base = {"epsg": 5186, "starts": [{"x": 0.0, "y": 0.0}], "ends": [{"x": 90.0, "y": 180.0}]} async def load_base(project_id): return base @@ -214,7 +214,7 @@ def test_get_saved_legacy_or_empty(client) -> None: client.db.answers["FROM plan_networks"] = [{"network_json": json.dumps(_network())}] got = client.get(URL).json() assert got["legacy"] is False and len(got["derived"]["pieces"]) == 3 - assert got["base"]["end"] == {"x": 90.0, "y": 180.0} and got["year_start"] == 2026 + assert got["base"]["ends"] == [{"x": 90.0, "y": 180.0}] and got["year_start"] == 2026 def test_put_stores_network_pieces_years(client) -> None: diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 08db5bb3b..bc8953865 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -31,8 +31,8 @@ class _Pool: def client(tmp_path, monkeypatch): plan = { "epsg": 5186, - "start": {"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]}, - "end": {"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]}, + "starts": [{"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]}], + "ends": [{"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]}], } async def load_base(project_id): @@ -61,7 +61,7 @@ def test_extent_uses_whole_ends(client) -> None: extra = client.get(f"{URL}/extent", params={"extra": json.dumps([[2000, 0]])}).json() assert extra["box_m"][2] == 2000 + extra["margin_m"] assert client.get(f"{URL}/extent", params={"extra": "[1]"}).status_code == 400 - client.plan["end"] = None # 종점 빔 — 1단계로 + client.plan["ends"] = [] # 종점 빔 — 1단계로 assert client.get(f"{URL}/extent").status_code == 400