fix(B05): 초기화가 저장된 관 지점을 남기던 문제

[초기화]가 화면 목록만 자동 배치로 되돌리고 edits/pipe_points.json은 그대로 두어,
페이지를 다시 열면 옛 관이 살아났다. B04와 같은 파일을 보므로 B04에도 그대로 남았다.

- DELETE /{project_id}/drainage/pipe-points 신설: 저장분과 파생물
  (04_detailed_basins.geojson)까지 지우고 자동 배치 결과를 돌려준다.
- clear_pipe_points() 헬퍼 추가.
- B05 [초기화] 버튼이 이 경로를 쓴다.
- loadSaved / analyze / resetPipes가 같은 뼈대를 반복해 run() 하나로 묶었다.

검증: 초기화 전 {user:1, stream:1, spacing:2} → 초기화 후 {stream:1, spacing:3},
두 파일 삭제 확인, 재조회도 같은 자동 배치.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 00:38:24 +09:00
co-authored by Claude Opus 5
parent 9cd70a14dc
commit a58fac58d0
4 changed files with 74 additions and 35 deletions
@@ -506,6 +506,15 @@ export async function computeDetailBasins(
});
}
/** 저장된 관 지점을 버리고 기본 관 + 자동 보충 배치로 되돌린다("초기화").
*
* 화면만 되돌리면 다시 들어왔을 때 옛 관이 살아나므로 저장분까지 지운다. */
export async function resetDetailPipePoints(projectId: string): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
method: "DELETE",
});
}
/** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */
export async function saveDetailPipePoints(
projectId: string,
@@ -28,6 +28,7 @@ from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_USER,
PipePoint,
clear_pipe_points,
load_pipe_points,
parse_pipe_points,
route_signature,
@@ -218,6 +219,25 @@ async def post_detail_basins(
return _payload(project_id, context, detail, points, saved=False)
@router.delete("/{project_id}/drainage/pipe-points", response_model=None)
async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""저장된 관 지점을 버리고 기본 관 + 자동 보충 배치로 되돌린다("초기화").
화면만 되돌리면 다시 들어왔을 때 옛 관이 살아난다 — 저장분과 파생 산출물까지 지운다
(2026-08-02 사용자 보고).
"""
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
await asyncio.to_thread(clear_pipe_points, context.stored_path)
resolved = await _resolve(project_id, None, use_stored=False)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, _ = resolved
return _payload(project_id, context, detail, points, saved=False)
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
async def put_pipe_points(
project_id: UUID, payload: dict[str, Any] | None = None
@@ -5,6 +5,7 @@ import {
computeDetailBasins,
fetchDetailPipePoints,
getVWorldMapUrl,
resetDetailPipePoints,
saveDetailPipePoints,
type DetailBasin,
type DetailBasinResponse,
@@ -389,43 +390,15 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
callbacks.onPipesChanged?.(pipeEditor.chainages());
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */
async function loadSaved(): Promise<void> {
if (!projectId) return;
status.hidden = false;
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
try {
apply(await fetchDetailPipePoints(projectId));
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin");
scheduleDraw();
} catch (error) {
status.hidden = false;
status.textContent =
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
} finally {
showProgress(null, null);
}
}
/** 세부유역을 다시 나눈다. `auto=true`면 편집분을 버리고 자동 배치로 되돌린다.
* 격자 해석은 하지 않으므로 즉시 끝난다. */
async function analyze(auto = false): Promise<void> {
/** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */
async function run(request: () => Promise<DetailBasinResponse>): Promise<void> {
if (!projectId) return;
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
try {
const points =
!auto && pipeEditor.pipes().length > 0
? pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
}))
: [];
apply(await computeDetailBasins(projectId, points));
apply(await request());
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin");
scheduleDraw();
@@ -439,12 +412,28 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
}
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */
const loadSaved = (): Promise<void> => run(() => fetchDetailPipePoints(projectId as string));
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
const resetPipes = (): Promise<void> => run(() => resetDetailPipePoints(projectId as string));
/** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다. */
const analyze = (): Promise<void> =>
run(() =>
computeDetailBasins(
projectId as string,
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
);
analyzeButton.addEventListener("click", () => void analyze());
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
autoButton.addEventListener("click", () => {
pipeEditor.setPipes([]);
void analyze(true);
});
autoButton.addEventListener("click", () => void resetPipes());
/** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인).
*
+21
View File
@@ -130,6 +130,27 @@ def save_pipe_points(stored_path: str, signature: str, points: list[PipePoint])
return len(points)
def clear_pipe_points(stored_path: str) -> bool:
"""저장된 관 지점과 그 파생물을 지운다. 하나라도 지웠으면 True.
"초기화"는 화면만 되돌리는 것이 아니라 **저장분까지** 되돌린다 — 화면만 되돌리면 다시
들어왔을 때 옛 관이 살아나 사용자가 초기화한 적 없는 상태를 보게 된다
(2026-08-02 사용자 보고).
"""
removed = False
for path in (pipe_points_path(stored_path), detail_basins_path(stored_path)):
try:
path.unlink()
removed = True
except FileNotFoundError:
continue
except OSError:
logger.warning("배수유역: 저장분을 지우지 못했습니다 (%s).", path)
if removed:
logger.info("배수유역: 관 지점 저장분을 초기화했습니다 (%s).", stored_path)
return removed
def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path:
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다)."""
path = detail_basins_path(stored_path)