"""협상 초청 메일(수동 발송)과 세션 chat URL.""" import uuid from common.authz import is_owner_or_admin from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import quotations, sessions from common.enums import DBWRType, ErrorType from common.logger import LOG from common.utils.gtime import GTime from config.server_configs import web_server_config from router.v1.quotation.protocol import Res_NotifySessions from services.email import EmailUnavailable, build_invite_email, send_email class InvitesMixin: @staticmethod def _session_chat_url(session_id) -> str: """세션 chat 실행 URL(공급사 협상 프론트). ChatPage 가 session_id 쿼리로 진입한다.""" base = (web_server_config.nego_chat_url or "").rstrip("/") return f"{base}/chat?session_id={session_id}" async def notify_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions: """[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다. 대상 = email_sent_at IS NULL + 담당자 이메일 보유.""" res = Res_NotifySessions() qt_uuid = uuid.UUID(qt_id) err_type, quotation = await self._fetch(qt_uuid, company_id) if err_type != ErrorType.SUCCESS or quotation is None: res.result.SetResult(err_type) return res # 소유자 게이팅 — 본인 견적 또는 최고관리자만 초청메일 발송(user_id 미지정=내부 호출은 스킵). if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role): res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다." return res err_type, rows = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, lambda s: self.quotation_crud.list_sessions_with_supplier(s, qt_uuid), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 행 언팩: (session, supplier_name, manager_email). targets = [] # [(session, name, email)] for r in rows: sess, sp_name, email = r[0], r[1], r[2] res.total += 1 if sess.email_sent_at is not None: continue # 이미 발송됨 — 재발송은 행 단위 endpoint 로 if not email: res.skipped += 1 continue targets.append((sess, sp_name, email)) sent_ids = await self._send_invites(quotation, targets, res) if sent_ids: await self._mark_emailed(sent_ids) return res async def notify_session(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions: """[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송).""" res = Res_NotifySessions() sess_uuid = uuid.UUID(session_id) err_type, got = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, lambda s: self.quotation_crud.get_session_with_supplier(s, sess_uuid), ) if err_type != ErrorType.SUCCESS or got is None: res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND) return res sess, sp_name, email = got[0], got[1], got[2] res.total = 1 err_type, quotation = await self._fetch(sess.quotation_id, company_id) if err_type != ErrorType.SUCCESS or quotation is None: res.result.SetResult(err_type) return res # 소유자 게이팅 — 본인 견적 또는 최고관리자만 재발송(user_id 미지정=내부 호출은 스킵). if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role): res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다." return res if not email: res.skipped = 1 return res sent_ids = await self._send_invites(quotation, [(sess, sp_name, email)], res) if sent_ids: await self._mark_emailed(sent_ids) return res async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list: """targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고 성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED).""" # 회사명·브랜딩(메일 헤더·색·로고·문구) 한 번 조회 — 견적당 동일. 회사명은 헤더 기본값. company_name, settings = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, lambda s: self.quotation_crud.get_company_brand(s, quotation.user_id), ) branding = settings.get("branding") or {} sent_ids = [] for sess, sp_name, email in targets: subject, html, text = build_invite_email( supplier_name=sp_name or "", quotation_name=quotation.name, qt_number=quotation.number, end_time=quotation.end_time, chat_url=self._session_chat_url(sess.session_id), company_name=company_name, branding=branding, ) try: await send_email(email, subject, html, text) sent_ids.append(sess.session_id) res.sent += 1 except EmailUnavailable as e: res.result.SetResult(ErrorType.EMAIL_NOT_CONFIGURED) # 발송 채널 없음 — 더 시도해도 무의미 res.msg = str(e) break except Exception as ex: LOG.e_no_callstack(ex) res.failed += 1 # 보낼 대상이 있었는데 전부 실패면 명시적 실패 코드(설정은 됐으나 발송 실패). if res.sent == 0 and res.failed > 0 and res.result.success: res.result.SetResult(ErrorType.EMAIL_SEND_FAILED) return sent_ids async def _mark_emailed(self, session_ids: list) -> None: """발송 성공 세션들의 email_sent_at 갱신(write 트랜잭션).""" now = GTime.UTC() await DB_SESSION_MNG.execute_lambda_run( [sessions.DBType()], [lambda s: self.quotation_crud.mark_sessions_emailed(s, session_ids, now)], )