- 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>
340 lines
15 KiB
Python
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"]),
|
|
}
|