diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 398513963..1e7cb00e4 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -64,8 +64,6 @@ export interface PlanNetworkView { lines: Array<{ id: string; length_m: number; stations: number[] }>; pieces: Array; } | null; - /** 옛 연도 노선을 선으로 바꾼 것인가. */ - legacy: boolean; /** 사업연도 시작(있으면). */ year_start?: number | null; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py index 734991d93..da9e9e593 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py @@ -2,7 +2,7 @@ (요구 389 6 · 11 · 13 · 14 · 391 · 452 · PLAN 562-2 · 590-1). - `GET /api/projects/{id}/plan/network` — 설계 내용 파일(`D03_PlanAnalysis_Store`) + 서버가 다시 센 - 길이 · 측점 · 조각 · 파일이 없으면 옛 DB 칸을 옮겨 보임(쓰지 않음) + 길이 · 측점 · 조각 · 파일이 없으면 빈 설계 - `PUT …/plan/network` — [저장] · [초기화](빈 선) — 선 · 마크 · 조각 연도만 받아 서버가 정본으로 다시 셈 → 파일(원자 쓰기) · 연도 상태 줄(`plan_route_years` 지문 · 확정) · 사업연도 끝 @@ -213,51 +213,6 @@ def _sha1(value: Any) -> str: return hashlib.sha1(json.dumps(value, sort_keys=True).encode()).hexdigest() # noqa: S324 -def _json(value: Any) -> Any: - if isinstance(value, (str, bytes)): - try: - return json.loads(value) - except ValueError: - return None - return value - - -def legacy_network(rows: list[dict[str, Any]]) -> dict[str, Any] | None: - """옛 연도 노선(연도 차례 · 직전 종점 = 다음 시점) → 본선 하나 + 연도 경계 마크 + 연도 조각.""" - vertices: list[list[float]] = [] - pieces: list[dict[str, Any]] = [] - epsg = None - for row in rows: - route = _json(row.get("route_json")) - planned = route.get("planned") if isinstance(route, dict) else None - if not planned or len(planned) < 2: - continue - epsg = epsg or row.get("epsg") - points = [[float(p[0]), float(p[1])] for p in planned] - if vertices and draw.segment_length(vertices[-1], points[0]) <= SAME_M: - points = points[1:] - start = draw.line_length(vertices) - vertices.extend(points) - end = draw.line_length(vertices) - pieces.append( - { - "id": f"y{row['year']}", - "line": "main", - "from_m": start, - "to_m": end, - "year": row["year"], - } - ) - if not pieces: - return None - return { - "epsg": int(epsg) if epsg is not None else None, - "lines": [{"id": "main", "vertices": vertices, "branch": None}], - "marks": [{"line": "main", "at_m": p["to_m"]} for p in pieces[:-1]], - "pieces": pieces, - } - - def work_epsg(plan: dict[str, Any], site: dict[str, Any] | None) -> int | None: """작업 좌표계 — 파일 머리 · 없으면 기준점 경도의 TM 띠(첫 [저장] · 지형 받기 때 굳음).""" if plan.get("epsg") is not None: @@ -285,7 +240,6 @@ def _view(plan: dict[str, Any], project: dict[str, Any]) -> dict[str, Any]: "year_start": project["year_start"], "network": network if plan["lines"] else None, "derived": derive(network) if plan["lines"] else None, - "legacy": False, } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Store.py b/D03_PlanAnalysis/D03_PlanAnalysis_Store.py index 6727c4bae..d8217a2ae 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Store.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Store.py @@ -6,8 +6,7 @@ 연도 지문은 읽을 때 `plan_state` 가 셈(저장 안 함). - DB 에 남는 것 = 등록 칸 · 연도 상태(`plan_route_years` 의 year · fingerprint · confirmed_at) · 업체 줄(`plan_route_assignees` 조각 번호 · 업체 · 메일 시각 · 연결 — 연도는 이 파일에서). -- 옮김 = 파일이 없으면 옛 DB 칸(`projects.plan_base_json` · `plan_networks` · `plan_route_years`)을 - 메모리에서 새 꼴로(`migrate`) · 파일은 첫 쓰기 때 생김 · 옛 칸은 손대지 않음(590-3 에서 지움). +- 파일이 없으면 빈 설계(새 프로젝트) · 파일은 첫 쓰기 때 생김(옛 DB 칸 옮김 = 590-3 에서 끝남). - 쓰기 = `project_lock(project_id)` 안에서 `save_plan`(같은 폴더 임시 파일 → 바꿔치기 · 반쯤 쓴 파일이 안 보임) · 읽기는 잠금 없음. - 확정 = 연도 상태 줄 지문이 파일 지문과 같을 때만(파일만 바뀌고 DB 가 못 따라간 틈 = @@ -24,7 +23,6 @@ from uuid import UUID import aiomysql from fastapi import HTTPException -from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -66,74 +64,9 @@ def save_plan(root: str | Path, plan: dict[str, Any]) -> None: atomic_write_json(plan_path(root), _clean(plan)) -def _parsed(value: Any) -> Any: - if isinstance(value, (str, bytes)): - try: - return json.loads(value) - except ValueError: - return None - return value - - -def _old_points(base: dict[str, Any] | None, rows: list[dict[str, Any]]) -> tuple[Any, list]: - """옛 지점 칸 → (epsg, [{x, y}]) — points · 옛 starts/ends 목록 · 더 옛 start/end 한 점 · - 칸이 비면 [첫 연도 시점, 마지막 연도 종점]으로 접음.""" - if base: - items = base.get("points") - if items is None: - starts = base.get("starts", [base["start"]] if base.get("start") else []) - ends = base.get("ends", [base["end"]] if base.get("end") else []) - items = [*starts, *ends] - epsg = base.get("epsg") - else: - dated = [r for r in rows if r.get("start_x") is not None or r.get("end_x") is not None] - if not dated: - return None, [] - first, last = dated[0], dated[-1] - epsg = first.get("epsg") - items = [ - {"x": first.get("start_x"), "y": first.get("start_y")}, - {"x": last.get("end_x"), "y": last.get("end_y")}, - ] - points = [ - {"x": float(p["x"]), "y": float(p["y"])} - for p in items - if p and p.get("x") is not None and p.get("y") is not None - ] - return (epsg if points else None), points - - -def migrate(base_json: Any, network_json: Any, year_rows: list[dict[str, Any]]) -> dict[str, Any]: - """옛 DB 칸 → 새 꼴(메모리) — 노선 = `plan_networks` · 없으면 옛 연도 노선을 한 선으로.""" - rows = sorted(year_rows, key=lambda r: int(r["year"])) - epsg, points = _old_points(_parsed(base_json), rows) - net = _parsed(network_json) or network.legacy_network(rows) - plan = empty_plan(int(epsg) if epsg is not None else None) - plan["points"] = points - if net and net.get("lines"): - net_epsg = int(net["epsg"]) if net.get("epsg") is not None else plan["epsg"] - if points and plan["epsg"] is not None and net_epsg != plan["epsg"]: - plan["points"] = [ - dict( - zip( - ("x", "y"), - lonlat_to_tm(*tm_to_lonlat(p["x"], p["y"], plan["epsg"]), net_epsg), - ) - ) - for p in points - ] - plan.update( - epsg=net_epsg, - lines=net.get("lines", []), - marks=net.get("marks", []), - pieces=net.get("pieces", []), - ) - return plan - - async def _project(cursor: Any, project_id: UUID | str) -> dict[str, Any]: await cursor.execute( - "SELECT project_kind, project_year, storage_path, plan_base_json FROM projects" + "SELECT project_kind, project_year, storage_path FROM projects" " WHERE id = %s AND deleted_at IS NULL", (str(project_id),), ) @@ -148,26 +81,14 @@ async def _project(cursor: Any, project_id: UUID | str) -> dict[str, Any]: async def load_plan(project_id: UUID | str) -> tuple[Path, dict[str, Any]]: - """(프로젝트 실경로, 설계 내용) — 파일 · 없으면 옛 DB 칸을 옮긴 것(쓰지 않음).""" - pid = str(project_id) + """(프로젝트 실경로, 설계 내용) — 파일 · 없으면 빈 설계(쓰지 않음).""" async with ( get_db_pool().acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor, ): - project = await _project(cursor, pid) - root = Path(resolve_stored_project_path(project["storage_path"])) - plan = read_plan(root) - if plan is not None: - return root, plan - await cursor.execute("SELECT network_json FROM plan_networks WHERE project_id = %s", (pid,)) - row = await cursor.fetchone() - await cursor.execute( - "SELECT year, start_x, start_y, end_x, end_y, epsg, route_json FROM plan_route_years" - " WHERE project_id = %s ORDER BY year", - (pid,), - ) - years = list(await cursor.fetchall()) - return root, migrate(project["plan_base_json"], row and row["network_json"], years) + project = await _project(cursor, project_id) + root = Path(resolve_stored_project_path(project["storage_path"])) + return root, read_plan(root) or empty_plan() def plan_units(plan: dict[str, Any]) -> list[dict[str, Any]]: diff --git a/D04_PlanConfirm/D04_PlanConfirm_Link.py b/D04_PlanConfirm/D04_PlanConfirm_Link.py index fc9f4f3f8..105ce2358 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Link.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Link.py @@ -68,14 +68,14 @@ class LinkRequest(BaseModel): las_free: bool = False # B03 「LAS 없이 설계」(화면 기억값) — 입력 완료 판정에만 -def _parsed(route_json: Any) -> dict[str, Any] | None: - value = json.loads(route_json) if isinstance(route_json, str | bytes) else route_json +def _parsed(route: Any) -> dict[str, Any] | None: + value = json.loads(route) if isinstance(route, str | bytes) else route return value if isinstance(value, dict) else None -def route_fingerprint(route_json: Any) -> str | None: +def route_fingerprint(route: Any) -> str | None: """연도 노선 내용 지문(sha1) — 고른 중심선 점(mm 반올림)만 봄 · 같은 값이면 같은 지문.""" - body = _parsed(route_json) + body = _parsed(route) planned = (body or {}).get("planned") if not planned: return None @@ -83,14 +83,12 @@ def route_fingerprint(route_json: Any) -> str | None: return hashlib.sha1(json.dumps(points).encode()).hexdigest() -def update_available( - route_json: Any, source_fingerprint: str | None, confirmed: bool = True -) -> bool: +def update_available(route: Any, source_fingerprint: str | None, confirmed: bool = True) -> bool: """연결 때 지문 ≠ 지금 지문 — 지금 노선이 비면(지점 바뀜 · 분석 전) 알리지 않음. 확정이 풀린 연도(D02 지점 바뀜 뒤 다시 [저장]만)도 알리지 않음 — 가져오기가 409 라서. """ - now = route_fingerprint(route_json) + now = route_fingerprint(route) return confirmed and bool(source_fingerprint) and now is not None and now != source_fingerprint @@ -104,7 +102,7 @@ async def _fetch_one(sql: str, args: tuple) -> dict[str, Any] | None: async def piece_route(project_id: str, piece_id: str | None) -> dict[str, Any]: - """그 조각(파일) — {year, epsg, route_json: {planned, 누가 · 길이}, confirmed_at} · + """그 조각(파일) — {year, epsg, route: {planned, 누가 · 길이}, confirmed_at} · 조각이 없으면 노선 · 연도 · 확정 None.""" state = await store.plan_state(project_id) unit = next((u for u in state["units"] if u["id"] == piece_id), None) @@ -112,13 +110,13 @@ async def piece_route(project_id: str, piece_id: str | None) -> dict[str, Any]: return { "year": None, "epsg": state["plan"]["epsg"], - "route_json": None, + "route": None, "confirmed_at": None, } return { "year": unit["year"], "epsg": unit["epsg"], - "route_json": { + "route": { "planned": unit["planned"], "start_chainage_m": unit["from_m"], "end_chainage_m": unit["to_m"], @@ -175,7 +173,7 @@ async def record_link(project_id: str, assignee: dict[str, Any], fingerprint: st def year_files(assignee: dict[str, Any]) -> dict[str, bytes]: """그 조각 shp 한 벌(.shp .shx .dbf .prj .cpg) — 파일명 → 바이트.""" - body = _parsed(assignee["route_json"]) or {} + body = _parsed(assignee["route"]) or {} route = YearRoute( year=int(assignee["year"]), points=[(p[0], p[1]) for p in body["planned"]], @@ -278,7 +276,7 @@ async def import_route_files( async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str: """예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수).""" - body = _parsed(assignee["route_json"]) or {} + body = _parsed(assignee["route"]) or {} # 「2026 홍천 노선 계획」 꼴 = 앞 연도를 그 연도로 갈아 끼움(연도 두 번 안 붙음) · 그 밖 = 뒤에 연도 name, dated = re.subn(r"^\d{4}(?:~\d{4})?(?=\s)", str(assignee["year"]), assignee["name"]) created = await create_project( @@ -304,7 +302,7 @@ async def link_plan_route( raise HTTPException(status_code=404, detail="지정 업체 행을 찾을 수 없습니다.") if assignee["company_id"] is None or assignee["company_id"] != session.get("company_id"): raise HTTPException(status_code=403, detail="이 예정노선을 지정받은 회사가 아닙니다.") - if not assignee["confirmed_at"] or route_fingerprint(assignee["route_json"]) is None: + if not assignee["confirmed_at"] or route_fingerprint(assignee["route"]) is None: raise HTTPException(status_code=409, detail="확정된 노선이 없습니다.") if payload.mode == "existing": if payload.project_id is None: @@ -329,7 +327,7 @@ async def link_plan_route( return _confirm_replace_response(exc) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - fingerprint = route_fingerprint(assignee["route_json"]) + fingerprint = route_fingerprint(assignee["route"]) await record_link(project_id, assignee, fingerprint) return { "status": "success", @@ -364,6 +362,6 @@ async def plan_source(project_id: UUID) -> dict[str, Any]: "year": row["year"], "piece_id": row.get("piece_id"), "update_available": update_available( - row["route_json"], row["source_fingerprint"], row["confirmed_at"] is not None + row["route"], row["source_fingerprint"], row["confirmed_at"] is not None ), } diff --git a/D04_PlanConfirm/D04_PlanConfirm_Router.py b/D04_PlanConfirm/D04_PlanConfirm_Router.py index a735914e0..8601636a9 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Router.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Router.py @@ -186,14 +186,11 @@ def visible_to( async def _units(project_id: UUID) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - """(연도 줄, 단위) — 파일에서 · 연도 줄 = {year, epsg, route_json, fingerprint, + """(연도 줄, 단위) — 파일에서 · 연도 줄 = {year, epsg, route, fingerprint, confirmed_at}.""" state = await store.plan_state(project_id) epsg = state["plan"]["epsg"] - rows = [ - {"year": year, "epsg": epsg, "route_json": row["route"], **row} - for year, row in state["years"].items() - ] + rows = [{"year": year, "epsg": epsg, **row} for year, row in state["years"].items()] return rows, route_units(state["units"]) @@ -334,7 +331,7 @@ def _end_point(point: list[float], epsg: int) -> dict[str, float]: def year_summary(row: dict[str, Any]) -> dict[str, Any]: """연도 한 줄 — 분석 [저장]이 없으면 saved False 만.""" - body = row.get("route_json") or {} + body = row.get("route") or {} planned = body.get("planned") or [] out = {"year": int(row["year"]), "saved": len(planned) >= 2, "confirmed_at": None} if row.get("confirmed_at"): diff --git a/common_util/common_util_project_copy.py b/common_util/common_util_project_copy.py index a90949e35..56cb9dda7 100644 --- a/common_util/common_util_project_copy.py +++ b/common_util/common_util_project_copy.py @@ -4,9 +4,9 @@ full DB 행(projects · project_members · input_files · processed_point_cloud · surface_models · terrain_layers · routes · route_points · route_statistics · - longitudinal_sections · cross_sections · project_workflow_stages · D 의 plan_networks · - plan_route_pieces · plan_route_years · plan_route_assignees)을 새 id 로 다시 이어 - 한 트랜잭션에 넣고 저장소 폴더 통째(robocopy) — 복제 도구(`db_management/ + longitudinal_sections · cross_sections · project_workflow_stages · D 의 + plan_route_years · plan_route_assignees)을 새 id 로 다시 이어 한 트랜잭션에 넣고 + 저장소 폴더 통째(robocopy · D 설계 내용 = 폴더 파일) — 복제 도구(`db_management/ tools_clone_project.py`)의 본체. 종류(B · D · E)는 `project_kind` 로 판별 — 같은 길로 뜨고(빈 표는 건너뜀) E 의 원본 칸(source_project_id 등)은 projects 행째 그대로. stability DB 행은 넣지 않고 E 프로젝트 저장소 `E01_StabRegister/source/` 에 동결 스냅샷 @@ -36,7 +36,7 @@ from common_util.common_util_time import utc_now_text Scope = Literal["full", "stability"] KIND_LABELS = {"ROAD": "B", "PLAN": "D", "STABILITY": "E"} #: 자동증가 id 없이 project_id 만 바꿔 넣는 D 표 -_PLAN_TABLES = ("plan_networks", "plan_route_pieces", "plan_route_years") +_PLAN_TABLES = ("plan_route_years",) _PLAN_ALL = (*_PLAN_TABLES, "plan_route_assignees") MANIFEST_NAME = "copy_manifest.json" #: E 프로젝트 안 복사본 자리 — E 엔진은 `E01_StabRegister_Source` 로만 읽음 diff --git a/db_management/024_plan_site.sql b/db_management/024_plan_site.sql index 99886553a..9dccc79e4 100644 --- a/db_management/024_plan_site.sql +++ b/db_management/024_plan_site.sql @@ -1,7 +1,7 @@ -- 024_plan_site.sql -- 예정노선 등록 기준점 하나 + 주소 · 설계속도 비움 (요구 452 · 453 · PLAN 591-1) -- --- 추가만 함(칸 셋) · 옛 PLAN 기준점 = 지점 가운데 → 이 파일 뒤 `024_plan_site_fill.py` 한 번 +-- 추가만 함(칸 셋) · 옛 PLAN 기준점 = 지점 가운데 → 이 파일 뒤 채움 도구 한 번(590-3 에서 걷음 · 모두 채움) -- 설계속도 칸은 남김(옛 코드 대시보드가 SELECT) · 예정노선 값만 비움 = B 로 넘길 때 B05 기본 20 USE aislo_db; diff --git a/db_management/024_plan_site_fill.py b/db_management/024_plan_site_fill.py deleted file mode 100644 index bf8a9b777..000000000 --- a/db_management/024_plan_site_fill.py +++ /dev/null @@ -1,74 +0,0 @@ -"""옛 예정노선 기준점 채움 — 지점 상자 가운데 · 주소 VWorld (PLAN 591-1 · SQL 024 뒤 한 번). - -기준점 칸이 빈 PLAN 만 · 지점 = `plan_base_json`(새 꼴 points · 옛 start/end · starts/ends) · -지점이 없으면 건너뜀(알림 한 줄) · 다시 돌려도 같음(채운 행은 안 건드림). - -쓰는 법 - ./venv/Scripts/python.exe db_management/024_plan_site_fill.py -""" - -from __future__ import annotations - -import asyncio -import json -import sys -from pathlib import Path -from typing import Any - -sys.path.append(str(Path(__file__).resolve().parent.parent)) - -from dotenv import load_dotenv # noqa: E402 - -load_dotenv() - -import aiomysql # noqa: E402 - -from B01_Dashboard.B01_Dashboard_Map import reverse_place # noqa: E402 -from common_util.common_util_crs import tm_to_lonlat # noqa: E402 -from config.config_db import close_db_pool, init_db_pool # noqa: E402 - - -def base_center(base: dict[str, Any] | None) -> tuple[float, float] | None: - """지점 상자 가운데(TM m) — 꼴 셋을 모두 읽음 · 지점 없음 = None.""" - base = base or {} - points = [ - *base.get("points", []), - *base.get("starts", []), - *base.get("ends", []), - *(base[k] for k in ("start", "end") if base.get(k)), - ] - xs = [float(p["x"]) for p in points if p] - ys = [float(p["y"]) for p in points if p] - if not xs: - return None - return (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2 - - -async def main() -> None: - pool = await init_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - "SELECT id, name, plan_base_json FROM projects" - " WHERE project_kind = 'PLAN' AND site_lon IS NULL" - ) - for row in await cursor.fetchall(): - raw = row["plan_base_json"] - base = json.loads(raw) if isinstance(raw, str) else raw - center = base_center(base) - if not center or not (base or {}).get("epsg"): - print(f"건너뜀(지점 없음) {row['id']} {row['name']}") - continue - lon, lat = tm_to_lonlat(*center, int(base["epsg"])) - address = (await reverse_place(lon, lat))["address"] - await cursor.execute( - "UPDATE projects SET site_lon = %s, site_lat = %s, site_address = %s" - " WHERE id = %s AND site_lon IS NULL", - (lon, lat, address, row["id"]), - ) - print(f"채움 {row['name']} {lon:.6f} {lat:.6f} {address}") - await connection.commit() - await close_db_pool() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/db_management/025_plan_old_columns.sql b/db_management/025_plan_old_columns.sql new file mode 100644 index 000000000..f70c12017 --- /dev/null +++ b/db_management/025_plan_old_columns.sql @@ -0,0 +1,43 @@ +-- 025_plan_old_columns.sql +-- 예정노선 옛 설계 칸 · 표 지움 (요구 458 · 459 · PLAN 590-3) +-- +-- 설계 내용 = 프로젝트 폴더 `D03_PlanAnalysis/plan_network.json`(023 · 590-3 에서 11 개 모두 옮김 · 대조 같음) +-- 적용 = 데스크탑 임시 배포가 590-3 코드를 받은 뒤(옛 코드가 이 칸을 읽음) · 다시 돌려도 같음 +-- 남김 = plan_route_years(year · fingerprint · confirmed_at) · plan_route_assignees(조각 번호 · 업체 · 메일 · 연결) + +USE aislo_db; + +-- 1) 단계 번호(PLAN_BASE 걷음 · 591-2) — PLAN_ANALYSIS 0 · PLAN_CONFIRM 1 · 상태 그대로 +DELETE w FROM project_workflow_stages w +JOIN projects p ON p.id = w.project_id +WHERE p.project_kind = 'PLAN' AND w.stage_key = 'PLAN_BASE'; + +UPDATE project_workflow_stages w +JOIN projects p ON p.id = w.project_id +SET w.stage_no = w.stage_no + 100 +WHERE p.project_kind = 'PLAN' AND w.stage_no < 100; + +UPDATE project_workflow_stages w +JOIN projects p ON p.id = w.project_id +SET w.stage_no = CASE w.stage_key WHEN 'PLAN_ANALYSIS' THEN 0 WHEN 'PLAN_CONFIRM' THEN 1 END +WHERE p.project_kind = 'PLAN' AND w.stage_no >= 100; + +-- 2) 옛 설계 표 · 칸 +DROP TABLE IF EXISTS plan_route_pieces; +DROP TABLE IF EXISTS plan_networks; + +ALTER TABLE projects DROP COLUMN IF EXISTS plan_base_json; + +ALTER TABLE plan_route_years + DROP COLUMN IF EXISTS start_x, + DROP COLUMN IF EXISTS start_y, + DROP COLUMN IF EXISTS end_x, + DROP COLUMN IF EXISTS end_y, + DROP COLUMN IF EXISTS epsg, + DROP COLUMN IF EXISTS route_json, + COMMENT = '예정노선 연도 상태(지문 · 확정)'; + +-- 업체 줄 연도 = 파일 조각 연도(023) · 연도 색인 = 조각 색인(project_id, piece_id)이 갈음 +ALTER TABLE plan_route_assignees + DROP INDEX IF EXISTS idx_plan_route_assignees_project, + DROP COLUMN IF EXISTS year; diff --git a/db_management/tools_clone_project.py b/db_management/tools_clone_project.py index 229f2f9c4..788c07445 100644 --- a/db_management/tools_clone_project.py +++ b/db_management/tools_clone_project.py @@ -11,8 +11,8 @@ 무엇을 뜨나 DB projects · project_members · input_files · processed_point_cloud · surface_models · terrain_layers · routes · route_points · route_statistics · longitudinal_sections · - cross_sections · project_workflow_stages · (D) plan_networks · plan_route_pieces · - plan_route_years · plan_route_assignees — 종류(B · D · E)는 project_kind 로 판별 + cross_sections · project_workflow_stages · (D) plan_route_years · plan_route_assignees — + 종류(B · D · E)는 project_kind 로 판별 · D 설계 내용 = 폴더 파일(아래 파일째 따라감) ⚠ 자동증가 id 는 **새로 받고** 참조(외래키)를 새 id 로 다시 이어 붙인다. 파일 저장소 폴더 통째(robocopy). 경로가 프로젝트 루트 기준 상대라 그대로 쓰인다. 치환 파일 안에 박힌 **옛 프로젝트 UUID**, 초기값 스냅숏의 **surface_model_id**. diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index 27768f1d6..947a87f1c 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -106,23 +106,6 @@ def test_year_rows_group_pieces() -> None: assert rows[2027]["route"]["start_chainage_m"] == 75.0 -def test_legacy_rows_become_one_line() -> None: - rows = [ - {"year": 2026, "epsg": 5186, "route_json": json.dumps({"planned": [[0, 0], [30, 40]]})}, - {"year": 2027, "epsg": 5186, "route_json": None}, - {"year": 2028, "epsg": 5186, "route_json": {"planned": [[30, 40], [30, 100]]}}, - ] - got = net.legacy_network(rows) - assert got["lines"][0]["vertices"] == [[0, 0], [30, 40], [30, 100]] - assert got["marks"] == [{"line": "main", "at_m": 50.0}] - assert [(p["year"], p["from_m"], p["to_m"]) for p in got["pieces"]] == [ - (2026, 0, 50), - (2028, 50, 110), - ] - assert net.derive(got)["pieces"][1]["planned"] == [[30, 40], [30, 100]] - assert net.legacy_network([{"year": 2026, "epsg": 5186, "route_json": None}]) is None - - class _Cursor: def __init__(self, db): self.db = db @@ -188,14 +171,14 @@ BASE = {"epsg": 5186, "points": [{"x": 0.0, "y": 0.0}, {"x": 90.0, "y": 180.0}]} @pytest.fixture def client(monkeypatch, tmp_path): - """파일 = tmp_path 진짜 파일 · 없으면 옛 DB 칸(`old`)을 옮김 · DB = 가짜 기록.""" + """파일 = tmp_path 진짜 파일 · 없으면 `old` 설계(지점만) · DB = 가짜 기록.""" db = _Db() monkeypatch.setattr(net, "get_db_pool", lambda: db) - old = {"base": json.dumps(BASE), "network": None, "rows": []} + old = {**store.empty_plan(5186), "points": BASE["points"]} async def load(project_id): plan = store.read_plan(tmp_path) - return tmp_path, plan or store.migrate(old["base"], old["network"], old["rows"]) + return tmp_path, plan or dict(old) monkeypatch.setattr(store, "load_plan", load) app = FastAPI() @@ -213,14 +196,9 @@ def client(monkeypatch, tmp_path): URL = f"/api/projects/{PID}/plan/network" -def test_get_migrates_old_db_or_empty(client) -> None: +def test_get_reads_plan_or_empty(client) -> None: assert client.get(URL).json()["network"] is None - client.old["rows"] = [ - {"year": 2026, "epsg": 5186, "route_json": '{"planned": [[0, 0], [30, 40]]}'} - ] - got = client.get(URL).json() - assert got["legacy"] is False and got["derived"]["pieces"][0]["length_m"] == 50 - client.old["network"] = json.dumps(_network()) + client.old.update(_network()) got = client.get(URL).json() assert len(got["derived"]["pieces"]) == 3 and got["network"]["epsg"] == 5186 assert got["base"]["points"][1]["x"] == 90.0 and got["year_start"] == 2026 @@ -233,7 +211,7 @@ def test_put_writes_file_and_year_status_only(client) -> None: assert got.json()["year_start"] == 2026 saved = json.loads(client.file.read_text("utf-8")) assert saved["version"] == 1 and saved["epsg"] == 5186 - assert saved["points"] == BASE["points"] # 옛 지점 칸 → 파일로 옮김 + assert saved["points"] == BASE["points"] # 몸에 지점 없음 = 그대로 assert [p["id"] for p in saved["pieces"]] == ["p1", "p2", "p3"] assert "length_m" not in json.dumps(saved) # 파생 저장 안 함 log = client.db.log diff --git a/resources/tester/test_531_3_plan_link.py b/resources/tester/test_531_3_plan_link.py index ea8401eae..113c4501c 100644 --- a/resources/tester/test_531_3_plan_link.py +++ b/resources/tester/test_531_3_plan_link.py @@ -71,7 +71,7 @@ def _assignee(**kw): "road_type": "work", "route_start_m": 0.0, "epsg": 5186, - "route_json": json.dumps(_route(P2026)), + "route": json.dumps(_route(P2026)), "confirmed_at": "2026-10-25 12:00:00", } row.update(kw) @@ -212,7 +212,7 @@ def test_link_writes_route_set_like_upload(env, monkeypatch): assert meta["point_count"] == len(P2026) stage_meta = json.loads((env["root"] / "B03_FileInput/metadata.json").read_text("utf-8")) assert len(stage_meta["files"]) == 5 - fp = link.route_fingerprint(_assignee()["route_json"]) + fp = link.route_fingerprint(_assignee()["route"]) assert env["links"] == [(ROAD_ID, 7, fp)] and len(fp) == 40 assert any("SUPERSEDED" in sql for sql, _ in env["log"] if isinstance(sql, str)) @@ -325,7 +325,7 @@ def test_assigned_list_flags_update(plan_file, monkeypatch): (8, 2026, False), (9, 2027, False), ] - assert "route_json" not in out[0] and "source_fingerprint" not in out[0] + assert "route" not in out[0] and "source_fingerprint" not in out[0] def test_plan_source(plan_file, monkeypatch): diff --git a/resources/tester/test_563_2_plan_pieces.py b/resources/tester/test_563_2_plan_pieces.py index 4002f3ed4..3fd859add 100644 --- a/resources/tester/test_563_2_plan_pieces.py +++ b/resources/tester/test_563_2_plan_pieces.py @@ -218,7 +218,7 @@ def test_confirm_drops_when_file_changes(plan, monkeypatch): def test_link_uses_piece_route(plan): unit = asyncio.run(link.piece_route(PLAN_ID, "p3")) assert unit["year"] == 2026 and unit["epsg"] == 5186 and unit["confirmed_at"] == "x" - assert unit["route_json"]["planned"] == P3 and unit["route_json"]["length_m"] == 130.0 + assert unit["route"]["planned"] == P3 and unit["route"]["length_m"] == 130.0 files = link.year_files({**unit, "name": "2026 홍천 노선 계획", "region": "일월.용화"}) with tempfile.TemporaryDirectory() as tmp: for name, data in files.items(): @@ -226,7 +226,7 @@ def test_link_uses_piece_route(plan): meta = analyze_input_metadata(next(Path(tmp).glob("*.shp"))) assert meta["point_count"] == 2 and float(meta["attributes"]["종점누가"]) == 130.0 gone = asyncio.run(link.piece_route(PLAN_ID, "없음")) - assert gone["route_json"] is None and gone["year"] is None + assert gone["route"] is None and gone["year"] is None class _Cursor: diff --git a/resources/tester/test_573_1_clone_de.py b/resources/tester/test_573_1_clone_de.py index ef9b692a0..a9ef0a3cc 100644 --- a/resources/tester/test_573_1_clone_de.py +++ b/resources/tester/test_573_1_clone_de.py @@ -1,7 +1,7 @@ """573-1 복제 도구 D · E 갈래 — 임시 storage 사본에서(요구 404 · 405). -- D(예정노선) — plan_networks · plan_route_pieces · plan_route_years · plan_route_assignees · - plan_base_json · 지표면 · 지형층 행 수 같음 · 자동증가 id 새로 · 지형층 → 새 지표면 · +- D(예정노선) — plan_route_years · plan_route_assignees · 지표면 · 지형층 행 수 같음 · + 설계 내용 = 폴더 파일(plan_network.json) 그대로 따라감 · 자동증가 id 새로 · 지형층 → 새 지표면 · 업체 연결 끊김 · 지형 models_plan · _derived 파일 그대로 - E(안정성평가) — 원본 칸(source_project_id 등) 그대로 · 단계 행 여섯 상태 그대로 · E05 보고서 · _derived 파일 수 같음 @@ -34,6 +34,8 @@ from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # PLAN_ID = "87d47800-9936-4a56-9850-276fbcb34cac" STAB_ID = "088e173d-a8ab-4c51-badb-f5a95afc43bb" ROOT = Path(__file__).resolve().parents[2] +#: 복제 안 뜨는 옛 D 표 — 지움 SQL 025 적용 전까지 남음(설계 내용 = 폴더 파일) +RETIRED = {"plan_networks", "plan_route_pieces"} TABLES = ( "surface_models", "project_members", @@ -159,7 +161,7 @@ def _common(source, root, new_id): new_root = root.parent / new_id manifest = json.loads((new_root / cp.MANIFEST_NAME).read_text(encoding="utf-8")) assert manifest["kind"] == cp.KIND_LABELS[source["project_kind"]] - assert manifest["skipped_tables"] == {} + assert set(manifest["skipped_tables"]) <= RETIRED assert _files(new_root) - {cp.MANIFEST_NAME} == _files(root) for path in new_root.rglob("*.json"): if path.name != cp.MANIFEST_NAME: @@ -170,7 +172,8 @@ def _common(source, root, new_id): def test_clone_plan(monkeypatch): with _clone(PLAN_ID, "시험_573_1_D", monkeypatch) as (source, root, new_id): new, new_root = _common(source, root, new_id) - assert json.loads(new["plan_base_json"]) == json.loads(source["plan_base_json"]) + plan = Path("D03_PlanAnalysis") / "plan_network.json" + assert (new_root / plan).read_bytes() == (root / plan).read_bytes() assert (new_root / "B04_PreProcess" / "models_plan").is_dir() assert (new_root / "_derived").is_dir() @@ -179,22 +182,19 @@ def test_clone_plan(monkeypatch): f"SELECT * FROM {table} WHERE project_id = %s ORDER BY {order}", (project_id,) ) - old_net, new_net = ( - rows("plan_networks", PLAN_ID, "project_id"), - rows("plan_networks", new_id, "project_id"), + old, now = ( + rows("plan_route_years", PLAN_ID, "year"), + rows("plan_route_years", new_id, "year"), ) - assert json.loads(new_net[0]["network_json"]) == json.loads(old_net[0]["network_json"]) - for table, order in (("plan_route_pieces", "piece_id"), ("plan_route_years", "year")): - old, now = rows(table, PLAN_ID, order), rows(table, new_id, order) - drop = ("project_id", "updated_at") - assert [{k: v for k, v in r.items() if k not in drop} for r in now] == [ - {k: v for k, v in r.items() if k not in drop} for r in old - ] + drop = ("project_id", "updated_at") + assert [{k: v for k, v in r.items() if k not in drop} for r in now] == [ + {k: v for k, v in r.items() if k not in drop} for r in old + ] old_a = rows("plan_route_assignees", PLAN_ID, "id") new_a = rows("plan_route_assignees", new_id, "id") assert not {r["id"] for r in old_a} & {r["id"] for r in new_a} - assert [(r["piece_id"], r["year"], r["company_name"]) for r in new_a] == [ - (r["piece_id"], r["year"], r["company_name"]) for r in old_a + assert [(r["piece_id"], r["company_name"]) for r in new_a] == [ + (r["piece_id"], r["company_name"]) for r in old_a ] assert all(r["linked_project_id"] is None for r in new_a) diff --git a/resources/tester/test_590_1_plan_file.py b/resources/tester/test_590_1_plan_file.py index 2cd15e56f..9c1e420d3 100644 --- a/resources/tester/test_590_1_plan_file.py +++ b/resources/tester/test_590_1_plan_file.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- """590-1 예정노선 설계 내용 = 프로젝트 폴더 한 파일(요구 452 · 453) — 지킴 시험. -- 옮김 = 옛 DB 칸(지점 points · starts/ends · start/end · 빈 칸 = 연도 행 접기 · 노선 정본 · - 옛 연도 노선) → 새 꼴 한 벌(좌표계 머리 한 곳 · 빈 연도 없음) +- 파일 없음 = 빈 설계 · 옛 DB 칸(590-3 에서 옮김 끝) 안 읽음 · 코드에 옛 칸 이름 0 - 원자 쓰기 = 바꿔치기 실패에도 옛 파일 그대로 · 임시 파일 안 남음 · 새 판 파일 거절 - 연도 읽기 = 업체 줄 연도는 파일 조각에서(낡은 DB 연도 무시 · ⑤) · 확정 = 지문 같을 때만 - 잠금 = 같은 프로젝트 [저장] 둘이 겹치지 않음 @@ -24,7 +23,6 @@ sys.path.insert(0, str(Path(__file__).parent)) from helper_plan_file import use_plan, year_line_plan # noqa: E402 -from common_util.common_util_crs import lonlat_to_tm # noqa: E402 from D03_PlanAnalysis import D03_PlanAnalysis_Network as net # noqa: E402 from D03_PlanAnalysis import D03_PlanAnalysis_Store as store # noqa: E402 from D04_PlanConfirm import D04_PlanConfirm_Router as d04 # noqa: E402 @@ -43,50 +41,59 @@ NETWORK = { } -# --- 옮김 --- +# --- 옛 DB 칸 안 읽음(590-3) --- + +OLD_NAMES = ("plan_base_json", "plan_networks", "plan_route_pieces", "route_json", "start_x") +ROOT = Path(__file__).resolve().parents[2] -@pytest.mark.parametrize( - "base", - [ - {"epsg": 5186, "points": [A, B]}, - {"epsg": 5186, "starts": [A], "ends": [B]}, - {"epsg": 5186, "start": A, "end": B}, - ], -) -def test_migrate_old_point_forms(base): - plan = store.migrate(json.dumps(base), json.dumps(NETWORK), []) - assert plan["points"] == [A, B] and plan["epsg"] == 5186 - assert plan["lines"] == NETWORK["lines"] and plan["pieces"] == NETWORK["pieces"] - assert set(plan) == {"version", "epsg", "points", "lines", "marks", "pieces"} +class _Pool: + """가짜 DB — 프로젝트 행 하나 · 부른 SQL 기록.""" + + def __init__(self): + self.sqls = [] + + def acquire(self): + return self + + def cursor(self, *_): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql, args=()): + self.sqls.append(sql) + + async def fetchone(self): + return {"project_kind": "PLAN", "project_year": 2027, "storage_path": "x"} -def test_migrate_folds_year_rows_and_legacy_route(): - rows = [ - {"year": 2027, "epsg": 5186, "route_json": None, - "start_x": 1.0, "start_y": 2.0, "end_x": 3.0, "end_y": 4.0}, - {"year": 2026, "epsg": 5186, "route_json": json.dumps({"planned": [[0, 0], [30, 40]]}), - "start_x": 0.0, "start_y": 0.0, "end_x": 1.0, "end_y": 2.0}, - {"year": 2028, "epsg": None, "route_json": None, - "start_x": None, "start_y": None, "end_x": None, "end_y": None}, # 빈 연도 줄 - ] # fmt: skip - plan = store.migrate(None, None, rows) - assert plan["points"] == [{"x": 0.0, "y": 0.0}, {"x": 3.0, "y": 4.0}] # 첫 시점 · 끝 종점 - assert [p["year"] for p in plan["pieces"]] == [2026] # 빈 연도는 조각이 아님 - assert store.plan_units(plan)[0]["length_m"] == 50.0 - assert store.migrate(None, None, []) == store.empty_plan() +def test_no_file_is_empty_plan(tmp_path, monkeypatch): + pool = _Pool() + monkeypatch.setattr(store, "get_db_pool", lambda: pool) + monkeypatch.setattr(store, "resolve_stored_project_path", lambda _p: str(tmp_path)) + root, plan = asyncio.run(store.load_plan(PID)) + assert root == tmp_path and plan == store.empty_plan() + assert len(pool.sqls) == 1 and not any(n in pool.sqls[0] for n in OLD_NAMES) + assert not store.plan_path(tmp_path).exists() # 읽기만 = 안 씀 -def test_migrate_one_epsg_converts_points(): - lon_lat = (127.1, 37.5) - x, y = lonlat_to_tm(*lon_lat, 5185) - plan = store.migrate( - json.dumps({"epsg": 5185, "points": [{"x": x, "y": y}]}), json.dumps(NETWORK), [] - ) - assert plan["epsg"] == 5186 - assert (plan["points"][0]["x"], plan["points"][0]["y"]) == pytest.approx( - lonlat_to_tm(*lon_lat, 5186), abs=1e-3 - ) +def test_code_has_no_old_db_names(): + """옛 설계 칸 · 표 이름 = 코드 0(지움 SQL 025 · 지난 SQL · 시험 빼고).""" + dirs = [p for p in ROOT.iterdir() if p.is_dir() and p.name[:3] in {"B01", "D01", "D03", "D04"}] + files = [f for d in dirs for f in d.rglob("*") if f.suffix in {".py", ".ts"}] + files += [*(ROOT / "common_util").glob("*.py"), *(ROOT / "db_management").glob("*.py")] + hits = [ + f"{f.relative_to(ROOT)}:{n}" + for f in files + for n in OLD_NAMES + if n in f.read_text(encoding="utf-8") + ] + assert not hits, hits # --- 원자 쓰기 · 판 --- diff --git a/resources/tester/test_591_1_plan_site.py b/resources/tester/test_591_1_plan_site.py index 0b7cc63a7..cd795a882 100644 --- a/resources/tester/test_591_1_plan_site.py +++ b/resources/tester/test_591_1_plan_site.py @@ -4,12 +4,11 @@ - B08 한 줄 주소(도로명 먼저)는 그대로 - GET /api/map/address — 출처 VWorld - 등록 몸 — 기준점 필수 · 경위도 범위 · 설계속도 칸 없음 -- 옛 PLAN 기준점 = 지점 상자 가운데(꼴 셋) · 행 만들기 = 기준점 칸 셋 · 설계속도 칸 안 씀 +- 행 만들기 = 기준점 칸 셋 · 설계속도 칸 안 씀 - 화면 — 지도 · 기준점 필수 · 출처 표기 · 설계속도 글 없음 """ import asyncio -import importlib.util import json import sys from pathlib import Path @@ -25,16 +24,6 @@ from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 from common_util import common_util_map_router as map_router # noqa: E402 from D01_PlanRegister import D01_PlanRegister_Router as d01 # noqa: E402 - -def _fill_tool(): - spec = importlib.util.spec_from_file_location( - "fill024", ROOT / "db_management" / "024_plan_site_fill.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - PARCEL = {"type": "parcel", "text": "경상북도 울진군 금강송면 광회리 산 47", "structure": {"level1": "경상북도", "level2": "울진군", "level3": "", "level4L": "금강송면", "level4A": ""}} # fmt: skip @@ -80,18 +69,6 @@ def test_등록_몸_기준점_필수(): assert (ok.site.lon, ok.site.lat) == (129.1, 36.95) -def test_옛_기준점_지점_가운데(): - center = _fill_tool().base_center - assert center({"epsg": 5186, "start": {"x": 0, "y": 10}, "end": {"x": 100, "y": 30}}) == ( - 50, - 20, - ) - pts = [{"x": 0, "y": 0}, {"x": 40, "y": 10}, {"x": 20, "y": 100}] - assert center({"points": pts}) == (20, 50) - assert center({"starts": [{"x": 0, "y": 0}], "ends": [{"x": 10, "y": 10}]}) == (5, 5) - assert center(None) is None and center({"points": []}) is None - - def test_행_만들기_기준점_칸(): src = (ROOT / "common_util" / "common_util_project_kind.py").read_text(encoding="utf-8") assert "site_lon, site_lat, site_address" in src and "design_speed_kph" not in src diff --git a/resources/tester/test_591_2_plan_points.py b/resources/tester/test_591_2_plan_points.py index aa5684508..a83353f9e 100644 --- a/resources/tester/test_591_2_plan_points.py +++ b/resources/tester/test_591_2_plan_points.py @@ -79,9 +79,8 @@ class _Pool: @pytest.fixture def api(monkeypatch, tmp_path): - """파일 = tmp_path 의 진짜 파일 · 없으면 옛 DB 칸(`base_json`)을 옮김 · 연도 상태 줄은 안 씀.""" + """파일 = tmp_path 의 진짜 파일 · 없으면 빈 설계 · 연도 상태 줄은 안 씀.""" state = { - "base_json": None, "root": tmp_path, "site": SITE, "session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1}, @@ -89,7 +88,7 @@ def api(monkeypatch, tmp_path): async def load(project_id): plan = store.read_plan(tmp_path) - return tmp_path, plan or store.migrate(state["base_json"], None, []) + return tmp_path, plan or store.empty_plan() async def project_info(project_id): return {"year_start": 2026, "site": state["site"]} @@ -159,15 +158,6 @@ def test_epsg_stays_once_saved(api) -> None: assert got.status_code == 400 # 파일 머리 좌표계 한 곳 -def test_old_start_end_points_visible(api) -> None: - old = {"epsg": 5186, "starts": [{"x": A[0], "y": A[1]}], "ends": [{"x": B[0], "y": B[1]}]} - api.state["base_json"] = json.dumps(old) - points = api.get(URL).json()["base"]["points"] - assert [p["x"] for p in points] == pytest.approx([A[0], B[0]]) # 시점 뒤 종점 - api.state["base_json"] = json.dumps({"epsg": 5186, "start": {"x": A[0], "y": A[1]}}) - assert [p["x"] for p in api.get(URL).json()["base"]["points"]] == pytest.approx([A[0]]) - - # --- D02 걷음 --- diff --git a/resources/tester/test_593_time.py b/resources/tester/test_593_time.py index 32aa3279d..9d9423e6d 100644 --- a/resources/tester/test_593_time.py +++ b/resources/tester/test_593_time.py @@ -118,7 +118,7 @@ def test_D04_확정_시각_끝_Z(): from D04_PlanConfirm.D04_PlanConfirm_Router import unit_summary, year_summary at = datetime(2026, 10, 30, 1, 2, 3) - assert year_summary({"year": 2026, "route_json": {}, "confirmed_at": at})["confirmed_at"] == ( + assert year_summary({"year": 2026, "route": {}, "confirmed_at": at})["confirmed_at"] == ( "2026-10-30T01:02:03Z" ) unit = {