138 lines
6.6 KiB
Python
138 lines
6.6 KiB
Python
"""협상 초청 메일(수동 발송)과 세션 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)],
|
|
)
|