Files
Aislo/B06_Section/B06_Section_Router_Confirm.py
eomsangdonandClaude Opus 5 f3f37494f2 fix(B06): [저장]이 「행은 있는데 설계가 빈 측점」도 채우게 함
재생성이 `cross_sections` 행을 지우고 다시 쓰면 `data.design` 에 면적 4키만 남는다.
`get_cross_section_designs` 는 `ground_type` 있는 줄만 담으므로 그 상태는 「설계 없음」이고,
B08 토적표가 통째로 0 이 됐다(창 셋에서 같은 증상).

원인은 결함이 아니라 분담이었다 — 체인은 재생성 뒤 [확정]을 불러 그 자리를 채웠고,
재생성만 따로 도는 길에는 그 단계가 없었다. 그래서 [확정] 없이도 정본이 서게 한다.

- [저장]이 확정과 **같은 조회**(`get_cross_sections_missing_design_chainages`)로
  빈 측점을 골라 기본 설계로 채운다.
- ⚠ **값이 있는 행은 안 건드린다** — 사용자 조작값·이월분이 거기 있다. 시험으로 잠금.
- 실측(route 184): 저장 뒤 DB 설계 62행·38키 복구, 층따기 13,696.8 ㎡,
  측구 토사 0.173 / 암 8.886 ㎡ — 파일 기반 값과 일치.

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

355 lines
17 KiB
Python

"""B06 종횡단 **저장·확정** 라우터.
`_Router.py`가 700줄을 넘겨 조회·계산(그쪽)과 저장·확정(여기)을 갈랐다.
임시 저장과 확정은 **저장하는 내용이 같다**(표준횡단 설정 · 유토곡선 · 측점별 암 경계선).
다른 것은 뒤처리뿐이다 — 확정만 미지정 측점을 기본값으로 채우고, 경로 상태를 CONFIRMED로
바꾸고, 워크플로 단계를 닫고, 측구 방향을 B05 종단 정본에 역반영한다. 그래서 공통 저장을
`_apply_section_edits()` 하나로 두고 두 엔드포인트가 함께 쓴다.
"""
import asyncio
import json
import logging
import time
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 B03_FileInput.B03_FileInput_Service_Chain import _log_steps
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:
# 필드를 손으로 나열하지 않는다(2026-09-07) — 나열이 네 곳에 흩어져 있어 새 값을
# 더할 때 한 곳만 빠지면 그 값이 **조용히 사라졌다**(`extra_spans` 실사고 `b6941bd2`).
# 스키마가 곧 목록이므로 통째로 덤프하고 **최상위 None 만** 걷는다.
# (중첩 None 은 남긴다 — 기슭막이 4축의 `d: null` 은 「자동」이라는 뜻이다.)
dumped = patch_item.model_dump()
patch: dict[str, Any] = {
key: value
for key, value in dumped.items()
if key != "chainage_m" and value is not None
}
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)
# 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이
# 없거나 **`ground_type` 이 없는 것**이다 — 재생성이 행을 다시 쓰면 면적 4키만
# 남는데, 그 상태를 화면·B08 이 「설계 없음」으로 읽어 토적표가 통째로 0 이 됐다.
# 예전에는 체인이 뒤이어 부르는 [확정]이 이 자리를 채워 가려져 있었다.
# ⚠ **값이 있는 행은 건드리지 않는다** — 사용자 조작값·이월분이 거기 있다.
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
rowless = 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 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()
marks = [("시작", time.perf_counter())]
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)
marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter()))
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,
)
marks.append(("기본설계 계산(미지정 측점)", time.perf_counter()))
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
marks.append(("확정 저장(트랜잭션)", time.perf_counter()))
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다(임시저장과 같은 자리).
await _recompute_stored_designs(project_id, route_id)
marks.append(("서버 재계산", time.perf_counter()))
# 측구 방향(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,
)
marks.append(("측구 방향 B05 역반영", time.perf_counter()))
_log_steps("B06 확정", marks)
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": "종횡단 확정 처리 중 오류가 발생했습니다."},
)