diff --git a/backend/apps/pisadmin/basicinfo/admin.py b/backend/apps/pisadmin/basicinfo/admin.py index 64f2e0b..7fcd190 100644 --- a/backend/apps/pisadmin/basicinfo/admin.py +++ b/backend/apps/pisadmin/basicinfo/admin.py @@ -25,7 +25,7 @@ class SupplierAdmin(admin.ModelAdmin): @admin.register(SupplierUser) class SupplierUserAdmin(admin.ModelAdmin): - list_display = ['supplier_id', 'supplier_name', 'user_email', 'user_name', 'company_code', 'status'] + list_display = ['supplier_id', 'supplier_name', 'user_email', 'user_name', 'status'] search_fields = ['supplier_id', 'supplier_name', 'user_email', 'user_name'] diff --git a/backend/apps/pisadmin/basicinfo/models.py b/backend/apps/pisadmin/basicinfo/models.py index 4d81d9e..d2ac2a8 100644 --- a/backend/apps/pisadmin/basicinfo/models.py +++ b/backend/apps/pisadmin/basicinfo/models.py @@ -74,9 +74,14 @@ class Supplier(CoreModel): class SupplierUser(CoreModel): - """供应商用户信息""" - company_code = models.CharField(max_length=10, verbose_name="交易厂区", help_text="交易厂区(公司)", null=True, blank=True) - supplier_id = models.CharField(max_length=20, unique=True, verbose_name="供应商唯一ID", help_text="供应商唯一ID") + """供应商用户信息(独立主数据;通过 supplier_id 与供应商主档逻辑关联,非数据库外键)""" + + supplier_id = models.CharField( + max_length=20, + db_index=True, + verbose_name="供应商唯一ID", + help_text="与供应商信息表 supplier_id 同值,可多条(多联系人)", + ) supplier_name = models.CharField(max_length=100, verbose_name="供应商全称", help_text="供应商全称") supplier_role = models.IntegerField(verbose_name="供应商角色", help_text="供应商角色(1:报价)") user_email = models.CharField(max_length=100, verbose_name="联络人邮箱", help_text="联络人邮箱") @@ -89,9 +94,6 @@ class SupplierUser(CoreModel): verbose_name = "供应商用户信息" verbose_name_plural = verbose_name ordering = ("-create_datetime", "id") - indexes = [ - models.Index(fields=["company_code"]), - ] def __str__(self): return self.supplier_name diff --git a/backend/apps/pisadmin/basicinfo/views/email_template.py b/backend/apps/pisadmin/basicinfo/views/email_template.py index b370daf..3b429b9 100644 --- a/backend/apps/pisadmin/basicinfo/views/email_template.py +++ b/backend/apps/pisadmin/basicinfo/views/email_template.py @@ -2,14 +2,16 @@ """邮件正文/主题模板:按业务场景 key 渲染,供询价发布、报价结束、询价截止提醒等流程调用。 扩展方式: -1. 在下方注册 ``EMAIL_TEMPLATE_FILES``(主题模板路径 + HTML 正文路径), - 再实现 ``build_context_<场景>(...)``,通过 ``render_email(TEMPLATE_xxx, ctx)`` 调用。 -2. 若需完全自定义渲染逻辑(非一对 .txt/.html),将 ``TEMPLATE_xxx`` 登记到 ``_CUSTOM_RENDERERS``。 +1. 推荐:单文件 ``emails/_template.html``,首行 ```` 为主题, + 余下为 HTML 正文;在 ``_CUSTOM_RENDERERS`` 中注册渲染函数(通常调用 ``_split_combined_subject_html``)。 +2. 或:在 ``EMAIL_TEMPLATE_FILES`` 登记 (subject.txt, body.html) 路径对,由 ``render_template_pair`` 渲染。 +3. 运行时注册:``register_email_template(..., renderer=...)`` 或 ``register_email_template(..., sub, body)``。 """ from __future__ import annotations import os +import re from typing import Any, Callable, Dict, Optional, Tuple from django.conf import settings @@ -19,6 +21,8 @@ from django.utils import timezone # 杂采询价单发布通知(新询价邀请) TEMPLATE_RFS_PUBLISH = "RFS_publish" +# 招标方式发布通知(单文件 HTML:首行注释解析主题 + 正文) +TEMPLATE_BIDS_PUBLISH = "Bids_publish" # 全部供应商已报价 → 询价单进入「报价结束」— 通知采购负责人 TEMPLATE_QUOTE_ENDED = "Quote_ended" # 报价截止后无待报价/报价中单 → 询价单收口为「报价结束」(典型:sync_expired)— 询价截止提醒 @@ -129,6 +133,56 @@ def build_context_rfs_publish( } +def build_context_bids_publish( + inquiry: Any, + supplier_group: Dict[str, Any], + *, + purchaser_company_name: str = "", +) -> Dict[str, Any]: + """ + 招标邀请邮件(``bids_publish_template.html``):在 ``build_context_rfs_publish`` 基础上增加 + ``bid_time_range``、``material_short``、``portal_url``、``purchaser_name`` 等变量。 + """ + ctx = build_context_rfs_publish(inquiry, supplier_group, purchaser_company_name=purchaser_company_name) + + bs = getattr(inquiry, "bid_start_time", None) + be = getattr(inquiry, "bid_end_time", None) + if bs and be: + bsl = _for_local_display(bs) + bel = _for_local_display(be) + if bsl and bel and bsl.date() == bel.date(): + bid_time_range = f"{bsl.strftime('%Y-%m-%d %H:%M')}~{bel.strftime('%H:%M')}" + elif bsl and bel: + bid_time_range = f"{bsl.strftime('%Y-%m-%d %H:%M')} ~ {bel.strftime('%Y-%m-%d %H:%M')}" + else: + bid_time_range = "—" + elif be: + bel = _for_local_display(be) + bid_time_range = bel.strftime("%Y-%m-%d %H:%M") if bel else "请登录系统查看" + elif bs: + bsl = _for_local_display(bs) + bid_time_range = (bsl.strftime("%Y-%m-%d %H:%M") + " 起") if bsl else "请登录系统查看" + else: + bid_time_range = str(ctx.get("deadline_time") or "请登录系统查看") + + mat_plain = str(ctx.get("material_info") or "") + material_short = mat_plain[:120] + ("…" if len(mat_plain) > 120 else "") + + purchaser_name = _quote_ended_purchaser_name(inquiry) + portal_url = str(ctx.get("system_link") or "").strip() + + ctx.update( + { + "bid_time_range": bid_time_range, + "material_short": material_short, + "portal_url": portal_url, + "purchaser_name": purchaser_name, + "purchaser_phone": str(ctx.get("contact_phone") or "").strip(), + } + ) + return ctx + + def _quote_ended_purchaser_name(inquiry: Any) -> str: """采购负责人展示名:优先系统用户姓名,否则采购负责人字段原文。""" buyer = (getattr(inquiry, "buyer", None) or "").strip() @@ -357,14 +411,10 @@ def build_context_quote_deadline_expired( # --------------------------------------------------------------------------- -# 模板文件注册:template_key -> (subject 相对 templates/, body 相对 templates/) -# 新增一类邮件时:在此增加一行,并放置对应 templates/emails/*.txt / *.html +# 可选:template_key -> (subject 相对 templates/, body 相对 templates/),由 render_template_pair 渲染。 +# 内置场景均已改为单文件 *_template.html,见 _CUSTOM_RENDERERS。 # --------------------------------------------------------------------------- -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"), -} +EMAIL_TEMPLATE_FILES: Dict[str, Tuple[str, str]] = {} def render_template_pair( @@ -382,7 +432,7 @@ def render_template_pair( def _render_rfs_publish_body(ctx: Dict[str, Any]) -> Tuple[str, str]: - """RFS_publish:主题需 material_short,在渲染前注入。""" + """RFS_publish:单文件 ``rfs_publish_template.html``;主题需 material_short,在渲染前注入。""" mat_plain = str(ctx.get("material_info") or "") material_short = mat_plain[:120] + ("…" if len(mat_plain) > 120 else "") @@ -390,15 +440,52 @@ def _render_rfs_publish_body(ctx: Dict[str, Any]) -> Tuple[str, str]: **ctx, "material_short": material_short, } - return render_template_pair( - "emails/rfs_publish_subject.txt", - "emails/rfs_publish_body.html", - render_ctx, - ) + html = render_to_string("emails/rfs_publish_template.html", render_ctx) + return _split_combined_subject_html(html) + + +def _render_quote_ended_combined(ctx: Dict[str, Any]) -> Tuple[str, str]: + """Quote_ended:单文件 ``quote_ended_template.html``。""" + html = render_to_string("emails/quote_ended_template.html", ctx) + return _split_combined_subject_html(html) + + +def _render_quote_timeout_combined(ctx: Dict[str, Any]) -> Tuple[str, str]: + """Quote_timeout:单文件 ``quote_timeout_template.html``。""" + html = render_to_string("emails/quote_timeout_template.html", ctx) + return _split_combined_subject_html(html) + + +# 首行必须为:(主题内勿含连续两个减号 ``--``,以免破坏 HTML 注释) +_COMBINED_EMAIL_SUBJECT_RE = re.compile( + r"^\s*\s*", + re.IGNORECASE | re.DOTALL, +) + + +def _split_combined_subject_html(html: str) -> Tuple[str, str]: + """从合并模板中解析 (subject, body_html)。""" + m = _COMBINED_EMAIL_SUBJECT_RE.match(html) + if not m: + raise ValueError( + "合并邮件模板必须以 开头(首行),参见 emails/*_template.html" + ) + subject = m.group(1).replace("\r", " ").replace("\n", " ").strip() + body = html[m.end() :].lstrip() + return subject, body + + +def _render_bids_publish_combined(ctx: Dict[str, Any]) -> Tuple[str, str]: + """Bids_publish:单文件 HTML,首行注释为主题。""" + html = render_to_string("emails/bids_publish_template.html", ctx) + return _split_combined_subject_html(html) _CUSTOM_RENDERERS: Dict[str, Callable[[Dict[str, Any]], Tuple[str, str]]] = { TEMPLATE_RFS_PUBLISH: _render_rfs_publish_body, + TEMPLATE_BIDS_PUBLISH: _render_bids_publish_combined, + TEMPLATE_QUOTE_ENDED: _render_quote_ended_combined, + TEMPLATE_QUOTE_TIMEOUT: _render_quote_timeout_combined, } @@ -407,7 +494,7 @@ def render_email(template_key: str, context: Dict[str, Any]) -> Tuple[str, str]: 按模板类型渲染邮件。 解析顺序: - 1. ``_CUSTOM_RENDERERS`` 中注册的完全自定义渲染器; + 1. ``_CUSTOM_RENDERERS``(单文件 ``*_template.html`` 等); 2. ``EMAIL_TEMPLATE_FILES`` 中的 (subject, body) 路径对; 否则抛出 ``ValueError``。 diff --git a/backend/apps/pisadmin/basicinfo/views/email_utils.py b/backend/apps/pisadmin/basicinfo/views/email_utils.py index 353ed44..a5dd0e3 100644 --- a/backend/apps/pisadmin/basicinfo/views/email_utils.py +++ b/backend/apps/pisadmin/basicinfo/views/email_utils.py @@ -4,13 +4,15 @@ import tempfile import urllib.parse import urllib.request from datetime import timedelta -from typing import Tuple, Dict, Any, List +from typing import Any, Dict, List, Tuple from rest_framework import serializers from rest_framework.decorators import action +from rest_framework.response import Response from django.conf import settings from django.core.mail import EmailMessage, get_connection from django.utils import timezone +from django.utils.dateparse import parse_datetime from dvadmin.utils.serializers import CustomModelSerializer from dvadmin.utils.viewset import CustomModelViewSet from django.contrib.auth import get_user_model @@ -85,6 +87,7 @@ def resolve_inquiry_purchaser_emails(inquiry) -> list: def send_quote_ended_notice_to_purchaser(inquiry, *, last_quotation_no: str = "") -> bool: """ 询价单进入「报价结束」时通知采购负责人(HTML 邮件 + EmailNotice 记录)。 + 正文与主题由 ``emails/quote_ended_template.html`` 首行 ```` 解析。 无有效收件人时跳过发送,返回 False。 """ from apps.pisadmin.basicinfo.views.email_template import ( @@ -155,6 +158,7 @@ def send_quote_timeout_notice_to_purchaser(inquiry) -> bool: """ 询价截止提醒(HTML + EmailNotice):由 ``notify_purchasers_quote_timeout_for_inquiries`` 在 供应商端 ``POST .../quotation_master/sync_expired/`` 将超期未报价单置为已过期之后按需调用。 + 正文与主题由 ``emails/quote_timeout_template.html`` 首行 ```` 解析。 无有效收件人时跳过发送,返回 False。 """ from apps.pisadmin.basicinfo.views.email_template import ( @@ -229,6 +233,76 @@ def notify_purchasers_quote_timeout_for_inquiries(inquiry_nos: List[str]) -> int return sent +def send_bids_publish_notice_to_supplier( + inquiry, + supplier_group: Dict[str, Any], + *, + purchaser_company_name: str = "", +) -> Tuple[bool, EmailNotice]: + """ + 招标邀请邮件(单文件 ``bids_publish_template.html``):向单个供应商分组发送 HTML 邮件并写入 EmailNotice。 + + 无有效收件人时仍创建 notice 并标记失败,返回 (False, notice);成功发送返回 (True, notice)。 + """ + from apps.pisadmin.basicinfo.views.email_template import ( + TEMPLATE_BIDS_PUBLISH, + build_context_bids_publish, + render_email, + ) + + email = (supplier_group.get("contact_email") or "").strip() + to_list = [email] if email else [] + + ctx = build_context_bids_publish( + inquiry, + supplier_group, + purchaser_company_name=purchaser_company_name or "", + ) + subject, body = render_email(TEMPLATE_BIDS_PUBLISH, ctx) + + notice = EmailNotice.objects.create( + subject=subject, + body=body, + to_emails=to_list, + cc_emails=[], + bcc_emails=[], + attachments=[], + biz_type="inquiry_bids_publish", + biz_id=getattr(inquiry, "inquiry_no", None) or "", + status="pending", + payload={ + "template_key": TEMPLATE_BIDS_PUBLISH, + "is_html": True, + "inquiry_no": ctx.get("rfq_number"), + "inquiry_title": ctx.get("inquiry_title"), + "bid_time_range": ctx.get("bid_time_range"), + "supplier_name": (supplier_group.get("supplier_name") or "").strip(), + }, + ) + + if not to_list: + notice.status = "failed" + notice.last_error = "缺少供应商邮箱" + notice.save(update_fields=["status", "last_error", "update_datetime"]) + return False, notice + + 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, notice + + def send_email_notice(notice) -> Tuple[bool, Dict[str, Any]]: """Send an email based on EmailNotice instance. diff --git a/backend/apps/pisadmin/basicinfo/views/supplier.py b/backend/apps/pisadmin/basicinfo/views/supplier.py index 7737feb..a45625d 100644 --- a/backend/apps/pisadmin/basicinfo/views/supplier.py +++ b/backend/apps/pisadmin/basicinfo/views/supplier.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- from rest_framework import serializers -from rest_framework.decorators import action from apps.pisadmin.basicinfo.models import Supplier, SupplierUser -from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse from dvadmin.utils.serializers import CustomModelSerializer from dvadmin.utils.viewset import CustomModelViewSet class SupplierUserSerializer(CustomModelSerializer): - """供应商用户信息(子表 pis_supplier_users)""" + """供应商用户信息""" class Meta: model = SupplierUser @@ -26,25 +24,13 @@ class SupplierUserCreateUpdateSerializer(CustomModelSerializer): class SupplierSerializer(CustomModelSerializer): - """供应商信息-序列化器(含关联 supplier_user)""" - - supplier_user = serializers.SerializerMethodField(read_only=True) + """供应商信息""" class Meta: model = Supplier - fields = [f.name for f in Supplier._meta.concrete_fields] + ["supplier_user"] + fields = [f.name for f in Supplier._meta.concrete_fields] read_only_fields = ["id"] - def get_supplier_user(self, obj): - cache = self.context.get("supplier_user_by_sid") - if cache is not None: - su = cache.get(obj.supplier_id) - else: - su = SupplierUser.objects.filter(supplier_id=obj.supplier_id).first() - if su is None: - return None - return SupplierUserSerializer(su, context=self.context).data - class SupplierCreateUpdateSerializer(CustomModelSerializer): """供应商信息 创建/更新""" @@ -73,71 +59,14 @@ class SupplierViewSet(CustomModelViewSet): search_fields = ["supplier_name", "supplier_short_name", "supplier_id", "company_code"] ordering = ["-create_datetime"] - def get_serializer_context(self): - ctx = super().get_serializer_context() - m = getattr(self, "_supplier_user_map", None) - if m is not None: - ctx["supplier_user_by_sid"] = m - return ctx - - def list(self, request, *args, **kwargs): - queryset = self.filter_queryset(self.get_queryset()) - page = self.paginate_queryset(queryset) - if page is not None: - rows = page - else: - rows = list(queryset) - sids = [row.supplier_id for row in rows] - self._supplier_user_map = { - u.supplier_id: u for u in SupplierUser.objects.filter(supplier_id__in=sids) - } - try: - serializer = self.get_serializer(rows, many=True, request=request) - if page is not None: - return self.get_paginated_response(serializer.data) - return SuccessResponse(data=serializer.data, msg="获取成功") - finally: - self._supplier_user_map = None - - @action(methods=["get", "put", "patch"], detail=True, url_path="supplier_user") - def supplier_user_nested(self, request, pk=None): - """按供应商主键维护关联的供应商用户信息(与 Supplier.supplier_id 对应,唯一)。""" - supplier = self.get_object() - sid = (supplier.supplier_id or "").strip() - if not sid: - return ErrorResponse(msg="供应商缺少 supplier_id") - - if request.method == "GET": - su = SupplierUser.objects.filter(supplier_id=sid).first() - if su is None: - return DetailResponse(data=None, msg="暂无供应商用户信息") - return DetailResponse(data=SupplierUserSerializer(su, context={"request": request}).data) - - su = SupplierUser.objects.filter(supplier_id=sid).first() - ser = SupplierUserCreateUpdateSerializer( - instance=su, - data=request.data, - partial=request.method == "PATCH", - context={"request": request}, - ) - ser.is_valid(raise_exception=True) - name = ser.validated_data.get("supplier_name") - if name is None or str(name).strip() == "": - name = supplier.supplier_name - obj = ser.save(supplier_id=sid, supplier_name=name) - return DetailResponse( - data=SupplierUserSerializer(obj, context={"request": request}).data, - msg="保存成功", - ) - class SupplierUserViewSet(CustomModelViewSet): - """供应商用户信息接口(子表;与 Supplier.supplier_id 关联)""" + """供应商用户信息(独立表;按 supplier_id 筛选可得到某供应商下全部联系人)""" queryset = SupplierUser.objects.all() serializer_class = SupplierUserSerializer create_serializer_class = SupplierUserCreateUpdateSerializer update_serializer_class = SupplierUserCreateUpdateSerializer - filter_fields = ["company_code", "supplier_id", "supplier_role", "status", "user_email"] + filter_fields = ["supplier_id", "supplier_role", "status", "user_email"] search_fields = ["supplier_id", "supplier_name", "user_email", "user_name", "user_phone"] ordering = ["-create_datetime", "id"] diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index cf3b556..8fd763c 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -921,67 +921,5 @@ class RFQOperationLogs(CoreModel): except Exception: logger.exception("写入询价操作日志失败") - @classmethod - def bulk_append_quote_deadline_expired( - cls, - quotation_rows: list, - inquiry_purchase_type: dict, - *, - operation_user: Optional[str] = None, - ) -> int: - """ - 供应商端批量将超期报价单置为「已过期」时,按报价单一笔一条写入操作类型「报价截止」(5)。 - - ``quotation_rows``:``values()`` 得到的列表,每项须含 ``quotation_no``、``inquiry_no``、``status``(置过期前,1/2)、``supplier_name``。 - ``inquiry_purchase_type``:``inquiry_no`` -> ``purchase_type``(杂采/策采)。 - """ - from apps.pissupplier.models import QuotationMaster - - if not quotation_rows: - return 0 - now = timezone.now() - op_user = (operation_user or "").strip()[:20] if operation_user else None - qm_status_label = dict(QuotationMaster.STATUS_CHOICES) - cur_label = (qm_status_label.get(4) or "已过期")[:100] - - batch = [] - for row in quotation_rows: - inq_no = str(row.get("inquiry_no") or "").strip()[:20] - qn = str(row.get("quotation_no") or "").strip()[:20] or "-" - supplier_name = (str(row.get("supplier_name") or "").strip() or "—")[:50] - desc = f"供应商({supplier_name})逾期未报价"[:200] - try: - old_st = int(row.get("status")) - except (TypeError, ValueError): - old_st = None - pt = inquiry_purchase_type.get(inq_no) - if pt is None: - continue - per_label = (qm_status_label.get(old_st) or "")[:50] if old_st is not None else None - batch.append( - cls( - operation_type="5", - operation_user=op_user, - operation_time=now, - operation_desc=desc, - inquiry_no=inq_no, - quotation_no=qn, - per_status=per_label or None, - cur_status=cur_label, - is_show_user=1, - purchase_type=int(pt), - create_datetime=now, - update_datetime=now, - ) - ) - if not batch: - return 0 - try: - cls.objects.bulk_create(batch, batch_size=300) - except Exception: - logger.exception("批量写入报价截止询价操作日志失败") - return 0 - return len(batch) - def __str__(self) -> str: return f"{self.inquiry_no}-{self.quotation_no}" diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 71d5584..7fa320f 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1,5 +1,6 @@ import logging from decimal import Decimal +from datetime import datetime from typing import Optional from django.db import transaction @@ -16,7 +17,7 @@ from apps.pisadmin.basicinfo.views.email_template import ( build_context_rfs_publish, render_email, ) -from apps.pisadmin.basicinfo.views.email_utils import send_email_notice +from apps.pisadmin.basicinfo.views.email_utils import send_bids_publish_notice_to_supplier, send_email_notice from apps.pissupplier.models import ( QuotationMaster, @@ -30,6 +31,70 @@ from apps.pissupplier.models import ( logger = logging.getLogger(__name__) +def _fmt_dt_for_inquiry_quotation(dt: Optional[datetime]) -> Optional[str]: + if not dt: + return None + if timezone.is_naive(dt): + dt = timezone.make_aware(dt, timezone.get_current_timezone()) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def _quotation_master_status_label(status) -> str: + """与 ``QuotationMaster.STATUS_CHOICES`` 一致的可读文案。""" + labels = {1: "待报价", 2: "报价中", 3: "已报价", 4: "已过期"} + try: + st = int(status) if status is not None else 1 + except (TypeError, ValueError): + return "—" + return labels.get(st, str(st)) + + +def build_invited_supplier_quotation_rows(inquiry) -> list: + """询价单发布后各受邀供应商报价单当前状态(与 ``QuotationMaster`` 一致,供操作日志嵌套表使用)。""" + inquiry_no = (inquiry.inquiry_no or "").strip() + if not inquiry_no: + return [] + + name_map: dict[str, str] = {} + for inv in InquirySupplier.objects.filter(inquiry_no=inquiry_no).only("supplier_code", "supplier_name"): + code = (inv.supplier_code or "").strip() + if not code: + continue + n = (inv.supplier_name or "").strip() + prev = name_map.get(code, "") + if len(n) > len(prev): + name_map[code] = n + + rows_out: list[dict] = [] + qms = QuotationMaster.objects.filter(inquiry_no=inquiry_no).order_by("supplier_code", "autoid") + for qm in qms: + code = (qm.supplier_code or "").strip() + full_name = (name_map.get(code) or (qm.supplier_name or "").strip() or code or "—").strip() + + st = int(qm.status or 1) + if st == 3 and qm.quotetime: + op_time = _fmt_dt_for_inquiry_quotation(qm.quotetime) + else: + op_time = _fmt_dt_for_inquiry_quotation(qm.creattime) if qm.creattime else None + + if st == 3: + op_user = (qm.quoteuser or "").strip() or "—" + else: + op_user = "—" + + rows_out.append( + { + "operation_time": op_time or "—", + "supplier_full_name": full_name, + "operator": op_user, + "quotation_status": _quotation_master_status_label(qm.status), + "operation_desc": "", + "quotation_no": (qm.quotation_no or "").strip(), + } + ) + return rows_out + + from .models import ( MiscProcurementMaterialInfo, MiscProcurementStationInfo, @@ -150,12 +215,13 @@ def _rfq_qty_decimal(inquiry: Inquiry, pid: str) -> Optional[Decimal]: def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: """ 制程最低价落库(与比价展开明细一致): + - 前端比价展开不依赖成本结构模板:材料按材质分组仅展示重量/单价/材料费用,加工按工站分组仅展示加工费;落库口径仍按下列规则。 - 询价单下**每个上阶物料料号**单独一套主/次表数据(多料号互不合并)。 - **材料**:次表两行分别记录最低重量、最低单价;souce_no 为取得该最小值对应的报价单单号,单价若来自「杂采材料信息」 则存交易厂区(factory);主表材料行 souce_no 聚合为 W:…;U:…(重量来源与单价来源可能不同)。 - **加工**:仅主表一行(无次表、不按工站);每报价单合计加工费后取最小,全空/全 0 仍写入 MinPrice=0。 - 注意:材料行必须用 quotation_no__in=报价单号列表 过滤,勿用 QuerySet(QuotationMaster) 作 __in,否则 ORM 按主键匹配会查不到材料行。 - - **其它**:包装费、运输费分列取 min(优先 sup_quotation_other;无则用上阶物料 total_other_expense 回退)。 + - **其它**:包装费、运输费分列取 min(优先 sup_quotation_other;无则用上阶物料 total_other_expense 回退)。采购端比价主表「其它成本」行制程最低价列与「加工成本」相同,可链向合计最低的报价单详情。 - **利润率**:cost_type=6,各报价单该料号利润率取 min。杂采不落库管销研(cost_type=5)。 """ inquiry_no = (inquiry.inquiry_no or "").strip() @@ -363,6 +429,8 @@ class MiscMaterialViewSet(CustomModelViewSet): update_serializer_class = MiscMaterialCreateUpdateSerializer search_fields = ["materialtype", "factory"] ordering = ["-create_datetime"] + # 与询价单 `Inquiry.company_code` 一致,须精确匹配 `factory`,避免 sz_avc 与 sz_avcx 串数据 + filter_fields = ("factory", "status", "materialtype") class MiscStationViewSet(CustomModelViewSet): @@ -372,6 +440,7 @@ class MiscStationViewSet(CustomModelViewSet): update_serializer_class = MiscStationCreateUpdateSerializer search_fields = ["stationname", "stationcode", "company_code"] ordering = ["-create_datetime"] + filter_fields = ("company_code", "status", "stationcode", "stationname") class MiscPartViewSet(CustomModelViewSet): @@ -783,6 +852,7 @@ class InquiryViewSet(CustomModelViewSet): 按询价单供应商子表 `InquirySupplier` 汇总:同一 `supplier_code`(空代码时用行级占位)合并多料号 `part_id`, 供 `_create_supplier_quotations` 为每个供应商生成一份 `QuotationMaster`。 返回条目的 supplier_code / supplier_name / 联系人字段均来自子表行数据(截断至报价主表字段长度)。 + 采购端保存时,联系人/邮箱/电话由前端在「供应商用户信息」表中按 supplier_id 选择后写入子表,此处不再与主档做 ORM 关联校验。 """ supplier_map = {} for row in inquiry.suppliers.all(): @@ -1100,6 +1170,8 @@ class InquiryViewSet(CustomModelViewSet): return ErrorResponse(msg='只有“确认”状态的询价单才能发布') current_user = self._get_request_username() or None current_time = timezone.now() + supplier_groups = self._group_inquiry_suppliers(instance) + supplier_count = len(supplier_groups) try: with transaction.atomic(): self._create_supplier_quotations(instance) # 发布询价单时按供应商关联表,逐个创建对应的报价单 @@ -1112,7 +1184,7 @@ class InquiryViewSet(CustomModelViewSet): release_user=current_user, release_time=current_time, operation_type=3, - operation_desc="询价单发布", + operation_desc=f"询价单发布,共{supplier_count}个受邀供应商", ) except serializers.ValidationError as exc: detail = getattr(exc, "detail", None) @@ -1226,6 +1298,13 @@ class InquiryViewSet(CustomModelViewSet): data = RFQOperationLogsSerializer(qs, many=True).data return DetailResponse(data=data, msg="success") + @action(methods=["get"], detail=True, url_path="invited_supplier_quotations") + def invited_supplier_quotations(self, request, pk=None): + """受邀供应商报价单一览(与发布生成的 ``QuotationMaster`` 一致,供操作日志嵌套表使用)。""" + instance = self.get_object() + data = build_invited_supplier_quotation_rows(instance) + return DetailResponse(data=data, msg="success") + @action(methods=["put"], detail=True, url_path="save_negotiation_records") def save_negotiation_records(self, request, pk=None): """按报价单写入杂采议价记录:议价结果 + 该报价单议价前含税/不含税总价快照(来自上阶物料明细)。""" @@ -1320,11 +1399,20 @@ class InquiryViewSet(CustomModelViewSet): return purchaser_company_name = self._resolve_purchaser_company_name(inquiry) + is_bidding = int(getattr(inquiry, "buying_method", 1) or 1) == 2 for vendor in supplier_groups: if not isinstance(vendor, dict): continue + if is_bidding: + send_bids_publish_notice_to_supplier( + inquiry, + vendor, + purchaser_company_name=purchaser_company_name, + ) + continue + supplier_name = (vendor.get("supplier_name") or "").strip() email = (vendor.get("contact_email") or "").strip() to_list = [email] if email else [] diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py index e40bd39..6895777 100644 --- a/backend/apps/pissupplier/serializers.py +++ b/backend/apps/pissupplier/serializers.py @@ -266,6 +266,8 @@ class NestedQuotationProcessSerializer(QuotationProcessSerializer): class QuotationOtherSerializer(serializers.ModelSerializer): + """报价单其它成本子表:按料号维度存包装费、运输费;采购端比价展开「其它成本」明细与此字段一致。""" + class Meta: model = QuotationOther fields = [ @@ -447,6 +449,7 @@ class QuotationMasterSerializer(BusinessAuditSerializer): return cache[inquiry_no] def get_inquiry_company_code(self, obj): + """与 `Inquiry.company_code` 一致;供应商端拉取杂采材质/工站列表时须按此精确过滤厂区。""" code, _ = self._inquiry_plant_tuple(getattr(obj, "inquiry_no", None)) return code diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index d4c8114..ec45a6a 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -43,8 +43,8 @@ def _supplier_bidding_window_error(instance: QuotationMaster): be = getattr(instance, "bid_end_time", None) if bs is None or be is None: return ErrorResponse(msg="招标项目未设置投标开始或截止时间,无法报价或提交") - if now < bs: - return ErrorResponse(msg="投标尚未开始") + # if now < bs: + # return ErrorResponse(msg="投标尚未开始") if now > be: return ErrorResponse(msg="已超过投标截止时间") return None @@ -53,7 +53,7 @@ def _supplier_bidding_window_error(instance: QuotationMaster): class QuotationMasterViewSet(CustomModelViewSet): """杂采报价单主表管理接口 - 详情 GET 与采购端比价弹窗「供应商报价预览」共用:返回 `QuotationMasterSerializer` 及嵌套材料/加工/其它/利润/上阶物料等。 + 详情 GET 与采购端比价弹窗「供应商报价预览」共用:返回 `QuotationMasterSerializer` 及嵌套材料/加工/其它(包装费、运输费)/利润/上阶物料等。 """ queryset = QuotationMaster.objects.prefetch_related( @@ -114,12 +114,6 @@ class QuotationMasterViewSet(CustomModelViewSet): username = getattr(getattr(request, "user", None), "username", None) with transaction.atomic(): updated = qs.update(status=4) - if quotation_rows: - RFQOperationLogs.bulk_append_quote_deadline_expired( - quotation_rows, - inquiry_purchase_type, - operation_user=username, - ) inquiries_closed = Inquiry.sync_to_quote_closed_when_no_open_quotations( inquiry_nos, actor_username=username, diff --git a/backend/templates/emails/bids_publish_template.html b/backend/templates/emails/bids_publish_template.html new file mode 100644 index 0000000..cae4724 --- /dev/null +++ b/backend/templates/emails/bids_publish_template.html @@ -0,0 +1,41 @@ + + + + + +

