diff --git a/backend/apps/pisadmin/basicinfo/views/email_template.py b/backend/apps/pisadmin/basicinfo/views/email_template.py index ab127a3..e91a555 100644 --- a/backend/apps/pisadmin/basicinfo/views/email_template.py +++ b/backend/apps/pisadmin/basicinfo/views/email_template.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""邮件正文/主题模板:按业务场景 key 渲染,供询价发布、报价结束等流程调用。 +"""邮件正文/主题模板:按业务场景 key 渲染,供询价发布、报价结束、询价截止提醒等流程调用。 扩展方式: 1. 在下方注册 ``EMAIL_TEMPLATE_FILES``(主题模板路径 + HTML 正文路径), @@ -13,6 +13,7 @@ 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 @@ -20,6 +21,8 @@ from django.utils import timezone TEMPLATE_RFS_PUBLISH = "RFS_publish" # 全部供应商已报价 → 询价单进入「报价结束」— 通知采购负责人 TEMPLATE_QUOTE_ENDED = "Quote_ended" +# 报价截止后无待报价/报价中单 → 询价单收口为「报价结束」(典型:sync_expired)— 询价截止提醒 +TEMPLATE_QUOTE_TIMEOUT = "Quote_timeout" def _for_local_display(dt: Any): @@ -124,37 +127,184 @@ def build_context_rfs_publish( } +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]: """ - 报价结束通知(采购端):全部供应商已提交报价,询价单进入「报价结束」。 + 报价完成通知(采购端):受邀供应商均已提交报价,与模板「报价完成通知」文案一致。 """ - rfq_number = (getattr(inquiry, "inquiry_no", None) or "").strip() + from apps.pisadmin.miscprocurement.models import InquirySupplier + + inquiry_no = (getattr(inquiry, "inquiry_no", None) or "").strip() title = (getattr(inquiry, "title", None) or "").strip() - buyer = (getattr(inquiry, "buyer", None) or "").strip() - purchaser_name = buyer or "采购同事" + 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 "" - closed_time = now.strftime("%Y-%m-%d %H:%M:%S") if now else "—" - - base = admin_portal_base_url() - admin_link = base if base else "" - - t_short = title[:40] + ("…" if len(title) > 40 else "") if title else "—" return { "purchaser_name": purchaser_name, - "rfq_number": rfq_number, - "inquiry_title": title or "—", - "inquiry_title_short": t_short, + "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": closed_time, + "closed_time": completion_time, "current_date": current_date, - "admin_link": admin_link, + "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(), } @@ -165,6 +315,7 @@ def build_context_quote_ended( # --------------------------------------------------------------------------- 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"), } diff --git a/backend/apps/pisadmin/basicinfo/views/email_utils.py b/backend/apps/pisadmin/basicinfo/views/email_utils.py index 2736408..353ed44 100644 --- a/backend/apps/pisadmin/basicinfo/views/email_utils.py +++ b/backend/apps/pisadmin/basicinfo/views/email_utils.py @@ -1,7 +1,9 @@ +import logging import os import tempfile import urllib.parse import urllib.request +from datetime import timedelta from typing import Tuple, Dict, Any, List from rest_framework import serializers from rest_framework.decorators import action @@ -15,6 +17,8 @@ from django.contrib.auth import get_user_model from apps.pisadmin.basicinfo.models import EmailNotice +logger = logging.getLogger(__name__) + def _collect_attachment_paths(raw_list: List[Any]) -> List[str]: paths: List[str] = [] @@ -109,7 +113,9 @@ def send_quote_ended_notice_to_purchaser(inquiry, *, last_quotation_no: str = "" payload={ "template_key": TEMPLATE_QUOTE_ENDED, "is_html": True, - "inquiry_no": getattr(inquiry, "inquiry_no", None), + "inquiry_no": ctx.get("inquiry_no"), + "supplier_count": ctx.get("supplier_count"), + "completion_time": ctx.get("completion_time"), }, ) notice.status = "sending" @@ -129,6 +135,100 @@ def send_quote_ended_notice_to_purchaser(inquiry, *, last_quotation_no: str = "" return success +def should_send_quote_timeout_reminder(inquiry_no: str, *, cooldown_hours: int = 24) -> bool: + """ + 同一询价单「询价截止提醒」在冷却期内不重复发送,避免供应商端多次刷新列表/详情导致采购重复收信。 + """ + key = (inquiry_no or "").strip() + if not key: + return False + cutoff = timezone.now() - timedelta(hours=cooldown_hours) + return not EmailNotice.objects.filter( + biz_type="inquiry_quote_timeout", + biz_id=key, + status="success", + sent_at__gte=cutoff, + ).exists() + + +def send_quote_timeout_notice_to_purchaser(inquiry) -> bool: + """ + 询价截止提醒(HTML + EmailNotice):由 ``notify_purchasers_quote_timeout_for_inquiries`` 在 + 供应商端 ``POST .../quotation_master/sync_expired/`` 将超期未报价单置为已过期之后按需调用。 + 无有效收件人时跳过发送,返回 False。 + """ + from apps.pisadmin.basicinfo.views.email_template import ( + TEMPLATE_QUOTE_TIMEOUT, + build_context_quote_timeout, + render_email, + ) + + to_list = resolve_inquiry_purchaser_emails(inquiry) + if not to_list: + return False + + ctx = build_context_quote_timeout(inquiry) + subject, body = render_email(TEMPLATE_QUOTE_TIMEOUT, ctx) + + notice = EmailNotice.objects.create( + subject=subject, + body=body, + to_emails=to_list, + cc_emails=[], + bcc_emails=[], + attachments=[], + biz_type="inquiry_quote_timeout", + biz_id=getattr(inquiry, "inquiry_no", None) or "", + status="pending", + payload={ + "template_key": TEMPLATE_QUOTE_TIMEOUT, + "is_html": True, + "inquiry_no": ctx.get("inquiry_no"), + "quoted_count": ctx.get("quoted_count"), + "invited_count": ctx.get("invited_count"), + "deadline": ctx.get("deadline"), + }, + ) + notice.status = "sending" + notice.save(update_fields=["status", "update_datetime"]) + + success, detail = send_email_notice(notice) + notice.response = detail or {} + if success: + notice.status = "success" + notice.sent_at = timezone.now() + notice.last_error = None + else: + notice.status = "failed" + notice.last_error = detail.get("error") if isinstance(detail, dict) else str(detail) + + notice.save(update_fields=["status", "sent_at", "response", "last_error", "update_datetime"]) + return success + + +def notify_purchasers_quote_timeout_for_inquiries(inquiry_nos: List[str]) -> int: + """ + 在供应商端 ``sync_expired`` 将超期未报价单置为已过期之后调用:对涉及询价单向采购负责人发送截止提醒邮件。 + 返回成功发送的询价单数量(去重后、且受冷却期限制)。 + """ + from apps.pisadmin.miscprocurement.models import Inquiry + + unique = sorted({(x or "").strip() for x in inquiry_nos if x and str(x).strip()}) + sent = 0 + for inq_no in unique: + if not should_send_quote_timeout_reminder(inq_no): + continue + inq = Inquiry.objects.filter(inquiry_no=inq_no).first() + if not inq: + continue + try: + if send_quote_timeout_notice_to_purchaser(inq): + sent += 1 + except Exception: + logger.exception("询价截止提醒邮件发送失败 inquiry_no=%s", inq_no) + return sent + + def send_email_notice(notice) -> Tuple[bool, Dict[str, Any]]: """Send an email based on EmailNotice instance. diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index 592e7c2..a41ba24 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -1,3 +1,4 @@ +import logging from typing import Optional from django.db import transaction @@ -17,6 +18,7 @@ from apps.pissupplier.models import ( QuotationProfit, QuotationItem, ) +from apps.pisadmin.basicinfo.views.email_utils import notify_purchasers_quote_timeout_for_inquiries from apps.pissupplier.serializers import ( QuotationMasterSerializer, QuotationMasterCreateUpdateSerializer, @@ -28,6 +30,8 @@ from apps.pissupplier.serializers import ( QuotationItemSerializer, ) +logger = logging.getLogger(__name__) + def _supplier_bidding_window_error(instance: QuotationMaster): """招标:报价/提交仅允许在投标开始时间~投标截止时间(含端点)内。""" @@ -107,8 +111,18 @@ class QuotationMasterViewSet(CustomModelViewSet): inquiry_nos, actor_username=username, ) + quote_timeout_emails_sent = 0 + if updated: + try: + quote_timeout_emails_sent = notify_purchasers_quote_timeout_for_inquiries(inquiry_nos) + except Exception: + logger.exception("询价截止提醒邮件批量通知失败") return SuccessResponse( - data={"updated": updated, "inquiries_closed": inquiries_closed}, + data={ + "updated": updated, + "inquiries_closed": inquiries_closed, + "quote_timeout_emails_sent": quote_timeout_emails_sent, + }, msg="同步成功", ) diff --git a/backend/templates/emails/quote_ended_body.html b/backend/templates/emails/quote_ended_body.html index 37f72ad..5d2d7df 100644 --- a/backend/templates/emails/quote_ended_body.html +++ b/backend/templates/emails/quote_ended_body.html @@ -1,34 +1,31 @@
- +尊敬的 {{ purchaser_name }}:
您好!
-询价单「{{ inquiry_title }}」(单号:{{ rfq_number }})已报价结束:所有受邀供应商均已提交报价。
+您负责的询价单(编号:{{ inquiry_no }})的所有供应商已完成报价,具体信息如下:
-请您及时登录系统进行比价操作,确保采购流程顺利推进。
+ + {% if comparison_page_url %}- 立即前往采购端查看 + 点击此处进入比价页面
-若按钮无法点击,请复制以下链接到浏览器打开:
- +若链接无法打开,请复制以下地址到浏览器:
+ {% else %} -系统未配置采购端门户地址(PIS_ADMIN_PORTAL_URL),请登录系统查看该询价单。
+系统未配置采购端门户地址(PIS_ADMIN_PORTAL_URL),请登录系统进入「比价/议价」页面处理。
{% endif %} -祝工作顺利!
-{{ system_name }}
-{{ current_date }}
+如有疑问,请联系系统支持团队。
此邮件由系统自动发送,请勿直接回复。
diff --git a/backend/templates/emails/quote_ended_subject.txt b/backend/templates/emails/quote_ended_subject.txt index f4d06bb..eaac796 100644 --- a/backend/templates/emails/quote_ended_subject.txt +++ b/backend/templates/emails/quote_ended_subject.txt @@ -1 +1 @@ -{% autoescape off %}【报价结束】{{ rfq_number|default:"—" }} {{ inquiry_title_short|default:"—" }}{% endautoescape %} +{% autoescape off %}【报价完成通知】询价单{{ inquiry_no }}-{{ material_or_project_short }}已全部报价,请进行比价{% endautoescape %} diff --git a/backend/templates/emails/quote_timeout_body.html b/backend/templates/emails/quote_timeout_body.html new file mode 100644 index 0000000..c94a4c2 --- /dev/null +++ b/backend/templates/emails/quote_timeout_body.html @@ -0,0 +1,31 @@ + + + + +尊敬的 {{ purchaser_name }}:
+您好!
+您负责的询价单(编号:{{ inquiry_no }})已到截止时间,具体信息如下:
+ +请您登录系统查看报价情况并完成比价。
+ + {% if comparison_page_url %} ++ 点击此处进入比价页面 +
+若链接无法打开,请复制以下地址到浏览器:
+ + {% else %} +系统未配置采购端门户地址(PIS_ADMIN_PORTAL_URL),请登录系统进入「比价/议价」页面处理。
+ {% endif %} + +若需重新发起询价或调整供应商,请在系统中操作。
+此邮件由系统自动发送,请勿直接回复。
+ + diff --git a/backend/templates/emails/quote_timeout_subject.txt b/backend/templates/emails/quote_timeout_subject.txt new file mode 100644 index 0000000..b322be3 --- /dev/null +++ b/backend/templates/emails/quote_timeout_subject.txt @@ -0,0 +1 @@ +{% autoescape off %}【询价截止提醒】询价单{{ inquiry_no }}-{{ material_or_project_short }}已截止,请进行比价{% endautoescape %} diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue index 4f883dd..f127aac 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue @@ -430,7 +430,7 @@ -