Files
django-vue3-admin/backend/apps/pisadmin/basicinfo/views/email_template.py
T

447 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""邮件正文/主题模板:按业务场景 key 渲染,供询价发布、报价结束、询价截止提醒等流程调用。
扩展方式:
1. 在下方注册 ``EMAIL_TEMPLATE_FILES``(主题模板路径 + HTML 正文路径),
再实现 ``build_context_<场景>(...)``,通过 ``render_email(TEMPLATE_xxx, ctx)`` 调用。
2. 若需完全自定义渲染逻辑(非一对 .txt/.html),将 ``TEMPLATE_xxx`` 登记到 ``_CUSTOM_RENDERERS``。
"""
from __future__ import annotations
import os
from typing import Any, Callable, Dict, Optional, Tuple
from django.conf import settings
from django.contrib.auth import get_user_model
from django.template.loader import render_to_string
from django.utils import timezone
# 杂采询价单发布通知(新询价邀请)
TEMPLATE_RFS_PUBLISH = "RFS_publish"
# 全部供应商已报价 → 询价单进入「报价结束」— 通知采购负责人
TEMPLATE_QUOTE_ENDED = "Quote_ended"
# 报价截止后无待报价/报价中单 → 询价单收口为「报价结束」(典型:sync_expired)— 询价截止提醒
TEMPLATE_QUOTE_TIMEOUT = "Quote_timeout"
# 报价截止时间到期提醒(多个供应商合并一封邮件,按询价单维度)
TEMPLATE_QUOTE_DEADLINE_EXPIRED = "Quote_deadline_expired"
def _for_local_display(dt: Any):
"""
转为用于邮件展示的「本地」时间。
- USE_TZ=False 时 ORM 常返回 naive(即便库中是 timestamptz),应直接按业务时区墙钟时间格式化,
不可调用 timezone.localtime(naive),否则会触发 ValueError。
- aware 时再用 localtime 转到当前激活时区展示。
"""
if dt is None:
return None
if timezone.is_naive(dt):
return dt
return timezone.localtime(dt)
def supplier_portal_base_url() -> str:
"""供应商端入口基址;优先 Django settings,其次环境变量 PIS_SUPPLIER_PORTAL_URL。"""
base = getattr(settings, "PIS_SUPPLIER_PORTAL_URL", None) or os.getenv("PIS_SUPPLIER_PORTAL_URL", "") or ""
return str(base).strip().rstrip("/")
def admin_portal_base_url() -> str:
"""采购/管理端前端基址;优先 settings.PIS_ADMIN_PORTAL_URL,其次环境变量。"""
base = getattr(settings, "PIS_ADMIN_PORTAL_URL", None) or os.getenv("PIS_ADMIN_PORTAL_URL", "") or ""
return str(base).strip().rstrip("/")
def system_brand_name() -> str:
"""邮件落款系统名称。"""
return str(getattr(settings, "PIS_EMAIL_SYSTEM_NAME", None) or "AVC PIS").strip() or "AVC PIS"
def build_context_rfs_publish(
inquiry: Any,
supplier_group: Dict[str, Any],
*,
purchaser_company_name: str = "",
) -> Dict[str, Any]:
"""
组装 RFS_publish 模板变量(均为未转义纯文本,由 Django 模板引擎在渲染 HTML 时自动转义)。
supplier_group:与 `_group_inquiry_suppliers` 一致的结构。
"""
vendor_name = (supplier_group.get("supplier_name") or "").strip() or "贵司"
rfq_number = (getattr(inquiry, "inquiry_no", None) or "").strip()
title = (getattr(inquiry, "title", None) or "").strip()
raw_part_ids = supplier_group.get("part_ids") or set()
part_ids = {str(x) for x in raw_part_ids}
lines: list[str] = []
rfq_mgr = getattr(inquiry, "rfq_items", None)
if rfq_mgr is not None:
for row in rfq_mgr.all():
if str(row.part_id) not in part_ids:
continue
seg = " ".join(x for x in (row.part_id, (row.product_name or "").strip()) if x).strip()
if seg:
lines.append(seg)
if not lines and part_ids:
lines = sorted(part_ids)
material_info = ";".join(lines) if lines else (title or "—")
qd = getattr(inquiry, "quote_deadline", None)
# 招标:主表不存报价截止时,发布邮件展示投标截止时间
if qd is None and int(getattr(inquiry, "buying_method", 1) or 1) == 2:
qd = getattr(inquiry, "bid_end_time", None)
if qd:
local_qd = _for_local_display(qd)
deadline_time = local_qd.strftime("%Y-%m-%d %H:%M")
deadline_date_subject = local_qd.strftime("%Y-%m-%d")
else:
deadline_time = "请登录系统查看"
deadline_date_subject = "待定"
now = _for_local_display(timezone.now())
current_date = now.strftime("%Y-%m-%d") if now else ""
base = supplier_portal_base_url()
system_link = base if base else ""
buyer = (getattr(inquiry, "buyer", None) or "").strip()
contact_person = buyer or "采购部"
contact_phone = "" # 询价单主表暂无采购电话字段,预留
pcn = (purchaser_company_name or "").strip()
if not pcn:
pcn = (getattr(inquiry, "company_code", None) or "").strip() or "我司"
return {
"vendor_name": vendor_name,
"purchaser_company_name": pcn,
"rfq_number": rfq_number,
"material_info": material_info,
"inquiry_title": title,
"deadline_time": deadline_time,
"deadline_date_subject": deadline_date_subject,
"system_link": system_link,
"contact_person": contact_person,
"contact_phone": contact_phone,
"current_date": current_date,
}
def _quote_ended_purchaser_name(inquiry: Any) -> str:
"""采购负责人展示名:优先系统用户姓名,否则采购负责人字段原文。"""
buyer = (getattr(inquiry, "buyer", None) or "").strip()
if not buyer:
return "采购同事"
User = get_user_model()
u = User.objects.filter(username=buyer).exclude(name__isnull=True).exclude(name="").first()
if not u:
u = User.objects.filter(name=buyer).first()
if u and (getattr(u, "name", None) or "").strip():
return str(u.name).strip()
return buyer
def _quote_ended_material_or_project_name(inquiry: Any) -> str:
"""物料/项目名称:优先 RFQ 行料号+品名汇总,否则询价单名称/材料类型。"""
lines: list[str] = []
rfq_mgr = getattr(inquiry, "rfq_items", None)
if rfq_mgr is not None:
for row in rfq_mgr.all():
seg = " ".join(x for x in (row.part_id, (getattr(row, "product_name", None) or "").strip()) if x).strip()
if seg:
lines.append(seg)
if lines:
return ";".join(lines)
title = (getattr(inquiry, "title", None) or "").strip()
if title:
return title
mt = (getattr(inquiry, "material_type", None) or "").strip()
return mt or "—"
def _quote_ended_completion_time(inquiry: Any) -> Any:
"""全部已报价单中,取最晚的报价时间(quotetime 优先,否则 creattime)。"""
from apps.pissupplier.models import QuotationMaster
inq_no = (getattr(inquiry, "inquiry_no", None) or "").strip()
if not inq_no:
return None
latest: Any = None
qs = QuotationMaster.objects.filter(inquiry_no=inq_no, status=3).only("quotetime", "creattime")
for row in qs:
t = row.quotetime or row.creattime
if t is None:
continue
if latest is None or t > latest:
latest = t
return latest
def build_context_quote_ended(
inquiry: Any,
*,
last_quotation_no: str = "",
) -> Dict[str, Any]:
"""
报价完成通知(采购端):受邀供应商均已提交报价,与模板「报价完成通知」文案一致。
"""
from apps.pisadmin.miscprocurement.models import InquirySupplier
inquiry_no = (getattr(inquiry, "inquiry_no", None) or "").strip()
title = (getattr(inquiry, "title", None) or "").strip()
inquiry_name = title or "—"
purchaser_name = _quote_ended_purchaser_name(inquiry)
material_or_project_name = _quote_ended_material_or_project_name(inquiry)
plain = material_or_project_name
material_or_project_short = plain[:40] + ("…" if len(plain) > 40 else "") if plain and plain != "—" else "—"
supplier_count = (
InquirySupplier.objects.filter(inquiry_no=inquiry)
.values_list("supplier_code", flat=True)
.distinct()
.count()
)
raw_done = _quote_ended_completion_time(inquiry)
if raw_done is None:
raw_done = _for_local_display(timezone.now())
done_local = _for_local_display(raw_done)
completion_time = done_local.strftime("%Y-%m-%d %H:%M") if done_local else "—"
base = admin_portal_base_url()
pk = getattr(inquiry, "pk", None) or getattr(inquiry, "id", None)
comparison_page_url = ""
if base and pk is not None:
comparison_page_url = f"{base}/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice/{pk}"
now = _for_local_display(timezone.now())
current_date = now.strftime("%Y-%m-%d") if now else ""
return {
"purchaser_name": purchaser_name,
"inquiry_no": inquiry_no,
"inquiry_name": inquiry_name,
"material_or_project_name": material_or_project_name,
"material_or_project_short": material_or_project_short,
"supplier_count": supplier_count,
"completion_time": completion_time,
"comparison_page_url": comparison_page_url,
# 兼容旧模板变量(若外部仍有引用)
"rfq_number": inquiry_no,
"inquiry_title": inquiry_name,
"inquiry_title_short": material_or_project_short,
"last_quotation_no": (last_quotation_no or "").strip(),
"closed_time": completion_time,
"current_date": current_date,
"admin_link": comparison_page_url or base,
"system_name": system_brand_name(),
}
def _inquiry_effective_deadline_for_display(inquiry: Any) -> Tuple[Any, str]:
"""询价/招标下用于邮件展示的截止时间:询价用 quote_deadline,招标用 bid_end_time。"""
qd = getattr(inquiry, "quote_deadline", None)
if qd is None and int(getattr(inquiry, "buying_method", 1) or 1) == 2:
qd = getattr(inquiry, "bid_end_time", None)
if not qd:
return None, "—"
loc = _for_local_display(qd)
return qd, loc.strftime("%Y-%m-%d %H:%M") if loc else "—"
def build_context_quote_timeout(inquiry: Any) -> Dict[str, Any]:
"""
询价截止提醒(采购端):已到截止时间、需查看报价并完成比价;与模板「询价截止提醒」文案一致。
"""
from apps.pisadmin.miscprocurement.models import InquirySupplier
from apps.pissupplier.models import QuotationMaster
inquiry_no = (getattr(inquiry, "inquiry_no", None) or "").strip()
title = (getattr(inquiry, "title", None) or "").strip()
inquiry_name = title or "—"
purchaser_name = _quote_ended_purchaser_name(inquiry)
material_or_project_name = _quote_ended_material_or_project_name(inquiry)
plain = material_or_project_name
material_or_project_short = plain[:40] + ("…" if len(plain) > 40 else "") if plain and plain != "—" else "—"
_, deadline_display = _inquiry_effective_deadline_for_display(inquiry)
invited_count = (
InquirySupplier.objects.filter(inquiry_no=inquiry)
.values_list("supplier_code", flat=True)
.distinct()
.count()
)
quoted_count = (
QuotationMaster.objects.filter(inquiry_no=inquiry_no, status=3)
.values_list("supplier_code", flat=True)
.distinct()
.count()
)
base = admin_portal_base_url()
pk = getattr(inquiry, "pk", None) or getattr(inquiry, "id", None)
comparison_page_url = ""
if base and pk is not None:
comparison_page_url = f"{base}/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice/{pk}"
now = _for_local_display(timezone.now())
current_date = now.strftime("%Y-%m-%d") if now else ""
return {
"purchaser_name": purchaser_name,
"inquiry_no": inquiry_no,
"inquiry_name": inquiry_name,
"material_or_project_name": material_or_project_name,
"material_or_project_short": material_or_project_short,
"deadline": deadline_display,
"quoted_count": quoted_count,
"invited_count": invited_count,
"comparison_page_url": comparison_page_url,
"rfq_number": inquiry_no,
"inquiry_title": inquiry_name,
"current_date": current_date,
"admin_link": comparison_page_url or base,
"system_name": system_brand_name(),
}
def build_context_quote_deadline_expired(
inquiry: Any,
expired_suppliers: list,
) -> Dict[str, Any]:
"""
报价截止到期提醒(采购端):按询价单合并多个已到期未报价供应商,发送一封邮件。
expired_suppliers: list[{"supplier_name", "contact_person", "quote_deadline", "status"}, ...]
"""
from apps.pisadmin.miscprocurement.models import InquirySupplier
inquiry_no = (getattr(inquiry, "inquiry_no", None) or "").strip()
title = (getattr(inquiry, "title", None) or "").strip()
inquiry_name = title or "—"
purchaser_name = _quote_ended_purchaser_name(inquiry)
material_or_project_name = _quote_ended_material_or_project_name(inquiry)
invited_count = (
InquirySupplier.objects.filter(inquiry_no=inquiry)
.values_list("supplier_code", flat=True)
.distinct()
.count()
)
base = admin_portal_base_url()
pk = getattr(inquiry, "pk", None) or getattr(inquiry, "id", None)
comparison_page_url = ""
if base and pk is not None:
comparison_page_url = f"{base}/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice/{pk}"
return {
"purchaser_name": purchaser_name,
"inquiry_no": inquiry_no,
"inquiry_name": inquiry_name,
"material_or_project_name": material_or_project_name,
"supplier_count": len(expired_suppliers),
"expired_suppliers": expired_suppliers,
"invited_count": invited_count,
"comparison_page_url": comparison_page_url,
"current_date": _for_local_display(timezone.now()).strftime("%Y-%m-%d"),
"admin_link": comparison_page_url or base,
"system_name": system_brand_name(),
}
# ---------------------------------------------------------------------------
# 模板文件注册:template_key -> (subject 相对 templates/, body 相对 templates/)
# 新增一类邮件时:在此增加一行,并放置对应 templates/emails/*.txt / *.html
# ---------------------------------------------------------------------------
EMAIL_TEMPLATE_FILES: Dict[str, Tuple[str, str]] = {
TEMPLATE_QUOTE_ENDED: ("emails/quote_ended_subject.txt", "emails/quote_ended_body.html"),
TEMPLATE_QUOTE_TIMEOUT: ("emails/quote_timeout_subject.txt", "emails/quote_timeout_body.html"),
TEMPLATE_QUOTE_DEADLINE_EXPIRED: ("emails/quote_deadline_expired_subject.txt", "emails/quote_deadline_expired_body.html"),
}
def render_template_pair(
subject_template: str,
body_template: str,
context: Dict[str, Any],
) -> Tuple[str, str]:
"""
通用:按一对 Django 模板路径渲染 (subject, html_body)。
subject 一般为纯文本;HTML 正文由引擎对变量自动转义(HTML 安全)。
"""
subject = render_to_string(subject_template, context).strip()
body = render_to_string(body_template, context)
return subject, body
def _render_rfs_publish_body(ctx: Dict[str, Any]) -> Tuple[str, str]:
"""RFS_publish:主题需 material_short,在渲染前注入。"""
mat_plain = str(ctx.get("material_info") or "")
material_short = mat_plain[:120] + ("…" if len(mat_plain) > 120 else "")
render_ctx: Dict[str, Any] = {
**ctx,
"material_short": material_short,
}
return render_template_pair(
"emails/rfs_publish_subject.txt",
"emails/rfs_publish_body.html",
render_ctx,
)
_CUSTOM_RENDERERS: Dict[str, Callable[[Dict[str, Any]], Tuple[str, str]]] = {
TEMPLATE_RFS_PUBLISH: _render_rfs_publish_body,
}
def render_email(template_key: str, context: Dict[str, Any]) -> Tuple[str, str]:
"""
按模板类型渲染邮件。
解析顺序:
1. ``_CUSTOM_RENDERERS`` 中注册的完全自定义渲染器;
2. ``EMAIL_TEMPLATE_FILES`` 中的 (subject, body) 路径对;
否则抛出 ``ValueError``。
:return: (subject, body);body 为 HTML 时由调用方在 EmailNotice.payload 中标记 is_html。
"""
custom = _CUSTOM_RENDERERS.get(template_key)
if custom:
return custom(context)
paths = EMAIL_TEMPLATE_FILES.get(template_key)
if paths:
sub_path, body_path = paths
return render_template_pair(sub_path, body_path, context)
raise ValueError(f"Unknown email template: {template_key!r}")
def register_email_template(
template_key: str,
subject_template: str,
body_template: str,
*,
renderer: Optional[Callable[[Dict[str, Any]], Tuple[str, str]]] = None,
) -> None:
"""
运行时注册(可选):用于插件或测试注入额外模板。
- 若提供 ``renderer``,则走自定义渲染,不再使用路径对。
- 否则将 ``subject_template`` / ``body_template`` 登记到 ``EMAIL_TEMPLATE_FILES``。
"""
if renderer is not None:
_CUSTOM_RENDERERS[template_key] = renderer
EMAIL_TEMPLATE_FILES.pop(template_key, None)
return
EMAIL_TEMPLATE_FILES[template_key] = (subject_template, body_template)
_CUSTOM_RENDERERS.pop(template_key, None)