diff --git a/D02_PlanBase/D02_PlanBase_Router.py b/D02_PlanBase/D02_PlanBase_Router.py index b61e39661..79d888bf0 100644 --- a/D02_PlanBase/D02_PlanBase_Router.py +++ b/D02_PlanBase/D02_PlanBase_Router.py @@ -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)} diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index d4e9c89fb..d7320eeca 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -1,7 +1,6 @@ /* ============================================================================= * D02_PlanBase_UI_Page.ts - * D02 1단계 기준 지정(요구서 345 3-2 · PLAN 529-3) — 메인 = 카카오맵 · 좌 = 연도 지점 표. - * 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로. + * D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점. * 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) * · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장). * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03. @@ -34,13 +33,11 @@ interface ServerPoint extends LonLat { y: number; } interface PointsView { - year_start: number; - year_end: number; epsg: number | null; - years: Array<{ year: number; start: ServerPoint | null; end: ServerPoint | null }>; + start: ServerPoint | null; + end: ServerPoint | null; } -const YEAR_COLORS = ["#E60046", "#1F6FEB", "#1A9E55", "#D97706", "#8B5CF6", "#0891B2"]; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm @@ -58,10 +55,10 @@ async function request(path: string, init?: RequestInit): Promise { return data as T; } -/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */ +/** 서버 두 점 → [시점, 종점] */ function boundaryPoints(view: PointsView): Array { const pick = (p: ServerPoint | null): LonLat | null => (p ? { lon: p.lon, lat: p.lat } : null); - return [pick(view.years[0]?.start ?? null), ...view.years.map((y) => pick(y.end))]; + return [pick(view.start), pick(view.end)]; } export async function renderD02PlanBase(root: HTMLElement): Promise { @@ -88,13 +85,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { let active: number | null = null; // 지도 누름이 놓을 지점 let editable = false; // 산림청만 고침 - const years = (): number[] => (view ? view.years.map((y) => y.year) : []); - const pointName = (i: number): string => { - const ys = years(); - if (i === 0) return `${ys[0]} ${t("D02_Start")}`; - if (i === ys.length) return `${ys[i - 1]} ${t("D02_End")}`; - return `${ys[i - 1]} ${t("D02_End")} · ${ys[i]} ${t("D02_Start")}`; - }; + const pointName = (i: number): string => t(i === 0 ? "D02_Start" : "D02_End"); const firstEmpty = (): number | null => { const i = points.findIndex((p) => !p); return i < 0 ? null : i; @@ -126,19 +117,17 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { const pins: MapPin[] = points.flatMap((p, i) => p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [], ); - const lines: Array> = []; - const colors: string[] = []; - years().forEach((_, i) => { - const a = points[i]; - const b = points[i + 1]; - if (!a || !b) return; - lines.push([ - [a.lat, a.lon], - [b.lat, b.lon], - ]); - colors.push(YEAR_COLORS[i % YEAR_COLORS.length]); - }); - map.set(pins, lines, colors, focus); + const [a, b] = points; + const lines: Array> = + a && b + ? [ + [ + [a.lat, a.lon], + [b.lat, b.lon], + ], + ] + : []; + map.set(pins, lines, undefined, focus); } function drawTable(): void { @@ -186,17 +175,13 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { lat, ], }); - const color = YEAR_COLORS[Math.min(i, years().length - 1) % YEAR_COLORS.length]; - row.style.setProperty("--d02-year-color", color); return row; }); table.replaceChildren(...rows); - const saved = view?.years.find((y) => y.start); + const saved = view?.start; info.textContent = view?.epsg ? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` + - (saved?.start - ? ` · ${saved.year} X ${saved.start.x.toFixed(3)} Y ${saved.start.y.toFixed(3)}` - : "") + (saved ? ` · X ${saved.x.toFixed(3)} Y ${saved.y.toFixed(3)}` : "") : t("D02_Not_Saved"); } @@ -214,9 +199,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { if (!view) return false; showLoadingOverlay(); try { - const body = { - years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })), - }; + const body = { start: points[0], end: points[1] }; adopt( await request(`/projects/${projectId}/plan/points`, { method: "PUT", diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css index 39cf82ad8..fe70a3929 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Style.css +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -1,4 +1,4 @@ -/* D02 기준 지정(529-3) — 메인 지도 = 본문 높이 가득 · 좌 연도 지점 표 */ +/* D02 기준 지정(529-3 · 560-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 · 종점 표 */ .d02-map { height: calc(100dvh - var(--app-header-h)); } @@ -20,7 +20,7 @@ gap: 4px; align-items: center; padding: 4px 6px; - border-left: 4px solid var(--d02-year-color); + border-left: 4px solid var(--draw-primary); border-radius: 4px; } diff --git a/db_management/021_plan_base_json.sql b/db_management/021_plan_base_json.sql new file mode 100644 index 000000000..af164e2e8 --- /dev/null +++ b/db_management/021_plan_base_json.sql @@ -0,0 +1,7 @@ +-- 021_plan_base_json.sql +-- 예정노선 전체 시점 · 종점 두 점 (요구서 389 3장 1 · PLAN 560-2) +-- {"epsg": int, "start": {"x", "y"}, "end": {"x", "y"}} · TM m · 비면 옛 연도 행을 접어 보임(D02 서버) + +USE aislo_db; + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS plan_base_json JSON NULL COMMENT '예정노선 전체 시점 · 종점(D02)'; diff --git a/resources/tester/test_529_3_plan_points.py b/resources/tester/test_529_3_plan_points.py index 7c65cf634..892df2b10 100644 --- a/resources/tester/test_529_3_plan_points.py +++ b/resources/tester/test_529_3_plan_points.py @@ -1,10 +1,13 @@ -"""D02 기준 지정(PLAN 529-3) — 경위도 ↔ TM 변환 · 지점 정본 API(잇기 · 빈 연도 · 권한). +"""D02 기준 지정(PLAN 529-3 · 560-2) — 경위도 ↔ TM 변환 · 전체 두 점 정본 API · 옛 연도 행 접기 +· 연도 잇기(D03 [저장]이 씀). ./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q """ +import json + import pytest -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from common_util.common_util_auth import require_company @@ -59,21 +62,16 @@ def api(monkeypatch): "project": {"project_kind": "PLAN", "project_year": 2026, "project_year_end": 2027}, "rows": {}, "session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1}, - "stores": [], } async def load(project_id): return state["project"], {y: dict(r) for y, r in state["rows"].items()} - async def store(project_id, epsg, writes, drops): - state["stores"].append((epsg, writes, drops)) - for row in writes: - state["rows"][row["year"]] = {**row, "epsg": epsg} - for year in drops: - state["rows"].pop(year) + async def store_base(project_id, base): + state["project"] = {**state["project"], "plan_base_json": json.dumps(base)} monkeypatch.setattr(router_mod, "_load", load) - monkeypatch.setattr(router_mod, "_store", store) + monkeypatch.setattr(router_mod, "_store_base", store_base) app = FastAPI() app.include_router(router_mod.router) app.dependency_overrides[require_company] = lambda: state["session"] @@ -82,68 +80,58 @@ def api(monkeypatch): return client -def test_put_chains_years(api) -> None: - # 2027 시점을 엉뚱하게 보내도 서버가 2026 종점으로 맞춤 - body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "start": A, "end": C}]} - got = api.put(URL, json=body) +def test_put_two_points(api) -> None: + got = api.put(URL, json={"start": A, "end": C}) assert got.status_code == 200 data = got.json() - assert data["epsg"] == 5186 and data["changed_years"] == [2026, 2027] - y26, y27 = data["years"] - assert y27["start"]["x"] == y26["end"]["x"] and y27["start"]["y"] == y26["end"]["y"] - assert abs(y27["start"]["lon"] - B["lon"]) < 1e-8 and abs(y27["start"]["lat"] - B["lat"]) < 1e-8 - assert (y26["start"]["x"], y26["start"]["y"]) == pytest.approx( + assert data["epsg"] == 5186 + assert (data["start"]["x"], data["start"]["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 + stored = json.loads(api.state["project"]["plan_base_json"]) + assert set(stored) == {"epsg", "start", "end"} and set(stored["start"]) == {"x", "y"} + assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦 -def test_put_rejects_empty_and_out_of_range(api) -> None: - only_first = {"years": [{"year": 2026, "start": A, "end": B}]} - got = api.put(URL, json=only_first) - assert got.status_code == 400 and "2027년 종점" in got.json()["detail"] - no_start = {"years": [{"year": 2026, "end": B}, {"year": 2027, "end": C}]} - assert "2026년 시점" in api.put(URL, json=no_start).json()["detail"] - outside = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2028, "end": C}]} - assert api.put(URL, json=outside).status_code == 400 - assert api.state["stores"] == [] +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 + data = api.put(URL, json={}).json() + assert data == {"status": "success", "epsg": None, "start": None, "end": None} def test_put_forest_only(api) -> None: - body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]} api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1} - assert api.put(URL, json=body).status_code == 403 + assert api.put(URL, json={"start": A, "end": B}).status_code == 403 api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None} - assert api.put(URL, json=body).status_code == 200 + assert api.put(URL, json={"start": A, "end": B}).status_code == 200 -def test_put_clears_only_changed_years(api) -> None: - body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]} - api.put(URL, json=body) - # 같은 지점 다시 저장 = 쓰기 없음(분석 노선 그대로) · 2027 종점만 옮기면 2027 만 - assert api.put(URL, json=body).json()["changed_years"] == [] - moved = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": A}]} - assert api.put(URL, json=moved).json()["changed_years"] == [2027] - assert len(api.state["stores"]) == 2 - - -def test_get_lists_every_year_and_drops_outside(api) -> None: - api.state["rows"][2030] = { - "year": 2030, - "start_x": 1, - "start_y": 1, - "end_x": 2, - "end_y": 2, - "epsg": 5186, - } +def test_get_folds_old_year_rows(api) -> None: + epsg = 5186 + a, b, c = (lonlat_to_tm(p["lon"], p["lat"], epsg) for p in (A, B, C)) + for year, s, e in ((2026, a, b), (2027, b, c)): + api.state["rows"][year] = { + "year": year, + **{"start_x": s[0], "start_y": s[1], "end_x": e[0], "end_y": e[1]}, + "epsg": epsg, + } data = api.get(URL).json() - assert [y["year"] for y in data["years"]] == [2026, 2027] - assert data["years"][0]["start"] is None - body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]} - api.put(URL, json=body) - assert api.state["stores"][-1][2] == [2030] and 2030 not in api.state["rows"] + assert data["epsg"] == epsg + assert (data["start"]["x"], data["end"]["x"]) == pytest.approx((a[0], c[0])) -def test_not_plan_project(api) -> None: - api.state["project"] = {"project_kind": "ROAD", "project_year": 2026, "project_year_end": None} - # _load 대역이 그대로 돌려주므로 진짜 _load 의 거절은 서버 시험(ORCA)에서 - assert api.get(URL).status_code == 200 +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]) diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 7e510a954..7442a766c 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -118,10 +118,10 @@ export const ui_locales_de = { DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."], /* --- D02 기준 지정(529-3) --- */ - D02_Points_Title: ["연도 지점", "Yearly Points"], + D02_Points_Title: ["전체 시점 · 종점", "Overall Start · End"], D02_Points_Hint: [ - "📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 직전 종점 = 다음 시점.", - "Pick a point with 📍 and click the map, drag markers, or type lon · lat. Each end is the next start.", + "📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 연도 나누기는 노선분석에서.", + "Pick a point with 📍 and click the map, drag markers, or type lon · lat. Split by year in analysis.", ], D02_Start: ["시점", "start"], D02_End: ["종점", "end"],