feat(D 예정노선): 옛 DB 설계 칸 읽기 걷음 · 지움 SQL 025(만들기만) — 설계 내용 = 폴더 파일만 (PLAN 590-3 · 요구 458 · 459)

- 옮김 끝 — 파일 없는 PLAN 10 개 = 옛 칸 → plan_network.json 한 번(일회 스크립트 · 11 개 모두 파일 대 DB 대조 같음)
- 저장 모듈 = 파일 · 없으면 빈 설계(옛 칸 · 표 안 읽음) · 옛 연도 노선 접기(legacy_network) · 응답 legacy 칸 걷음
- D04 조각 노선 열쇠 route_json → route(옛 칸 이름과 헷갈림 걷음 · 메모리 값)
- 복제 도구 = 옛 D 표 안 뜸 · 예정노선 설계는 폴더 파일로 따라감
- 024_plan_site_fill.py 지움 — 옛 칸을 읽는 일회 도구 · 기준점 11 개 모두 채움
- SQL 025 = 단계 번호(PLAN_BASE 걷음) · plan_networks · plan_route_pieces · projects.plan_base_json · 연도 줄 노선 칸 · 업체 줄 year 지움 — 공유 DB 적용 안 함(데스크탑 임시 배포가 새 코드를 받은 뒤 · 임시 DB 두 번 돌림 확인)
- 시험: 590_1 옮김 시험 셋 → 파일 없음 = 빈 설계 · 코드에 옛 칸 이름 0 · 530_2 옛 연도 노선 시험 지움(그 길 걷음) · 591_2 옛 지점 꼴 시험 지움 · 591_1 채움 도구 시험 지움(도구 지움) · 573_1 = 파일째 따라감 · 옛 표 목록 밖 허용(025 전) · 531_3 · 563_2 · 593 = 열쇠 이름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 12:45:53 +09:00
co-authored by Claude Opus 5.5
parent 912cfe8263
commit 152516bb59
18 changed files with 153 additions and 364 deletions
-2
View File
@@ -64,8 +64,6 @@ export interface PlanNetworkView {
lines: Array<{ id: string; length_m: number; stations: number[] }>;
pieces: Array<PlanPiece & { length_m: number; planned: Vertex[] }>;
} | null;
/** 옛 연도 노선을 선으로 바꾼 것인가. */
legacy: boolean;
/** 사업연도 시작(있으면). */
year_start?: number | null;
}
+1 -47
View File
@@ -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,
}
+6 -85
View File
@@ -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]]:
+14 -16
View File
@@ -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
),
}
+3 -6
View File
@@ -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"):
+4 -4
View File
@@ -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` 로만 읽음
+1 -1
View File
@@ -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;
-74
View File
@@ -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())
+43
View File
@@ -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;
+2 -2
View File
@@ -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**.
+6 -28
View File
@@ -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
+3 -3
View File
@@ -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):
+2 -2
View File
@@ -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:
+16 -16
View File
@@ -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)
+48 -41
View File
@@ -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
# --- 원자 쓰기 · 판 ---
+1 -24
View File
@@ -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
+2 -12
View File
@@ -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 걷음 ---
+1 -1
View File
@@ -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 = {