새 사용자 값을 더할 때 **네 곳을 다 고쳐야** 했고, 하나만 빠지면 그 값이 저장에서
조용히 사라졌음(`extra_spans` 실사고 `b6941bd2`). 나열을 걷어 목록 한 벌로 모음.
- 확정·저장 payload 조립(`_Router_Confirm.py`) — `if patch_item.X is not None:` 11개를
스키마 통째 덤프 + 최상위 None 걷기로 바꿈. 새 필드가 저절로 실림.
중첩 None 은 남김(기슭막이 4축 `d: null` 은 「자동」이라는 뜻).
- 브라우저 payload(`_Section_Store.ts`) — `put("X", design.X)` 10줄을 목록 순회로.
- `_Cross_Refresh.ts` 에 `USER_TOUCHED_KEYS` 를 내보내고 `PRESERVED_KEYS` 는
거기에 상태 둘을 더해 만들게 함. 서버 목록과 짝임을 머리에 적음.
전수 조사 결과 `design` 키는 세 갈래였음 — ① 재계산 인자로 되먹여 사는 것 8개
② 목록으로 베껴야 사는 것 12개 ③ 순수 계산값 24개. 위험한 칸은 ② 하나뿐임.
시험 3건 추가:
- `test_b06_design_key_split.py` — 사용자 값이 ①·② 어디에도 안 걸리면 깨짐(불변식).
지금 코드는 통과 = 알려지지 않은 구멍 없음.
- `test_b06_recompute_keeps_user_values.py` — 포장 재계산 뒤 ② 값 보존·① 인자 전달.
- `test_b06_user_touched_keys.py` — 손나열이 되살아나면 깨지는 검사 둘 추가.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
349 lines
16 KiB
Python
349 lines
16 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)
|
|
|
|
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()
|
|
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": "종횡단 확정 처리 중 오류가 발생했습니다."},
|
|
)
|