B05 페이지 초안
This commit is contained in:
@@ -7,12 +7,10 @@ from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Email import (
|
||||
send_analysis_completion_email,
|
||||
send_analysis_error_email,
|
||||
send_file_upload_complete_email,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Engine import (
|
||||
@@ -44,20 +42,17 @@ from B03_FileInput.B03_FileInput_Schema import (
|
||||
UploadFinalizeRequest,
|
||||
UploadStatusResponse,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow import load_project_workflow
|
||||
from common_util.common_util_workflow_state import (
|
||||
complete_stage,
|
||||
fail_stage,
|
||||
get_workflow_state,
|
||||
start_stage,
|
||||
)
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import (
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
SURFACE_MODEL_PRECOMPUTE,
|
||||
SURFACE_MODEL_SOURCE_FILTERS,
|
||||
UPLOAD_CHUNK_SIZE_BYTES,
|
||||
UPLOAD_MAX_FILES,
|
||||
)
|
||||
@@ -144,147 +139,11 @@ async def _send_upload_complete_notification(
|
||||
)
|
||||
|
||||
|
||||
async def trigger_wf1_analysis_and_email(
|
||||
*,
|
||||
project_id: UUID,
|
||||
input_file_id: int,
|
||||
) -> None:
|
||||
"""WF1 분석 실행, DB 저장, 완료/오류 이메일 발송을 백그라운드에서 수행한다.
|
||||
|
||||
실행 흐름:
|
||||
1. 프로젝트 저장 경로와 입력 파일 정보를 조회한다.
|
||||
2. 무거운 WF1 분석은 워커 스레드에서 실행한다.
|
||||
3. 분석 결과를 단일 DB 트랜잭션으로 저장한다.
|
||||
4. 완료 이메일은 SEND_ANALYSIS_COMPLETION_EMAIL 설정이 켜진 경우에만 발송한다.
|
||||
5. 분석 또는 DB 저장 실패 시 오류 이메일을 발송한다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
project_info: dict[str, Any] | None = None
|
||||
try:
|
||||
source_filters = list(SURFACE_MODEL_SOURCE_FILTERS)
|
||||
methods = list(SURFACE_MODEL_PRECOMPUTE)
|
||||
params = {
|
||||
"input_file_id": str(input_file_id),
|
||||
"source_filters": source_filters,
|
||||
"methods": methods,
|
||||
"force": False,
|
||||
}
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 1, params)
|
||||
await connection.commit()
|
||||
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_info = await _get_project_notification_info(connection, project_id)
|
||||
if not project_info or not project_info.get("user_email"):
|
||||
logger.warning("WF1 분석 이메일 수신자 없음: project_id=%s", project_id)
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import get_input_file
|
||||
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
las_path = project_root / Path(str(input_file["raw_file_path"]))
|
||||
if not las_path.is_file():
|
||||
raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.")
|
||||
|
||||
logger.info(
|
||||
"WF1 백그라운드 분석 시작: project_id=%s input_file_id=%s",
|
||||
project_id,
|
||||
input_file_id,
|
||||
)
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import (
|
||||
save_surface_analysis_to_db,
|
||||
write_surface_progress,
|
||||
)
|
||||
|
||||
# 업로드 자동 분석도 progress.json에 진행률을 기록한다 (PLAN C-4)
|
||||
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
|
||||
|
||||
def _on_progress(percent: int, stage: str, message: str) -> None:
|
||||
write_surface_progress(project_root, percent, stage, message)
|
||||
|
||||
logger.info("WF1 분석 엔진 시작: las_path=%s", las_path)
|
||||
analysis_result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
las_path,
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=False,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
logger.info("WF1 분석 엔진 완료: 모델 %d개 생성됨", len(analysis_result.get("models", [])))
|
||||
|
||||
logger.info("WF1 분석 결과 DB 저장 시작: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await save_surface_analysis_to_db(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
input_file_id=input_file_id,
|
||||
analysis_result=analysis_result,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 1)
|
||||
await connection.commit()
|
||||
logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id)
|
||||
except Exception as e:
|
||||
logger.exception("WF1 분석 결과 DB 저장 실패: %s", e)
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s",
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
bool(project_info),
|
||||
)
|
||||
if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"):
|
||||
logger.info("WF1 완료 이메일 발송 시작: to=%s", project_info["user_email"])
|
||||
await send_analysis_completion_email(
|
||||
project_id=project_id,
|
||||
project_name=str(project_info.get("project_name") or project_id),
|
||||
to_email=str(project_info["user_email"]),
|
||||
analysis_result=analysis_result,
|
||||
)
|
||||
logger.info("WF1 완료 이메일 발송 완료: project_id=%s", project_id)
|
||||
else:
|
||||
logger.info(
|
||||
"WF1 완료 이메일 발송 스킵: SEND_ANALYSIS_COMPLETION_EMAIL=%s, has_email=%s",
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
project_info and project_info.get("user_email") is not None,
|
||||
)
|
||||
logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id)
|
||||
except Exception as exc:
|
||||
logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||
await connection.commit()
|
||||
if project_info and project_info.get("user_email"):
|
||||
await send_analysis_error_email(
|
||||
project_id=project_id,
|
||||
project_name=str(project_info.get("project_name") or project_id),
|
||||
to_email=str(project_info["user_email"]),
|
||||
error_message=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/files", response_model=FileUploadResponse)
|
||||
async def upload_project_files(
|
||||
project_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||
if not files or len(files) > UPLOAD_MAX_FILES:
|
||||
@@ -391,6 +250,7 @@ async def upload_project_files(
|
||||
trigger_wf1_analysis_and_email(
|
||||
project_id=project_id,
|
||||
input_file_id=point_cloud_result.input_file_id,
|
||||
user_role=str(session["role"]),
|
||||
),
|
||||
task_name=f"b04-wf1-auto-{project_id}",
|
||||
)
|
||||
@@ -525,6 +385,7 @@ async def upload_project_chunk(
|
||||
async def finalize_project_upload(
|
||||
project_id: UUID,
|
||||
payload: UploadFinalizeRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
|
||||
pool = get_db_pool()
|
||||
@@ -617,6 +478,7 @@ async def finalize_project_upload(
|
||||
trigger_wf1_analysis_and_email(
|
||||
project_id=project_id,
|
||||
input_file_id=result.input_file_id,
|
||||
user_role=str(session["role"]),
|
||||
),
|
||||
task_name=f"b04-wf1-auto-{project_id}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""B03 업로드 이후 WF1 백그라운드 분석·자동 확정 서비스."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
from B03_FileInput.B03_FileInput_Email import (
|
||||
send_analysis_completion_email,
|
||||
send_analysis_error_email,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
|
||||
from common_util.common_util_workflow_state import fail_stage, start_stage
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import (
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
SURFACE_MODEL_PRECOMPUTE,
|
||||
SURFACE_MODEL_SOURCE_FILTERS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_project_notification_info(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT p.id, p.name AS project_name, u.email AS user_email, u.name AS user_name
|
||||
FROM projects p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = %s AND p.deleted_at IS NULL AND u.deleted_at IS NULL
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def trigger_wf1_analysis_and_email(
|
||||
*,
|
||||
project_id: UUID,
|
||||
input_file_id: int,
|
||||
user_role: str,
|
||||
) -> None:
|
||||
"""WF1 분석·DB 저장 후 역할에 따라 자동 확정하고 이메일을 발송한다."""
|
||||
pool = get_db_pool()
|
||||
project_info: dict[str, Any] | None = None
|
||||
try:
|
||||
source_filters = list(SURFACE_MODEL_SOURCE_FILTERS)
|
||||
methods = list(SURFACE_MODEL_PRECOMPUTE)
|
||||
params = {
|
||||
"input_file_id": str(input_file_id),
|
||||
"source_filters": source_filters,
|
||||
"methods": methods,
|
||||
"force": False,
|
||||
}
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 1, params)
|
||||
await connection.commit()
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_info = await _get_project_notification_info(connection, project_id)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import get_input_file
|
||||
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
las_path = project_root / Path(str(input_file["raw_file_path"]))
|
||||
if not las_path.is_file():
|
||||
raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.")
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import save_surface_analysis_to_db
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import write_surface_progress
|
||||
|
||||
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
|
||||
|
||||
def _on_progress(percent: int, stage: str, message: str) -> None:
|
||||
write_surface_progress(project_root, percent, stage, message)
|
||||
|
||||
analysis_result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
las_path,
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=False,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
|
||||
auto_confirmation_error: str | None = None
|
||||
auto_confirmed = False
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await save_surface_analysis_to_db(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
input_file_id=input_file_id,
|
||||
analysis_result=analysis_result,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
if user_role != "SYSTEM_ADMIN":
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Service import (
|
||||
confirm_surface_selection,
|
||||
find_surface_model_for_selection,
|
||||
)
|
||||
|
||||
selection = surface_confirmation_defaults()
|
||||
try:
|
||||
model_id = await find_surface_model_for_selection(
|
||||
connection, project_id, selection
|
||||
)
|
||||
except LookupError as exc:
|
||||
auto_confirmation_error = str(exc)
|
||||
logger.error(
|
||||
"WF1 자동 확정 보류: project_id=%s reason=%s",
|
||||
project_id,
|
||||
auto_confirmation_error,
|
||||
)
|
||||
else:
|
||||
await confirm_surface_selection(connection, project_id, model_id, selection)
|
||||
auto_confirmed = True
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
if auto_confirmation_error:
|
||||
progress_stage = "awaiting_confirmation"
|
||||
progress_message = f"WF1 자동 확정 보류 — {auto_confirmation_error}"
|
||||
elif auto_confirmed:
|
||||
progress_stage = "completed"
|
||||
progress_message = "지표면 모델 자동 확정 완료 — 노선 설계로 이동하세요."
|
||||
else:
|
||||
progress_stage = "awaiting_confirmation"
|
||||
progress_message = "WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요."
|
||||
write_surface_progress(project_root, 100, progress_stage, progress_message)
|
||||
|
||||
if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"):
|
||||
await send_analysis_completion_email(
|
||||
project_id=project_id,
|
||||
project_name=str(project_info.get("project_name") or project_id),
|
||||
to_email=str(project_info["user_email"]),
|
||||
analysis_result=analysis_result,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||
await connection.commit()
|
||||
if project_info and project_info.get("user_email"):
|
||||
await send_analysis_error_email(
|
||||
project_id=project_id,
|
||||
project_name=str(project_info.get("project_name") or project_id),
|
||||
to_email=str(project_info["user_email"]),
|
||||
error_message=str(exc),
|
||||
)
|
||||
@@ -12,6 +12,7 @@ import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import {
|
||||
checkWF1AnalysisStatus,
|
||||
createUploadSession,
|
||||
@@ -50,6 +51,9 @@ function L(key: keyof typeof ui_locales): string {
|
||||
}
|
||||
|
||||
export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const dashboardUser = await fetchDashboardMe();
|
||||
const completionRoute =
|
||||
dashboardUser.role === "SYSTEM_ADMIN" ? ROUTES.B04_WF1_SURFACE : ROUTES.B05_WF2_ROUTE;
|
||||
const slots = initializeSlots();
|
||||
const cardMap = new Map<FileSlot, HTMLElement>();
|
||||
const resultList = document.createElement("ul");
|
||||
@@ -441,6 +445,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (status.status === "completed") {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
status.current_stage === "awaiting_confirmation" &&
|
||||
status.message.includes("자동 확정 보류")
|
||||
) {
|
||||
pageError.textContent = status.message;
|
||||
showToast(status.message, "warning");
|
||||
return false;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
@@ -468,7 +480,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const analysisComplete = await pollWF1Analysis(activeProjectId);
|
||||
|
||||
if (analysisComplete) {
|
||||
navigateTo(ROUTES.B04_WF1_SURFACE);
|
||||
navigateTo(completionRoute);
|
||||
} else {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
@@ -576,7 +588,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
label: L("B03_File_Restore_State"),
|
||||
container: resumeBanner,
|
||||
poll: pollWF1Analysis,
|
||||
onComplete: () => navigateTo(ROUTES.B04_WF1_SURFACE),
|
||||
onComplete: () => navigateTo(completionRoute),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user