fix(B05,B03): 초기 설계 실패 시 초기값 부재 처리 — 실패 마커·안내 메일·[초기화] 거부

증상: 파일 입력 직후 초기 계산값과 B05 [초기화] 결과가 다름.

원인
- 자동설계 체인이 5단계까지 전부 성공해야만 `save_initial_snapshot()` 호출.
  중간에 깨지면 `initial_snapshot/` 미생성 → [초기화]가 복원 대신 재계산 폴백.
- 재계산 폴백의 지표면 기준이 체인과 다름 — 체인은 stage 1 확정값
  (`get_surface_confirmation_params`), [초기화]는 config 기본값
  (`surface_confirmation_defaults`). 실측: 프로젝트 stage1 `classification`/5m 대
  config `csf`/1m.
- 체인이 깨져도 WF1 은 초기 분석 완료 메일을 그대로 발송.

수정 (2026-09-02 사용자 확정 — 부분 결과는 분석 안 됨과 다르지 않으므로 부분
스냅샷은 만들지 않음)
- `initial_design.failed` 마커 신설 — 프로젝트 루트(스냅샷 4트리 밖), 실패 사유 기록.
  체인 진입 시 옛 마커 제거, 실패 5지점 + 스냅샷 저장 실패에서 기록.
- WF1 이 마커를 읽어 완료 메일 대신 `send_initial_design_failed_email()` 발송
  (관리자 주소 `ADMIN_EMAIL`, 없으면 주소 없이 안내).
- B05 [초기화]: 스냅샷 있으면 복원, 실패 마커 있으면 409 `initial_design_failed`
  로 거부(DELETE 앞에서 반환 — 데이터 무변경), 옛 프로젝트만 종전 재계산 폴백.
- 재계산 폴백의 지표면 기준을 `get_surface_confirmation_params()` 로 통일.

자체검증: `pytest tmp/tests/ -q` 366 passed / 14 skipped / 0 failed (+4).
공용 브라우저 실측 — 실패 프로젝트 [초기화] 409 응답·`routes` 행 무변경 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:03:18 +09:00
co-authored by Claude Opus 5
parent 1a9947426a
commit 2e57d29156
6 changed files with 176 additions and 18 deletions
+51 -1
View File
@@ -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'<p>관리자에게 문의해 주세요 — <a href="mailto:{html.escape(contact)}">'
f"{html.escape(contact)}</a></p>"
if contact
else "<p>관리자에게 문의해 주세요.</p>"
)
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"""
<p>프로젝트 <strong>{safe_name}</strong>의 <strong>초기 설계 자동 계산</strong>이
완료되지 못했습니다.</p>
<div class="box">
{rows}
</div>
<div class="notice">
<p>초기 설계가 끝나지 않아 종·횡단 화면의 값이 기준값으로 확정되지 않았습니다.
이 상태에서는 [초기화]로 되돌릴 초기값도 없습니다.</p>
{contact_line}
</div>
<p>기술 지원팀이 확인할 수 있도록 서버 로그에도 같은 내용을 기록했습니다.</p>
"""
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
+24 -3
View File
@@ -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:
+20 -7
View File
@@ -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: