Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-19 17:24:18 +09:00
6 changed files with 152 additions and 37 deletions
+5 -3
View File
@@ -22,7 +22,8 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) ·
추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) · 1차 계획선(438).
추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) · 1차 계획선(438) ·
횡단 손 입력도 안 넘김(요구 277 · 측점마다 기본 설계).
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
"""
@@ -275,8 +276,9 @@ async def _carry_profile(
def carries_cross_inputs(initial: bool) -> bool:
"""횡단 손 입력을 새 측점(같은 자리)에 넘기나 — [적용]도 넘김(475-3 · 사용자 답 대기 476)."""
return True
"""횡단 손 입력을 새 측점(같은 자리 · 475-3)에 넘기나 — 추천 [적용]은 안 넘김(요구 277 · 새 노선
기준 초기화 · 종단 편집 · 횡단 손 입력 전부 버림) · 손 노선 [저장]만 넘김."""
return not initial
async def _carry_cross(
+35 -26
View File
@@ -22,7 +22,6 @@
"""
import asyncio
import csv
import functools
import json
import logging
@@ -49,7 +48,6 @@ from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이
)
from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import (
densify_route,
expected_route_csv_path,
planned_route_initial_path,
planned_route_working_path,
@@ -59,11 +57,6 @@ from common_util.common_util_route_geometry import (
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system import (
ROUTE_DIRECT_LINK_CELL_FACTOR,
ROUTE_GRID_RES_M,
ROUTE_PLANNED_DENSIFY_SAFETY,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
@@ -307,25 +300,6 @@ def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
return summary
def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int:
"""계획노선(수정본) CSV를 쓴다. 열 이름은 `read_planned_route_csv()`가 아는 것.
쓰기 전에 **조밀화**한다 — 정점이 성기면 B05 격자 탐색이 원좌표를 그대로 잇지 못하고
제 나름의 길을 찾아 사용자가 그린 선과 달라진다(`densify_route` 주석). 평면 형상은
바뀌지 않고 같은 선 위에 점만 더 찍힌다.
"""
dense = densify_route(
vertices,
ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY,
)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(("sequence", "x", "y"))
writer.writerows((index, round(x, 4), round(y, 4)) for index, (x, y) in enumerate(dense))
return len(dense)
async def _recompute(
project_id: UUID,
project_root: Path,
@@ -552,7 +526,42 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
"""노선 초안을 정본으로 저장한다(346-3 · 요청 · 응답 모양 = `B05_Profile_Route_Save` 머리).
배수유역은 다시 안 셈(낡음 표시) · 초기값은 추천 [적용](`as_initial`)일 때만 다시 뜬다.
추천 [적용] = 즉시 저장(요구 275 ②) · [저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(476 ·
472-6 `common_util_save_guard`) · 응답 `waited` · ⚠ `/design/save` 잠금 안에서 as_initial 금지.
"""
if not getattr(body, "as_initial", False):
return await _save_route(project_id, body)
from B06_Section.B06_Section_Router_DesignSave import _restore
from common_util import common_util_save_guard as guard
lock = guard.project_lock(str(project_id))
waited = lock.locked()
async with lock:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
pool = get_db_pool()
async with pool.acquire() as connection: # 설계 지형 바꿈(지표면 확정)도 떠 둠
rows = await guard.snapshot_rows(connection, str(project_id), surface=True)
files = await asyncio.to_thread(
guard.snapshot_storage, paths[0], guard.STORAGE_SCOPE + guard.PIPE_SCOPE
)
try:
result = await _save_route(project_id, body)
except Exception:
await _restore(project_id, rows, files)
raise
else:
if isinstance(result, JSONResponse) and result.status_code >= 400:
await _restore(project_id, rows, files)
return result
finally:
files.discard()
return {**result, "waited": waited}
async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any] | JSONResponse:
"""노선 저장 본체(잠금 · 떠 두기는 부르는 쪽 — `replan_route` · `/design/save`)."""
as_initial = bool(getattr(body, "as_initial", False)) # 옛 요청 꼴(파이썬 호출)도 받음
try:
request = body.resolved() if isinstance(body, RouteSaveRequest) else body
@@ -21,7 +21,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 교점 추가 · 삭제 | 편집기 메모리 → `route-draft` | 묶음 전 단계 · 측점 수 바뀜 → 갈아끼움 | 같음 | 같음 | 11 | `B05_Profile_UI_RouteEdit_Input.ts:149-158` · `logic/B05_Profile_UI_RouteEdit_Edits.ts:410-426` |
| 곡선 R · L | 편집기 `curveRadius` · `curveArc` → `route-draft` | 묶음 전 단계 · 확폭 · MC 이동 → 곡선 열쇠 옮김(`curveMoves`) | 같음 | `/design/save` `route` 칸 — 넣은 R · L 로 서버가 같은 규칙 | 11 | `B05_Profile_UI_RouteEdit_CurveBar.ts:76-162` · `logic/B05_Profile_Sections_Stations.ts:332-337` · `B05_Profile_Router_Replan.py:582 · 599-600` |
| 스냅 켬 · 간격 | 간격 = pref `routeedit-snap-step` · 켬 = `route-draft.stationSnap` | 켬 = 곡선 IP 를 옮김(한 걸음) → 묶음 전 단계 | — | 곡선표 파일 · 서버는 측점만 맞춤(IP 맞춤은 브라우저만) | 3 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` · `logic/design_pipeline_stations.ts:52-59` · `B05_Profile_Engine_Sections_Core.py:457-464` |
| 추천 노선 [적용] | result `routeedit-initial` → `route-draft` | 묶음 한 바퀴 | 미리 셈 `/route/initial` → `route_recommend/*` | **저장 밖** — 바로 `/route/replan`(as_initial) · 종단 = 새 규칙 초기 종단(옛 계획고 · 편집 · L 안 옮김 · 476) · 초기값 다시 뜸 · 초안 전부 지움 · 페이지 다시 세움 | 11 | `B05_Profile_UI_RouteEdit_Initial.ts:139 · 161-181` · `B05_Profile_UI_RouteEdit_Apply.ts:211 · 220-245` · `B05_Profile_Router_Replan.py:659-662` |
| 추천 노선 [적용] | result `routeedit-initial` → `route-draft` | 묶음 한 바퀴 | 미리 셈 `/route/initial` → `route_recommend/*` | **저장 밖** — 바로 `/route/replan`(as_initial) · 종단 = 새 규칙 초기 종단 · 횡단 = 기본 설계(옛 계획고 · 편집 · L · 횡단 손 입력 안 옮김 · 476 · 요구 277) · [저장]과 같은 잠금 · 초기값 다시 뜸 · 초안 전부 지움 · 페이지 다시 세움 | 11 | `B05_Profile_UI_RouteEdit_Initial.ts:139 · 161-181` · `B05_Profile_UI_RouteEdit_Apply.ts:211 · 220-245` · `B05_Profile_Router_Replan.py:659-662` |
## 시설(관 · 구조물 · 추가 측점)
+2 -2
View File
@@ -30,7 +30,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 측점 열쇠 = `common_util_station_key` 한 벌 · 노선 바뀜 = 관 · 구조물 좌표(x · y) 따라감 · 누가거리 다시 매김 | `common_util_station_key.ts:32` · `common_util_route_follow.ts:291` | `test_344_3_station_key_mirror` · `test_410_follow_xy` |
| 노선 바뀜 넘김 = 열쇠마다 지반 · 측구 종류 · 측구 선택 · 사면 쉬프트 · 소단 · 소단 깊이 · 자리 칸(`SEAT_KEYS` = 암 경계 · 암 절토 경사 · 2단 경사 · 손 상단측 = 단면 유형 · 측구 방향)은 같은 자리(`sameSeat` · 중심 0.01 m · 축 1e-3) 측점만 · 새 자리 = 기본값 · 세션 값은 쓴 때 자리(`@seat`)로 판정(↶ ↷ 유지) · 끌기 갈아끼움 · 미리보기 · Node 넘김 · 서버 노선 저장 한 규칙 | `logic/B06_Section_Station_Swap.ts` SEAT_KEYS · unseated · `logic/B06_Section_Route_Carry.ts` carryKeyedStates · `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` crossAtSeat · `B05_Profile_Route_Save_Parts.py` carry_cross_inputs | `test_475_3_seat_carry` |
| 노선 초안 ↶(버림) = 차례 하나 · 버림 동안 종단도 그리기 멈춤 · 낡음 다시 셈 미룸 → 되돌림 · 저장 기준 맞춤 · 뒤 차례 끝에 한 번 그림(사이 섞인 유토곡선 없음) | `design_pipeline_page.ts:642 · 403` · `B06_Section_Section_Store.ts:316` | `test_472_5c_drop_once` |
| 추천 [적용] 종단 = 새 규칙 초기 종단 — 끔 · 균형 셈 실패 = 1차 계획선(438 · L 20) · 켬 = 균형 종단을 꼭짓점에 얹음(438) · 옛 계획고 · 편집 · L 안 옮김 · 손 노선 저장만 옮김 | `B05_Profile_Route_Save.py` run_route_save_chain `initial` · 미리 보기 = `B05_Profile_Engine_InitialProfile`(같은 조각) | `test_476_1_apply_initial` · `test_476_1_recommend_initial` |
| 추천 [적용] = 새 노선 기준 초기화 — 종단 = 새 규칙 초기 종단(끔 · 균형 셈 실패 = 1차 계획선 438 · L 20 · 켬 = 균형 종단을 꼭짓점에 얹음) · 옛 계획고 · 편집 · L · 횡단 손 입력 안 넘김(요구 277 · 측점마다 기본 설계) · 손 노선 저장만 옮김 · 넘김 | `B05_Profile_Route_Save.py` run_route_save_chain `initial` · carries_cross_inputs · 미리 보기 = `B05_Profile_Engine_InitialProfile`(같은 조각) | `test_476_1_apply_initial` · `test_476_1_recommend_initial` |
| B05 · B06 캐시 = 한 벌 · [저장] 한 번에 함께 나감 | `design_shared.ts:395-448` · `design_save.ts:237` | — |
| 관 · 구조물 주인 = 가게 칸 하나 — 화면 사본은 가게 알림으로 맞춤(제 쓰기는 같은 값 · 남 고침은 다시 실음) | `design_view_profile.ts` 가게 구독 · `design_view_cross.ts` onShop · `B06_Section_UI_Page_Structures_Panel.ts` seen | `test_472_5_shop_owner` |
| 정본 자리 = 값을 낳은 주인 — 노선 시설 = `pipe_points.json` · `structures.json` · 횡단 값 = 횡단 패치 | `B05_Profile_Api_Pipes_Draft.ts:30-37` · `B05_Profile_Structures_Router.py:386-405` · `B06_Section_Section_Store.ts:449` | — |
@@ -53,7 +53,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 내용 | 자리(파일:줄) | 상태 |
|---|---|---|
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용](즉시 저장 · [저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림 · 476 ④) | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` · `B05_Profile_Router_Replan.py` replan_route | 사용자 확정(요구 275 ②) · 확인창 |
| `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 |
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 |
| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · 암선 · 암 경사 · 상단측 = 세션 칸이 주인(사본 없음 · 472-5) | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore · `B06_Section_UI_Page_Persist.ts` readCells | 관 · 구조물 = `test_472_5_shop_owner` · 관 초안 없는 걸음 = 저장분 · ↶ 켜짐 = `test_472_d_b05_pipe_undo` · 조정창 = `test_472_5d_adjust_owner` · 암선 · 암 경사 = `test_472_5r_rock_owner` |
@@ -467,6 +467,12 @@ def api_world(monkeypatch, tmp_path):
monkeypatch.setattr(
route_initial, "refresh_initial_snapshot", lambda *a: calls.append(a) or _async(None)()
)
# 476 ④ — 추천 [적용]은 [저장]과 같은 잠금 · 떠 두기(DB · storage) 안에서 돎
import common_util.common_util_save_guard as guard
monkeypatch.setattr(replan, "get_db_pool", lambda: _Pool([]))
monkeypatch.setattr(guard, "snapshot_rows", _async({}))
monkeypatch.setattr(guard, "snapshot_storage", lambda *a: SimpleNamespace(discard=lambda: None))
return calls
+103 -5
View File
@@ -13,6 +13,8 @@ import sys
from pathlib import Path
from uuid import uuid4
import pytest
ROOT = Path(__file__).resolve().parents[2]
TESTER = Path(__file__).resolve().parent
for path in (ROOT, TESTER):
@@ -96,11 +98,107 @@ def test_적용은_체인에_initial(api_world, monkeypatch):
monkeypatch.setattr(replan, "_recompute", recompute)
body = save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}])
asyncio.run(replan.replan_route(uuid4(), body))
asyncio.run(replan.replan_route(uuid4(), body.model_copy(update={"as_initial": True})))
asyncio.run(replan._save_route(uuid4(), body)) # 잠금 · 떠 두기 밖 본체(④)
asyncio.run(replan._save_route(uuid4(), body.model_copy(update={"as_initial": True})))
assert seen == [False, True]
def test_횡단_손_입력_넘김_한_자리():
# 사용자 답 전 = [적용]도 넘김(475-3) — 답 뒤 이 함수 한 줄
assert save.carries_cross_inputs(True) is True and save.carries_cross_inputs(False) is True
def test_적용은_횡단_손_입력_버림(chain_world, monkeypatch):
# 요구 277 — 추천 [적용] = 새 노선 기준 초기화(횡단 손 입력 전부 버림) · 손 노선 [저장]은 넘김
import B06_Section.B06_Section_Repository as b06_repo
seen: list = []
async def carry(project_id, root, route_id, relative, old_long, old_designs, *rest):
seen.append(old_designs)
return 0
designs = [{"chainage_m": 20.0, "design": {"rock_boundary_offset_m": -0.7}}]
monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async(designs))
monkeypatch.setattr(save, "_carry_cross", carry)
_run(chain_world, initial=True)
_run(chain_world)
assert seen == [[], designs] # 옛 코드 = [적용]도 손 입력 넘김
assert save.carries_cross_inputs(True) is False and save.carries_cross_inputs(False) is True
# ── ④ [적용] = [저장]과 같은 잠금 · 떠 두고 실패면 되돌림 ─────────────────────────
class _Files:
def discard(self):
pass
@pytest.fixture
def guarded(monkeypatch, tmp_path):
from fastapi.responses import JSONResponse
import B06_Section.B06_Section_Router_DesignSave as design_save
import common_util.common_util_save_guard as guard
log: list = []
class _Pool:
def acquire(self):
class _A:
async def __aenter__(self):
return None
async def __aexit__(self, *exc):
return False
return _A()
async def save(project_id, body):
rev = int(body.vertices[0].x)
log.append(("start", rev))
await asyncio.sleep(0.05)
log.append(("end", rev))
if rev == 9:
return JSONResponse(status_code=409, content={"status": "error", "message": "끊김"})
return {"status": "success", "route_id": 1}
async def restore(project_id, rows, files):
log.append(("restore", rows))
return True
monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p")))
monkeypatch.setattr(replan, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(replan, "_save_route", save)
monkeypatch.setattr(guard, "snapshot_rows", lambda *a, **k: log.append("rows") or _async("R")())
monkeypatch.setattr(guard, "snapshot_storage", lambda *a: log.append("files") or _Files())
monkeypatch.setattr(design_save, "_restore", restore)
return log
def _apply(rev: int, as_initial: bool = True):
body = save.RouteSaveRequest(
vertices=[{"x": rev, "y": 0}, {"x": 5, "y": 5}], as_initial=as_initial
)
return replan.replan_route(_PID, body) # 같은 프로젝트 = 같은 잠금
_PID = uuid4()
def test_적용_겹치면_기다림(guarded):
async def both():
return await asyncio.gather(_apply(1), _apply(2))
first, second = asyncio.run(both())
assert first["waited"] is False and second["waited"] is True
starts = [entry for entry in guarded if isinstance(entry, tuple) and entry[0] != "restore"]
assert starts == [("start", 1), ("end", 1), ("start", 2), ("end", 2)] # 하나씩
assert "rows" in guarded and "files" in guarded
def test_적용_실패면_되돌림(guarded):
response = asyncio.run(_apply(9))
assert response.status_code == 409
assert ("restore", "R") in guarded
def test_손_노선_저장은_잠금_밖(guarded):
result = asyncio.run(_apply(1, as_initial=False))
assert "waited" not in result and "rows" not in guarded # /design/save 가 잠금 · 떠 둠