측점마다 SELECT+UPDATE 두 왕복을 냈고 DB 가 원격이라 왕복 하나가 약 12ms. 22행이면 670ms 이고 측점 수에 선형으로 늘었음(보조 창 서버 내부 측정). - B06_Section_Repository_Bulk.merge_cross_section_designs 신설 — 노선 측점을 한 번에 읽고, 파이썬에서 chainage 를 맞춰 JSON 을 합친 뒤 `UPDATE ... SET data = CASE id ...` 한 문장으로 되돌려 씀(없는 행은 다중 INSERT). 행 수와 무관하게 왕복 두 번. Repository 가 685줄이라 파일을 나눔(700줄 한계). - 부르는 자리 셋을 묶음으로 바꿈 — _apply_section_edits 의 기본설계·측점 patch 두 루프, _recompute 의 보정·면적 두 루프. 자체검증(공용 브라우저 [저장] 3회) — sections/save 3,593ms -> 2,205 / 1,870 / 2,547ms. 버튼 전체 대기 4,137ms -> 2,624~3,415ms. 진행 표시는 2~3ms 만에 뜸. 시험 tmp/tests/test_b06_bulk_designs.py 5건(같은 JSON 결과·왕복 두 번·1cm 허용오차· 행 없을 때 INSERT·빈 목록은 왕복 0). 전체 400 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
373 lines
18 KiB
Python
373 lines
18 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_longitudinal_section_data,
|
|
merge_longitudinal_section_options,
|
|
)
|
|
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
|
|
|
# 원본은 `B06_Section_Router_Design` 이다 — `B06_Section_Router` 를 거쳐 들여오던 것을
|
|
# 곧바로 잇는다(2026-09-06). 그 재수출이 없어지면서 서버가 뜨지 못했다.
|
|
from B06_Section.B06_Section_Router_Design import (
|
|
compute_default_designs as _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:
|
|
"""임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다."""
|
|
# 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다.
|
|
await merge_cross_section_designs(
|
|
connection,
|
|
route_id=route_id,
|
|
entries=list(default_designs),
|
|
replace=True,
|
|
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:
|
|
patches: list[tuple[float, dict[str, Any]]] = []
|
|
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.box_adjust is not None:
|
|
patch["box_adjust"] = patch_item.box_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.extra_spans is not None:
|
|
patch["extra_spans"] = {
|
|
wall: span.model_dump() for wall, span in patch_item.extra_spans.items()
|
|
}
|
|
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
|
|
# 카드 버튼 선택 — 브라우저가 고른 값을 그대로 정본에 얹는다(2026-09-06).
|
|
for choice_key in (
|
|
"ground_type",
|
|
"section_mode",
|
|
"ditch_side",
|
|
"ditch_type",
|
|
"paved",
|
|
"two_stage_slope",
|
|
):
|
|
choice = getattr(patch_item, choice_key)
|
|
if choice is not None:
|
|
patch[choice_key] = choice
|
|
# 구조물 폐회로 면적 — 브라우저가 계산해 보낸 값을 그대로 정본에 얹는다.
|
|
for area_key in ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"):
|
|
value = getattr(patch_item, area_key)
|
|
if value is not None:
|
|
patch[area_key] = value
|
|
if patch:
|
|
patches.append((patch_item.chainage_m, patch))
|
|
# 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다.
|
|
await merge_cross_section_designs(
|
|
connection, route_id=route_id, entries=patches, replace=False
|
|
)
|
|
|
|
|
|
async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
|
|
"""정본을 **서버가 다시 계산**한다 — 사용자 편집이 들어간 **뒤**에 돈다.
|
|
|
|
포장 구간·세월교 노면 하강 보정에 더해 구조물 면적·유토곡선(배분·운반거리 포함)까지
|
|
Node 진입점으로 새로 낸다. 브라우저가 보낸 값을 그대로 받아 적지 않는다.
|
|
|
|
**왜 서버인가(2026-09-06 저녁 사용자 확정)** — 속도가 아니라 보안이다. 저장 경로가
|
|
브라우저 계산이면 유토 배분·운반거리 코드가 번들에 남아야 해서 화면에서 안 그려도
|
|
뺄 수 없다. 서버가 정본을 내면 그 몫이 번들에서 빠진다. 대가는 저장 대기가
|
|
850ms → 약 980ms 인데(Node 131ms) 기다리는 조작이라 허용한다.
|
|
|
|
사용자가 끌어 옮긴 balloon 위치는 서버가 만들지 않으므로 저장분에서 떼어 도로 붙인다
|
|
(`B06_Section_Server_Calc_Prebuild`). 실패는 비치명적이다 — 저장은 그대로 남는다.
|
|
"""
|
|
try:
|
|
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
|
|
|
|
await recompute_server_side(project_id, route_id)
|
|
except Exception:
|
|
logger.exception(
|
|
"저장분 서버 재계산 실패 (저장은 유지): project_id=%s route_id=%s",
|
|
project_id,
|
|
route_id,
|
|
)
|
|
|
|
|
|
@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
|
|
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다 — 바뀐 벽·측점이 면적·유토곡선에 실린다.
|
|
await _recompute_stored_designs(project_id, route_id)
|
|
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
|
|
|
|
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다(임시저장과 같은 자리).
|
|
await _recompute_stored_designs(project_id, route_id)
|
|
|
|
# 측구 방향(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": "종횡단 확정 처리 중 오류가 발생했습니다."},
|
|
)
|