자동 체인 상태 전이 분리: - confirm_latest_route/confirm_sections에 mark_stage_complete 플래그 추가 - 자동 체인(신규·재확정)은 데이터만 확정하고 stage 2·3을 IN_PROGRESS로 남김 (스텝바 기존 노란 테두리 = 계산됨·확정 전 상태로 재사용) - B06 [확정]이 stage 2+3 동시 완료 + 경로 상태 CONFIRMED 보강 후 B07 수량 이동 B05 좌측 패널 개편: - 포인트 팔레트 + 임도 기준·옵션을 경로 계산 설정 한 컨테이너로 병합, [최적 경로 계산] 버튼을 컨테이너 내부로 이동 (가끔 쓰는 무거운 재계산) - 하단 액션 행 [초기화][임시저장][횡단 이동]으로 교체, 경로 단독 확정 폐지 - [임시저장] = 관로 + 계획선 델타 + 비정규 측점·상단측 저장(mark_stage_complete=false) - [횡단 이동]/B06 [종단 이동] = 저장 없이 페이지 이동만 (B05·B06 캐시 공유 구조 유지) - [초기화] = POST /route/reset 신설: 기존 경로 삭제 후 계획노선 CSV 기본값으로 자동 체인 재실행(파일입력 직후 상태 복원), 분석용 타임아웃 적용 B06 액션 행: [종단 이동][임시저장][확정] 3버튼 구성, locale 키 정비 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
219 lines
9.9 KiB
Python
219 lines
9.9 KiB
Python
"""B06 종횡단 **저장·확정** 라우터.
|
|
|
|
`_Router.py`가 700줄을 넘겨 조회·계산(그쪽)과 저장·확정(여기)을 갈랐다.
|
|
|
|
임시 저장과 확정은 **저장하는 내용이 같다**(표준횡단 설정 · 유토곡선 · 측점별 암 경계선).
|
|
다른 것은 뒤처리뿐이다 — 확정만 미지정 측점을 기본값으로 채우고, 경로 상태를 CONFIRMED로
|
|
바꾸고, 워크플로 단계를 닫고, 측구 방향을 B05 종단 정본에 역반영한다. 그래서 공통 저장을
|
|
`_apply_section_edits()` 하나로 두고 두 엔드포인트가 함께 쓴다.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter, Body
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_Profile.B05_Profile_Repository import confirm_route as confirm_route_status
|
|
from B05_Profile.B05_Profile_Router_Confirm import _merge_uphill_overrides_into_longitudinal
|
|
from B06_Section.B06_Section_Repository import (
|
|
confirm_sections_for_route,
|
|
get_cross_section_designs,
|
|
get_cross_sections_missing_design_chainages,
|
|
get_longitudinal_section,
|
|
merge_cross_section_design_patch,
|
|
merge_longitudinal_section_data,
|
|
merge_longitudinal_section_options,
|
|
update_cross_section_design,
|
|
)
|
|
from B06_Section.B06_Section_Router import _compute_default_designs
|
|
from B06_Section.B06_Section_Schema import (
|
|
SectionConfirmRequest,
|
|
SectionConfirmResponse,
|
|
)
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_workflow_state import complete_stage, start_stage
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
|
|
|
|
|
async def _apply_section_edits(
|
|
connection: aiomysql.Connection,
|
|
route_id: int,
|
|
request: SectionConfirmRequest | None,
|
|
default_designs: list[tuple[float, dict[str, Any]]],
|
|
project_id: UUID | None = None,
|
|
) -> None:
|
|
"""임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다."""
|
|
for chainage_m, design in default_designs:
|
|
await update_cross_section_design(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_m=chainage_m,
|
|
design=design,
|
|
project_id=project_id,
|
|
)
|
|
if request and request.standard_cross_section:
|
|
await merge_longitudinal_section_options(
|
|
connection,
|
|
route_id=route_id,
|
|
options_patch={"standard_cross_section": request.standard_cross_section},
|
|
)
|
|
# 유토곡선 결과는 생성 옵션이 아니므로 data.options가 아니라 최상위 키에 둔다.
|
|
if request and request.mass_haul:
|
|
await merge_longitudinal_section_data(
|
|
connection, route_id=route_id, data_patch={"mass_haul": request.mass_haul}
|
|
)
|
|
# 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합.
|
|
if request and request.cross_patches:
|
|
for patch_item in request.cross_patches:
|
|
patch: dict[str, Any] = {}
|
|
if patch_item.rock_boundary_offset_m is not None:
|
|
patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m
|
|
if patch_item.display_half_width_m is not None:
|
|
patch["display_half_width_m"] = patch_item.display_half_width_m
|
|
if patch:
|
|
await merge_cross_section_design_patch(
|
|
connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse)
|
|
async def save_sections(
|
|
project_id: UUID,
|
|
route_id: int,
|
|
request: SectionConfirmRequest | None = Body(default=None),
|
|
) -> SectionConfirmResponse | JSONResponse:
|
|
"""편집 중인 종횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장).
|
|
|
|
저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않는다. 미지정 측점을
|
|
기본값으로 채우지도 않는다 — 임시 저장은 **사용자가 실제로 손댄 것만** 남기는 게 맞다.
|
|
"""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
if not await get_longitudinal_section(connection, project_id, route_id):
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "저장할 종횡단이 없습니다."},
|
|
)
|
|
await connection.begin()
|
|
try:
|
|
await _apply_section_edits(connection, route_id, request, [])
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
return SectionConfirmResponse(
|
|
project_id=str(project_id), route_id=route_id, confirmed=False
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"B06 종횡단 임시 저장 실패: project_id=%s route_id=%s", project_id, route_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 임시 저장 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse)
|
|
async def confirm_sections(
|
|
project_id: UUID,
|
|
route_id: int,
|
|
request: SectionConfirmRequest | None = Body(default=None),
|
|
mark_stage_complete: bool = True,
|
|
) -> SectionConfirmResponse | JSONResponse:
|
|
"""경로의 종횡단면을 확정(CONFIRMED)한다.
|
|
|
|
지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 뒤 확정한다.
|
|
표준 횡단면 설정값이 함께 오면 longitudinal_sections.data.options에 저장한다.
|
|
|
|
B05·B06은 한 흐름이라 사용자 [확정]은 stage 2(종단)와 3(횡단)을 함께 닫는다
|
|
(B05 단독 확정 폐지, 2026-08-08 워크플로우 재정의). `mark_stage_complete=False`는
|
|
자동 계산 체인용 — 데이터만 확정하고 stage 3을 IN_PROGRESS(검토 대기)로 남긴다.
|
|
"""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
existing = await get_longitudinal_section(connection, project_id, route_id)
|
|
if not existing:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "확정할 종횡단이 없습니다."},
|
|
)
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
|
|
|
# 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀).
|
|
default_designs: list[tuple[float, dict[str, Any]]] = []
|
|
if missing:
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
default_designs = await asyncio.to_thread(
|
|
_compute_default_designs,
|
|
project_root,
|
|
str(existing["longitudinal_file_path"]),
|
|
missing,
|
|
request.standard_cross_section if request else None,
|
|
)
|
|
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
await _apply_section_edits(
|
|
connection, route_id, request, default_designs, project_id
|
|
)
|
|
await confirm_sections_for_route(connection, route_id)
|
|
async with connection.cursor() as cursor:
|
|
if mark_stage_complete:
|
|
# B05 단독 확정 폐지 보완 — 재탐색 후 임시저장 없이 바로 확정해도
|
|
# 경로 상태(DRAFT)가 남지 않도록 여기서 함께 CONFIRMED로 닫는다.
|
|
await confirm_route_status(connection, route_id)
|
|
await complete_stage(cursor, str(project_id), 2)
|
|
await complete_stage(cursor, str(project_id), 3)
|
|
else:
|
|
await start_stage(cursor, str(project_id), 3)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
|
|
# 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7).
|
|
# 파일 기반·비치명적: 실패해도 확정은 유지한다.
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
designs = await get_cross_section_designs(connection, route_id)
|
|
overrides = [
|
|
{"chainage_m": record["chainage_m"], "side": record["design"]["ditch_side"]}
|
|
for record in designs
|
|
if isinstance(record.get("design"), dict)
|
|
and record["design"].get("ditch_side") in ("left", "right")
|
|
]
|
|
if overrides:
|
|
await asyncio.to_thread(
|
|
_merge_uphill_overrides_into_longitudinal,
|
|
Path(resolve_stored_project_path(stored_path)),
|
|
str(existing["longitudinal_file_path"]),
|
|
overrides,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"B06 측구 방향 B05 역반영 실패 (확정은 유지): project_id=%s route_id=%s",
|
|
project_id,
|
|
route_id,
|
|
)
|
|
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
|
|
except Exception:
|
|
logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "종횡단 확정 처리 중 오류가 발생했습니다."},
|
|
)
|