Files
Aislo/B06_Section/B06_Section_Router_Confirm.py
T
eomsangdonandClaude Opus 5 cd8d14f105 feat(B05·B06): 기슭막이 구간값 조정창 · 연동 기슭막이 옵션 · 가림 구간 선 정리
기슭막이 범위(길이·기준측점 전/후)를 B06 횡단도 조정창에서 지정하게 하고, 그
범위가 덮는 옆 측점의 기슭막이를 "연동" 파생물로 다루도록 체계를 세웠다.
값의 원천은 배수관 옵션(pipe_points) 하나다 — 같은 숫자를 B06 정본에 따로 담으면
B05 배수관 카드와 갈려 어느 쪽이 참인지 알 수 없다.

① 집수정 전/후 필드 신설 — inlet_basin_before_m·after_m(기본 각 1m), 길이 2m 유지.
   종방향 구간 계산을 revetSpanOfSpec·basinSpanOfSpec 한 곳으로 모아 2D 링크 판정과
   3D 스윕이 같은 값을 쓰게 했다.
② B06 조정창에 길이·전·후 행 — 기슭막이(유입·유출)·집수정 모두. 캐시를 고쳐 즉시
   보여주고 저장은 조작이 멎으면 한 번만 나간다(세부유역 재계산이 매번 돌지 않게).
   길이를 바꾸면 늘어난 몫만 지금 비율대로 나눠 담는다 — 총길이에서 비율로 다시
   계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다.
③ 링크 카드 선택 허용 — "소유 측점 통째 복사"에서 "위치 4축만 물려받고 원지반
   접합은 이 측점 지형으로 재계산"으로 바꿨다. 구조물 형식은 언제나 소유 측점 값이다.
   손대기 전에도 어긋나지 않게 소유 측점이 실제로 적용한 4축(revetShift)을 명시값으로
   물려준다.
④ 연동(측점별)·경사(전체 공통) 토글 — 연동 조작은 소유 측점 키에 실리고 그 연장이
   덮는 측점을 함께 다시 그린다. 경사는 3D 전용 스위치다(끄면 스윕 프레임 dz=0).
   횡단도까지 -dz로 밀면 노견에서 출발하는 성토선까지 내려가 노견과 벌어진다.
⑤ 가림 구간 선 정리 — 유입부 접속선과 유출부 접지 후 절토선을 2D·3D 모두에서
   빼되 기하는 남긴다. 면적·수량 계산은 건드리지 않았다(전면 개편 대상).
⑥ 집수정은 소유 측점 횡단도 하나에만 — 링크 카드에서 집수정·계류측 다단을 뺀다.
⑦ 3D 구간 겹침 정리 — 측점 간격이 구조물 연장보다 좁으면 두 구간 제어점이 번갈아
   서서 짧은 쪽(집수정 2m)이 잘려 보였다. 두 소유 측점의 중간에서 끊는다.
⑧ 조정창 정리 — 십자·9키를 맨 아래로, 창을 위쪽 기준 오버레이로(낮은 횡단도에서
   제목이 잘리던 문제), 연동·경사를 1행 토글 버튼으로, 스크롤 자리 유지.

파일 분리: B06_Section_UI_Cross_View_Structure.ts(조정창 배선),
B06_Section_Api_Culvert_Options.ts(구간값 저장) — 700줄 제한.
BUILD_VERSION 14 → 17(저장 코리도 만료).
tsc/ruff/prettier 통과, pytest 196 passed(기존 실패 1건 유지).
작업이력·자체검증 기록: docs/raw/PLAN.md §4-9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:26:35 +09:00

310 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.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": "종횡단 확정 처리 중 오류가 발생했습니다."},
)