Files
Aislo/B06_Section/B06_Section_Router_Confirm.py
T
eomsangdonandClaude Opus 5 2a247184fd feat(B06): 집수정 좌우·상하 이동 및 내공 조정 + 설계선 스냅 급경사 제거
- 집수정 조정값(BasinAdjust: 내공 폭·높이, 좌우 lateralM, 상하 slopeM) 신설.
  측점별 세션 보관 + 백엔드 design.basin_adjust 저장 경로(Schema·Router) 연동
- 조정창에 집수정 조정 항목 추가, I형 관 시작점을 벽 안쪽 변과 원지반의
  교차점으로 산출(이분법), 집수정 성토(되메움)선 5° → 성토 비탈 1:1.2로 통일
- 설계선 트림 수정: 배수관 레이아웃이 사면 폴리라인을 준 쪽은 그 시작점(노견)에서
  설계선을 끊는다 — 종전에는 트림 경계에서 끝점 표고를 벽 상단으로 강제 스냅해
  집수정을 좌우로 옮기면 마지막 구간이 수직으로 튀는 선이 남았다(사용자 지적)
- basinApproachPoints의 길이 0 중복 꼭짓점 제거

검증: 4+4.3(집수정 I형 좌우 2.9m 이동)·13+15.7·5+0.0·2+0.0에서 급경사
세그먼트 0건 · tsc --noEmit 통과 · ruff check 통과 · prettier 적용 ·
pytest tmp/tests/ 148 passed, 7 skipped · B06_Section 700줄 초과 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:50:45 +09:00

223 lines
10 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_item.inlet_structure is not None:
patch["inlet_structure"] = patch_item.inlet_structure
if patch_item.basin_adjust is not None:
patch["basin_adjust"] = patch_item.basin_adjust.model_dump()
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": "종횡단 확정 처리 중 오류가 발생했습니다."},
)