diff --git a/B03_FileInput/B03_FileInput_Email.py b/B03_FileInput/B03_FileInput_Email.py index 0d7d2001..4da46696 100644 --- a/B03_FileInput/B03_FileInput_Email.py +++ b/B03_FileInput/B03_FileInput_Email.py @@ -6,7 +6,7 @@ from typing import Any from uuid import UUID from common_util.common_util_email import send_email -from config.config_system import APP_PUBLIC_BASE_URL +from config.config_system import ADMIN_EMAIL, APP_PUBLIC_BASE_URL logger = logging.getLogger(__name__) @@ -285,3 +285,53 @@ async def send_analysis_error_email( ) logger.info("WF1 오류 이메일 발송 %s: %s", "성공" if success else "실패", to_email) return success + + +async def send_initial_design_failed_email( + *, + project_id: UUID, + project_name: str, + to_email: str, + reason: str, +) -> bool: + """초기 설계(B05·B06 자동 계산) 실패를 알리고 관리자 문의를 안내한다. + + 부분 결과를 초기값으로 삼지 않는다는 방침(2026-09-02 사용자 확정)에 따라, 체인이 + 깨진 프로젝트는 [초기화]가 되돌릴 기준이 없다. 완료 메일 대신 이 메일을 보내 + 사용자가 화면에서 헛작업을 하지 않게 한다. + """ + contact = ADMIN_EMAIL.strip() + contact_line = ( + f'
관리자에게 문의해 주세요 — ' + f"{html.escape(contact)}
" + if contact + else "관리자에게 문의해 주세요.
" + ) + rows = "\n".join( + [ + _summary_row("프로젝트 ID", html.escape(str(project_id))), + _summary_row("중단 지점", html.escape(reason or "초기 설계 처리 실패")), + ] + ) + safe_name = html.escape(project_name) + body = f""" +프로젝트 {safe_name}의 초기 설계 자동 계산이 + 완료되지 못했습니다.
+초기 설계가 끝나지 않아 종·횡단 화면의 값이 기준값으로 확정되지 않았습니다. + 이 상태에서는 [초기화]로 되돌릴 초기값도 없습니다.
+ {contact_line} +기술 지원팀이 확인할 수 있도록 서버 로그에도 같은 내용을 기록했습니다.
+ """ + subject = f"초기 설계 실패 알림 - {project_name}" + success = await send_email( + to_email=to_email, + subject=subject, + html=_email_shell(subject, body, accent="#dc2626"), + ) + logger.info("초기 설계 실패 이메일 발송 %s: %s", "성공" if success else "실패", to_email) + return success diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index de3a99b7..39bd3125 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -139,7 +139,9 @@ async def run_auto_design_chain( from B05_Profile.B05_Profile_Schema import RoutePoint, RouteSolveRequest from B06_Section.B06_Section_Router_Confirm import confirm_sections from common_util.common_util_initial_snapshot import ( + clear_design_failed, clear_designing, + mark_design_failed, mark_designing, save_initial_snapshot, ) @@ -167,6 +169,8 @@ async def run_auto_design_chain( # 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면 # 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장). mark_designing(project_root) + # 옛 실패 마커는 여기서 지운다 — 이번 체인의 결과로 다시 판정한다. + clear_design_failed(project_root) # WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을 # 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이 # 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다. @@ -176,6 +180,7 @@ async def run_auto_design_chain( points = _planned_route_points_in_project_crs(project_root, defaults) if not points: logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) + mark_design_failed(project_root, "계획노선 CSV가 없어 초기 노선을 세울 수 없습니다.") return None # 3) B05 경로 계산 @@ -198,6 +203,10 @@ async def run_auto_design_chain( project_id, solve_result.status_code, ) + mark_design_failed( + project_root, + f"B05 초기 노선 계산 실패 (status={solve_result.status_code}).", + ) return None route_id = int(solve_result.route_id) logger.info( @@ -216,6 +225,10 @@ async def run_auto_design_chain( project_id, confirm_result.status_code, ) + mark_design_failed( + project_root, + f"B05 초기 노선 확정 실패 (status={confirm_result.status_code}).", + ) return None # 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출. @@ -234,6 +247,10 @@ async def run_auto_design_chain( route_id, sections_result.status_code, ) + mark_design_failed( + project_root, + f"B06 초기 횡단 확정 실패 (status={sections_result.status_code}).", + ) return None logger.info( "자동 설계 체인 완료(B05·B06 기본값 확정): project_id=%s route_id=%s", @@ -242,21 +259,25 @@ async def run_auto_design_chain( ) # 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장). - # 체인 규약대로 실패는 비치명적이다: 스냅샷이 없으면 [초기화]가 재계산으로 돈다. + # 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨 + # [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정). try: async with pool.acquire() as connection: await save_initial_snapshot(connection, project_root, route_id) - except Exception: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 + except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) + mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") 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: + except Exception as exc: # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. logger.exception("자동 설계 체인 실패: project_id=%s", project_id) + if project_root is not None: + mark_design_failed(project_root, f"초기 설계 처리 중 오류: {exc}") finally: # 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다. if project_root is not None: diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 125be8fd..5ffb7cb3 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -11,8 +11,10 @@ import aiomysql from B03_FileInput.B03_FileInput_Email import ( send_analysis_error_email, send_initial_analysis_complete_email, + send_initial_design_failed_email, ) from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_initial_snapshot import is_design_failed, read_design_failure 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 @@ -210,14 +212,25 @@ async def trigger_wf1_analysis_and_email( # 알림 메일은 여기 한 통뿐이다 — 업로드 직후와 분석 직후로 나눠 두 통을 보내던 것을 # 초기 설계(B04~B06)까지 마친 시점의 통합 메일로 합쳤다(2026-08-08 사용자 지시). + # 체인이 깨졌으면 완료 메일 대신 관리자 문의 안내를 보낸다 — 부분 결과는 분석 + # 안 됨과 다르지 않다(2026-09-02 사용자 확정). + design_failure = read_design_failure(project_root) if is_design_failed(project_root) else "" if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_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, - ) + if design_failure: + await send_initial_design_failed_email( + project_id=project_id, + project_name=str(project_info.get("project_name") or project_id), + to_email=str(project_info["user_email"]), + reason=design_failure, + ) + else: + 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, + ) except Exception as exc: logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 05cd24bd..b137cef6 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -275,7 +275,9 @@ export interface RouteResetResponse { } /** [초기화] — 사용자 편집을 전부 버리고 초기값으로 되돌린다. 초기값 스냅샷이 있으면 - * 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. */ + * 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. + * 초기 설계가 실패로 끝난 프로젝트는 서버가 409로 거부한다 — 되돌릴 기준이 없어 + * 재계산으로 얼버무리지 않는다(2026-09-02). 그 안내 문구가 그대로 오류 토스트에 뜬다. */ export async function resetRouteDesign(projectId: string): Promise