From 336335c611450274165d800cd9186cdce63a09db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 8 Aug 2026 11:30:41 +0900 Subject: [PATCH] =?UTF-8?q?change(B03):=20=EC=97=85=EB=A1=9C=EB=93=9C/?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=95=8C=EB=A6=BC=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EC=B4=88=EA=B8=B0=20=EC=84=A4=EA=B3=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=EC=A0=90=20=ED=95=9C=20=ED=86=B5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 업로드 한 번에 업로드 완료 메일과 지표면 분석 완료 메일이 두 통 도착했다. 사용자가 실제로 화면을 볼 수 있는 시점은 초기 설계(B04 전처리 + B05 종단 + B06 횡단)까지 끝난 때 하나뿐이므로 그 시점에 통합 메일 한 통만 보낸다. - send_initial_analysis_complete_email 신설: 지표면 요약 + 노선 연장/측점 수 + 종단설계 화면 링크. 자동 체인 미실행/실패 시 전처리 요약만 싣고 안내 문구 전환 - run_auto_design_chain이 요약(route_id/length_m/cross_section_count) 반환 - WF1 서비스: 체인 실행 -> 통합 메일 순으로 재배치 - 업로드 직후 메일 발송 호출 제거(일반·청크 두 경로). 발송 함수는 프로젝트 생성 전 임시 보관함 안내용으로 남겨 두고 주석으로 용도 명시 Co-Authored-By: Claude Fable 5 --- B03_FileInput/B03_FileInput_Email.py | 76 ++++++++++++++++---- B03_FileInput/B03_FileInput_Router.py | 34 ++++----- B03_FileInput/B03_FileInput_Service_Chain.py | 22 ++++-- B03_FileInput/B03_FileInput_Service_WF1.py | 24 ++++--- 4 files changed, 106 insertions(+), 50 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Email.py b/B03_FileInput/B03_FileInput_Email.py index a4068574..0d7d2001 100644 --- a/B03_FileInput/B03_FileInput_Email.py +++ b/B03_FileInput/B03_FileInput_Email.py @@ -118,7 +118,13 @@ async def send_file_upload_complete_email( file_size_mb: float, metadata: dict[str, Any], ) -> bool: - """파일 업로드 완료 직후 사용자에게 WF1 분석 시작을 안내한다.""" + """파일 저장만 끝난 시점의 안내 메일. + + 프로젝트 업로드 경로에서는 더 이상 쓰지 않는다 — 그 흐름은 초기 설계까지 마친 뒤 + [[send_initial_analysis_complete_email]] 한 통으로 알린다. 이 메일은 프로젝트를 + 만들기 전 임시 보관함(storage/tmp)에 자료를 올렸을 때 보관 안내용으로 쓴다 + (2026-08-08 사용자 지시, 임시 보관 기능 구현 시 연결). + """ bounds = metadata.get("bounds") or {} statistics = metadata.get("statistics") or {} min_z, max_z = _extract_elevation_range(bounds, statistics) @@ -158,14 +164,22 @@ async def send_file_upload_complete_email( return success -async def send_analysis_completion_email( +async def send_initial_analysis_complete_email( *, project_id: UUID, project_name: str, to_email: str, analysis_result: dict[str, Any], + design_summary: dict[str, Any] | None = None, ) -> bool: - """WF1 Surface 분석 완료 후 결과 요약 이메일을 발송한다.""" + """업로드 ~ 초기 설계(B04 전처리·B05 종단·B06 횡단)까지 마친 뒤 한 통으로 알린다. + + 예전에는 업로드 직후와 지표면 분석 직후에 각각 메일을 보내 같은 작업으로 두 통이 + 도착했다. 사용자가 실제로 확인할 시점은 초기 설계까지 끝나 화면에서 검토할 수 있게 + 된 때 하나뿐이므로 그때 한 통만 보낸다(2026-08-08 사용자 지시). + + `design_summary`가 없으면(자동 설계 체인 미실행·실패) 전처리 결과까지만 싣는다. + """ processed = analysis_result.get("processed") or {} statistics = processed.get("statistics") or {} bounds = processed.get("bounds") or {} @@ -173,39 +187,71 @@ async def send_analysis_completion_email( min_z = statistics.get("min_z", bounds.get("min_z")) max_z = statistics.get("max_z", bounds.get("max_z")) point_count = processed.get("point_count", 0) - result_url = _app_url(f"/projects/{project_id}/surface") - rows = "\n".join( + result_url = _app_url("/#/b05-profile") + + surface_rows = "\n".join( [ _summary_row("분석 포인트 수", _format_number(point_count)), _summary_row( "고도 범위", f"{_format_elevation(min_z)} ~ {_format_elevation(max_z)} m", ), - _summary_row("생성 모델 수", f"{len(models):,}개"), - _summary_row("포함 데이터", "DTM, TIN, 미리보기 레이어"), + _summary_row("생성 지표면 모델", f"{len(models):,}개"), ] ) - body = f""" -

프로젝트 {html.escape(project_name)}의 지표면 분석이 완료되었습니다.

+ if design_summary: + length_m = design_summary.get("length_m") + design_rows = "\n".join( + [ + _summary_row( + "노선 연장", + f"{length_m:,.1f} m" if isinstance(length_m, int | float) else "N/A", + ), + _summary_row("측점 수", _format_number(design_summary.get("cross_section_count"))), + _summary_row("초기 설계", "종단 계획선·횡단 기본 설계 저장 완료"), + ] + ) + design_block = f""" +

초기 노선·종횡단 설계

- {rows} + {design_rows}
-

결과는 지표면 분석 페이지에서 확인할 수 있습니다.

+
+ 기본값으로 계산한 결과입니다. 종단설계 화면에서 검토·수정한 뒤 횡단설계에서 + [확정]을 누르면 다음 단계(수량 산출)로 넘어갑니다. +
+ """ + else: + design_block = """ +
+ 초기 노선·종횡단 자동 계산은 완료되지 않았습니다. 종단설계 화면에서 직접 + 경로를 계산해 주세요. +
+ """ + + body = f""" +

프로젝트 {html.escape(project_name)}의 입력 파일 저장과 + 초기 분석이 모두 끝났습니다.

+

지표면 전처리

+
+ {surface_rows} +
+ {design_block}

- 분석 결과 보기 + 종단설계 화면 열기

- 로그인이 필요한 경우 인증 후 같은 결과 페이지로 이동합니다. + 로그인이 필요한 경우 인증 후 같은 화면으로 이동합니다.

""" - subject = f"임도 지표면 분석 완료 - {project_name}" + subject = f"임도 초기 분석 완료 - {project_name}" success = await send_email( to_email=to_email, subject=subject, html=_email_shell(subject, body), ) - logger.info("WF1 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email) + logger.info("초기 분석 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email) return success diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 1ee99b67..b4e4e37c 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -76,6 +76,11 @@ def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: def _is_point_cloud_result(result: UploadedFileResult) -> bool: + """포인트클라우드 결과인지 — 임시 보관함 안내 메일 경로에서 쓴다. + + 프로젝트 업로드 경로는 더 이상 이 판정으로 메일을 보내지 않는다 + ([[_send_upload_complete_notification]] 주석 참고). + """ return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES @@ -196,6 +201,12 @@ async def _send_upload_complete_notification( project_id: UUID, uploaded_file: UploadedFileResult, ) -> None: + """저장만 끝났을 때 보내는 안내. + + 프로젝트 업로드 경로에서는 호출하지 않는다 — 그 흐름은 초기 설계까지 마친 뒤 + 통합 메일 한 통으로 알린다. 프로젝트 생성 전 임시 보관함 업로드에서 쓸 예정이라 + 지워두지 않았다(2026-08-08 사용자 지시). + """ pool = get_db_pool() async with pool.acquire() as connection: project_info = await _get_project_notification_info(connection, project_id) @@ -324,18 +335,8 @@ async def upload_project_files( workflow_path = project_root / "workflow.json" if not workflow_path.exists(): atomic_write_json(workflow_path, load_project_workflow(project_root)) - point_cloud_result = next( - (result for result in results if _is_point_cloud_result(result)), - None, - ) - if point_cloud_result: - _schedule_background_task( - _send_upload_complete_notification( - project_id=project_id, - uploaded_file=point_cloud_result, - ), - task_name=f"b03-upload-email-{project_id}", - ) + # 업로드 직후 안내 메일은 보내지 않는다 — 초기 설계(B04~B06)까지 마친 뒤 + # WF1 서비스가 통합 메일 한 통을 보낸다(2026-08-08 사용자 지시). if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( @@ -558,14 +559,7 @@ async def finalize_project_upload( ) stage_root = project_root / "B03_FileInput" _write_stage_metadata(stage_root, project_id, [result]) - if _is_point_cloud_result(result): - _schedule_background_task( - _send_upload_complete_notification( - project_id=project_id, - uploaded_file=result, - ), - task_name=f"b03-upload-email-{project_id}", - ) + # 업로드 직후 안내 메일은 보내지 않는다 — 위 일반 업로드 경로와 같은 이유. if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 7801731b..d4cb4639 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -52,11 +52,16 @@ def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, f return [{"x": x, "y": y} for x, y in points] -async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None = None) -> None: +async def run_auto_design_chain( + project_id: UUID, surface_model_id: int | None = None +) -> dict[str, Any] | None: """B05 기본 경로 계산·확정 → B06 기본 횡단 설계 확정을 기본값으로 이어 실행한다. WF1 자동 확정 직후 같은 백그라운드 태스크에서 호출된다. 어떤 단계가 실패해도 예외를 밖으로 던지지 않는다 — 로그와 각 단계의 workflow 상태 기록으로 남긴다. + + 성공하면 초기 분석 완료 메일에 실을 요약(노선 id·연장·측점 수)을 돌려주고, + 건너뛰거나 실패하면 None을 돌려준다. """ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Repository import get_latest_route @@ -79,14 +84,14 @@ async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None = project_id, existing.get("id"), ) - return + return None # 2) 계획노선 CSV → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. project_root = Path(resolve_stored_project_path(stored_path)) points = _planned_route_points_in_project_crs(project_root) if not points: logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) - return + return None # 3) B05 경로 계산 — WF1 자동 확정과 같은 config 기본값을 쓴다. defaults = surface_confirmation_defaults() @@ -109,7 +114,7 @@ async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None = project_id, solve_result.status_code, ) - return + return None route_id = int(solve_result.route_id) logger.info( "자동 설계 체인 B05 경로 계산 완료: project_id=%s route_id=%s length=%.1fm", @@ -127,7 +132,7 @@ async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None = project_id, confirm_result.status_code, ) - return + return None # 5) B06 횡단 설계 확정 — 미지정 측점을 기본값으로 채워 저장. stage 3은 # IN_PROGRESS(스텝바 노란 표시)로 남겨 사용자 검토·확정을 기다린다. @@ -141,12 +146,17 @@ async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None = route_id, sections_result.status_code, ) - return + return None logger.info( "자동 설계 체인 완료(B05·B06 기본값 확정): project_id=%s route_id=%s", project_id, route_id, ) + return { + "route_id": route_id, + "length_m": float(solve_result.total_length_m or 0.0), + "cross_section_count": solve_result.cross_section_count, + } except Exception: # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. logger.exception("자동 설계 체인 실패: project_id=%s", project_id) diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 415479ce..4f10c038 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -9,8 +9,8 @@ from uuid import UUID import aiomysql from B03_FileInput.B03_FileInput_Email import ( - send_analysis_completion_email, send_analysis_error_email, + send_initial_analysis_complete_email, ) from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from common_util.common_util_storage import resolve_stored_project_path @@ -156,20 +156,26 @@ async def trigger_wf1_analysis_and_email( progress_message = "WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요." write_surface_progress(project_root, 100, progress_stage, progress_message) + # WF1 자동 확정까지 끝났으면 같은 백그라운드 태스크에서 B05 기본 경로 → B06 기본 + # 횡단 설계 체인을 잇는다(2026-08-04 사용자 확정). 체인은 실패를 스스로 격리한다. + design_summary: dict[str, Any] | None = None + if auto_confirmed and AUTO_DESIGN_CHAIN_ENABLED: + from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain + + design_summary = await run_auto_design_chain( + project_id, surface_model_id=confirmed_model_id + ) + + # 알림 메일은 여기 한 통뿐이다 — 업로드 직후와 분석 직후로 나눠 두 통을 보내던 것을 + # 초기 설계(B04~B06)까지 마친 시점의 통합 메일로 합쳤다(2026-08-08 사용자 지시). if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"): - await send_analysis_completion_email( + await send_initial_analysis_complete_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, + design_summary=design_summary, ) - - # WF1 자동 확정까지 끝났으면 같은 백그라운드 태스크에서 B05 기본 경로 → B06 기본 - # 횡단 설계 체인을 잇는다(2026-08-04 사용자 확정). 체인은 실패를 스스로 격리한다. - if auto_confirmed and AUTO_DESIGN_CHAIN_ENABLED: - from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain - - await run_auto_design_chain(project_id, surface_model_id=confirmed_model_id) except Exception as exc: logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) async with pool.acquire() as connection, connection.cursor() as cursor: