증상: 파일 입력 직후 초기 계산값과 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>
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""B03 업로드 및 B04 WF1 분석 결과 이메일 발송."""
|
|
|
|
import html
|
|
import logging
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from common_util.common_util_email import send_email
|
|
from config.config_system import ADMIN_EMAIL, APP_PUBLIC_BASE_URL
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _format_number(value: Any) -> str:
|
|
if isinstance(value, int | float):
|
|
return f"{value:,}"
|
|
return "N/A"
|
|
|
|
|
|
def _format_elevation(value: Any) -> str:
|
|
if isinstance(value, int | float):
|
|
return f"{value:.2f}"
|
|
return "N/A"
|
|
|
|
|
|
def _extract_elevation_range(bounds: dict[str, Any], statistics: dict[str, Any]) -> tuple[Any, Any]:
|
|
if isinstance(bounds.get("z"), list | tuple) and len(bounds["z"]) >= 2:
|
|
return bounds["z"][0], bounds["z"][1]
|
|
return (
|
|
bounds.get("min_z", statistics.get("min_z")),
|
|
bounds.get("max_z", statistics.get("max_z")),
|
|
)
|
|
|
|
|
|
def _app_url(path: str) -> str:
|
|
return f"{APP_PUBLIC_BASE_URL}/{path.lstrip('/')}"
|
|
|
|
|
|
def _email_shell(title: str, body: str, *, accent: str = "#2563eb") -> str:
|
|
box_style = (
|
|
f"border: 1px solid #e5e7eb; border-left: 4px solid {accent}; "
|
|
"padding: 18px; margin: 20px 0;"
|
|
)
|
|
row_style = (
|
|
"display: flex; justify-content: space-between; gap: 16px; padding: 9px 0; "
|
|
"border-bottom: 1px solid #f3f4f6;"
|
|
)
|
|
return f"""<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body {{
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #111827;
|
|
background: #f3f4f6;
|
|
}}
|
|
.container {{ max-width: 640px; margin: 0 auto; padding: 24px; }}
|
|
.header {{
|
|
background: {accent};
|
|
color: #ffffff;
|
|
padding: 28px;
|
|
border-radius: 8px 8px 0 0;
|
|
text-align: center;
|
|
}}
|
|
.header h1 {{ margin: 0; font-size: 24px; letter-spacing: 0; }}
|
|
.content {{ background: #ffffff; padding: 28px; border-radius: 0 0 8px 8px; }}
|
|
.box {{ {box_style} }}
|
|
.row {{ {row_style} }}
|
|
.row:last-child {{ border-bottom: 0; }}
|
|
.label {{ color: #6b7280; font-weight: 700; }}
|
|
.value {{ color: #111827; text-align: right; }}
|
|
.notice {{ background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; border-radius: 6px; }}
|
|
.button {{
|
|
display: inline-block;
|
|
background: {accent};
|
|
color: #ffffff !important;
|
|
padding: 12px 22px;
|
|
border-radius: 6px;
|
|
text-decoration: none;
|
|
font-weight: 700;
|
|
}}
|
|
.footer {{ color: #6b7280; font-size: 12px; text-align: center; margin-top: 24px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header"><h1>{html.escape(title)}</h1></div>
|
|
<div class="content">
|
|
{body}
|
|
<div class="footer">
|
|
<p>© 2026 Aislo - AI 임도 설계 솔루션</p>
|
|
<p>문의: support@aislo.example.com</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def _summary_row(label: str, value: str) -> str:
|
|
return (
|
|
'<div class="row">'
|
|
f'<span class="label">{html.escape(label)}</span>'
|
|
f'<span class="value">{value}</span>'
|
|
"</div>"
|
|
)
|
|
|
|
|
|
async def send_file_upload_complete_email(
|
|
*,
|
|
to_email: str,
|
|
user_name: str,
|
|
project_name: str,
|
|
file_name: str,
|
|
file_size_mb: float,
|
|
metadata: dict[str, Any],
|
|
) -> bool:
|
|
"""파일 저장만 끝난 시점의 안내 메일.
|
|
|
|
프로젝트 업로드 경로에서는 더 이상 쓰지 않는다 — 그 흐름은 초기 설계까지 마친 뒤
|
|
[[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)
|
|
point_count = metadata.get("point_count", metadata.get("points", 0))
|
|
epsg = metadata.get("epsg", "N/A")
|
|
rows = "\n".join(
|
|
[
|
|
_summary_row("파일명", html.escape(file_name)),
|
|
_summary_row("파일 크기", f"{file_size_mb:.2f} MB"),
|
|
_summary_row("포인트 수", _format_number(point_count)),
|
|
_summary_row("좌표계", f"EPSG:{html.escape(str(epsg))}"),
|
|
_summary_row(
|
|
"고도 범위",
|
|
f"{_format_elevation(min_z)} ~ {_format_elevation(max_z)} m",
|
|
),
|
|
]
|
|
)
|
|
|
|
body = f"""
|
|
<p>안녕하세요, <strong>{html.escape(user_name or "사용자")}</strong>님.</p>
|
|
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 입력 파일이 저장되었습니다.</p>
|
|
<div class="box">
|
|
{rows}
|
|
</div>
|
|
<div class="notice">
|
|
지표면 분석(WF1)이 백그라운드에서 자동으로 시작되었습니다.
|
|
완료되면 분석 요약과 결과 페이지 링크를 다시 보내드립니다.
|
|
</div>
|
|
"""
|
|
subject = f"파일 업로드 완료 - {project_name}"
|
|
success = await send_email(
|
|
to_email=to_email,
|
|
subject=subject,
|
|
html=_email_shell(subject, body),
|
|
)
|
|
logger.info("파일 업로드 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
|
return success
|
|
|
|
|
|
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:
|
|
"""업로드 ~ 초기 설계(B04 전처리·B05 종단·B06 횡단)까지 마친 뒤 한 통으로 알린다.
|
|
|
|
예전에는 업로드 직후와 지표면 분석 직후에 각각 메일을 보내 같은 작업으로 두 통이
|
|
도착했다. 사용자가 실제로 확인할 시점은 초기 설계까지 끝나 화면에서 검토할 수 있게
|
|
된 때 하나뿐이므로 그때 한 통만 보낸다(2026-08-08 사용자 지시).
|
|
|
|
`design_summary`가 없으면(자동 설계 체인 미실행·실패) 전처리 결과까지만 싣는다.
|
|
"""
|
|
processed = analysis_result.get("processed") or {}
|
|
statistics = processed.get("statistics") or {}
|
|
bounds = processed.get("bounds") or {}
|
|
models = analysis_result.get("models") or []
|
|
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("/#/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):,}개"),
|
|
]
|
|
)
|
|
|
|
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"""
|
|
<h3 style="margin:24px 0 8px;font-size:15px;">초기 노선·종횡단 설계</h3>
|
|
<div class="box">
|
|
{design_rows}
|
|
</div>
|
|
<div class="notice">
|
|
기본값으로 계산한 결과입니다. 종단설계 화면에서 검토·수정한 뒤 횡단설계에서
|
|
[확정]을 누르면 다음 단계(수량 산출)로 넘어갑니다.
|
|
</div>
|
|
"""
|
|
else:
|
|
design_block = """
|
|
<div class="notice">
|
|
초기 노선·종횡단 자동 계산은 완료되지 않았습니다. 종단설계 화면에서 직접
|
|
경로를 계산해 주세요.
|
|
</div>
|
|
"""
|
|
|
|
body = f"""
|
|
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 입력 파일 저장과
|
|
초기 분석이 모두 끝났습니다.</p>
|
|
<h3 style="margin:20px 0 8px;font-size:15px;">지표면 전처리</h3>
|
|
<div class="box">
|
|
{surface_rows}
|
|
</div>
|
|
{design_block}
|
|
<p style="text-align: center; margin: 26px 0;">
|
|
<a class="button" href="{html.escape(result_url)}">종단설계 화면 열기</a>
|
|
</p>
|
|
<p style="color:#6b7280;font-size:13px;">
|
|
로그인이 필요한 경우 인증 후 같은 화면으로 이동합니다.
|
|
</p>
|
|
"""
|
|
subject = f"임도 초기 분석 완료 - {project_name}"
|
|
success = await send_email(
|
|
to_email=to_email,
|
|
subject=subject,
|
|
html=_email_shell(subject, body),
|
|
)
|
|
logger.info("초기 분석 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
|
return success
|
|
|
|
|
|
async def send_analysis_error_email(
|
|
*,
|
|
project_id: UUID,
|
|
project_name: str,
|
|
to_email: str,
|
|
error_message: str,
|
|
) -> bool:
|
|
"""WF1 Surface 분석 실패 알림 이메일을 발송한다."""
|
|
rows = "\n".join(
|
|
[
|
|
_summary_row("프로젝트 ID", html.escape(str(project_id))),
|
|
_summary_row("오류 내용", html.escape(error_message)),
|
|
]
|
|
)
|
|
safe_name = html.escape(project_name)
|
|
body = f"""
|
|
<p>프로젝트 <strong>{safe_name}</strong>의 지표면 분석 중 오류가 발생했습니다.</p>
|
|
<div class="box">
|
|
{rows}
|
|
</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("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
|