- 분석 응답 · 저장분(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
475 lines
22 KiB
Python
475 lines
22 KiB
Python
"""상세 배수유역 API 라우터 (B04 — 관리자 검토용).
|
|
|
|
격자 해석은 하지 않는다. 배수유역 분석이 저장해 둔 `03_road_routing` 산출물을 읽어
|
|
|
|
· 기본 관(도로 × 상류 세류선 교차점) + 최대 간격 자동 보충으로 관 목록을 만들고
|
|
· 계획노선 종단 Z로 노면 물이 어느 관으로 가는지 정해 세부유역을 나눈다
|
|
|
|
계산 알고리즘은 B05 사용자 화면과 공용이다(`common_util_drainage_detail`) — 같은 이름의
|
|
버튼은 같은 결과를 내야 하기 때문이다(2026-08-01 사용자 지시).
|
|
|
|
편집분(관 지점)은 "이 모델 확정" 시점에만 파일로 남는다. 그 전까지는 화면 메모리에만 있고,
|
|
확정 없이 나가면 저장된 값으로 되돌아온다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import math
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
|
|
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,
|
|
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,
|
|
)
|
|
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
|
from common_util.common_util_route_geometry import StructureCandidate
|
|
from config.config_system import (
|
|
DRAINAGE_PIPE_MAX_SPACING_M,
|
|
DRAINAGE_PIPE_MIN_SPACING_M,
|
|
FORD_MANNING_N,
|
|
FORD_SLOPE,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
|
|
|
|
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
|
|
# 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3).
|
|
STALE_MESSAGE = (
|
|
"배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다."
|
|
)
|
|
|
|
|
|
def _build(
|
|
stored_path: str, context: DrainageContext, points: list[PipePoint] | None
|
|
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
|
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
|
|
|
|
`points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
|
|
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
|
|
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
|
|
"""
|
|
directory = drainage_dir(stored_path)
|
|
chainages = [point.chainage_m for point in points] if points is not None else None
|
|
detail = build_detail(directory, context.vertices, chainages)
|
|
if detail is None:
|
|
return None, []
|
|
if points is not None:
|
|
_retag(detail.pipes, points)
|
|
rebuilt = _rebuilt(detail)
|
|
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
|
|
fresh = [
|
|
pick is None
|
|
for pick in carry_indices(
|
|
[point.chainage_m for point in rebuilt], [point.chainage_m for point in points or []]
|
|
)
|
|
]
|
|
# 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다
|
|
# (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교).
|
|
carried = carry_facility_attributes(rebuilt, points or [])
|
|
# 관경 · 시설 자동 지정 없음 — 10-16 사용자 지시로 끔 · 제안(recommended_*)만 응답에 실음.
|
|
# 새 관은 아래 등록부 기본값(구조물정의 배관 관경)을 받는다.
|
|
flows = {round(basin.chainage_m, 2): basin.design_flow_m3s for basin in detail.basins}
|
|
_apply_new_defaults([point for point, new in zip(carried, fresh) if new], flows)
|
|
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")
|
|
|
|
|
|
def _float(value: Any) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _apply_new_defaults(
|
|
points: list[PipePoint], flows: dict[float, float | None] | None = None
|
|
) -> None:
|
|
"""새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7).
|
|
|
|
시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로
|
|
(옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05).
|
|
세월교 빈 월류 높이 = 담당 유역 설계유량(`flows` · 측점 → ㎥/s)을 월류 폭으로 넘기는
|
|
필요 수심(65-4).
|
|
"""
|
|
types = structure_type_map()
|
|
for point in points:
|
|
definition = types.get(point.facility)
|
|
if definition is None:
|
|
continue
|
|
options = dict(point.options or {})
|
|
for field in definition.options:
|
|
if field.key not in options and field.default not in (None, ""):
|
|
options[field.key] = field.default
|
|
# 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`)
|
|
if options.get("inlet_type") == "집수정":
|
|
options.setdefault("inlet_structure", "L")
|
|
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`)
|
|
keys = {field.key for field in definition.options}
|
|
diameter_m = _float(options.get("pipe_diameter_mm")) / 1000
|
|
if diameter_m > 0:
|
|
for key in PIPE_HEIGHT_KEYS:
|
|
if key in keys:
|
|
side = (
|
|
"inlet" if key == "inlet_revet_height_m" else ""
|
|
) # 유입 흙막이 + 0.7(211-1)
|
|
options.setdefault(key, pipe_wall_min_height_m(diameter_m, side))
|
|
if point.facility == "ford_bridge" and "ford_height_m" not in options:
|
|
flow = (flows or {}).get(round(point.chainage_m, 2))
|
|
height = ford_height_m(flow, float(options.get("ford_width_m") or 0))
|
|
if height is not None:
|
|
options["ford_height_m"] = height
|
|
point.options = options or None
|
|
|
|
|
|
def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None:
|
|
"""월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림 — 유량 · 폭이 없으면 None.
|
|
|
|
짝: `A00_Common/design/design_structure_ford_calc.fordHeightM`(+ `fordSection` 같은 풀이 ·
|
|
Manning n · S = `FORD_MANNING_N` · `FORD_SLOPE`) · 거울 시험 test_65_ford_defaults.
|
|
"""
|
|
if not flow_m3s or flow_m3s <= 0 or not math.isfinite(width_m) or width_m <= 0:
|
|
return None
|
|
root = math.sqrt(FORD_SLOPE)
|
|
|
|
def velocity(depth: float) -> float:
|
|
return (1 / FORD_MANNING_N) * ((width_m * depth) / (width_m + 2 * depth)) ** (2 / 3) * root
|
|
|
|
depth = ((flow_m3s * FORD_MANNING_N) / (width_m * root)) ** (3 / 5)
|
|
for _ in range(20):
|
|
nxt = flow_m3s / (width_m * velocity(depth))
|
|
if abs(nxt - depth) < 1e-6:
|
|
depth = nxt
|
|
break
|
|
depth = nxt
|
|
return math.ceil(depth * 100 - 1e-9) / 100
|
|
|
|
|
|
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
|
|
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
|
|
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
|
|
for pipe in pipes:
|
|
source = by_chainage.get(round(pipe.chainage_m, 2))
|
|
if source is None and points:
|
|
nearest = min(points, key=lambda item: abs(item.chainage_m - pipe.chainage_m))
|
|
source = nearest.source
|
|
pipe.reason = source or PIPE_SOURCE_USER
|
|
|
|
|
|
def _payload(
|
|
project_id: UUID,
|
|
context: DrainageContext,
|
|
detail: DrainageDetail,
|
|
points: list[PipePoint],
|
|
saved: bool,
|
|
stale: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다.
|
|
|
|
좌표는 위경도 7자리(약 1.1cm)로 맞춰 내보낸다 — 저장된 값에는 뜻 없는 자리가 붙어 있어
|
|
응답이 151KB → 100KB(압축까지 얹으면 22KB)가 된다(2026-09-06 실측). 화면이 그리는 값이고
|
|
다시 계산에 넣지 않으므로 줄여도 결과가 안 갈린다.
|
|
"""
|
|
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(
|
|
project_id: UUID,
|
|
context: DrainageContext,
|
|
detail: DrainageDetail,
|
|
points: list[PipePoint],
|
|
saved: bool,
|
|
) -> dict[str, Any]:
|
|
"""자릿수를 줄이기 전의 응답 본문."""
|
|
to_lonlat = context.to_lonlat
|
|
return {
|
|
"status": "success",
|
|
"project_id": str(project_id),
|
|
# 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다.
|
|
"z_source": context.z_source,
|
|
"route_length_m": round(context.vertices[-1].chainage_m, 2),
|
|
"max_spacing_m": DRAINAGE_PIPE_MAX_SPACING_M,
|
|
"min_spacing_m": DRAINAGE_PIPE_MIN_SPACING_M,
|
|
"saved": saved,
|
|
# 도로 1m 구간별 유입 면적 — 계획선을 색으로 칠하는 데 쓴다(B05와 같은 값).
|
|
"strength_profile": detail.strength_profile,
|
|
# 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 화면에서 토글로 켠다.
|
|
"inflow_hotspots": detail.inflow_hotspots,
|
|
# ── 아래는 B05 배수유역도가 그리는 데 필요한 값. B04 지도는 자체 오버레이가 있어
|
|
# 쓰지 않지만, 두 화면이 같은 응답을 받아야 결과가 갈리지 않는다(2026-08-01 일원화).
|
|
"route_lonlat": detail.route_lonlat,
|
|
# 2차 전체 유역 외곽선 = 분수령. 해석 결과 그대로(손으로 고치는 기능 없음).
|
|
"main_polygon_lonlat": detail.basin_lonlat,
|
|
"grid_cell_m": detail.grid_cell_m,
|
|
"flow_arrows": detail.flow_arrows,
|
|
"arrow_spacing_m": detail.arrow_spacing_m,
|
|
"upstream_lonlat": detail.upstream_lonlat,
|
|
# 시설 종류·구간·옵션까지 함께 싣는다(as_dict — 기본 배관·폭 0은 필드 생략).
|
|
"pipe_points": [
|
|
{
|
|
**point.as_dict(),
|
|
"chainage_m": round(pipe.chainage_m, 2),
|
|
"lonlat": list(to_lonlat(pipe.x, pipe.y)),
|
|
"source": pipe.reason,
|
|
}
|
|
for pipe, point in zip(detail.pipes, points)
|
|
],
|
|
"basins": [
|
|
{
|
|
"index": basin.index,
|
|
"chainage_m": round(basin.chainage_m, 2),
|
|
"outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)),
|
|
# 옛 소비처를 위한 외곽 링 하나. 그리기는 아래 링 목록을 쓴다.
|
|
"polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy],
|
|
# 조각·구멍을 모두 편 링 목록 — 도넛 유역과 떨어진 조각을 그대로 그린다.
|
|
"polygon_rings_lonlat": [
|
|
[list(to_lonlat(x, y)) for x, y in ring] for ring in basin.boundary_rings
|
|
],
|
|
"area_m2": round(basin.area_m2, 1),
|
|
"relief_m": round(basin.relief_m, 2),
|
|
"flow_length_m": round(basin.flow_length_m, 1),
|
|
# 배수 유효직경(합리식 산출). 강우량표가 아직 없으면 None → "미정" 표기.
|
|
"pipe_diameter_mm": basin.pipe_diameter_mm,
|
|
# 산출 근거 — 도달시간(분)·설계강우강도(mm/hr)·설계유량(m³/s, 2.0배 반영).
|
|
"tc_minutes": basin.tc_minutes,
|
|
"intensity_mm_hr": basin.intensity_mm_hr,
|
|
"design_flow_m3s": basin.design_flow_m3s,
|
|
# 유효직경이 관 최대 규격 초과 — 세월교·물넘이·교량 검토 대상(임도설치규정 제12조).
|
|
"bridge_required": basin.bridge_required,
|
|
# 필요 통수단면적(㎡) — 물넘이·세월교 개략 단면의 출발값.
|
|
"required_area_m2": basin.required_area_m2,
|
|
# 유량 근거 추천(2026-08-17 사용자 확정) — 배관이면 규격 스냅 관경이 붙는다.
|
|
"recommended_facility": basin.recommended_facility,
|
|
"recommended_diameter_mm": basin.recommended_diameter_mm,
|
|
}
|
|
for basin in detail.basins
|
|
],
|
|
"pipe_count": len(points),
|
|
}
|
|
|
|
|
|
def _basin_features(
|
|
context: DrainageContext, detail: DrainageDetail, points: list[PipePoint]
|
|
) -> list[dict[str, Any]]:
|
|
"""세부유역을 저장용 GeoJSON 피처로 바꾼다(관 지점도 같은 파일에 함께 남긴다)."""
|
|
to_lonlat = context.to_lonlat
|
|
features: list[dict[str, Any]] = []
|
|
for basin in detail.basins:
|
|
# GeoJSON 규격 그대로 — 조각마다 [외곽, 구멍...], 조각이 여럿이면 MultiPolygon.
|
|
parts = [
|
|
[[list(to_lonlat(x, y)) for x, y in ring] for ring in part if len(ring) >= 4]
|
|
for part in basin.boundary_parts
|
|
]
|
|
parts = [part for part in parts if part]
|
|
if not parts:
|
|
continue
|
|
geometry = (
|
|
{"type": "Polygon", "coordinates": parts[0]}
|
|
if len(parts) == 1
|
|
else {"type": "MultiPolygon", "coordinates": parts}
|
|
)
|
|
features.append(
|
|
{
|
|
"type": "Feature",
|
|
"properties": {
|
|
"kind": "detail_basin",
|
|
"index": basin.index,
|
|
"chainage_m": round(basin.chainage_m, 2),
|
|
"area_m2": round(basin.area_m2, 1),
|
|
"relief_m": round(basin.relief_m, 2),
|
|
"flow_length_m": round(basin.flow_length_m, 1),
|
|
"pipe_diameter_mm": basin.pipe_diameter_mm,
|
|
# 규격관(도면 배수규격 표기용) — 소요 관경과 구분해 함께 남긴다.
|
|
"recommended_diameter_mm": basin.recommended_diameter_mm,
|
|
"tc_minutes": basin.tc_minutes,
|
|
"intensity_mm_hr": basin.intensity_mm_hr,
|
|
"design_flow_m3s": basin.design_flow_m3s,
|
|
"bridge_required": basin.bridge_required,
|
|
},
|
|
"geometry": geometry,
|
|
}
|
|
)
|
|
for pipe, point in zip(detail.pipes, points):
|
|
features.append(
|
|
{
|
|
"type": "Feature",
|
|
# 시설 종류·구간·옵션도 함께 남긴다(as_dict — 기본값 생략) — GeoJSON만
|
|
# 보고도 세월교인지 배관인지 알 수 있어야 한다.
|
|
"properties": {"kind": "pipe", **point.as_dict()},
|
|
"geometry": {"type": "Point", "coordinates": list(to_lonlat(pipe.x, pipe.y))},
|
|
}
|
|
)
|
|
return features
|
|
|
|
|
|
async def _resolve(
|
|
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
|
|
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool, bool] | JSONResponse:
|
|
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.
|
|
|
|
분석의 노선 지문이 지금 노선과 다르면(옛 저장분 = 지문 없음) 나누지 않고 관 자리만 낡음으로
|
|
돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음.
|
|
|
|
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
|
|
"""
|
|
context, reason = await load_drainage_context(project_id)
|
|
if context is None:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
|
|
|
try:
|
|
raw = (payload or {}).get("points")
|
|
requested = None if raw is None else parse_pipe_points(raw)
|
|
except ValueError as error: # 집계 항목(summary_item) 모양이 틀림
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
|
signature = route_signature(context.vertices)
|
|
stored = (
|
|
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, saved, False
|
|
|
|
|
|
@router.get("/{project_id}/drainage/pipe-points", response_model=None)
|
|
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, stale = resolved
|
|
return _payload(project_id, context, detail, points, saved, stale)
|
|
|
|
|
|
@router.post("/{project_id}/drainage/detail-basins", response_model=None)
|
|
async def post_detail_basins(
|
|
project_id: UUID, payload: dict[str, Any] | None = None
|
|
) -> dict[str, Any] | JSONResponse:
|
|
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
|
|
|
`points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
|
|
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
|
|
"""
|
|
resolved = await _resolve(project_id, payload, use_stored=False)
|
|
if isinstance(resolved, JSONResponse):
|
|
return resolved
|
|
context, detail, points, _, stale = resolved
|
|
return _payload(project_id, context, detail, points, False, stale)
|
|
|
|
|
|
@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, _, stale = resolved
|
|
return _payload(project_id, context, detail, points, False, stale)
|
|
|
|
|
|
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
|
|
async def put_pipe_points(
|
|
project_id: UUID,
|
|
payload: dict[str, Any] | None = None,
|
|
session: dict[str, Any] | None = Depends(get_optional_session), # 가져온이(142-2)
|
|
) -> dict[str, Any] | JSONResponse:
|
|
"""관 지점을 정본으로 확정하고 세부유역 산출물까지 함께 남긴다(모델 확정 시점)."""
|
|
resolved = await _resolve(project_id, payload, use_stored=True)
|
|
if isinstance(resolved, JSONResponse):
|
|
return 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
|
|
)
|
|
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
|
|
|
|
failed = await asyncio.to_thread(
|
|
safe_sync, resolve_stored_project_path(context.stored_path), importer(session)
|
|
)
|
|
result = _payload(project_id, context, detail, points, True, stale)
|
|
result["saved_count"] = saved
|
|
result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림
|
|
return result
|