diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 9df0672e..57a6eb68 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -88,6 +88,22 @@ def _rowless_station_chainages( return result +async def _stand_patch_stations( + project_id: UUID, request: SectionConfirmRequest | None, known: list[float] +) -> None: + """행 없는 측점의 횡단 수정 — 버리지 않고 그 측점부터 세운다(B05·B06 한 페이지처럼). + + 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없다. 여기서 + [측점 만들기]와 같은 조건으로 세우면 아래 `_rowless_station_chainages` 가 행을 만들고 + 수정이 그 행에 얹힌다. + """ + patches = (request.cross_patches or []) if request else [] + if any(all(abs(p.chainage_m - k) >= 0.01 for k in known) for p in patches): + from B06_Section.B06_Section_Router_Stations import ensure_structure_stations + + await ensure_structure_stations(project_id) + + async def _apply_section_edits( connection: aiomysql.Connection, route_id: int, @@ -196,6 +212,7 @@ async def save_sections( # ⚠ **값이 있는 행은 건드리지 않는다** — 사용자 조작값·이월분이 거기 있다. missing = await get_cross_sections_missing_design_chainages(connection, route_id) + await _stand_patch_stations(project_id, request, known) project_root = Path(resolve_stored_project_path(stored_path)) rowless = missing + await asyncio.to_thread( _rowless_station_chainages, @@ -268,6 +285,7 @@ async def confirm_sections( missing = await get_cross_sections_missing_design_chainages(connection, route_id) known = await get_cross_section_chainages(connection, route_id) + await _stand_patch_stations(project_id, request, known) marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter())) project_root = Path(resolve_stored_project_path(stored_path)) # 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면 diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 53e73e5e..e6b68584 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -129,6 +129,83 @@ async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None return Path(resolve_stored_project_path(stored)), route +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, + ) + 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 + + stored_options, crs_epsg, longitudinal = await run_with_connection(_load) + + def _regenerate() -> int: + 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, + ) + if stations and longitudinal: + _merge_irregular_into_longitudinal( + project_root, str(longitudinal["longitudinal_file_path"]), stations + ) + return len(stations) + + return await asyncio.to_thread(_regenerate) + + +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"])) + if not missing: + return 0 + snapshot = await _sampling_conditions(project_id, project_root) + if snapshot is None: + logger.warning("B06 저장: 빠진 구조물 측점 %d곳 — 샘플링 조건이 없어 못 세움", len(missing)) + return 0 + await _regenerate_structure_stations(project_id, project_root, route, snapshot) + return len(missing) + + @router.get("/{project_id}/section/missing-stations") async def get_missing_stations(project_id: UUID) -> JSONResponse: """측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다.""" @@ -164,21 +241,6 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse: ⚠ 구조물 측점 전체를 다시 뜬다 — 종단 병합이 비정규 측점을 **통째로 교체**하므로 빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다. """ - 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, - ) - from B06_Section.B06_Section_Repository import ( - get_latest_section_options, - get_longitudinal_section, - ) - try: paths = await _project_paths(project_id) if paths is None: @@ -199,37 +261,7 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse: if not missing: return JSONResponse(content={"status": "success", "created": 0, "missing": []}) - 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 - - stored_options, crs_epsg, longitudinal = await run_with_connection(_load) - - def _regenerate() -> int: - 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, - ) - if stations and longitudinal: - _merge_irregular_into_longitudinal( - project_root, str(longitudinal["longitudinal_file_path"]), stations - ) - return len(stations) - - made = await asyncio.to_thread(_regenerate) + made = await _regenerate_structure_stations(project_id, project_root, route, snapshot) except Exception: logger.exception("B06 측점 만들기 실패: project_id=%s", project_id) return JSONResponse( diff --git a/resources/tester/test_b06_patch_station.py b/resources/tester/test_b06_patch_station.py new file mode 100644 index 00000000..58481163 --- /dev/null +++ b/resources/tester/test_b06_patch_station.py @@ -0,0 +1,36 @@ +"""횡단 저장 — 행 없는 측점의 수정을 버리지 않고 그 측점부터 세우는지(PLAN 19장). + +옮긴 관 · 구조물 측점은 B05 확정 전이면 DB 행이 없다. 저장 · 확정이 그 자리 수정을 받으면 +`ensure_structure_stations`([측점 만들기]와 같은 길)를 불러 측점을 세운 뒤 행을 만든다. +DB · 파일 전체 왕복은 test_b05_b06_save_roundtrip [B06] 이 봄 — 여기서는 부르는 조건만. +""" + +import asyncio + +import B06_Section.B06_Section_Router_Stations as stations +from B06_Section.B06_Section_Router_Confirm import _stand_patch_stations +from B06_Section.B06_Section_Schema import SectionConfirmRequest + +PROJECT = "936be972-11bc-46c2-8bf3-b15d8de7df0d" + + +def _calls(monkeypatch, patches: list[float], known: list[float]) -> list[str]: + called: list[str] = [] + + async def fake(project_id): + called.append(str(project_id)) + return 1 + + monkeypatch.setattr(stations, "ensure_structure_stations", fake) + request = SectionConfirmRequest(cross_patches=[{"chainage_m": c} for c in patches]) + asyncio.run(_stand_patch_stations(PROJECT, request, known)) + return called + + +def test_행_없는_측점_수정이면_측점을_세움(monkeypatch): + assert _calls(monkeypatch, [120.0, 263.47], [120.0, 140.0]) == [PROJECT] + + +def test_모든_수정에_행이_있으면_안_부름(monkeypatch): + assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == [] + assert _calls(monkeypatch, [], [120.0]) == []