Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -57,11 +57,14 @@ export interface UploadStatusResponse {
|
||||
*/
|
||||
export class ReplaceOutputsConfirmRequired extends Error {
|
||||
readonly targets: string[];
|
||||
/** 지형 파일이 그대로라 전처리 결과를 살리는지 — 「처음부터 다시」를 따로 묻는다. */
|
||||
readonly preprocessKept: boolean;
|
||||
|
||||
constructor(message: string, targets: string[]) {
|
||||
constructor(message: string, targets: string[], preprocessKept = false) {
|
||||
super(message);
|
||||
this.name = "ReplaceOutputsConfirmRequired";
|
||||
this.targets = targets;
|
||||
this.preprocessKept = preprocessKept;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +73,14 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
|
||||
message?: string;
|
||||
confirm?: string;
|
||||
targets?: string[];
|
||||
preprocess_kept?: boolean;
|
||||
};
|
||||
if (!response.ok) {
|
||||
if (response.status === 409 && payload.confirm === "replace_outputs") {
|
||||
throw new ReplaceOutputsConfirmRequired(
|
||||
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
|
||||
payload.targets ?? [],
|
||||
payload.preprocess_kept === true,
|
||||
);
|
||||
}
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
@@ -115,6 +120,7 @@ export async function createUploadSession(
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
freshStart = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
@@ -128,6 +134,7 @@ export async function createUploadSession(
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
fresh_start: freshStart,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
@@ -160,6 +167,7 @@ export async function finalizeUploadSession(
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
freshStart = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
@@ -172,6 +180,7 @@ export async function finalizeUploadSession(
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
fresh_start: freshStart,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
|
||||
@@ -113,6 +113,7 @@ async def create_project_upload_session(
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
payload.fresh_start,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -309,6 +310,7 @@ async def finalize_project_upload(
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
payload.fresh_start,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -29,7 +29,7 @@ from B03_FileInput.B03_FileInput_Schema import (
|
||||
)
|
||||
from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_project_reset import purge_project_outputs
|
||||
from common_util.common_util_project_reset import PREPROCESS_STAGE_DIR, purge_project_outputs
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import (
|
||||
complete_stage,
|
||||
@@ -61,8 +61,10 @@ class OutputsWouldBeDiscarded(Exception):
|
||||
로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다.
|
||||
"""
|
||||
|
||||
def __init__(self, targets: list[str]) -> None:
|
||||
def __init__(self, targets: list[str], preprocess_kept: bool = False) -> None:
|
||||
self.targets = targets
|
||||
# 지형 파일이 그대로라 전처리 결과를 살리는지 — 화면이 「처음부터 다시」를 따로 묻는다.
|
||||
self.preprocess_kept = preprocess_kept
|
||||
super().__init__("설계 산출물이 있어 확인이 필요합니다.")
|
||||
|
||||
|
||||
@@ -78,14 +80,17 @@ def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse:
|
||||
"아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets)
|
||||
),
|
||||
"targets": exc.targets,
|
||||
"preprocess_kept": exc.preprocess_kept,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def describe_existing_outputs(project_root: Path) -> list[str]:
|
||||
def describe_existing_outputs(project_root: Path, keep_preprocess: bool = False) -> list[str]:
|
||||
"""새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록."""
|
||||
targets: list[str] = []
|
||||
for stage, label in _OUTPUT_STAGE_LABELS:
|
||||
if keep_preprocess and stage == PREPROCESS_STAGE_DIR:
|
||||
continue
|
||||
stage_root = project_root / stage
|
||||
if not stage_root.is_dir():
|
||||
continue
|
||||
@@ -201,16 +206,37 @@ async def _already_uploaded(
|
||||
)
|
||||
|
||||
|
||||
def preprocess_input_unchanged(project_root: Path, terrain_paths: list[Path]) -> bool:
|
||||
"""지형 파일이 마지막 전처리 때와 같은지 — `processed/source_identity.json` 과 대조.
|
||||
|
||||
같은 지문(크기 + 앞·가운데·끝) 파일은 전송을 건너뛰어 파일이 그대로 남는다 —
|
||||
이름·크기·수정시각이 전처리가 적어 둔 값과 같으면 같은 입력이다.
|
||||
"""
|
||||
if not terrain_paths:
|
||||
return False
|
||||
from B04_PreProcess.B04_PreProcess_Engine import _source_identity
|
||||
|
||||
identity_path = project_root / PREPROCESS_STAGE_DIR / "processed" / "source_identity.json"
|
||||
try:
|
||||
stored = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
return stored == _source_identity(terrain_paths)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
async def _complete_file_input_if_ready(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
las_free: bool = False,
|
||||
confirm_replace: bool = True,
|
||||
fresh_start: bool = False,
|
||||
) -> int:
|
||||
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
|
||||
|
||||
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
|
||||
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
|
||||
지형 파일이 그대로면 전처리 결과는 살리고 단계 표시만 되돌린다 —
|
||||
`fresh_start=True`(확인 창의 「처음부터 다시」)면 전처리까지 지운다.
|
||||
"""
|
||||
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
@@ -234,14 +260,15 @@ async def _complete_file_input_if_ready(
|
||||
gap_message = merge_gap_error(terrain_paths)
|
||||
if gap_message:
|
||||
raise ValueError(gap_message)
|
||||
keep_preprocess = not fresh_start and preprocess_input_unchanged(project_root, terrain_paths)
|
||||
# ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다.
|
||||
if not confirm_replace:
|
||||
targets = describe_existing_outputs(project_root)
|
||||
targets = describe_existing_outputs(project_root, keep_preprocess)
|
||||
if targets:
|
||||
raise OutputsWouldBeDiscarded(targets)
|
||||
raise OutputsWouldBeDiscarded(targets, keep_preprocess)
|
||||
clear_designing(project_root)
|
||||
discard_initial_snapshot(project_root)
|
||||
await purge_project_outputs(connection, str(project_id), project_root)
|
||||
await purge_project_outputs(connection, str(project_id), project_root, keep_preprocess)
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await reset_stages_after_input_change(cursor, str(project_id))
|
||||
await complete_stage(cursor, str(project_id), 0)
|
||||
|
||||
@@ -65,6 +65,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
# 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다.
|
||||
fresh_start: bool = False
|
||||
|
||||
|
||||
class ChunkSessionCreateResponse(BaseModel):
|
||||
@@ -109,6 +111,8 @@ class UploadFinalizeRequest(BaseModel):
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
# 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다.
|
||||
fresh_start: bool = False
|
||||
|
||||
|
||||
class UploadStatusResponse(BaseModel):
|
||||
|
||||
@@ -18,6 +18,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -65,7 +66,11 @@ def _planned_route_points_in_project_crs(
|
||||
|
||||
|
||||
async def _prepare_drainage_pipes_and_reprofile(
|
||||
project_id: UUID, route_id: int, *, refresh: bool = False
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
*,
|
||||
refresh: bool = False,
|
||||
stored_options: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성.
|
||||
|
||||
@@ -75,12 +80,13 @@ async def _prepare_drainage_pipes_and_reprofile(
|
||||
들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다.
|
||||
|
||||
실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다.
|
||||
|
||||
`stored_options` = 노선 [확인] 체인이 건너뛴 첫 생성의 옵션(342-2) — 반폭 · 재생성 옵션.
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import put_pipe_points
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import get_primary_region
|
||||
from B06_Section.B06_Section_Repository import get_latest_section_options
|
||||
from B06_Section.B06_Section_Router import regenerate_sections
|
||||
from B06_Section.B06_Section_Schema import SectionRegenerateRequest
|
||||
from B06_Section.B06_Section_Router import regenerate_route_sections
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import SECTION_CROSS_HALF_WIDTH_M
|
||||
|
||||
@@ -113,14 +119,16 @@ async def _prepare_drainage_pipes_and_reprofile(
|
||||
marks.append(("관 지점 확정", time.perf_counter()))
|
||||
|
||||
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
seeded = stored_options is not None
|
||||
if not seeded:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
half_width = float(
|
||||
(stored_options or {}).get("cross_half_width_m") or SECTION_CROSS_HALF_WIDTH_M
|
||||
)
|
||||
regenerated = await regenerate_sections(
|
||||
project_id, route_id, SectionRegenerateRequest(cross_half_width_m=half_width)
|
||||
regenerated = await regenerate_route_sections(
|
||||
project_id, route_id, half_width, stored_options=stored_options if seeded else None
|
||||
)
|
||||
if isinstance(regenerated, JSONResponse):
|
||||
logger.error(
|
||||
@@ -319,26 +327,8 @@ async def run_auto_design_chain(
|
||||
route_id,
|
||||
)
|
||||
|
||||
# 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다
|
||||
# (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가
|
||||
# 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다.
|
||||
try:
|
||||
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
|
||||
|
||||
await prebuild_corridor(project_id, route_id, project_root)
|
||||
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
|
||||
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
|
||||
|
||||
# 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에
|
||||
# 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는
|
||||
# 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴
|
||||
# 기준선에도 보정이 담긴다.
|
||||
try:
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
|
||||
|
||||
await recompute_server_side(project_id, route_id)
|
||||
except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다
|
||||
logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id)
|
||||
# 서버 재계산(구조물 면적 · 유토곡선)과 3D 길 모형 짓기는 [확정](confirm_sections) 끝에서
|
||||
# 한 번씩 — 재계산 → 짓기 차례 · 초기값 스냅샷 **앞**이라 기준선에도 담긴다(342-2).
|
||||
|
||||
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
|
||||
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
|
||||
@@ -426,13 +416,19 @@ async def run_redesign_chain(
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route
|
||||
from B05_Profile.B05_Profile_Router import (
|
||||
_section_options,
|
||||
confirm_latest_route,
|
||||
solve_route_record,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from B06_Section.B06_Section_Router import regenerate_route_sections
|
||||
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections
|
||||
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs
|
||||
from B06_Section.B06_Section_Schema import SectionConfirmRequest
|
||||
@@ -476,6 +472,7 @@ async def run_redesign_chain(
|
||||
state = await get_workflow_state(cursor, str(project_id))
|
||||
old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id)
|
||||
old_designs = await get_cross_section_designs(connection, old_route_id)
|
||||
old_section_options = await get_latest_section_options(connection, project_id)
|
||||
stage2 = next(
|
||||
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
|
||||
)
|
||||
@@ -528,7 +525,8 @@ async def run_redesign_chain(
|
||||
start_elevation_offset_m=params.get("start_elevation_offset_m"),
|
||||
end_elevation_offset_m=params.get("end_elevation_offset_m"),
|
||||
)
|
||||
solve_result: Any = await solve_route(project_id, request)
|
||||
# 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일.
|
||||
solve_result: Any = await solve_route_record(project_id, request, generate_sections=False)
|
||||
if isinstance(solve_result, JSONResponse):
|
||||
# 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06
|
||||
# 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음).
|
||||
@@ -571,7 +569,17 @@ async def run_redesign_chain(
|
||||
marks.append(("B05 확정", time.perf_counter()))
|
||||
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
|
||||
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
|
||||
await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True)
|
||||
# 건너뛴 첫 생성이 남겼을 옵션(요청 → 옛 저장 → 기본) — 재생성이 그것을 읽어 결과가 같다.
|
||||
seed = asdict(_section_options(request, old_section_options))
|
||||
seed.pop("extra_stations", None)
|
||||
piped = await _prepare_drainage_pipes_and_reprofile(
|
||||
project_id, new_route_id, refresh=True, stored_options=seed
|
||||
)
|
||||
if not piped:
|
||||
# 관 없이라도 종횡단은 세운다 — 새 노선엔 아직 종횡단이 없다(첫 생성을 건너뜀).
|
||||
await regenerate_route_sections(
|
||||
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
|
||||
)
|
||||
|
||||
marks.append(("배수·관·측점 재생성", time.perf_counter()))
|
||||
|
||||
|
||||
@@ -83,9 +83,11 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
|
||||
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
|
||||
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
|
||||
*/
|
||||
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
|
||||
async function askReplaceOutputs<T>(
|
||||
run: (confirmReplace: boolean, freshStart: boolean) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await run(false);
|
||||
return await run(false, false);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
|
||||
const detail = error.targets.length
|
||||
@@ -99,7 +101,16 @@ async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>
|
||||
되돌릴 수 없습니다. 계속할까요?`,
|
||||
);
|
||||
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
|
||||
return await run(true);
|
||||
// 지형 파일이 그대로면 전처리 결과는 살린다 — 「처음부터 다시」는 여기서 따로 고른다.
|
||||
const freshStart =
|
||||
error.preprocessKept &&
|
||||
window.confirm(
|
||||
`같은 지형 파일이라 전처리 결과(지표면)는 살려 다시 씁니다.
|
||||
|
||||
전처리까지 처음부터 다시 할까요?
|
||||
[확인] 처음부터 다시 · [취소] 전처리 결과 살림`,
|
||||
);
|
||||
return await run(true, freshStart);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +134,7 @@ export async function uploadOneFile(
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await askReplaceOutputs((confirmReplace) =>
|
||||
const created = await askReplaceOutputs((confirmReplace, freshStart) =>
|
||||
createUploadSession(
|
||||
projectId,
|
||||
file,
|
||||
@@ -132,6 +143,7 @@ export async function uploadOneFile(
|
||||
completeUpload,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
freshStart,
|
||||
),
|
||||
);
|
||||
if (created.already_uploaded) {
|
||||
@@ -184,7 +196,7 @@ export async function uploadOneFile(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await askReplaceOutputs((confirmReplace) =>
|
||||
const response = await askReplaceOutputs((confirmReplace, freshStart) =>
|
||||
finalizeUploadSession(
|
||||
projectId,
|
||||
session,
|
||||
@@ -193,6 +205,7 @@ export async function uploadOneFile(
|
||||
fingerprint,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
freshStart,
|
||||
),
|
||||
);
|
||||
localStorage.removeItem(storageKey);
|
||||
|
||||
Reference in New Issue
Block a user