From bbe2079cee4f7b262797062c7e7325fcd67ae5ba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:10:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(562-2):=20D03=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=EC=97=90=20=EC=A0=84=EC=B2=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20=EC=A2=85=EC=A0=90(base)=20=E2=80=94=20D02?= =?UTF-8?q?=20load=5Fbase=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=C2=B7=20=EC=A2=8C=ED=91=9C=EA=B3=84=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B4=EB=A9=B4=20=EA=B1=B0=EC=A0=88=20=C2=B7=20=EC=98=9B=20?= =?UTF-8?q?=EC=A0=91=EC=9D=8C=EC=9D=80=20[=EC=A0=80=EC=9E=A5]=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=EC=B9=B8=EC=97=90=20=EA=B5=B3=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET · PUT /plan/network 응답에 base({epsg, start, end}) - PUT network.epsg ≠ base.epsg = 400 - plan_base_json 이 비어 옛 연도 행을 접어 보이던 프로젝트 = 연도 행을 바꾸기 전에 접은 두 점을 칸에 씀(D02 시점 · 종점이 안 바뀜) - 시험 test_530_2_plan_analyze 18 · 실제 호출 한 바퀴(검증_sub3_D) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Network.py | 34 ++++++++++++++++---- resources/tester/test_530_2_plan_analyze.py | 21 +++++++++++- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py index 46307f804..c7faafe1f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py @@ -10,6 +10,8 @@ 길이 · 측점 · 높이는 저장하지 않음(늘 다시 셈 · 높이는 지형에서). 측점 = 선마다 No.0 부터. 조각 = 선의 마크 사이 구간(구역 × 갈래) · 업체 지정은 조각 단위(`plan_route_assignees.piece_id`). +전체 시점 · 종점(`base`) = D02 정본(`plan_base.load_base`) — 비어 옛 연도 행을 접어 보이던 것은 +[저장] 전에 칸에 굳힘(연도 행이 바뀌어도 그대로). 계산 자리 = ① 파이썬·TS 짝(`D03_PlanAnalysis_Draw`) — 화면은 그리는 즉시 · [저장]은 여기서 다시 셈. """ @@ -27,6 +29,7 @@ from pydantic import BaseModel, Field from common_util.common_util_auth import is_forest_service, require_company from config.config_db import get_db_pool +from D02_PlanBase import D02_PlanBase_Router as plan_base from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw router = APIRouter(prefix="/api/projects", tags=["D03 Plan Network"]) @@ -266,9 +269,10 @@ async def _load(connection: Any, project_id: UUID) -> tuple[dict[str, Any] | Non return legacy, legacy is not None -def _view(network: dict[str, Any] | None, legacy: bool) -> dict[str, Any]: +def _view(network: dict[str, Any] | None, legacy: bool, base: dict[str, Any]) -> dict[str, Any]: return { "status": "success", + "base": base, "network": network, "derived": derive(network) if network else None, "legacy": legacy, @@ -277,10 +281,11 @@ def _view(network: dict[str, Any] | None, legacy: bool) -> dict[str, Any]: @router.get("/{project_id}/plan/network", response_model=None) async def get_plan_network(project_id: UUID) -> dict[str, Any]: + base = await plan_base.load_base(project_id) async with get_db_pool().acquire() as connection: network, legacy = await _load(connection, project_id) try: - return _view(network, legacy) + return _view(network, legacy, base) except NetworkError as error: raise HTTPException(status_code=409, detail=f"저장된 노선이 틀립니다 — {error}") from error @@ -374,6 +379,19 @@ async def _store( ) +async def _freeze_base(connection: Any, project_id: UUID, base: dict[str, Any]) -> None: + """옛 연도 행을 접은 시점 · 종점 → D02 정본 칸(비었을 때만).""" + stored = { + "epsg": base["epsg"], + **{key: {"x": base[key]["x"], "y": base[key]["y"]} for key in ("start", "end")}, + } + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE projects SET plan_base_json = %s WHERE id = %s AND plan_base_json IS NULL", + (json.dumps(stored), str(project_id)), + ) + + @router.put("/{project_id}/plan/network", response_model=None) async def put_plan_network( project_id: UUID, @@ -384,23 +402,27 @@ async def put_plan_network( if not is_forest_service(session): raise HTTPException(status_code=403, detail="예정노선은 산림청만 고칠 수 있습니다.") network = payload.model_dump() + base = await plan_base.load_base(project_id) # 예정노선 프로젝트가 아니면 404 · 400 + if base["epsg"] is not None and int(base["epsg"]) != network["epsg"]: + raise HTTPException(status_code=400, detail="좌표계가 기준 지정과 다릅니다.") async with get_db_pool().acquire() as connection: async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - "SELECT project_year FROM projects WHERE id = %s", (str(project_id),) + "SELECT project_year, plan_base_json FROM projects WHERE id = %s", + (str(project_id),), ) project = await cursor.fetchone() - if not project: - raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") try: derived = derive(network, project["project_year"]) except NetworkError as error: 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"]: + await _freeze_base(connection, project_id, base) await _store(connection, project_id, network, derived) await connection.commit() except Exception: await connection.rollback() raise - return {"status": "success", "network": network, "derived": derived, "legacy": False} + return {**_view(network, False, base), "derived": derived} diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index 84a38395f..f5ef73635 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -174,7 +174,7 @@ class _Connection: class _Db: def __init__(self): self.log: list = [] - self.answers: dict = {"FROM projects": [{"project_year": 2026}]} + self.answers: dict = {"FROM projects": [{"project_year": 2026, "plan_base_json": "{}"}]} def acquire(self): return _Connection(self) @@ -184,6 +184,12 @@ 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}} + + async def load_base(project_id): + return base + + monkeypatch.setattr(net.plan_base, "load_base", load_base) app = FastAPI() app.include_router(net.router) session = {"role": "USER", "email": "a@forest.go.kr"} @@ -191,6 +197,7 @@ def client(monkeypatch): test = TestClient(app) test.db = db test.session = session + test.base = base return test @@ -207,6 +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} def test_put_stores_network_pieces_years(client) -> None: @@ -226,6 +234,16 @@ def test_put_stores_network_pieces_years(client) -> None: assert ("UPDATE projects SET project_year_end = %s WHERE id = %s", (2027, PID)) in log stale = [a for s, a in log if "year NOT IN" in s] assert stale == [(PID, 2026, 2027)] + assert not [s for s, _ in log if "plan_base_json = %s" in s] # 정본 칸 있음 = 안 굳힘 + + +def test_put_freezes_folded_base_first(client) -> None: + client.db.answers["FROM projects"] = [{"project_year": 2026, "plan_base_json": None}] + assert client.put(URL, json=_network()).status_code == 200 + sqls = [(s, a) for s, a in client.db.log if s.startswith("UPDATE projects SET plan_base_json")] + assert len(sqls) == 1 and json.loads(sqls[0][1][0]) == client.base + first_year = next(i for i, (s, _) in enumerate(client.db.log) if "plan_route_years" in s) + assert client.db.log.index(sqls[0]) < first_year def test_put_reset_clears_year_routes(client) -> None: @@ -254,5 +272,6 @@ def test_put_rejects(client) -> None: client.db.answers["piece_id IS NULL"] = [(2027,)] assert client.put(URL, json=one).status_code == 409 assert not [s for s, _ in client.db.log if s.startswith("INSERT")] + assert client.put(URL, json=_network(epsg=5187)).status_code == 400 # 기준 지정과 다른 좌표계 client.session["email"] = "a@gmail.com" assert client.put(URL, json=_network()).status_code == 403