feat(배수유역): 342-3 노선 · 관 자리 지문 — 노선이 분석 때와 다르면 세부 유역 안 나누고 「낡음」

- 분석 응답 · 저장분(00_watershed_response.json)에 route_signature · pipe_signature
- 세부 유역(GET · POST · DELETE · PUT pipe-points): 분석 지문 ≠ 지금 노선(옛 저장분 = 지문 없음) → 관 자리만 · basins 없음 · stale 까닭 글 · 관 목록도 없으면 409
- PUT 낡음 = 관 자리 · 속성만 정본 · 세부 유역 산출물(04_detailed_basins) 안 씀
- 응답에 지금 route_signature · pipe_signature · B05 배수유역 칸 = 낡음 글 표시(DetailBasinResponse 칸 셋)
- 시험 test_342_3_drainage_stale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-16 11:10:49 +09:00
co-authored by Claude Opus 5.5
parent 2124bc85b9
commit e503189cb8
6 changed files with 197 additions and 20 deletions
+63 -19
View File
@@ -28,15 +28,21 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
from common_util.common_util_auth import get_optional_session
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_detail import (
DrainageDetail,
build_detail,
pipes_from_chainages,
)
from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_USER,
PipePoint,
analysis_route_signature,
carry_facility_attributes,
carry_indices,
clear_pipe_points,
load_pipe_points,
parse_pipe_points,
pipe_signature,
route_signature,
save_detail_basins,
save_pipe_points,
@@ -54,6 +60,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
# 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3).
STALE_MESSAGE = (
"배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다."
)
def _build(
@@ -72,7 +82,7 @@ def _build(
return None, []
if points is not None:
_retag(detail.pipes, points)
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
rebuilt = _rebuilt(detail)
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
fresh = [
pick is None
@@ -90,6 +100,21 @@ def _build(
return detail, carried
def _rebuilt(detail: DrainageDetail) -> list[PipePoint]:
return [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
def _build_stale(context: DrainageContext, points: list[PipePoint]) -> DrainageDetail:
"""낡은 분석 — 세부 유역 없이 관 자리만(지금 노선 위) · 시설 속성은 넘겨받은 그대로."""
to_lonlat = context.to_lonlat
detail = DrainageDetail(
route_lonlat=[list(to_lonlat(vertex.x, vertex.y)) for vertex in context.vertices],
pipes=pipes_from_chainages(context.vertices, [point.chainage_m for point in points]),
)
_retag(detail.pipes, points)
return detail
# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1)
PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m")
@@ -181,6 +206,7 @@ def _payload(
detail: DrainageDetail,
points: list[PipePoint],
saved: bool,
stale: bool = False,
) -> dict[str, Any]:
"""관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다.
@@ -188,7 +214,12 @@ def _payload(
응답이 151KB → 100KB(압축까지 얹으면 22KB)가 된다(2026-09-06 실측). 화면이 그리는 값이고
다시 계산에 넣지 않으므로 줄여도 결과가 안 갈린다.
"""
return round_floats(_raw_payload(project_id, context, detail, points, saved), LONLAT_DIGITS)
payload = _raw_payload(project_id, context, detail, points, saved)
# 지문(342-3) — 화면이 지금 노선 · 관 자리와 견줌 · 낡으면 까닭 글(세부 유역 없음)
payload["route_signature"] = route_signature(context.vertices)
payload["pipe_signature"] = pipe_signature([pipe.chainage_m for pipe in detail.pipes])
payload["stale"] = STALE_MESSAGE if stale else None
return round_floats(payload, LONLAT_DIGITS)
def _raw_payload(
@@ -322,9 +353,12 @@ def _basin_features(
async def _resolve(
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool, bool] | JSONResponse:
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.
분석의 노선 지문이 지금 노선과 다르면(옛 저장분 = 지문 없음) 나누지 않고 관 자리만 낡음으로
돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음.
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
"""
context, reason = await load_drainage_context(project_id)
@@ -341,12 +375,21 @@ async def _resolve(
load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None
)
points = requested if requested is not None else stored
saved = requested is None and stored is not None
if analysis_route_signature(context.stored_path) != signature:
if points is None:
return JSONResponse(
status_code=409, content={"status": "stale", "message": STALE_MESSAGE}
)
detail = _build_stale(context, points)
return context, detail, carry_facility_attributes(_rebuilt(detail), points), saved, True
result = await asyncio.to_thread(_build, context.stored_path, context, points)
detail, resolved = result
if detail is None:
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
return context, detail, resolved, requested is None and stored is not None
return context, detail, resolved, saved, False
@router.get("/{project_id}/drainage/pipe-points", response_model=None)
@@ -355,8 +398,8 @@ async def get_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
resolved = await _resolve(project_id, None, use_stored=True)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, saved = resolved
return _payload(project_id, context, detail, points, saved)
context, detail, points, saved, stale = resolved
return _payload(project_id, context, detail, points, saved, stale)
@router.post("/{project_id}/drainage/detail-basins", response_model=None)
@@ -371,8 +414,8 @@ async def post_detail_basins(
resolved = await _resolve(project_id, payload, use_stored=False)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, _ = resolved
return _payload(project_id, context, detail, points, saved=False)
context, detail, points, _, stale = resolved
return _payload(project_id, context, detail, points, False, stale)
@router.delete("/{project_id}/drainage/pipe-points", response_model=None)
@@ -390,8 +433,8 @@ async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
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)
context, detail, points, _, stale = resolved
return _payload(project_id, context, detail, points, False, stale)
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
@@ -404,19 +447,20 @@ async def put_pipe_points(
resolved = await _resolve(project_id, payload, use_stored=True)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, _ = resolved
context, detail, points, _, stale = resolved
signature = route_signature(context.vertices)
# 좌표를 같이 남긴다 — 다른 선(B05 최적 경로)으로 읽어도 그 자리에 되놓는다.
saved = await asyncio.to_thread(
save_pipe_points, context.stored_path, signature, points, context.vertices
)
await asyncio.to_thread(
save_detail_basins,
context.stored_path,
_basin_features(context, detail, points),
context.crs,
)
if not stale: # 낡은 분석 — 관 자리 · 속성만 정본으로 · 세부 유역 산출물은 안 씀
await asyncio.to_thread(
save_detail_basins,
context.stored_path,
_basin_features(context, detail, points),
context.crs,
)
# 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지)
from common_util.common_util_storage import resolve_stored_project_path
from M02_MasterTemplete.M02_Item_Snapshot import importer, safe_sync
@@ -424,7 +468,7 @@ async def put_pipe_points(
failed = await asyncio.to_thread(
safe_sync, resolve_stored_project_path(context.stored_path), importer(session)
)
result = _payload(project_id, context, detail, points, saved=True)
result = _payload(project_id, context, detail, points, True, stale)
result["saved_count"] = saved
result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림
return result