Files
Aislo/D04_PlanConfirm/D04_PlanConfirm_Link.py
T
eomsangdonandClaude Opus 5.5 22e3797ab8 feat(531-4): 대시보드 받은 예정노선 [연결] 모달 · 새 임도 설계속도 → 단계 2 params · 확정 시각 KST
- B01 받은 예정노선 [연결] = 모달(새 임도 프로젝트로 · 기존 프로젝트에 연결) → link API · 409 확인 → 다시 · 끝나면 B03 이동 · 확정(KST) 열
- 연결 new 때 예정노선 설계속도(20/30/40)를 단계 2 params options.design_speed_kph 에 · existing 은 안 건드림
- B03 자동설계 체인이 단계 2 params 의 설계속도를 이어 넘김(없으면 None = 그대로 · 브레인 허락)
- 받은 예정노선 목록에 confirmed_at · D04 표 확정 시각 KST 표시(서버 UTC 그대로)
- 시험: new 연결 설계속도 기록 · existing · 범위 밖 값 안 넣음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:55:45 +09:00

340 lines
15 KiB
Python

"""D 예정노선 → 임도 연결 (요구 345 · 요구서 2-3 · 3-5 · PLAN 531-3).
- `POST /api/dashboard/plan-routes/{assignee_id}/link` — {mode: new | existing, project_id?}
new = 임도 프로젝트 새로(B02 저장소 함수) · existing = 고른 우리 회사 임도 프로젝트
- `GET /api/projects/{id}/plan-source` — 임도 프로젝트가 이어 받은 예정노선 · 업데이트 있음
연결 = 그 연도 shp 한 벌(D04 내보내기 함수)을 B03 입력 `input/shp/` 에 원래 이름으로 +
`input_files` 행 + metadata.json(B03 분석 함수) — 파일로 올린 것과 같은 길. 입력이 다 차면
B03 완료 판정(옛 결과 지우기 확인 · 단계 되돌림) 뒤 WF1. 지형은 업체가 올림.
연결 시점 복사 · 원본이 바뀌면 알림만(내용 지문 비교 · 자동 반영 없음).
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B02_ProjRegister.B02_ProjRegister_Repository import create_project
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
from B03_FileInput.B03_FileInput_Repository import (
create_input_file,
get_project_input_readiness,
get_project_storage_relative_path,
supersede_previous_input_files,
)
from B03_FileInput.B03_FileInput_Router_Helpers import (
_ANALYSIS_RUNNING_MESSAGE,
OutputsWouldBeDiscarded,
_complete_file_input_if_ready,
_confirm_replace_response,
_missing_required_file_types,
_schedule_background_task,
_write_stage_metadata,
)
from B03_FileInput.B03_FileInput_Schema import UploadedFileResult
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
from common_util.common_util_auth import require_company
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import is_analysis_running, merge_stage_params
from config.config_db import get_db_pool
from D04_PlanConfirm.D04_PlanConfirm_Export import build_export
from D04_PlanConfirm.D04_PlanConfirm_Router import year_routes_from_rows
router = APIRouter(prefix="/api/projects", tags=["D04 Plan Link"])
dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D04 Plan Link"])
ROUTE_DIR = "B03_FileInput/input/shp"
DESIGN_SPEEDS = (20, 30, 40) # B05 RouteSolveRequest 가 받는 값
class LinkRequest(BaseModel):
mode: Literal["new", "existing"]
project_id: UUID | None = None
confirm_replace: bool = False
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
return value if isinstance(value, dict) else None
def route_fingerprint(route_json: Any) -> str | None:
"""연도 노선 내용 지문(sha1) — 고른 중심선 점(mm 반올림)만 봄 · 같은 값이면 같은 지문."""
body = _parsed(route_json)
planned = (body or {}).get("planned")
if not planned:
return None
points = [[round(float(p[0]), 3), round(float(p[1]), 3)] for p in planned]
return hashlib.sha1(json.dumps(points).encode()).hexdigest()
def update_available(route_json: Any, source_fingerprint: str | None) -> bool:
"""연결 때 지문 ≠ 지금 지문 — 지금 노선이 비면(지점 바뀜 · 분석 전) 알리지 않음."""
now = route_fingerprint(route_json)
return bool(source_fingerprint) and now is not None and now != source_fingerprint
async def _fetch_one(sql: str, args: tuple) -> dict[str, Any] | None:
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(sql, args)
return await cursor.fetchone()
async def load_assignee(assignee_id: int) -> dict[str, Any] | None:
"""지정 행 + 예정노선 프로젝트 칸 + 그 연도 노선."""
return await _fetch_one(
"SELECT a.id, a.project_id, a.year, a.company_id, a.linked_project_id,"
" p.name, p.region, p.road_type, p.design_speed_kph, p.route_start_m,"
" y.epsg, y.route_json, y.confirmed_at"
" FROM plan_route_assignees a"
" JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL"
" LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year"
" WHERE a.id = %s",
(assignee_id,),
)
async def load_road_project(project_id: str) -> dict[str, Any] | None:
return await _fetch_one(
"SELECT id, company_id, project_kind FROM projects WHERE id = %s AND deleted_at IS NULL",
(project_id,),
)
async def record_link(
project_id: str, assignee: dict[str, Any], fingerprint: str | None, speed: Any
) -> None:
"""원본 칸 · 연결 표시 — 한 임도 프로젝트는 한 연도에만 이어짐(옛 연결은 끊음)."""
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await connection.begin()
try:
await cursor.execute(
"UPDATE projects SET source_project_id = %s, source_copied_at = NOW(),"
" source_fingerprint = %s, design_speed_kph = COALESCE(design_speed_kph, %s)"
" WHERE id = %s",
(assignee["project_id"], fingerprint, speed, project_id),
)
await cursor.execute(
"UPDATE plan_route_assignees SET linked_project_id = NULL"
" WHERE linked_project_id = %s AND id <> %s",
(project_id, assignee["id"]),
)
await cursor.execute(
"UPDATE plan_route_assignees SET linked_project_id = %s WHERE id = %s",
(project_id, assignee["id"]),
)
await connection.commit()
except Exception:
await connection.rollback()
raise
def year_files(assignee: dict[str, Any]) -> dict[str, bytes]:
"""그 연도 shp 한 벌(.shp .shx .dbf .prj .cpg) — 파일명 → 바이트."""
row = {**assignee, "route_json": _parsed(assignee["route_json"])}
routes = year_routes_from_rows([row])
bundle = build_export(assignee["name"], assignee["region"] or "", routes, with_xlsx=False)
return bundle.by_year[int(assignee["year"])]
async def _supersede_route_set(connection: aiomysql.Connection, project_id: str) -> None:
"""옛 노선 세트(다른 이름 포함) 행을 내림 — 노선 칸은 새 한 벌만 봄."""
async with connection.cursor() as cursor:
await cursor.execute(
"UPDATE input_files SET status = 'SUPERSEDED' WHERE project_id = %s"
" AND raw_file_path LIKE %s AND status IN ('UPLOADED', 'PROCESSED')",
(project_id, f"%{ROUTE_DIR}/%"),
)
async def import_route_files(
project_id: str,
files: dict[str, bytes],
session: dict[str, Any],
confirm_replace: bool = False,
las_free: bool = False,
) -> dict[str, Any]:
"""shp 한 벌 → B03 입력. 지울 결과가 있고 확인이 없으면 `OutputsWouldBeDiscarded`."""
pid = UUID(project_id)
async with get_db_pool().acquire() as connection:
async with connection.cursor(aiomysql.DictCursor) as cursor:
if await is_analysis_running(cursor, project_id):
raise HTTPException(status_code=409, detail=_ANALYSIS_RUNNING_MESSAGE)
stored = await get_project_storage_relative_path(connection, pid)
project_root = Path(resolve_stored_project_path(stored))
with tempfile.TemporaryDirectory() as tmp:
for name, data in files.items():
(Path(tmp) / name).write_bytes(data)
metas = {
name: await asyncio.to_thread(analyze_input_metadata, Path(tmp) / name)
for name in files
}
await connection.begin()
try:
await _supersede_route_set(connection, project_id)
results: list[UploadedFileResult] = []
for name, data in files.items():
meta, relative = metas[name], f"{ROUTE_DIR}/{name}"
epsg = meta.get("epsg")
input_id = await create_input_file(
connection,
project_id=pid,
file_type=Path(name).suffix.lower().lstrip("."),
original_filename=name,
relative_path=relative,
file_size_bytes=len(data),
upload_by=int(session["user_id"]),
crs_epsg=int(epsg) if epsg is not None else None,
metadata=meta,
)
await supersede_previous_input_files(connection, pid, name, input_id)
results.append(
UploadedFileResult(
input_file_id=input_id,
original_filename=name,
file_type=Path(name).suffix.lower().lstrip("."),
relative_path=relative,
size_bytes=len(data),
metadata=meta,
)
)
types, _, _ = await get_project_input_readiness(connection, pid)
wf1_input = None
if not _missing_required_file_types(types, las_free):
wf1_input = await _complete_file_input_if_ready(
connection, pid, las_free, confirm_replace
)
target = project_root / ROUTE_DIR
target.mkdir(parents=True, exist_ok=True)
for name in files:
shutil.move(str(Path(tmp) / name), str(target / name))
await connection.commit()
except Exception:
await connection.rollback()
raise
_write_stage_metadata(project_root / "B03_FileInput", pid, results)
if wf1_input is not None:
_schedule_background_task(
trigger_wf1_analysis_and_email(
project_id=pid,
input_file_id=wf1_input,
user_role=str(session["role"]),
started_by=session.get("user_id"),
),
task_name=f"b04-preprocess-auto-{project_id}",
)
return {"files": [r.original_filename for r in results], "analysis_started": bool(wf1_input)}
async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str:
"""예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수)."""
body = _parsed(assignee["route_json"]) or {}
created = await create_project(
user_id=int(session["user_id"]),
company_id=int(session["company_id"]),
name=f"{assignee['name']} {assignee['year']}",
region=assignee["region"] or "",
road_type=assignee["road_type"] or "work",
project_year=int(assignee["year"]),
estimated_length_m=body.get("length_m"),
memo=None,
)
return str(created["project_id"])
async def seed_design_speed(project_id: str, speed: Any) -> None:
"""새 임도만 — 예정노선 설계속도를 단계 2(B05) params `options.design_speed_kph` 에.
B05 페이지 설정 · 자동설계 체인(`B03_FileInput_Service_Chain`)이 읽는 자리 · 값만 넣음.
"""
if speed is None or int(speed) not in DESIGN_SPEEDS:
return
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await connection.begin()
await merge_stage_params(
cursor, project_id, 2, {"options": {"design_speed_kph": int(speed)}}
)
await connection.commit()
@dashboard_router.post("/plan-routes/{assignee_id}/link", response_model=None)
async def link_plan_route(
assignee_id: int, payload: LinkRequest, session: dict[str, Any] = Depends(require_company)
) -> dict[str, Any] | JSONResponse:
"""지정받은 연도 노선을 임도 프로젝트 B03 입력으로 — 지정 회사만."""
assignee = await load_assignee(assignee_id)
if not assignee:
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:
raise HTTPException(status_code=409, detail="확정된 노선이 없습니다.")
if payload.mode == "existing":
if payload.project_id is None:
raise HTTPException(status_code=400, detail="연결할 임도 프로젝트를 고르세요.")
project = await load_road_project(str(payload.project_id))
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if project["company_id"] != session.get("company_id"):
raise HTTPException(status_code=403, detail="다른 회사의 프로젝트입니다.")
if project["project_kind"] != "ROAD":
raise HTTPException(status_code=400, detail="임도설계 프로젝트만 연결할 수 있습니다.")
files = await asyncio.to_thread(year_files, assignee)
if payload.mode == "existing":
project_id = str(payload.project_id)
else:
project_id = await _new_road_project(assignee, session)
await seed_design_speed(project_id, assignee["design_speed_kph"])
try:
result = await import_route_files(
project_id, files, session, payload.confirm_replace, payload.las_free
)
except OutputsWouldBeDiscarded as exc:
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"])
await record_link(project_id, assignee, fingerprint, assignee["design_speed_kph"])
return {"status": "success", "project_id": project_id, "year": assignee["year"], **result}
@router.get("/{project_id}/plan-source", response_model=None)
async def plan_source(project_id: UUID) -> dict[str, Any]:
"""임도 프로젝트가 이어 받은 예정노선(B03 머리 띠) — 없으면 linked False."""
row = await _fetch_one(
"SELECT p.source_fingerprint, s.name AS plan_name, a.id AS assignee_id, a.year,"
" y.route_json"
" FROM projects p"
" JOIN projects s ON s.id = p.source_project_id AND s.project_kind = 'PLAN'"
" JOIN plan_route_assignees a ON a.linked_project_id = p.id"
" LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year"
" WHERE p.id = %s LIMIT 1",
(str(project_id),),
)
if not row:
return {"status": "success", "linked": False}
return {
"status": "success",
"linked": True,
"assignee_id": row["assignee_id"],
"plan_name": row["plan_name"],
"year": row["year"],
"update_available": update_available(row["route_json"], row["source_fingerprint"]),
}