- 저장 모듈 D03_PlanAnalysis_Store — 읽기 · 원자 쓰기 · 판 · 옛 DB 칸 읽을 때 옮김(지점 셋 꼴 · 연도 행 접기 · 옛 연도 노선) · plan_state(조각 · 연도 지문 · 확정 = 지문 같을 때만) - D03 [저장] · [초기화] · D02 지점 = 파일만 + 연도 상태 줄(지문 · 확정) · 프로젝트 잠금 · 파일 쓰기 실패 = DB 되돌림 · 빈 연도 줄 지움 - D04 요약 · 업체 지정 · 확정 메일 · 내보내기 · 대시보드 [연결] · 받은 예정노선 · 산림청 홈 · 개요 연결 = 파일에서 · 업체 줄 연도 칸 안 읽음(⑤) - 산림청 홈 「분석 전」 = 노선 안 그은 예정노선 프로젝트 수 · plan-source 지운 원본 거름 - 옛 연도 단위 지정 · 옛 연도 행 노선 길 걷음(DB 실측 0) · 연결 때 설계속도 씨앗 걷음(591-1 이음) - SQL 023 = 업체 줄 year NULL 허용(옛 칸은 590-3 에서 지움) - 시험: 새 지킴 590_1(옮김 · 원자 쓰기 · 연도 읽기 · 잠금) · 낡은 시험 고침 — 529_3 · 530_2 · 531_3 · 531_d04 · 547_2 · 563_2(DB 조각 · 연도 행 흉내 → 파일 흉내 · 옛 연도 지정 시험 · 설계속도 씨앗 시험 지움 = 그 길 걷음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
9.2 KiB
Python
215 lines
9.2 KiB
Python
"""D 예정노선 설계 내용 한 파일 — `<프로젝트>/D03_PlanAnalysis/plan_network.json`
|
|
(요구 452 · 453 · PLAN 590-1).
|
|
|
|
- 꼴 = `{version, epsg, points: [{x, y}], lines, marks, pieces: [{id, line, from_m, to_m, year}]}` —
|
|
좌표계 = 머리 한 곳 · 연도 = 조각의 `year` 뿐(빈 연도 줄 없음) · 길이 · 측점 · 조각 좌표 ·
|
|
연도 지문은 읽을 때 `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 에서 지움).
|
|
- 쓰기 = `project_lock(project_id)` 안에서 `save_plan`(같은 폴더 임시 파일 → 바꿔치기 · 반쯤 쓴
|
|
파일이 안 보임) · 읽기는 잠금 없음.
|
|
- 확정 = 연도 상태 줄 지문이 파일 지문과 같을 때만(파일만 바뀌고 DB 가 못 따라간 틈 =
|
|
풀린 것으로 봄).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
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
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
|
|
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names
|
|
|
|
FILE = Path("D03_PlanAnalysis") / "plan_network.json"
|
|
VERSION = 1
|
|
KEYS = ("epsg", "points", "lines", "marks", "pieces")
|
|
|
|
|
|
def empty_plan(epsg: int | None = None) -> dict[str, Any]:
|
|
return {"version": VERSION, "epsg": epsg, "points": [], "lines": [], "marks": [], "pieces": []}
|
|
|
|
|
|
def plan_path(root: str | Path) -> Path:
|
|
return Path(root) / FILE
|
|
|
|
|
|
def _clean(plan: dict[str, Any]) -> dict[str, Any]:
|
|
out = empty_plan()
|
|
out.update({key: plan[key] for key in KEYS if plan.get(key) is not None})
|
|
out["epsg"] = int(out["epsg"]) if out["epsg"] is not None else None
|
|
return out
|
|
|
|
|
|
def read_plan(root: str | Path) -> dict[str, Any] | None:
|
|
"""파일 — 없으면 None · 이 코드보다 새 판이면 거절(덮어써 잃지 않게)."""
|
|
path = plan_path(root)
|
|
if not path.is_file():
|
|
return None
|
|
plan = json.loads(path.read_text("utf-8"))
|
|
if int(plan.get("version") or 0) > VERSION:
|
|
raise HTTPException(status_code=409, detail="새 판 노선 파일입니다 — 서버를 새로 받으세요.")
|
|
return _clean(plan)
|
|
|
|
|
|
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"
|
|
" WHERE id = %s AND deleted_at IS NULL",
|
|
(str(project_id),),
|
|
)
|
|
project = await cursor.fetchone()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
|
if project["project_kind"] != "PLAN" or project["project_year"] is None:
|
|
raise HTTPException(status_code=400, detail="예정노선 프로젝트가 아닙니다.")
|
|
if not project["storage_path"]:
|
|
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
|
|
return project
|
|
|
|
|
|
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)
|
|
|
|
|
|
def plan_units(plan: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""조각마다 {id · line · line_name · year · epsg · from_m · to_m · length_m · planned ·
|
|
stations}
|
|
— 연도 · 선 차례 · 선 위 거리 차례 · 틀리면 `NetworkError`."""
|
|
pieces = network.derive(plan)["pieces"] if plan["lines"] else []
|
|
names = dict(zip((line["id"] for line in plan["lines"]), line_names(plan["lines"])))
|
|
for piece in pieces:
|
|
piece.update(line_name=names.get(piece["line"], "갈래"), epsg=plan["epsg"])
|
|
pieces.sort(key=lambda p: p["year"]) # 같은 연도 안 = derive 차례(선 · 거리) 그대로
|
|
return pieces
|
|
|
|
|
|
async def year_status(project_id: UUID | str) -> dict[int, dict[str, Any]]:
|
|
"""연도 상태 줄 — {연도: {fingerprint, confirmed_at}}."""
|
|
async with (
|
|
get_db_pool().acquire() as connection,
|
|
connection.cursor(aiomysql.DictCursor) as cursor,
|
|
):
|
|
await cursor.execute(
|
|
"SELECT year, fingerprint, confirmed_at FROM plan_route_years WHERE project_id = %s",
|
|
(str(project_id),),
|
|
)
|
|
return {int(r["year"]): r for r in await cursor.fetchall()}
|
|
|
|
|
|
async def plan_state(project_id: UUID | str) -> dict[str, Any]:
|
|
"""{root, plan, units, years} — `years` = `year_rows` + `confirmed_at`(지문이 같을 때만) ·
|
|
단위마다 `confirmed_at` · 노선이 틀리면 409."""
|
|
root, plan = await load_plan(project_id)
|
|
try:
|
|
units = plan_units(plan)
|
|
except network.NetworkError as error:
|
|
raise HTTPException(status_code=409, detail=f"저장된 노선이 틀립니다 — {error}") from error
|
|
years = network.year_rows(units)
|
|
status = await year_status(project_id)
|
|
for year, row in years.items():
|
|
saved = status.get(year)
|
|
same = saved is not None and saved["fingerprint"] == row["fingerprint"]
|
|
row["confirmed_at"] = saved["confirmed_at"] if same else None
|
|
for unit in units:
|
|
unit["confirmed_at"] = years[unit["year"]]["confirmed_at"]
|
|
return {"root": root, "plan": plan, "units": units, "years": years}
|