尊敬的 {{ vendor_name }} 合作伙伴:

+

您好!

+

我司({{ purchaser_company_name }})现正式发布新的采购询价单,诚邀贵司参与投标,我们非常期待与您的合作。

+ +

📋 询价单概要

+ + +

💡 如何参与报价?

+

请点击下方按钮登录系统查看详情并填写报价:

+ {% if portal_url %} +

+ 立即前往投标 +

+

若按钮无法点击,请复制以下链接到浏览器打开:

+

{{ portal_url }}

+ {% else %} +

系统未配置供应商门户地址(PIS_SUPPLIER_PORTAL_URL),请联系管理员获取登录方式。

+ {% endif %} + +

⚠️ 注意事项

+ + +

祝商祺!

+

{{ purchaser_company_name }} 采购部

+

{{ current_date }}

+

此邮件由系统自动发送,请勿直接回复。

+ + diff --git a/backend/templates/emails/quote_ended_subject.txt b/backend/templates/emails/quote_ended_subject.txt deleted file mode 100644 index eaac796..0000000 --- a/backend/templates/emails/quote_ended_subject.txt +++ /dev/null @@ -1 +0,0 @@ -{% autoescape off %}【报价完成通知】询价单{{ inquiry_no }}-{{ material_or_project_short }}已全部报价,请进行比价{% endautoescape %} diff --git a/backend/templates/emails/quote_ended_body.html b/backend/templates/emails/quote_ended_template.html similarity index 92% rename from backend/templates/emails/quote_ended_body.html rename to backend/templates/emails/quote_ended_template.html index 5d2d7df..6183771 100644 --- a/backend/templates/emails/quote_ended_body.html +++ b/backend/templates/emails/quote_ended_template.html @@ -1,3 +1,4 @@ + diff --git a/backend/templates/emails/quote_timeout_subject.txt b/backend/templates/emails/quote_timeout_subject.txt deleted file mode 100644 index b322be3..0000000 --- a/backend/templates/emails/quote_timeout_subject.txt +++ /dev/null @@ -1 +0,0 @@ -{% 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_template.html similarity index 92% rename from backend/templates/emails/quote_timeout_body.html rename to backend/templates/emails/quote_timeout_template.html index c94a4c2..08ef59b 100644 --- a/backend/templates/emails/quote_timeout_body.html +++ b/backend/templates/emails/quote_timeout_template.html @@ -1,3 +1,4 @@ + diff --git a/backend/templates/emails/rfs_publish_subject.txt b/backend/templates/emails/rfs_publish_subject.txt deleted file mode 100644 index 2a2d429..0000000 --- a/backend/templates/emails/rfs_publish_subject.txt +++ /dev/null @@ -1 +0,0 @@ -【新询价邀请】{{ rfq_number|default:"—" }} - {{ material_short }} - 截止:{{ deadline_date_subject|default:"—" }} diff --git a/backend/templates/emails/rfs_publish_body.html b/backend/templates/emails/rfs_publish_template.html similarity index 93% rename from backend/templates/emails/rfs_publish_body.html rename to backend/templates/emails/rfs_publish_template.html index 37d3f8c..5d6fe1f 100644 --- a/backend/templates/emails/rfs_publish_body.html +++ b/backend/templates/emails/rfs_publish_template.html @@ -1,3 +1,4 @@ + diff --git a/web/src/views/pisadmin/basicinfo/company/crud.tsx b/web/src/views/pisadmin/basicinfo/company/crud.tsx index f346b55..cb084eb 100644 --- a/web/src/views/pisadmin/basicinfo/company/crud.tsx +++ b/web/src/views/pisadmin/basicinfo/company/crud.tsx @@ -88,6 +88,9 @@ export const createCrudOptions = function ({ crudExpose }: Partial request({ url: apiPrefix, method: 'get', params }) diff --git a/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx b/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx index 609485c..f33d173 100644 --- a/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx @@ -135,6 +135,9 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp type: 'input', search: { show: true, component: { props: { placeholder: '请输入工站代码', clearable: true } } }, form: { rules: [{ required: true, message: '请输入工站代码' }] }, + editForm: { + component: { props: { disabled: true } } + }, column: { minWidth: 140, showOverflowTooltip: true } }, stationname: { diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index 87b3ef1..d69ecad 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -20,6 +20,9 @@ export const SubmitPriceAuditObj = (id: string | number) => request({ url: `${ba /** 询价单操作日志(按主键,返回该单 inquiry_no 下全部记录,时间倒序) */ export const GetOperationLogs = (id: string | number) => request({ url: `${baseUrl}${id}/operation_logs/`, method: 'get' }) +/** 受邀供应商报价单一览(与发布生成的报价主表一致,操作日志嵌套表用) */ +export const GetInvitedSupplierQuotations = (id: string | number) => + request({ url: `${baseUrl}${id}/invited_supplier_quotations/`, method: 'get' }) /** 查询杂采议价记录(比价议价价格存此表,非报价明细「中标价格」) */ export const GetNegotiationRecordsObj = (id: string | number, params?: { part_id?: string }) => request({ url: `${baseUrl}${id}/negotiation_records/`, method: 'get', params: params ?? {} }) diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue index e5e3d58..eff2daa 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue @@ -18,11 +18,11 @@
询价单号{{ displayTextEmpty(comparisonDialog.baseInfo.code) }}
采购件料号{{ displayTextEmpty(comparisonDialog.baseInfo.partNo) }}
采购件名称{{ displayTextEmpty(comparisonDialog.baseInfo.partName) }}
-
目标价格{{ displayNumericEmpty(comparisonDialog.baseInfo.targetPrice) }}
+
目标价格{{ compareNumericDisplay(comparisonDialog.baseInfo.targetPrice) }}
交易币别{{ displayTextEmpty(comparisonDialog.baseInfo.currency) }}
税率{{ displayPercentRate(comparisonDialog.baseInfo.taxRate) }}
-
当前成交价{{ displayNumericEmpty(comparisonDialog.baseInfo.dealPrice) }}
-
制程最低价{{ displayNumericEmpty(comparisonDialog.baseInfo.lowestProcessPrice) }}
+
当前成交价{{ compareNumericDisplay(comparisonDialog.baseInfo.dealPrice) }}
+
制程最低价{{ compareNumericDisplay(comparisonDialog.baseInfo.lowestProcessPrice) }}
-