Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
type StateName,
|
||||
} from "../A00_Common/b_page_state";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import type { SavePart } from "../A00_Common/design/design_save_api";
|
||||
|
||||
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
|
||||
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
|
||||
@@ -92,6 +93,14 @@ export async function flushCulvertOptions(
|
||||
writePending(sessionKey, {});
|
||||
}
|
||||
|
||||
/** 한 본문 [저장] 조각(472-6) — 세션 조각 {누가거리 0.01 글: 패치}(서버가 저장된 관 위에 얹음) · 없으면 null ·
|
||||
* 저장 뒤 세션 비움. */
|
||||
export function culvertOptionsPart(sessionKey: string | null): SavePart | null {
|
||||
const pending = readPending(sessionKey);
|
||||
if (!Object.keys(pending).length) return null;
|
||||
return { body: pending, after: () => writePending(sessionKey, {}) };
|
||||
}
|
||||
|
||||
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */
|
||||
export const STRUCTURE_ADJUST_KEYS = [
|
||||
"inlet_structure",
|
||||
|
||||
@@ -121,10 +121,7 @@ export async function confirmSections(
|
||||
/** 프론트에서 계산한 유토곡선 결과. 확정 시점에만 영구 저장한다. */
|
||||
massHaul?: Record<string, unknown>,
|
||||
): Promise<SectionConfirmResponse> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
|
||||
if (crossPatches?.length) body.cross_patches = crossPatches;
|
||||
if (massHaul) body.mass_haul = massHaul;
|
||||
const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul);
|
||||
return warnUnrecomputed(
|
||||
requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
|
||||
method: "POST",
|
||||
@@ -139,6 +136,25 @@ async function warnUnrecomputed(
|
||||
request: Promise<SectionConfirmResponse>,
|
||||
): Promise<SectionConfirmResponse> {
|
||||
const result = await request;
|
||||
warnSectionSave(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 횡단 저장 본문(저장 · 확정 · 한 본문 [저장] 조각 공통). */
|
||||
export function sectionConfirmBody(
|
||||
standardCrossSection?: StandardCrossSection,
|
||||
crossPatches?: CrossSectionPatch[],
|
||||
massHaul?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
|
||||
if (crossPatches?.length) body.cross_patches = crossPatches;
|
||||
if (massHaul) body.mass_haul = massHaul;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 횡단 저장 응답의 경고 — 재계산 실패 · 서버가 버린 고침 · 측점 맞춤 실패(한 본문 [저장]도 부름). */
|
||||
export function warnSectionSave(result: Partial<SectionConfirmResponse>): void {
|
||||
const dropped = [
|
||||
...new Set([
|
||||
...(result.dropped_patch_chainages ?? []),
|
||||
@@ -163,7 +179,6 @@ async function warnUnrecomputed(
|
||||
if (result.recomputed === false) showToast(L("B06_Recompute_Failed"), "warning", 8000);
|
||||
if (lost.length) showToast(lost.join(" "), "warning", 8000);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,10 +192,7 @@ export async function saveSections(
|
||||
crossPatches?: CrossSectionPatch[],
|
||||
massHaul?: Record<string, unknown>,
|
||||
): Promise<SectionConfirmResponse> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
|
||||
if (crossPatches?.length) body.cross_patches = crossPatches;
|
||||
if (massHaul) body.mass_haul = massHaul;
|
||||
const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul);
|
||||
return warnUnrecomputed(
|
||||
requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/save`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""B05 · B06 [저장] · [확정] 한 본문 창구(PLAN 472-6 6-A) — 노선 안 바뀐 저장.
|
||||
|
||||
브라우저가 세션 초안 · 사용자 칸만 한 본문으로 보내면 서버가 옛 차례 그대로 안쪽 창구를 부름:
|
||||
|
||||
페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 관 옵션 → 상단측(비정규 측점 맞춤) → 횡단
|
||||
|
||||
각 안쪽 창구가 정본을 다시 셈(브라우저 결과 안 받음 · 횡단 차례 끝 서버 재계산). 하나라도 실패하면
|
||||
(오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고
|
||||
그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인
|
||||
필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`).
|
||||
노선 바뀐 저장(노선 초안)은 아직 옛 길(`/route/replan` 뒤 차례 · 6-B).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Schema import (
|
||||
PageSettingsRequest,
|
||||
ProfileAlignmentSaveRequest,
|
||||
RouteConfirmRequest,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureSaveRequest
|
||||
from B06_Section.B06_Section_Router_Stations import PlusStationsRequest
|
||||
from B06_Section.B06_Section_Schema import SectionConfirmRequest
|
||||
from common_util import common_util_save_guard as guard
|
||||
from common_util.common_util_auth import get_optional_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Design Save"])
|
||||
|
||||
|
||||
class DesignSaveRequest(BaseModel):
|
||||
"""한 본문 — 칸이 없으면 그 차례를 건너뜀(관은 [확정]이면 칸 없어도 다시 셈)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
route_id: int
|
||||
confirm: bool = False
|
||||
settings: PageSettingsRequest | None = None
|
||||
#: 관 PUT 본문(`{points}` · 빈 dict = 서버 저장분 그대로)
|
||||
pipes: dict[str, Any] | None = None
|
||||
structures: StructureSaveRequest | None = None
|
||||
plus_stations: PlusStationsRequest | None = None
|
||||
alignment: ProfileAlignmentSaveRequest | None = None
|
||||
#: 관 옵션 조각 {누가거리 0.01 글: {키: 값}} — 저장된 관 위에 얹어 다시 씀
|
||||
culvert_options: dict[str, dict[str, Any]] | None = None
|
||||
uphill: RouteConfirmRequest | None = None
|
||||
cross: SectionConfirmRequest | None = None
|
||||
|
||||
|
||||
class StepFailed(Exception):
|
||||
def __init__(self, step: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.step = step
|
||||
|
||||
|
||||
def _body(response: Any) -> dict[str, Any]:
|
||||
"""안쪽 창구 응답 → dict · 오류 응답이면 StepFailed 로(까닭 그대로)."""
|
||||
if isinstance(response, JSONResponse):
|
||||
content = json.loads(bytes(response.body).decode("utf-8") or "{}")
|
||||
if response.status_code >= 400 or content.get("status") == "error":
|
||||
raise StepFailed("", str(content.get("message") or f"HTTP {response.status_code}"))
|
||||
return content
|
||||
if hasattr(response, "model_dump"):
|
||||
return response.model_dump(mode="json")
|
||||
return dict(response or {})
|
||||
|
||||
|
||||
def _patched_points(stored: dict[str, Any], patches: dict[str, dict[str, Any]]) -> list | None:
|
||||
"""저장된 관 목록에 관 옵션 조각 — 고친 것이 없으면 None(브라우저 `flushCulvertOptions` 짝)."""
|
||||
points, touched = [], False
|
||||
for point in stored.get("pipe_points") or []:
|
||||
item = {key: value for key, value in point.items() if key != "lonlat"}
|
||||
patch = patches.get(f"{float(point['chainage_m']):.2f}")
|
||||
if patch:
|
||||
touched = True
|
||||
item["options"] = {**(item.get("options") or {}), **patch}
|
||||
points.append(item)
|
||||
return points if touched else None
|
||||
|
||||
|
||||
async def _run_steps(
|
||||
project_id: UUID, request: DesignSaveRequest, session: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""차례대로 안쪽 창구 · 차례 이름째 실패를 던짐 → 차례별 응답(알림 · 화면 맞춤용)."""
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points, put_pipe_points
|
||||
from B05_Profile.B05_Profile_Router import save_profile_alignment
|
||||
from B05_Profile.B05_Profile_Router_Lifecycle import confirm_latest_route, save_page_settings
|
||||
from B05_Profile.B05_Profile_Structures_Router import write_structures
|
||||
from B06_Section.B06_Section_Router_Confirm import confirm_sections, save_sections
|
||||
from B06_Section.B06_Section_Router_Stations import put_plus_stations
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
async def step(name: str, call: Any) -> dict[str, Any]:
|
||||
try:
|
||||
out[name] = _body(await call)
|
||||
except StepFailed as failed:
|
||||
raise StepFailed(name, str(failed)) from None
|
||||
except Exception as error:
|
||||
logger.exception("한 본문 저장 %s 차례 실패: project_id=%s", name, project_id)
|
||||
raise StepFailed(name, str(error) or type(error).__name__) from None
|
||||
# 시험 갈래 — 화면에서 실패 · 되돌림을 돌려 볼 때만(서버를 이 환경 변수로 켬 · 평소 없음)
|
||||
if os.environ.get("AISLO_DESIGN_SAVE_FAIL_AFTER") == name:
|
||||
raise StepFailed(name, "시험 갈래 — 이 차례 뒤 일부러 실패")
|
||||
return out[name]
|
||||
|
||||
if request.settings is not None:
|
||||
await step("settings", save_page_settings(project_id, request.settings))
|
||||
if request.pipes is not None or request.confirm:
|
||||
pipes = await step(
|
||||
"pipes", put_pipe_points(project_id, request.pipes or {}, session, request.confirm)
|
||||
)
|
||||
if pipes.get("stations_failed"):
|
||||
raise StepFailed("pipes", "관 자리 측점을 못 맞췄습니다")
|
||||
if request.structures is not None:
|
||||
await step("structures", write_structures(project_id, request.structures, session))
|
||||
if request.plus_stations is not None:
|
||||
await step("plusStations", put_plus_stations(project_id, request.plus_stations))
|
||||
if request.alignment is not None:
|
||||
await step("alignment", save_profile_alignment(project_id, request.alignment))
|
||||
if request.culvert_options:
|
||||
stored = await step("culvertOptions", get_pipe_points(project_id))
|
||||
points = _patched_points(stored, request.culvert_options)
|
||||
if points is not None:
|
||||
await step(
|
||||
"culvertOptions", put_pipe_points(project_id, {"points": points}, session, False)
|
||||
)
|
||||
if request.uphill is not None:
|
||||
parts = await step("uphill", confirm_latest_route(project_id, request.uphill, False))
|
||||
if parts.get("failed_parts"):
|
||||
raise StepFailed("uphill", " · ".join(parts["failed_parts"]) + " 실패")
|
||||
if request.cross is not None:
|
||||
save = confirm_sections if request.confirm else save_sections
|
||||
cross = await step("cross", save(project_id, request.route_id, request.cross))
|
||||
if cross.get("recomputed") is False:
|
||||
raise StepFailed("cross", "서버 재계산 실패")
|
||||
if cross.get("stations_synced") is False:
|
||||
raise StepFailed("cross", "구조물 측점 맞춤 실패")
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{project_id}/design/save")
|
||||
async def save_design(
|
||||
project_id: UUID,
|
||||
request: DesignSaveRequest,
|
||||
session: dict[str, Any] | None = Depends(get_optional_session),
|
||||
) -> JSONResponse:
|
||||
"""[저장] · [확정] 한 본문 — 전부 저장하거나 아무것도 안 바꿈."""
|
||||
lock = guard.project_lock(str(project_id))
|
||||
waited = lock.locked()
|
||||
async with lock:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
rows = await guard.snapshot_rows(connection, str(project_id))
|
||||
root = Path(resolve_stored_project_path(stored_path))
|
||||
pipes = request.pipes is not None or bool(request.culvert_options) or request.confirm
|
||||
files = guard.snapshot_storage(
|
||||
root, guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ())
|
||||
)
|
||||
try:
|
||||
steps = await _run_steps(project_id, request, session)
|
||||
except StepFailed as failed:
|
||||
restored = await _restore(project_id, rows, files)
|
||||
return JSONResponse(
|
||||
status_code=409 if restored else 500,
|
||||
content={
|
||||
"status": "error",
|
||||
"step": failed.step,
|
||||
"message": str(failed)
|
||||
if restored
|
||||
else f"{failed} — 저장 실패 · 복구 확인 필요",
|
||||
"restored": restored,
|
||||
"waited": waited,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
files.discard()
|
||||
return JSONResponse(content={"status": "success", "waited": waited, "steps": steps})
|
||||
|
||||
|
||||
async def _restore(project_id: UUID, rows: dict[str, list], files: guard.StorageSnapshot) -> bool:
|
||||
"""떠 둔 대로 — 성공 = 참 · 하나라도 못 되돌리면 로그 남기고 거짓(조용히 넘기지 않음)."""
|
||||
ok = True
|
||||
try:
|
||||
files.restore()
|
||||
except Exception:
|
||||
logger.exception("한 본문 저장 되돌림 실패(storage): project_id=%s", project_id)
|
||||
ok = False
|
||||
try:
|
||||
async with get_db_pool().acquire() as connection:
|
||||
await guard.restore_rows(connection, str(project_id), rows)
|
||||
except Exception:
|
||||
logger.exception("한 본문 저장 되돌림 실패(DB): project_id=%s", project_id)
|
||||
ok = False
|
||||
return ok
|
||||
@@ -8,8 +8,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
confirmSections,
|
||||
saveSections,
|
||||
sectionConfirmBody,
|
||||
warnSectionSave,
|
||||
type CrossSectionPatch,
|
||||
type SectionContextResponse,
|
||||
type SectionDetailResponse,
|
||||
@@ -411,23 +411,27 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave {
|
||||
// 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측).
|
||||
prepare: () => ctx.reconcileDesigns(),
|
||||
// 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로).
|
||||
steps: { culvertOptions: () => ctx.flushCulvertOptions() },
|
||||
cross: async (confirm) => {
|
||||
// 관 옵션(조정창 구간값) = 기본 조각(세션 `culvertopt`) — 서버가 저장된 관 위에 얹음(472-6)
|
||||
crossPart: async () => {
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null || !ctx.projectId) return;
|
||||
if (routeId === null || !ctx.projectId) return null;
|
||||
const projectId = ctx.projectId;
|
||||
const edits = collectSectionEdits(ctx);
|
||||
// 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 옮긴 측점은 지반 등 입력까지) + 세션 고침(445 H2 · H3 · M2)
|
||||
// 가게는 늦게 불러옴(가게 → 다시 셈 → 이 파일 순환 없음)
|
||||
const { cachedCrossPatches } = await import("./B06_Section_Section_Store");
|
||||
const patches = cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches);
|
||||
await (confirm ? confirmSections : saveSections)(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
patches.length ? patches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4)
|
||||
const patches = cachedCrossPatches(projectId, routeId, edits.crossPatches);
|
||||
return {
|
||||
body: sectionConfirmBody(
|
||||
ctx.standardValues(),
|
||||
patches.length ? patches : undefined,
|
||||
edits.massHaul,
|
||||
),
|
||||
after: (response) => {
|
||||
clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4)
|
||||
if (response) warnSectionSave(response); // 버린 고침 · 측점 맞춤(445 M5)
|
||||
},
|
||||
};
|
||||
},
|
||||
done: async () => {
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
|
||||
Reference in New Issue
Block a user