"""B06 카드 폭 넘김 다시 만들기(446) — 지반 표본을 넓혀 종 · 횡단을 다시 만들되 사용자 값은 그대로. `regenerate_route_sections` 는 종 · 횡단 행을 지우고 새로 만든다. 같은 노선 · 같은 측점이므로 측점 열쇠(누가거리 0.01 m 안)로 옛 횡단 행의 설계(지반 · 메모 · CAD · 상세 표 · 조절) · 상태를, 옛 종단 행의 사용자 키(표준 횡단면 · 유토 풍선 · 계획 입력 · 상태)를 같은 트랜잭션에서 새 행에 옮겨 담고, 계획고 편집(종단 정본 `profile_alignment`)을 옛 계획선 통째로 되살린 뒤 [저장]과 같은 서버 재계산으로 넓힌 지반 위에 다시 셈. 뒤 단계가 실패하면 종 · 횡단 파일을 옛것으로 되돌림. """ from __future__ import annotations import asyncio import json import logging from pathlib import Path from typing import Any from uuid import UUID import aiomysql from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B06_Section.B06_Section_Repository import get_longitudinal_section from B06_Section.B06_Section_Schema import SectionDetailResponse from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool logger = logging.getLogger(__name__) _MATCH_M = 0.01 # 측점 열쇠 — 저장 길(`update_cross_section_design`)과 같은 근사 def carried_longitudinal_data(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]: """새 종단 data — 생성 요약 · 옵션은 새것 · 나머지(표준 횡단면 · 유토 · 계획 입력)는 옛것.""" merged = {**old, **new, "options": {**(old.get("options") or {}), **(new.get("options") or {})}} if "grade_overrides" in old: # 사용자 계획 입력 — 다시 만들기는 늘 빈 값으로 냄 merged["grade_overrides"] = old["grade_overrides"] return merged def carried_cross_rows( old_rows: list[dict[str, Any]], new_rows: list[dict[str, Any]] ) -> tuple[list[tuple[str, str, int]], int]: """(새 행 UPDATE 값 [(data, status, id)], 못 옮긴 옛 행 수) — 새 요약 위에 옛 data · 상태.""" updates: list[tuple[str, str, int]] = [] used: set[int] = set() for row in new_rows: at = float(row["chainage_m"]) near = [ index for index, old in enumerate(old_rows) if abs(float(old["chainage_m"]) - at) < _MATCH_M ] if not near: continue index = max(near, key=lambda i: int(old_rows[i]["id"])) # 같은 측점 둘이면 최신 행 used.update(near) old = old_rows[index] data = {**_as_dict(old.get("data")), **_as_dict(row.get("data"))} updates.append((json.dumps(data, ensure_ascii=False), str(old["status"]), int(row["id"]))) return updates, len(old_rows) - len(used) def _as_dict(value: Any) -> dict[str, Any]: if isinstance(value, (str, bytes)): value = json.loads(value) return value if isinstance(value, dict) else {} async def _cross_rows(connection: aiomysql.Connection, route_id: int) -> list[dict[str, Any]]: async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( "SELECT id, chainage_m, data, status FROM cross_sections" " WHERE route_id = %s ORDER BY id", (route_id,), ) return list(await cursor.fetchall()) async def widen_route_sections( project_id: UUID, route_id: int, cross_half_width_m: float ) -> SectionDetailResponse | JSONResponse: """표본 폭 넘김 다시 만들기 — 사용자 값을 옮겨 담은 뒤 새 상세를 돌려줌.""" from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR from B05_Profile.B05_Profile_Route_Save import _Backup, _carry_profile, _read_json from B06_Section.B06_Section_Router import get_section_detail, regenerate_route_sections from B06_Section.B06_Section_Router_Confirm import _recompute_stored_designs pool = get_db_pool() async with pool.acquire() as connection: old_long = await get_longitudinal_section(connection, project_id, route_id) old_rows = await _cross_rows(connection, route_id) stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) relative = str((old_long or {}).get("longitudinal_file_path") or "") old_alignment = (_read_json(project_root / relative) if relative else {}).get( "profile_alignment" ) or {} stage_dir = project_root / _STAGE_SUBDIR backup = _Backup(project_root, [], [stage_dir / "longitudinal", stage_dir / "cross_sections"]) dropped = 0 async def carry(connection: aiomysql.Connection) -> None: nonlocal dropped updates, dropped = carried_cross_rows(old_rows, await _cross_rows(connection, route_id)) async with connection.cursor() as cursor: if updates: await cursor.executemany( "UPDATE cross_sections SET data = %s, status = %s WHERE id = %s", updates ) if old_long: await cursor.execute( "SELECT id, data FROM longitudinal_sections WHERE route_id = %s" " ORDER BY id DESC LIMIT 1", (route_id,), ) row = await cursor.fetchone() data = carried_longitudinal_data(old_long.get("data") or {}, _as_dict(row[1])) await cursor.execute( "UPDATE longitudinal_sections SET data = %s, status = %s WHERE id = %s", (json.dumps(data, ensure_ascii=False), old_long["status"], int(row[0])), ) try: made = await regenerate_route_sections( project_id, route_id, cross_half_width_m, carry=carry ) if isinstance(made, JSONResponse): await asyncio.to_thread(backup.restore) return made try: # 같은 노선 — `_carry_profile` 의 옛 계획선 통째 갈래(빈 정점열 = 바뀐 구간 없음) async with pool.acquire() as connection: new_long = await get_longitudinal_section(connection, project_id, route_id) new_relative = str((new_long or {}).get("longitudinal_file_path") or "") await _carry_profile(project_root, new_relative, route_id, old_alignment, [], []) except Exception as exc: # noqa: BLE001 — 계획고 편집을 잃느니 파일을 옛것으로 logger.exception("B06 표본 넓힘: 계획고 옮김 실패 project_id=%s", project_id) await asyncio.to_thread(backup.restore) return JSONResponse( status_code=500, content={"status": "error", "message": f"계획고 편집 옮김 실패: {exc}"}, ) finally: backup.drop() if dropped: logger.warning( "B06 표본 넓힘: 새 측점에 없는 옛 횡단 행 %d건 project_id=%s", dropped, project_id ) await _recompute_stored_designs(project_id, route_id) # [저장]과 같은 서버 재계산(실패 = 로그) return await get_section_detail(project_id, route_id)