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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user