[견적·목표가] - 목표가 산정 KTC 기준 정비(MD 우선, 없으면 인터넷최저가·매입가·판매가 중 최저 / 신규는 인터넷최저가만) - 인터넷 평균 수수료 0.078 상수화(견적설정 컬럼 제거) - 앵커링가 세션 저장 + 목표가 클릭 시 산정내역 모달 - 재생성 시 목표가·앵커링가 재계산 없이 직전 값 상속(KTC) - MD 제시가·협력사 유형(유통·제조·총판·기타) 입력 [협상카드] - 사용 구분(공통/신규견적전용/재견적전용) [협상 진행] - 협력사 초청 메일 발송(일괄/개별·재발송, 발송상태 표시) [기타] - 견적상세 창 닫기 버그 수정, 불필요한 컬럼 주석 정리 - DB: 기존 DB는 postgres-init/04-alter.sql 적용 필요(자동 마이그레이션 없음) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.7 KiB
Python
126 lines
4.7 KiB
Python
"""협상 초청 메일 발송.
|
|
|
|
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender).
|
|
2순위(폴백): SMTP (aiosmtplib).
|
|
둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0).
|
|
|
|
설정 출처: config.<APP_ENV>.toml 의 [MailConfig] (config/config_models.py:MailConfig).
|
|
HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from email.message import EmailMessage
|
|
from html import escape
|
|
from pathlib import Path
|
|
from string import Template
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from common.logger import LOG
|
|
from config.server_configs import mail_config
|
|
|
|
_KST = ZoneInfo("Asia/Seoul")
|
|
|
|
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
|
|
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
|
|
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
|
|
|
|
|
|
class EmailUnavailable(RuntimeError):
|
|
"""ACS·SMTP 모두 미설정이라 발송 채널이 없음."""
|
|
|
|
|
|
async def _send_acs(to: str, subject: str, html: str, text: str) -> None:
|
|
"""Azure Communication Services Email — accesskey 인증."""
|
|
from azure.communication.email.aio import EmailClient
|
|
|
|
conn = f"endpoint={mail_config.azure_acs_endpoint};accesskey={mail_config.azure_acs_accesskey}"
|
|
message = {
|
|
"senderAddress": mail_config.azure_acs_sender,
|
|
"recipients": {"to": [{"address": to}]},
|
|
"content": {"subject": subject, "plainText": text, "html": html},
|
|
}
|
|
async with EmailClient.from_connection_string(conn) as client:
|
|
poller = await client.begin_send(message)
|
|
await poller.result()
|
|
LOG.i(f"email sent via ACS → {to} ({subject})")
|
|
|
|
|
|
async def _send_smtp(to: str, subject: str, html: str, text: str) -> None:
|
|
import aiosmtplib
|
|
|
|
msg = EmailMessage()
|
|
msg["From"] = mail_config.smtp_from
|
|
msg["To"] = to
|
|
msg["Subject"] = subject
|
|
msg.set_content(text)
|
|
msg.add_alternative(html, subtype="html")
|
|
|
|
await aiosmtplib.send(
|
|
msg,
|
|
hostname=mail_config.smtp_host,
|
|
port=mail_config.smtp_port,
|
|
username=mail_config.smtp_user or None,
|
|
password=mail_config.smtp_password or None,
|
|
start_tls=mail_config.smtp_starttls,
|
|
)
|
|
LOG.i(f"email sent via SMTP → {to} ({subject})")
|
|
|
|
|
|
async def send_email(to: str, subject: str, html: str, text: str) -> None:
|
|
"""ACS(설정 시) → SMTP(폴백) 순으로 1통 발송. 둘 다 없으면 EmailUnavailable."""
|
|
if mail_config.acs_configured:
|
|
await _send_acs(to, subject, html, text)
|
|
return
|
|
if mail_config.smtp_host:
|
|
await _send_smtp(to, subject, html, text)
|
|
return
|
|
# endpoint/accesskey 만 있고 sender 누락 시 원인을 명확히 안내.
|
|
if mail_config.azure_acs_endpoint and not mail_config.azure_acs_sender:
|
|
raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정")
|
|
raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요")
|
|
|
|
|
|
def _fmt_deadline(end_time: datetime | None) -> str:
|
|
"""협상 마감 시각 → 한국시간 'YYYY-MM-DD HH:MM' 표기. 값 없으면 빈 문자열."""
|
|
if not end_time:
|
|
return ""
|
|
dt = end_time if end_time.tzinfo else end_time.replace(tzinfo=ZoneInfo("UTC"))
|
|
return dt.astimezone(_KST).strftime("%Y-%m-%d %H:%M")
|
|
|
|
|
|
def build_invite_email(
|
|
*,
|
|
supplier_name: str,
|
|
quotation_name: str,
|
|
qt_number: str,
|
|
end_time: datetime | None,
|
|
chat_url: str,
|
|
) -> tuple[str, str, str]:
|
|
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
|
|
|
|
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
|
|
공급사는 링크로 협상 화면에 진입해 입찰한다.
|
|
"""
|
|
sp = supplier_name or "협력사"
|
|
deadline = _fmt_deadline(end_time) or "미정"
|
|
subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청"
|
|
|
|
# HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력).
|
|
html = _INVITE_HTML.substitute(
|
|
supplier_name=escape(sp),
|
|
quotation_name=escape(quotation_name),
|
|
qt_number=escape(qt_number),
|
|
deadline=escape(deadline),
|
|
chat_url=escape(chat_url),
|
|
)
|
|
|
|
text = (
|
|
f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n"
|
|
f" - 견적명: {quotation_name}\n"
|
|
f" - 견적번호: {qt_number}\n"
|
|
f" - 협상 마감: {deadline}\n\n"
|
|
f"협상 참여 링크: {chat_url}\n"
|
|
)
|
|
return subject, html, text
|