Files
Aislo/B06_Section/B06_Section_Router_Confirm.py
T
eomsangdonandClaude Opus 5 81bb505cc4 feat(B05·B06): 세월교 횡단 구체·조정창·3D 예상형상
B05 세월교 옵션에 BOX암거 날개벽 한 벌(설치·짧은쪽 높이 1m·길이 2m·각도 45°)을
그대로 붙이고, B06 횡단도에 세월교 구체를 그린다. 구체는 양측 ㄴ형 집수정 측벽 +
그 사이를 잇는 바닥판 + 바닥판 상면에 안힌 관 + 관 위 성토(노체) 채움이다.

- 바닥판 편측 연장 = 날개벽 길이 × cos(각도) — 각도는 관축 기준 벌어짐각
- 측벽은 buildBasin 재사용이라 1:0.3 기움·좌우·상하 대각 이동·계류측 성토부선이
  집수정과 같은 규칙으로 따라온다 (기슭막이 다단·재질과는 연계하지 않음)
- 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후 절반까지 붙는다
- 조정창: 측벽 높이·좌우·상하, 관경·수량(정본 pipe_points 되쓰기).
  높이·좌우·상하는 세션 + design.ford_adjust에 저장
- 3D 예상형상: 구체 부재는 월류 폭 구간 스윕, 관은 련수만큼 폭 안 등간격
- 부재 두께: 바닥판 0.3m(교본 물넘이 물받이 최소 30cm), 측벽 0.2m(집수정 승계)

700줄 제한으로 카드 렌더러·측점 제어기·페이지에서 배선을 분리했다
(_Cross_View_Ford, _Page_Ford_Controls, _Page_Patches, Culvert_Const 구간값 헬퍼).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:05:42 +09:00

312 lines
14 KiB
Python

"""B06 종횡단 **저장·확정** 라우터.
`_Router.py`가 700줄을 넘겨 조회·계산(그쪽)과 저장·확정(여기)을 갈랐다.
임시 저장과 확정은 **저장하는 내용이 같다**(표준횡단 설정 · 유토곡선 · 측점별 암 경계선).
다른 것은 뒤처리뿐이다 — 확정만 미지정 측점을 기본값으로 채우고, 경로 상태를 CONFIRMED로
바꾸고, 워크플로 단계를 닫고, 측구 방향을 B05 종단 정본에 역반영한다. 그래서 공통 저장을
`_apply_section_edits()` 하나로 두고 두 엔드포인트가 함께 쓴다.
"""
import asyncio
import json
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_chainages,
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"])
def _rowless_station_chainages(
project_root: Path, longitudinal_file_path: str, known: list[float]
) -> list[float]:
"""종단 정본 stations 중 **cross_sections 행이 없는** 측점의 chainage 목록.
구조물(비정규) 측점은 B05 확정이 횡단 파일만 쓰고 DB 행을 만들지 않는다
(`generate_irregular_sections`). 그래서 확정해도 설계가 정본으로 남지 않고 조회할
때마다 프리뷰 기본값이 다시 계산됐다 — 3D·수량이 확정 결과가 아니게 된다
(2026-08-24 사용자 확정: 3D는 종단·횡단 확정 뒤의 최종 산출물). 여기서 골라내
확정 대상에 넣으면 `update_cross_section_design`의 upsert가 행을 만든다.
"""
root = project_root.resolve()
path = (root / longitudinal_file_path).resolve()
if root not in path.parents or not path.is_file():
return []
try:
longitudinal = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None
if not isinstance(stations, list):
return []
result: list[float] = []
for station in stations:
if not isinstance(station, dict):
continue
try:
chainage = float(station.get("chainage_m"))
except (TypeError, ValueError):
continue
if any(abs(chainage - value) < 0.01 for value in known):
continue
result.append(chainage)
return result
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()
# 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 남긴다(2026-08-24).
if patch_item.revet_adjust is not None:
patch["revet_adjust"] = {
role: adjust.model_dump() for role, adjust in patch_item.revet_adjust.items()
}
if patch_item.ford_adjust is not None:
patch["ford_adjust"] = patch_item.ford_adjust.model_dump()
if patch_item.extra_wall_counts is not None:
patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump()
if patch_item.revet_link_detached is not None:
patch["revet_link_detached"] = patch_item.revet_link_detached
if patch_item.revet_follow_grade is not None:
patch["revet_follow_grade"] = patch_item.revet_follow_grade
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:
"""편집 중인 종횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장).
저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않는다. 지반유형 미지정
측점을 기본값으로 채우지도 않는다 — 임시 저장은 **사용자가 실제로 손댄 것만** 남긴다.
다만 **행 자체가 없는 측점**(구조물 등 비정규)은 예외다. 임시저장이 곧 캐시를
영구저장소에 내리는 시점인데(2026-08-24 사용자 확정), 행이 없으면 그 측점 패치가
통째로 버려져 조작값이 사라진다. 그래서 여기서 정본 행을 만든다.
"""
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)
known = await get_cross_section_chainages(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
rowless = await asyncio.to_thread(
_rowless_station_chainages,
project_root,
str(existing["longitudinal_file_path"]),
known,
)
default_designs: list[tuple[float, dict[str, Any]]] = []
if rowless:
default_designs = await asyncio.to_thread(
_compute_default_designs,
project_root,
str(existing["longitudinal_file_path"]),
rowless,
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 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)
known = await get_cross_section_chainages(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
# 조회 때마다 프리뷰가 다시 계산돼 3D·수량이 확정 결과가 아니게 된다(2026-08-24).
missing = missing + await asyncio.to_thread(
_rowless_station_chainages,
project_root,
str(existing["longitudinal_file_path"]),
known,
)
# 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀).
default_designs: list[tuple[float, dict[str, Any]]] = []
if missing:
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": "종횡단 확정 처리 중 오류가 발생했습니다."},
)