"""구조물 측점이 빠진 관·시설을 **알리고, 눌러서 만든다** (계획서 3-14 ㉯). 무엇이 문제였나 측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이다. 관을 저장하는 `PUT /drainage/pipe-points` 는 관 파일과 유역만 쓰고 측점을 다시 만들지 않는다. ⇒ **관을 나중에 놓거나 옮기면 그 측점이 안 생긴다.** 그 관은 횡단도에도 안 서고 수량·금액에서 통째로 빠지는데 **아무 말도 안 나온다**(실측: 배수관 넷). 왜 이 방식인가 (세 갈래 중 ㉯) ㉮ 관 저장 뒤 바로 만들기 — 그 엔드포인트에 노선·지표면 인자가 없어 끌어와야 함 ㉯ **알리고 [측점 만들기] 단추** — 누를 때만 돌아 비용이 적고 **왜 값이 없는지가 보임** ㉰ 그대로 두기 — 조용히 빠지는 것이 문제라 적어도 알림은 있어야 함 ⚠ **지어내지 않는 것** — 지표 샘플링 조건(어느 DTM·어느 방법)이 없으면 만들지 않는다. 조건이 다르면 그 측점만 다른 지표에서 뽑혀 **옆 측점과 지반고가 어긋난다.** 조건은 노선 확정 때 남긴 `B06_Section/sampling.json` 에서 읽고, 없으면 사유를 내고 막는다. """ from __future__ import annotations import asyncio import logging import math from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M from B05_Profile.B05_Profile_Repository import ( get_latest_route, get_surface_crs_epsg, update_longitudinal_grade_summary, ) from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B06 Section"]) #: 「그 자리에 측점이 있다」고 볼 거리(m) — 같은 측점 폭(213-1 · 반올림 차이 0.006m) 한 벌(219-1). #: 넓으면 조금 옮긴 관(769.28 → 769.38)을 「측점 있음」으로 세어 측점이 옛 자리에 남음(192-1). STATION_MATCH_TOLERANCE_M = SAME_STATION_SNAP_M SNAPSHOT_MISSING = ( "지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤" " 다시 눌러야 함. ⚠ 조건을 지어내면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋남" ) async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str, Any] | None: """이 프로젝트가 쓰는 지표 샘플링 조건. 둘 다 **기록된 값**이고 지어내지 않는다. ① 노선 [확정] 때 남긴 `B06_Section/sampling.json` — **그때 실제로 쓴 조건**이라 1순위. ② 없으면 1단계(지표 확정) 저장값 — B06 화면 `context` 가 쓰는 그 값이라 같은 조건이다. 옛 프로젝트는 ①이 없으므로 이 길이 없으면 단추가 영영 안 돈다. """ from common_util.common_util_surface_confirmation import get_surface_confirmation_params snapshot = load_sampling_snapshot(project_root) params = await run_with_connection(get_surface_confirmation_params, str(project_id)) surface = (params or {}).get("source_filter"), (params or {}).get("method") # 설계 지형이 바뀌었으면(B04 재확정 · 노선 [확인] 지형 바꿈 PLAN 133-2) ①은 옛 지형 — 버림. if snapshot is not None and ( not all(surface) or (snapshot["filter_key"], snapshot["method"], bool(snapshot.get("smooth"))) == (*surface, bool(params.get("smooth"))) ): return snapshot if not params or not params.get("source_filter") or not params.get("method"): return None return { "filter_key": params["source_filter"], "method": params["method"], "smooth": bool(params.get("smooth")), "surface_model_id": None, "source": "stage1", } def _planted(project_root: Path, route_data_path: str) -> list[tuple[float, str]]: """관 · 구조물 정본이 심는 구조물 측점 가운데 **노선 안**(세울 수 있는 것)만.""" from B05_Profile.B05_Profile_Engine_Sections import ( _load_pipe_points, _load_route_polyline, resolve_extra_stations, ) polyline = _load_route_polyline(project_root, route_data_path) extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) # 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄) end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:])) return [ (float(chainage), label) for chainage, label in extras if float(chainage) >= -STATION_MATCH_TOLERANCE_M and (end_m <= 0 or float(chainage) <= end_m + STATION_MATCH_TOLERANCE_M) ] def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]: """구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록.""" planted = _planted(project_root, route_data_path) if not planted: return [] existing = _station_chainages(project_root) missing = [ {"chainage_m": round(value, 3), "label": label} for value, label in planted if all(abs(value - other) > STATION_MATCH_TOLERANCE_M for other in existing) ] return sorted(missing, key=lambda item: item["chainage_m"]) def _station_chainages(project_root: Path) -> list[float]: """종단 정본에 실제로 서 있는 측점 누가거리. 파일이 없으면 빈 목록.""" import json folder = project_root / "B06_Section" / "longitudinal" values: list[float] = [] for path in sorted(folder.glob("*.json")): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): continue for station in data.get("stations") or []: chainage = station.get("chainage_m") if isinstance(chainage, (int, float)): values.append(float(chainage)) return values def _stale_marks(project_root: Path, route_data_path: str) -> int: """종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이 떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음.""" import json planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)} standing: set[float] = set() for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): continue for station in data.get("stations") or []: if station.get("kind") == "irregular" or station.get("structure"): standing.add(round(float(station.get("chainage_m") or 0.0), 2)) return len(standing ^ planted) async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None: async def _load(connection): stored = await get_project_storage_relative_path(connection, project_id) route = await get_latest_route(connection, project_id) return stored, route stored, route = await run_with_connection(_load) if not stored or not route: return None return Path(resolve_stored_project_path(stored)), route async def _generation_inputs( project_id: UUID, route: dict[str, Any], snapshot: dict[str, Any] ) -> tuple[Any, int | None, dict[str, Any] | None]: """측점을 뜰 때 쓰는 저장분 — (횡단 옵션, 좌표계 EPSG, 종단 행) · 만들기 · 미리 뜨기 한 벌.""" from B06_Section.B06_Section_Repository import ( get_latest_section_options, get_longitudinal_section, ) async def _load(connection): options = await get_latest_section_options(connection, project_id) crs_epsg = await get_surface_crs_epsg( connection, project_id, snapshot.get("surface_model_id") ) longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) return options, crs_epsg, longitudinal return await run_with_connection(_load) async def _regenerate_structure_stations( project_id: UUID, project_root: Path, route: dict[str, Any], snapshot: dict[str, Any] ) -> int: """구조물 측점 전체를 노선 확정 때와 같은 조건으로 다시 떠 종단 정본에 병합한다. 뜬 수. ⚠ 종단 병합이 비정규 측점을 **통째로 교체**하므로 빠진 것만이 아니라 전체를 뜬다. """ from B05_Profile.B05_Profile_Engine_Sections import ( _load_pipe_points, _load_route_polyline, generate_irregular_sections, resolve_extra_stations, ) from B05_Profile.B05_Profile_Router_Confirm import ( _merge_irregular_into_longitudinal, _section_options_from_stored, drop_rows_off_stations, ) stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot) def _regenerate() -> tuple[int, dict[str, Any] | None]: polyline = _load_route_polyline(project_root, str(route["route_data_path"])) pipes = _load_pipe_points(project_root, polyline) extras = resolve_extra_stations(project_root, pipes) stations = generate_irregular_sections( project_root, str(route["route_data_path"]), str(snapshot["filter_key"]), str(snapshot["method"]), bool(snapshot.get("smooth")), extra_stations=extras, options=_section_options_from_stored(stored_options), crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) summary = None if longitudinal: # 빈 목록이어도 맞춤 — 옛 구조물 측점을 지움(272) summary = _merge_irregular_into_longitudinal( project_root, str(longitudinal["longitudinal_file_path"]), stations ) return len(stations), summary made, summary = await asyncio.to_thread(_regenerate) if longitudinal: await run_with_connection( drop_rows_off_stations, route["id"], project_root, str(longitudinal["longitudinal_file_path"]), ) if summary is not None: # 옮긴 관 자리의 계획선이 다시 셈됨 — DB 등급 요약도 같이(계획선 저장과 같은 길). await run_with_connection( lambda connection: update_longitudinal_grade_summary( connection, route_id=route["id"], grade_summary=summary ) ) return made async def ensure_structure_stations(project_id: UUID) -> int: """빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎). 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리 횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다. 만들 수 없으면(노선 · 샘플링 조건 없음 · 빠진 것 없음) 0 — 저장을 막지 않는다. """ paths = await _project_paths(project_id) if paths is None: return 0 project_root, route = paths missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"])) # 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272) stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"])) if not missing and not stale: return 0 snapshot = await _sampling_conditions(project_id, project_root) if snapshot is None: logger.warning( "B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale ) return 0 await _regenerate_structure_stations(project_id, project_root, route, snapshot) return len(missing) or stale @router.get("/{project_id}/section/missing-stations") async def get_missing_stations(project_id: UUID) -> JSONResponse: """측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다.""" try: paths = await _project_paths(project_id) if paths is None: return JSONResponse(content={"status": "success", "missing": [], "can_create": False}) project_root, route = paths missing = await asyncio.to_thread( _missing_marks, project_root, str(route["route_data_path"]) ) snapshot = await _sampling_conditions(project_id, project_root) except Exception: logger.exception("B06 측점 점검 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "측점을 점검하지 못했습니다."}, ) return JSONResponse( content={ "status": "success", "missing": missing, "can_create": bool(missing) and snapshot is not None, "reason": "" if snapshot is not None else SNAPSHOT_MISSING, } ) @router.post("/{project_id}/section/missing-stations") async def create_missing_stations(project_id: UUID) -> JSONResponse: """빠진 구조물 측점을 **노선 확정 때와 같은 조건으로** 만들어 종단 정본에 병합한다. ⚠ 구조물 측점 전체를 다시 뜬다 — 종단 병합이 비정규 측점을 **통째로 교체**하므로 빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다. """ try: paths = await _project_paths(project_id) if paths is None: return JSONResponse( status_code=404, content={"status": "error", "message": "확정된 노선이 없습니다."}, ) project_root, route = paths snapshot = await _sampling_conditions(project_id, project_root) if snapshot is None: return JSONResponse( status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING}, ) missing = await asyncio.to_thread( _missing_marks, project_root, str(route["route_data_path"]) ) if not missing: return JSONResponse(content={"status": "success", "created": 0, "missing": []}) made = await _regenerate_structure_stations(project_id, project_root, route, snapshot) except Exception: logger.exception("B06 측점 만들기 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "측점을 만들지 못했습니다."}, ) return JSONResponse( content={ "status": "success", # 새로 선 것만 세어 낸다 — 다시 뜬 총수(`made`)와 다르다. "created": len(missing), "regenerated": made, "missing": missing, } ) #: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게. PREVIEW_MAX_STATIONS = 60 class PreviewStationsRequest(BaseModel): """미리 뜰 자리(누가거리 m) — 노선 밖 · 숫자 아닌 값은 서버가 버림.""" model_config = ConfigDict(extra="forbid") chainages: list[float] = Field(default_factory=list, max_length=PREVIEW_MAX_STATIONS) @router.post("/{project_id}/section/preview-stations") async def preview_structure_sections( project_id: UUID, request: PreviewStationsRequest ) -> JSONResponse: """초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 **미리 떠서 돌려만 준다**(273-2 · 요구 168). 파일 · DB 에 아무것도 안 쓴다 — 화면이 캐시에 얹어 B06 카드를 [저장] 전에 보이고, [저장] 때 서버가 같은 길(`sample_structure_sections` · `compute_default_designs`)로 다시 만들어 정본에 씀. 샘플링 조건은 측점 만들기와 같다(없으면 지어내지 않고 409). """ from B05_Profile.B05_Profile_Engine_Sections import sample_structure_sections from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored from B06_Section.B06_Section_Router_Design import compute_default_designs chainages = sorted({round(c, 3) for c in request.chainages if math.isfinite(c) and c >= 0}) if not chainages: return JSONResponse(content={"status": "success", "cross_sections": []}) try: paths = await _project_paths(project_id) if paths is None: return JSONResponse( status_code=404, content={"status": "error", "message": "확정된 노선이 없습니다."}, ) project_root, route = paths snapshot = await _sampling_conditions(project_id, project_root) if snapshot is None: return JSONResponse( status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING} ) stored_options, crs_epsg, longitudinal = await _generation_inputs( project_id, route, snapshot ) if not longitudinal: return JSONResponse(content={"status": "success", "cross_sections": []}) def _preview() -> list[dict[str, Any]]: _stations, crosses = sample_structure_sections( project_root, str(route["route_data_path"]), str(snapshot["filter_key"]), str(snapshot["method"]), bool(snapshot.get("smooth")), extra_stations=tuple((chainage, "") for chainage in chainages), options=_section_options_from_stored(stored_options), crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) wanted = set(chainages) picked = { round(float(cross["chainage_m"]), 3): cross for cross in crosses if round(float(cross["chainage_m"]), 3) in wanted } designs = compute_default_designs( project_root, str(longitudinal["longitudinal_file_path"]), list(picked), crosses=picked, ) for chainage, design in designs: picked[round(chainage, 3)]["design"] = design return list(picked.values()) sections = await asyncio.to_thread(_preview) except Exception: logger.exception("B06 미리 뜨기 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "횡단을 미리 뜨지 못했습니다."}, ) return JSONResponse(content={"status": "success", "cross_sections": sections})