diff --git a/backend/apps/pisadmin/basicinfo/admin.py b/backend/apps/pisadmin/basicinfo/admin.py index 18d8b97..64f2e0b 100644 --- a/backend/apps/pisadmin/basicinfo/admin.py +++ b/backend/apps/pisadmin/basicinfo/admin.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django.contrib import admin from apps.pisadmin.basicinfo.models import ( - Unit, Currency, Supplier, Company + Unit, Currency, Supplier, SupplierUser, Company ) @@ -23,6 +23,12 @@ class SupplierAdmin(admin.ModelAdmin): search_fields = ['supplier_name', 'supplier_id', 'company_code'] +@admin.register(SupplierUser) +class SupplierUserAdmin(admin.ModelAdmin): + list_display = ['supplier_id', 'supplier_name', 'user_email', 'user_name', 'company_code', 'status'] + search_fields = ['supplier_id', 'supplier_name', 'user_email', 'user_name'] + + @admin.register(Company) class CompanyAdmin(admin.ModelAdmin): list_display = ['company_code', 'company_name', 'company_short_name', 'status'] diff --git a/backend/apps/pisadmin/basicinfo/models.py b/backend/apps/pisadmin/basicinfo/models.py index 468e335..4d81d9e 100644 --- a/backend/apps/pisadmin/basicinfo/models.py +++ b/backend/apps/pisadmin/basicinfo/models.py @@ -73,6 +73,30 @@ class Supplier(CoreModel): return self.supplier_name +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_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="联络人邮箱") + user_name = models.CharField(max_length=20, verbose_name="联络人", help_text="联络人") + user_phone = models.CharField(max_length=100, verbose_name="联络人电话", help_text="联络人电话") + status = models.IntegerField(default=1, verbose_name="有效否", help_text="有效否(1有效0无效)") + + class Meta: + db_table = table_prefix + "supplier_users" + verbose_name = "供应商用户信息" + verbose_name_plural = verbose_name + ordering = ("-create_datetime", "id") + indexes = [ + models.Index(fields=["company_code"]), + ] + + def __str__(self): + return self.supplier_name + + class Company(CoreModel): """公司信息""" company_code = models.CharField(max_length=20, verbose_name="公司代码", help_text="公司代码") diff --git a/backend/apps/pisadmin/basicinfo/urls.py b/backend/apps/pisadmin/basicinfo/urls.py index 29cfd90..d1f2dab 100644 --- a/backend/apps/pisadmin/basicinfo/urls.py +++ b/backend/apps/pisadmin/basicinfo/urls.py @@ -3,7 +3,7 @@ from rest_framework import routers from apps.pisadmin.basicinfo.views.unit import UnitViewSet from apps.pisadmin.basicinfo.views.currency import CurrencyViewSet -from apps.pisadmin.basicinfo.views.supplier import SupplierViewSet +from apps.pisadmin.basicinfo.views.supplier import SupplierViewSet, SupplierUserViewSet from apps.pisadmin.basicinfo.views.company import CompanyViewSet from apps.pisadmin.basicinfo.views.system_no_rule import SystemNoRuleViewSet from apps.pisadmin.basicinfo.views.email_utils import EmailNoticeViewSet @@ -12,6 +12,7 @@ basicinfo_url = routers.SimpleRouter() basicinfo_url.register(r'units', UnitViewSet) basicinfo_url.register(r'currencies', CurrencyViewSet) basicinfo_url.register(r'suppliers', SupplierViewSet) +basicinfo_url.register(r'supplier_users', SupplierUserViewSet) basicinfo_url.register(r'companies', CompanyViewSet) basicinfo_url.register(r'system_no_rules', SystemNoRuleViewSet) basicinfo_url.register(r'email_notice', EmailNoticeViewSet) diff --git a/backend/apps/pisadmin/basicinfo/views/email_template.py b/backend/apps/pisadmin/basicinfo/views/email_template.py index ef629f5..ab127a3 100644 --- a/backend/apps/pisadmin/basicinfo/views/email_template.py +++ b/backend/apps/pisadmin/basicinfo/views/email_template.py @@ -1,23 +1,25 @@ # -*- coding: utf-8 -*- -"""邮件正文/主题模板:按业务场景 key 渲染,供询价发布等流程调用。""" +"""邮件正文/主题模板:按业务场景 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 html import os -from typing import Any, Callable, Dict, Tuple +from typing import Any, Callable, Dict, Optional, Tuple from django.conf import settings +from django.template.loader import render_to_string from django.utils import timezone # 杂采询价单发布通知(新询价邀请) TEMPLATE_RFS_PUBLISH = "RFS_publish" - - -def _h(text: Any) -> str: - if text is None: - return "" - return html.escape(str(text), quote=True) +# 全部供应商已报价 → 询价单进入「报价结束」— 通知采购负责人 +TEMPLATE_QUOTE_ENDED = "Quote_ended" def _for_local_display(dt: Any): @@ -41,6 +43,17 @@ def supplier_portal_base_url() -> str: 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], @@ -48,7 +61,7 @@ def build_context_rfs_publish( purchaser_company_name: str = "", ) -> Dict[str, Any]: """ - 组装 RFS_publish 模板变量(均为未转义纯文本,由渲染函数做 HTML 转义)。 + 组装 RFS_publish 模板变量(均为未转义纯文本,由 Django 模板引擎在渲染 HTML 时自动转义)。 supplier_group:与 `_group_inquiry_suppliers` 一致的结构。 """ vendor_name = (supplier_group.get("supplier_name") or "").strip() or "贵司" @@ -111,70 +124,81 @@ def build_context_rfs_publish( } -def _render_rfs_publish_body(ctx: Dict[str, Any]) -> Tuple[str, str]: - """返回 (subject, html_body)。""" - rfq = _h(ctx.get("rfq_number")) - mat = ctx.get("material_info") or "" - mat_plain = str(mat) - mid = mat_plain[:120] + ("…" if len(mat_plain) > 120 else "") - subject = f"【新询价邀请】{ctx.get('rfq_number') or '—'} - {mid} - 截止:{ctx.get('deadline_date_subject') or '—'}" +def build_context_quote_ended( + inquiry: Any, + *, + last_quotation_no: str = "", +) -> Dict[str, Any]: + """ + 报价结束通知(采购端):全部供应商已提交报价,询价单进入「报价结束」。 + """ + rfq_number = (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 "采购同事" - vendor = _h(ctx.get("vendor_name")) - purchaser = _h(ctx.get("purchaser_company_name")) - material_html = _h(ctx.get("material_info")) - deadline_line = _h(ctx.get("deadline_time")) - link = ctx.get("system_link") or "" - link_h = _h(link) - contact_name = _h(ctx.get("contact_person")) - phone_raw = (ctx.get("contact_phone") or "").strip() - phone = _h(phone_raw) if phone_raw else "—" - closing_date = _h(ctx.get("current_date")) + 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 "—" - if link: - button_block = f""" -

- 立即前往报价 -

-

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

-

{link_h}

""" - else: - button_block = """ -

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

""" + base = admin_portal_base_url() + admin_link = base if base else "" - body = f""" - - -

尊敬的 {vendor} 合作伙伴:

-

您好!

-

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

+ 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, + "last_quotation_no": (last_quotation_no or "").strip(), + "closed_time": closed_time, + "current_date": current_date, + "admin_link": admin_link, + "system_name": system_brand_name(), + } -

💡 如何参与报价?

-

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

- {button_block} -

⚠️ 注意事项

- +# --------------------------------------------------------------------------- +# 模板文件注册: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"), +} -

祝商祺!

-

{purchaser} 采购部

-

{closing_date}

-

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

-""" + +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 -_RENDERERS: Dict[str, Callable[[Dict[str, Any]], Tuple[str, str]]] = { +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, } @@ -182,9 +206,42 @@ _RENDERERS: Dict[str, Callable[[Dict[str, Any]], Tuple[str, str]]] = { 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。 """ - renderer = _RENDERERS.get(template_key) - if not renderer: - raise ValueError(f"Unknown email template: {template_key!r}") - return renderer(context) + 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) diff --git a/backend/apps/pisadmin/basicinfo/views/email_utils.py b/backend/apps/pisadmin/basicinfo/views/email_utils.py index 000618f..2736408 100644 --- a/backend/apps/pisadmin/basicinfo/views/email_utils.py +++ b/backend/apps/pisadmin/basicinfo/views/email_utils.py @@ -8,8 +8,11 @@ from rest_framework.decorators import action from django.conf import settings from django.core.mail import EmailMessage, get_connection +from django.utils import timezone from dvadmin.utils.serializers import CustomModelSerializer from dvadmin.utils.viewset import CustomModelViewSet +from django.contrib.auth import get_user_model + from apps.pisadmin.basicinfo.models import EmailNotice @@ -42,6 +45,90 @@ def _collect_attachment_paths(raw_list: List[Any]) -> List[str]: return paths +def resolve_inquiry_purchaser_emails(inquiry) -> list: + """ + 根据询价单解析采购端收件人邮箱:优先「采购负责人」对应系统用户邮箱; + 若无则依次尝试创建人(creator / creator_id)、创建人账号(create_user)。 + """ + User = get_user_model() + + buyer = (getattr(inquiry, "buyer", None) or "").strip() + if buyer: + u = User.objects.filter(username=buyer).exclude(email__isnull=True).exclude(email="").first() + if not u: + u = User.objects.filter(name=buyer).exclude(email__isnull=True).exclude(email="").first() + if u and getattr(u, "email", None): + return [str(u.email).strip()] + + cr = getattr(inquiry, "creator", None) + if cr is not None and getattr(cr, "email", None): + return [str(cr.email).strip()] + cid = getattr(inquiry, "creator_id", None) + if cid: + u = User.objects.filter(id=cid).exclude(email__isnull=True).exclude(email="").first() + if u and getattr(u, "email", None): + return [str(u.email).strip()] + + cu = (getattr(inquiry, "create_user", None) or "").strip() + if cu: + u = User.objects.filter(username=cu).exclude(email__isnull=True).exclude(email="").first() + if u and getattr(u, "email", None): + return [str(u.email).strip()] + + return [] + + +def send_quote_ended_notice_to_purchaser(inquiry, *, last_quotation_no: str = "") -> bool: + """ + 询价单进入「报价结束」时通知采购负责人(HTML 邮件 + EmailNotice 记录)。 + 无有效收件人时跳过发送,返回 False。 + """ + from apps.pisadmin.basicinfo.views.email_template import ( + TEMPLATE_QUOTE_ENDED, + build_context_quote_ended, + render_email, + ) + + to_list = resolve_inquiry_purchaser_emails(inquiry) + if not to_list: + return False + + ctx = build_context_quote_ended(inquiry, last_quotation_no=last_quotation_no or "") + subject, body = render_email(TEMPLATE_QUOTE_ENDED, ctx) + + notice = EmailNotice.objects.create( + subject=subject, + body=body, + to_emails=to_list, + cc_emails=[], + bcc_emails=[], + attachments=[], + biz_type="inquiry_quote_ended", + biz_id=getattr(inquiry, "inquiry_no", None) or "", + status="pending", + payload={ + "template_key": TEMPLATE_QUOTE_ENDED, + "is_html": True, + "inquiry_no": getattr(inquiry, "inquiry_no", None), + }, + ) + 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 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 fd56ff7..7737feb 100644 --- a/backend/apps/pisadmin/basicinfo/views/supplier.py +++ b/backend/apps/pisadmin/basicinfo/views/supplier.py @@ -1,19 +1,50 @@ # -*- coding: utf-8 -*- from rest_framework import serializers +from rest_framework.decorators import action -from apps.pisadmin.basicinfo.models import Supplier +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 + fields = [f.name for f in SupplierUser._meta.concrete_fields] + read_only_fields = ["id"] + + +class SupplierUserCreateUpdateSerializer(CustomModelSerializer): + """供应商用户信息 创建/更新""" + + class Meta: + model = SupplierUser + fields = "__all__" + + class SupplierSerializer(CustomModelSerializer): - """供应商信息-序列化器""" + """供应商信息-序列化器(含关联 supplier_user)""" + + supplier_user = serializers.SerializerMethodField(read_only=True) class Meta: model = Supplier - fields = "__all__" + fields = [f.name for f in Supplier._meta.concrete_fields] + ["supplier_user"] 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): """供应商信息 创建/更新""" @@ -41,3 +72,72 @@ class SupplierViewSet(CustomModelViewSet): update_serializer_class = SupplierCreateUpdateSerializer 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 关联)""" + + 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"] + 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 3ddafc9..d1eb6f9 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -367,6 +367,12 @@ class Inquiry(CoreModel): inq.update_user = username inq.save(update_fields=["status", "update_time", "update_user", "update_datetime"]) qn = (quotation_no or "-").strip()[:20] or "-" + qm_submit = ( + QuotationMaster.objects.filter(inquiry_no=inq_no, quotation_no=qn).first() + if qn != "-" + else None + ) + submitter_name = (getattr(qm_submit, "supplier_name", None) or "").strip() or "—" RFQOperationLogs.try_append( inquiry_no=inq.inquiry_no, purchase_type=int(inq.purchase_type), @@ -375,8 +381,14 @@ class Inquiry(CoreModel): quotation_no=qn, per_status=old_status, cur_status=5, - operation_desc="全部供应商已提交报价,询价单同步为报价结束", + operation_desc=f"供应商({submitter_name})提交报价,受邀供应商均提交报价,询价单报价结束", ) + try: + from apps.pisadmin.basicinfo.views.email_utils import send_quote_ended_notice_to_purchaser + + send_quote_ended_notice_to_purchaser(inq, last_quotation_no=qn) + except Exception: + logger.exception("报价结束通知邮件发送失败") return True @@ -808,7 +820,11 @@ class MiscNegotiationRecords(CoreModel): class RFQOperationLogs(CoreModel): - """询价单操作日志表""" + """询价单操作日志表。 + + 按询价单号拉取全部记录:采购端 ``InquiryViewSet.operation_logs``( + ``GET .../inquiry/{pk}/operation_logs/``)。 + """ # (01询价单创;02询价单确认;03询价单发布;04询价单还原;05询价发送通知;06报价;07比议价;08议价审核提交;09议价审核完成;10议价审核驳回) OPERATION_TYPE_CHOICES = ( @@ -816,8 +832,8 @@ class RFQOperationLogs(CoreModel): (2, "询价单确认"), (3, "询价单发布"), (4, "询价单还原"), - (5, "询价发送通知"), - (6, "报价"), + # (5, "询价发送通知"), + (6, "供应商报价"), (7, "比议价"), (8, "议价审核提交"), (9, "议价审核完成"), diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 35de75c..7a5084a 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -316,7 +316,11 @@ class MiscNegotiationRecordsSerializer(CustomModelSerializer): class RFQOperationLogsSerializer(CustomModelSerializer): - """询价单操作日志(rfq_operation_logs)""" + """询价单操作日志(rfq_operation_logs)。 + + 列表查询:``GET /api/.../miscprocurement/inquiry/{id}/operation_logs/``(按询价单主键, + 返回该单 ``inquiry_no`` 下全部日志,时间倒序)。 + """ operation_type = serializers.IntegerField(required=True) operation_time = serializers.DateTimeField( diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 030c9ce..71d5584 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1215,6 +1215,17 @@ class InquiryViewSet(CustomModelViewSet): data = MiscNegotiationRecordsSerializer(qs.order_by("id"), many=True).data return DetailResponse(data=data, msg="success") + @action(methods=["get"], detail=True, url_path="operation_logs") + def operation_logs(self, request, pk=None): + """按询价单号查询该询价单全部操作日志(``pis_rfq_operation_logs``)。""" + instance = self.get_object() + qs = ( + RFQOperationLogs.objects.filter(inquiry_no=instance.inquiry_no) + .order_by("-operation_time", "-create_datetime", "-id") + ) + data = RFQOperationLogsSerializer(qs, many=True).data + return DetailResponse(data=data, msg="success") + @action(methods=["put"], detail=True, url_path="save_negotiation_records") def save_negotiation_records(self, request, pk=None): """按报价单写入杂采议价记录:议价结果 + 该报价单议价前含税/不含税总价快照(来自上阶物料明细)。""" @@ -1503,11 +1514,22 @@ class RFQOperationLogsViewSet(CustomModelViewSet): create_serializer_class = RFQOperationLogsSerializer update_serializer_class = RFQOperationLogsSerializer filter_fields = ( - "inquiry_no", "quotation_no", "operation_type", "operation_user", "purchase_type", ) search_fields = ("inquiry_no", "quotation_no", "operation_user", "operation_desc") - ordering = ("-create_datetime", "-id") \ No newline at end of file + ordering = ("-create_datetime", "-id") + + def filter_queryset(self, queryset): + """询价单号模糊查询、按询价主表采购负责人筛选(GET 参数 ``inquiry_no``、``buyer``)。""" + queryset = super().filter_queryset(queryset) + inquiry_no = (self.request.query_params.get("inquiry_no") or "").strip() + if inquiry_no: + queryset = queryset.filter(inquiry_no__icontains=inquiry_no) + buyer = (self.request.query_params.get("buyer") or "").strip() + if buyer: + nos = Inquiry.objects.filter(buyer__icontains=buyer).values_list("inquiry_no", flat=True) + queryset = queryset.filter(inquiry_no__in=list(nos)) + return queryset \ No newline at end of file diff --git a/backend/apps/pissupplier/models.py b/backend/apps/pissupplier/models.py index 3227b28..0795918 100644 --- a/backend/apps/pissupplier/models.py +++ b/backend/apps/pissupplier/models.py @@ -68,7 +68,7 @@ class QuotationMaster(models.Model): bid_end_time = models.DateTimeField(null=True, blank=True, verbose_name="投标截止时间") class Meta: - db_table = table_prefix + "sup_quotation_master" + db_table = table_prefix + "misc_sup_quotation_master" verbose_name = "杂采报价单主表" verbose_name_plural = verbose_name ordering = ("-creattime", "-autoid") @@ -96,7 +96,7 @@ class QuotationAttachment(models.Model): uploaduser = models.CharField(max_length=20, null=True, blank=True, verbose_name="上传人员") class Meta: - db_table = table_prefix + "sup_quotation_attachment" + db_table = table_prefix + "misc_sup_quotation_attachment" verbose_name = "杂采报价单-附件关联表" verbose_name_plural = verbose_name ordering = ("-autoid",) @@ -159,7 +159,7 @@ class QuotationMaterial(models.Model): option_json = models.TextField(db_column="OptionJson", null=True, blank=True, verbose_name="可选扩展信息") class Meta: - db_table = table_prefix + "sup_quotation_material" + db_table = table_prefix + "misc_sup_quotation_material" verbose_name = "杂采报价单-材料成本明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -221,7 +221,7 @@ class QuotationProcess(models.Model): option_json = models.TextField(db_column="OptionJson", null=True, blank=True, verbose_name="可选扩展信息") class Meta: - db_table = table_prefix + "sup_quotation_process" + db_table = table_prefix + "misc_sup_quotation_process" verbose_name = "杂采报价单-加工成本明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -265,7 +265,7 @@ class QuotationOther(models.Model): ) class Meta: - db_table = table_prefix + "sup_quotation_other" + db_table = table_prefix + "misc_sup_quotation_other" verbose_name = "杂采报价单-其他费用明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -308,7 +308,7 @@ class QuotationProfit(models.Model): ) class Meta: - db_table = table_prefix + "sup_quotation_profit" + db_table = table_prefix + "misc_sup_quotation_profit" verbose_name = "杂采报价单-税率利润明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -439,7 +439,7 @@ class QuotationItem(models.Model): ) class Meta: - db_table = table_prefix + "sup_quot_items" + db_table = table_prefix + "misc_sup_quot_items" verbose_name = "杂采报价单-上阶物料明细表" verbose_name_plural = verbose_name ordering = ("autoid",) diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index ecfda42..592e7c2 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -201,7 +201,15 @@ class QuotationMasterViewSet(CustomModelViewSet): @action(methods=["post"], detail=True, url_path="submit") def submit(self, request, pk=None): - """正式提交报价:写入当前时间为报价时间,状态为已报价(3)。仅报价中(status=2)可提交。""" + """正式提交报价:写入当前时间为报价时间,状态为已报价(3)。仅报价中(status=2)可提交。 + + 若本次提交后,询价单下受邀供应商均已「已报价」,则 ``Inquiry.sync_to_quote_closed_when_all_suppliers_quoted`` + 将询价单置为「报价结束」,并由该同步逻辑向采购负责人发送 HTML 邮件(模板 ``Quote_ended``), + 不在本 action 内重复发信。 + + 若本次提交**未**触发询价单收口为「报价结束」,则单独写一条操作日志(描述「{供应商名称}供应商提交报价」、询价单前后状态均为报价中); + 若已收口,则仅由 ``sync_to_quote_closed_when_all_suppliers_quoted`` 写一条合并描述(含提交与报价结束),本处不再重复记日志。 + """ instance = self.get_object() if instance.status != 2: return ErrorResponse(msg="仅报价中状态可提交报价") @@ -224,6 +232,20 @@ class QuotationMasterViewSet(CustomModelViewSet): actor_username=username, quotation_no=getattr(instance, "quotation_no", None), ) + if not inquiry_quote_closed: + inq = Inquiry.objects.filter(inquiry_no=instance.inquiry_no).only("purchase_type").first() + purchase_type = int(inq.purchase_type) if inq else 2 + supplier_name = (getattr(instance, "supplier_name", None) or "").strip() or "—" + RFQOperationLogs.try_append( + inquiry_no=instance.inquiry_no, + purchase_type=purchase_type, + operation_type=6, + operation_user=username, + quotation_no=getattr(instance, "quotation_no", None), + per_status=4, + cur_status=4, + operation_desc=f"供应商({supplier_name})提交报价", + ) serializer = self.get_serializer(instance) payload = dict(serializer.data) payload["inquiry_quote_closed"] = inquiry_quote_closed diff --git a/backend/templates/emails/quote_ended_body.html b/backend/templates/emails/quote_ended_body.html new file mode 100644 index 0000000..37f72ad --- /dev/null +++ b/backend/templates/emails/quote_ended_body.html @@ -0,0 +1,34 @@ + + + + +

尊敬的 {{ purchaser_name }}:

+

您好!

+

询价单「{{ inquiry_title }}」(单号:{{ rfq_number }})已报价结束:所有受邀供应商均已提交报价。

+ + + + {% if admin_link %} +

+ 立即前往采购端查看 +

+

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

+

{{ admin_link }}

+ {% else %} +

系统未配置采购端门户地址(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 new file mode 100644 index 0000000..f4d06bb --- /dev/null +++ b/backend/templates/emails/quote_ended_subject.txt @@ -0,0 +1 @@ +{% autoescape off %}【报价结束】{{ rfq_number|default:"—" }} {{ inquiry_title_short|default:"—" }}{% endautoescape %} diff --git a/backend/templates/emails/rfs_publish_body.html b/backend/templates/emails/rfs_publish_body.html new file mode 100644 index 0000000..37d3f8c --- /dev/null +++ b/backend/templates/emails/rfs_publish_body.html @@ -0,0 +1,40 @@ + + + + +

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

+

您好!

+

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

+ +

📋 询价单概要

+ + +

💡 如何参与报价?

+

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

+ {% if system_link %} +

+ 立即前往报价 +

+

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

+

{{ system_link }}

+ {% else %} +

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

+ {% endif %} + +

⚠️ 注意事项

+ + +

祝商祺!

+

{{ purchaser_company_name }} 采购部

+

{{ current_date }}

+

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

+ + diff --git a/backend/templates/emails/rfs_publish_subject.txt b/backend/templates/emails/rfs_publish_subject.txt new file mode 100644 index 0000000..2a2d429 --- /dev/null +++ b/backend/templates/emails/rfs_publish_subject.txt @@ -0,0 +1 @@ +【新询价邀请】{{ rfq_number|default:"—" }} - {{ material_short }} - 截止:{{ deadline_date_subject|default:"—" }} diff --git a/web/src/router/backEnd.ts b/web/src/router/backEnd.ts index e810bd8..dd8a577 100644 --- a/web/src/router/backEnd.ts +++ b/web/src/router/backEnd.ts @@ -67,6 +67,23 @@ const pisadminMiscMaterialsIndexRoute: RouteRecordRaw = { icon: '' } }; + +/** 询价单操作日志(隐藏菜单;与菜单中配置的同名页可并存)。 */ +const pisadminRfsOperationLogsRoute: RouteRecordRaw = { + path: '/pisadmin/miscprocurement/rfs_operation_logs/index', + name: 'PisadminRfsOperationLogs', + component: () => import('/@/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue'), + meta: { + title: '询价单操作日志', + isLink: '', + isHide: true, + isKeepAlive: false, + isAffix: false, + isIframe: false, + roles: ['admin'], + icon: '' + } +}; import { storeToRefs } from 'pinia'; import pinia from '/@/stores/index'; import { useUserInfo } from '/@/stores/userInfo'; @@ -127,7 +144,8 @@ export async function initBackEndControlRoutes() { pissupplierQuotationDetailRoute, pisadminRfqMiscInquiryDetailRoute, pisadminRfqMiscComparePriceRoute, - pisadminMiscMaterialsIndexRoute + pisadminMiscMaterialsIndexRoute, + pisadminRfsOperationLogsRoute ] // 添加动态路由 await setAddRoute(); @@ -144,7 +162,8 @@ export async function setRouters(){ pissupplierQuotationDetailRoute, pisadminRfqMiscInquiryDetailRoute, pisadminRfqMiscComparePriceRoute, - pisadminMiscMaterialsIndexRoute + pisadminMiscMaterialsIndexRoute, + pisadminRfsOperationLogsRoute ] dynamicRoutes.forEach((item:any)=>{ router.addRoute(item) diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index e66cd2d..87b3ef1 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -17,6 +17,9 @@ export const StartBargainingObj = (id: string | number) => request({ url: `${bas export const ConfirmNegotiationObj = (id: string | number, data?: Record) => request({ url: `${baseUrl}${id}/confirm_negotiation/`, method: 'put', data: data ?? {} }) export const SubmitPriceAuditObj = (id: string | number) => request({ url: `${baseUrl}${id}/submit_price_audit/`, method: 'put' }) +/** 询价单操作日志(按主键,返回该单 inquiry_no 下全部记录,时间倒序) */ +export const GetOperationLogs = (id: string | number) => + request({ url: `${baseUrl}${id}/operation_logs/`, 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/miscInquiryDetail.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue index 56b7b1b..4f883dd 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue @@ -429,6 +429,80 @@ + + +
+ +
+
+
+
+ + 履历时间轴(询价单:{{ form.inquiry_no || '—' }}) +
+
+
+
+
+
+
+
{{ step.label }}
+
{{ step.timeText }}
+
+
+
+
+
+
+
+ + 操作履历明细表 +
+ + + + + + + + + + + + + + + + + + +
+
+
@@ -446,7 +520,7 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { ArrowLeft } from '@element-plus/icons-vue' +import { ArrowLeft, Clock, Document } from '@element-plus/icons-vue' import { normalizeDict, formatCostTemplateVersion, formatRfqApiErrorMessage } from './crud' import * as api from './api' import * as costTemplateApi from '../cost_template/api' @@ -492,6 +566,22 @@ const STATUS_OPEN = 1 const STATUS_CONFIRMED = 2 const STATUS_PUBLISHED = 3 +/** 履历时间轴节点(与主状态 1–7 + 结束里程碑 8 对齐) */ +const TIMELINE_STATUS_CODES = [1, 2, 3, 4, 5, 6, 7, 8] as const + +/** 与后端 RFQOperationLogs.OPERATION_TYPE_CHOICES 一致 */ +const OPERATION_TYPE_LABELS: Record = { + 1: '询价单创建', + 2: '询价单确认', + 3: '询价单发布', + 4: '询价单还原', + 6: '供应商报价', + 7: '比议价', + 8: '议价审核提交', + 9: '议价审核完成', + 10: '议价审核驳回' +} + const paymentMethods = [ { value: 1, label: '月结30天' }, { value: 2, label: '月结60天' }, @@ -974,6 +1064,23 @@ const pageTitle = computed(() => { }) const activeTab = ref('base') const skipTemplateWatch = ref(false) + +type RfqOperationLogRow = { + id?: number + operation_type?: number + operation_user?: string | null + operation_time?: string | null + operation_desc?: string | null + inquiry_no?: string + quotation_no?: string + per_status?: string | null + cur_status?: string | null + is_show_user?: number | null + purchase_type?: number +} + +const operationLogs = ref([]) +const operationLogsLoading = ref(false) let handlingPlantChange = false const userStore = useUserInfo() /** 与主表 `purchase_dept` CharField(max_length=20) 一致 */ @@ -1352,6 +1459,145 @@ const statusTagType = (s: unknown) => { } const statusLabel = (s: unknown) => statusDict.find((i) => i.value === getStatusCode(s))?.label || String(s ?? '') +const normalizeOperationLogRow = (raw: Record): RfqOperationLogRow => { + const ot = raw.operation_type + const opType = typeof ot === 'number' ? ot : Number(ot) + return { + ...raw, + operation_type: Number.isFinite(opType) ? opType : 0 + } as RfqOperationLogRow +} + +const parseOperationLogTime = (t: string | null | undefined): number => { + if (!t) return 0 + const s = String(t).trim() + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')) + return Number.isNaN(d.getTime()) ? 0 : d.getTime() +} + +const formatTimelineDay = (raw: string | null | undefined) => { + if (!raw) return '' + const s = String(raw).trim() + return s.length >= 10 ? s.slice(0, 10) : s +} + +const operationLogsChronological = computed(() => { + const list = [...operationLogs.value] + list.sort((a, b) => { + const d = parseOperationLogTime(a.operation_time) - parseOperationLogTime(b.operation_time) + if (d !== 0) return d + return (Number(a.id) || 0) - (Number(b.id) || 0) + }) + return list +}) + +const firstCurStatusDayByLabel = computed(() => { + const map = new Map() + for (const row of operationLogsChronological.value) { + const cur = (row.cur_status || '').trim() + if (cur && !map.has(cur)) { + map.set(cur, formatTimelineDay(row.operation_time)) + } + } + return map +}) + +const timelineEndStepLabel = computed(() => { + const s = getStatusCode(form.status) + if (s === 9) return '落标(结束)' + if (s === 8) return '核价通过(结束)' + return '已完成' +}) + +const timelineStepState = (stepCode: number, status: number): 'completed' | 'current' | 'pending' => { + if (status === 0) return 'pending' + if (status >= 8) { + return stepCode <= 8 ? 'completed' : 'pending' + } + if (stepCode < status) return 'completed' + if (stepCode === status) return 'current' + return 'pending' +} + +const timelineStepsView = computed(() => { + const st = getStatusCode(form.status) + return TIMELINE_STATUS_CODES.map((code) => { + const label = code === 8 ? timelineEndStepLabel.value : statusLabel(code) + const state = timelineStepState(code, st) + let timeText = '—' + if (code === 8) { + const tWin = firstCurStatusDayByLabel.value.get('核价通过(结束)') + const tLose = firstCurStatusDayByLabel.value.get('落标(结束)') + timeText = tWin || tLose || (state === 'pending' ? '待处理' : '—') + } else { + const lb = statusDict.find((x) => x.value === code)?.label || '' + if (lb) { + timeText = firstCurStatusDayByLabel.value.get(lb) || (state === 'pending' ? '待处理' : '—') + } + } + return { code, label, state, timeText } + }) +}) + +/** 明细表按时间正序,与参考稿「操作履历明细表」阅读习惯一致 */ +const operationLogsTableRows = computed(() => operationLogsChronological.value) + +const operationTypeLabel = (t: unknown) => { + const n = typeof t === 'number' ? t : Number(t) + if (!Number.isFinite(n) || n <= 0) return '—' + return OPERATION_TYPE_LABELS[n] || `类型${n}` +} + +/** 与参考稿 HTML 色块含义接近的样式类 */ +const operationTypeBadgeClass = (t: unknown) => { + const n = typeof t === 'number' ? t : Number(t) + if (n === 1) return 'op-type--create' + if (n === 2) return 'op-type--confirm' + if (n === 3) return 'op-type--publish' + if (n === 4) return 'op-type--restore' + if (n === 6) return 'op-type--quote' + if (n === 7) return 'op-type--compare' + if (n === 8 || n === 9) return 'op-type--audit' + if (n === 10) return 'op-type--reject' + return 'op-type--default' +} + +const formatOperationStatusChange = (per: string | null | undefined, cur: string | null | undefined) => { + const a = (per || '').trim() + const b = (cur || '').trim() + if (!a && !b) return '—' + if (!a) return `${b || '—'}` + if (!b) return `${a} → —` + return `${a} → ${b}` +} + +const loadOperationLogs = async () => { + if (!currentId.value) { + operationLogs.value = [] + return + } + operationLogsLoading.value = true + try { + const res = await api.GetOperationLogs(currentId.value) + const raw = unwrapResponseData(res) + const list = Array.isArray(raw) ? raw : (raw as { results?: unknown[] })?.results + operationLogs.value = Array.isArray(list) + ? list.map((row) => normalizeOperationLogRow(row as Record)) + : [] + } catch { + operationLogs.value = [] + ElMessage.error('加载操作日志失败') + } finally { + operationLogsLoading.value = false + } +} + +watch(activeTab, (name) => { + if (name === 'operation_logs' && currentId.value) { + void loadOperationLogs() + } +}) + const partNoHiddenSections = new Set(['加工成本', '其它成本', '利润', '税金']) const partNoHiddenDisplaySections = new Set(['材料成本', '加工成本', '其它成本', '利润', '税金']) @@ -1584,6 +1830,7 @@ const resetForm = () => { Object.assign(form, emptyForm()) costRows.value = [] sectionAddConfig.value = {} + operationLogs.value = [] activeTab.value = 'base' } @@ -2610,4 +2857,144 @@ onMounted(() => { color: #0f766e; padding: 8px 0; } + +/* —— 询价单操作日志(履历时间轴 + 明细表) */ +.operation-log-empty { + padding: 24px 12px 40px; + min-height: 240px; + display: flex; + align-items: center; + justify-content: center; +} +.operation-log-root { + display: flex; + flex-direction: column; + gap: 18px; +} +.op-timeline-card, +.op-table-card { + background: var(--el-bg-color); + border: 1px solid var(--el-border-color-lighter); + border-radius: 8px; + padding: 16px 18px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} +.op-timeline-header, +.op-table-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; +} +.op-timeline-header__icon, +.op-table-header__icon { + font-size: 18px; + color: var(--el-color-primary); +} +.op-timeline-header__title, +.op-table-header__title { + font-size: 16px; + font-weight: 600; + color: var(--el-text-color-primary); +} +.op-timeline-body { + overflow-x: auto; + padding: 4px 0 8px; +} +.op-timeline-track { + position: relative; + min-width: 720px; + padding: 8px 0 4px; +} +.op-timeline-line { + position: absolute; + left: 0; + right: 0; + top: 15px; + height: 2px; + background: var(--el-border-color); + z-index: 0; +} +.op-timeline-steps { + position: relative; + z-index: 1; + display: flex; + width: 100%; +} +.op-timeline-step { + flex: 1; + text-align: center; + min-width: 0; +} +.op-timeline-dot { + width: 14px; + height: 14px; + border-radius: 50%; + margin: 0 auto 8px; + box-sizing: border-box; + border: 2px solid transparent; +} +.op-timeline-dot.is-completed { + background: var(--el-color-primary); + border-color: var(--el-color-primary-light-5); +} +.op-timeline-dot.is-current { + background: var(--el-color-success); + border-color: var(--el-color-success-light-3); +} +.op-timeline-dot.is-pending { + background: var(--el-fill-color-dark); + border-color: var(--el-border-color-darker); +} +.op-timeline-step.is-pending .op-timeline-label, +.op-timeline-step.is-pending .op-timeline-time { + color: var(--el-text-color-placeholder); +} +.op-timeline-label { + font-size: 13px; + font-weight: 600; + color: var(--el-text-color-primary); + margin-bottom: 4px; + line-height: 1.25; +} +.op-timeline-time { + font-size: 12px; + color: var(--el-text-color-secondary); +} +.op-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + color: #fff; + line-height: 1.4; +} +.op-type--create { + background: #67c23a; +} +.op-type--confirm { + background: #409eff; +} +.op-type--publish { + background: #9c27b0; +} +.op-type--restore { + background: #909399; +} +.op-type--quote { + background: #e6a23c; +} +.op-type--compare { + background: #607d8b; +} +.op-type--audit { + background: #e91e63; +} +.op-type--reject { + background: #f56c6c; +} +.op-type--default { + background: #909399; +} diff --git a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/api.ts b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/api.ts new file mode 100644 index 0000000..024e959 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/api.ts @@ -0,0 +1,5 @@ +import { request } from '/@/utils/service' + +const baseUrl = '/api/pisadmin/miscprocurement/rfq_operation_logs/' + +export const GetList = (params: any) => request({ url: baseUrl, method: 'get', params }) diff --git a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx new file mode 100644 index 0000000..269ae48 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx @@ -0,0 +1,164 @@ +import { CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import * as api from './api' + +/** 与后端 RFQOperationLogs.OPERATION_TYPE_CHOICES 一致 */ +const OPERATION_TYPE_LABELS: Record = { + 1: '询价单创建', + 2: '询价单确认', + 3: '询价单发布', + 4: '询价单还原', + 6: '供应商报价', + 7: '比议价', + 8: '议价审核提交', + 9: '议价审核完成', + 10: '议价审核驳回' +} + +const PURCHASE_TYPE_LABELS: Record = { + 1: '策采', + 2: '杂采' +} + +const formatStatusChange = (per: unknown, cur: unknown) => { + const a = String(per ?? '').trim() + const b = String(cur ?? '').trim() + if (!a && !b) return '—' + if (!a) return `${b || '—'}` + if (!b) return `${a} → —` + return `${a} → ${b}` +} + +export const createCrudOptions = function ({ crudExpose, context: _ctx }: CreateCrudOptionsProps): CreateCrudOptionsRet { + void crudExpose + void _ctx + return { + crudOptions: { + form: { + labelWidth: '110px' + }, + request: { + pageRequest: async (query) => api.GetList(query) + }, + table: { + rowKey: 'id', + stripe: true, + border: true + }, + actionbar: { + buttons: { + add: { show: false } + } + }, + rowHandle: { + show: false + }, + columns: { + inquiry_no: { + title: '询价单号', + type: 'text', + search: { + show: true, + component: { props: { placeholder: '请输入询价单号', clearable: true } } + }, + form: { show: false }, + column: { minWidth: 130, showOverflowTooltip: true } + }, + buyer: { + title: '采购负责人', + type: 'text', + search: { + show: true, + component: { props: { placeholder: '请输入采购负责人', clearable: true } } + }, + form: { show: false }, + column: { show: false } + }, + purchase_type: { + title: '采购类别', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 88, + formatter: ({ row }: { row: any }) => { + const n = Number(row?.purchase_type) + return PURCHASE_TYPE_LABELS[n] ?? (row?.purchase_type != null ? String(row.purchase_type) : '—') + } + } + }, + operation_time: { + title: '操作时间', + type: 'datetime', + search: { show: false }, + form: { show: false }, + column: { + width: 168, + showOverflowTooltip: true, + sortable: 'custom' + } + }, + operation_type: { + title: '操作类型', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 130, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => { + const n = Number(row?.operation_type) + if (!Number.isFinite(n) || n <= 0) return '—' + return OPERATION_TYPE_LABELS[n] || `类型${n}` + } + } + }, + operation_user: { + title: '操作人', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 110, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => { + const s = String(row?.operation_user ?? '').trim() + return s || '—' + } + } + }, + status_change: { + title: '状态变更', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + minWidth: 160, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => formatStatusChange(row?.per_status, row?.cur_status) + } + }, + operation_desc: { + title: '操作描述', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { minWidth: 180, showOverflowTooltip: true } + }, + quotation_no: { + title: '报价单号', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 116, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => { + const q = String(row?.quotation_no ?? '').trim() + return q && q !== '-' ? q : '—' + } + } + } + } + } + } +} diff --git a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue new file mode 100644 index 0000000..debe9fe --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/web/src/views/pissupplier/quotation/api.ts b/web/src/views/pissupplier/quotation/api.ts index 62e7e48..4356730 100644 --- a/web/src/views/pissupplier/quotation/api.ts +++ b/web/src/views/pissupplier/quotation/api.ts @@ -28,6 +28,8 @@ export type PaymentCode = 1 | 2 | 3 | 4 /** * 列表:GET quotation_master/;详情/更新:pk 对应模型主键 autoid * + * 分页与 dvadmin 一致:查询参数为 `page`、`limit`(`page_size` 无效);默认每页 10 条。 + * * 报价主表(QuotationMaster)仅含 inquiry_no,不含询价名称与询价模板编号;列表展示由 crud 中 * 关联 Inquiry 与成本/价格模板接口补全 title、template 与模板名称。 * diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index edd00d5..fa0cb5f 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -430,7 +430,9 @@ const processFeeKeys = ['processFee', 'processprice', 'process_price', 'process_ const processStationKeys = ['process_station', 'processStation'] /** - * 与后端子表模型字段及 costRowsToNestedPayload 使用的 row.values 键一致(写死列,不随询价模板增减列)。 + * 与后端子表模型字段及 costRowsToNestedPayload 使用的 row.values 键一致。 + * 展示用列名优先来自询价 `template_sections` 各段 `fields[].label`(与 miscInquiryDetail 成本结构一致), + * 本常量仅在模板未配置字段时作列键与默认标题的 fallback。 * QuotationMaterial / QuotationProcess / QuotationOther / QuotationProfit 见 apps.pissupplier.models */ export type QuotationCostColumn = { @@ -531,52 +533,7 @@ const TEMPLATE_KEY_TO_UI_KEYS: Record> = { const normTplKey = (k: string) => String(k || '').toLowerCase().replace(/[^a-z0-9]/g, '') -/** 固定列 key 集合(用于判断模板字段是否已映射到内置列,避免重复) */ -const fixedQuotationSectionKeys = (section: string) => - new Set((FIXED_QUOTATION_SECTION_COLUMNS[section] || []).map((c) => c.key)) - -/** - * 在固定报价列基础上,仅按成本模板 `template_sections` 追加「扩展」列(如自定义 item_no),不展开整段 JSON。 - * 仅处理材料/加工(与 option_json 扩展落库一致);内置字段若已映射到固定列则跳过。 - */ -const mergeQuotationSectionColumns = (section: string, templateSections: any): QuotationCostColumn[] => { - const base = [...(FIXED_QUOTATION_SECTION_COLUMNS[section] || [])] - if (section !== '材料成本' && section !== '加工成本') return base - - const tpl = normalizeSections(templateSections).find( - (s: any) => (s.title || s.name || s.section || '') === section - ) - const fixedKeys = fixedQuotationSectionKeys(section) - const keyMap = TEMPLATE_KEY_TO_UI_KEYS[section] || {} - const seen = new Set(base.map((c) => c.key)) - const extras: QuotationCostColumn[] = [] - - for (const f of tpl?.fields || []) { - const rawKey = String(f?.key || '').trim() - if (!rawKey) continue - const nk = normTplKey(rawKey) - if (nk === 'partid' || rawKey === 'part_id') continue - - const uiKeys: string[] = keyMap[nk] || keyMap[String(f.key).toLowerCase()] || [rawKey] - const mapsToFixed = uiKeys.some((k) => fixedKeys.has(k)) - if (mapsToFixed) continue - if (fixedKeys.has(rawKey)) continue - if (seen.has(rawKey)) continue - seen.add(rawKey) - const label = String(f.label || f.nameCn || f.name || rawKey).trim() || rawKey - extras.push({ key: rawKey, label }) - } - - if (!extras.length) return base - const remarkIdx = base.findIndex((c) => c.key === 'remark') - if (remarkIdx >= 0) { - const remarkCol = base[remarkIdx] - const head = base.filter((c) => c.key !== 'remark') - return [...head, ...extras, remarkCol] - } - return [...base, ...extras] -} - +/** 模板未声明某固定列时的列标题兜底(与 merge 补列一致) */ const labelFallbacks: Record = { material: '材质', material_cost: '材料费用', @@ -612,7 +569,58 @@ const labelFallbacks: Record = { fee: '加工费', process_fee: '加工费', packageFee: '包装费', - transportFee: '运输费' + transportFee: '运输费', + profitRate: '利润率(%)', + taxRate: '税率(%)' +} + +const templateFieldDisplayLabel = (f: any, rawKey: string) => + String(f?.label ?? f?.nameCn ?? f?.name_cn ?? f?.name ?? rawKey).trim() || rawKey + +/** + * 成本结构列:与 miscInquiryDetail 一致,优先按 `template_sections` 各段 `fields` 顺序与中文名展示; + * 模板字段 key 经 TEMPLATE_KEY_TO_UI_KEYS 映射到报价单 UI 存储键;无模板字段时回退 FIXED_QUOTATION_SECTION_COLUMNS。 + */ +const mergeQuotationSectionColumns = (section: string, templateSections: any): QuotationCostColumn[] => { + const fixedFallback = [...(FIXED_QUOTATION_SECTION_COLUMNS[section] || [])] + const tpl = normalizeSections(templateSections).find( + (s: any) => (s.title || s.name || s.section || '') === section + ) + const fields = Array.isArray(tpl?.fields) ? tpl.fields : [] + const keyMap = TEMPLATE_KEY_TO_UI_KEYS[section] || {} + + if (!fields.length) { + return fixedFallback + } + + const out: QuotationCostColumn[] = [] + const seen = new Set() + + for (const f of fields) { + const rawKey = String(f?.key || '').trim() + if (!rawKey) continue + const nk = normTplKey(rawKey) + if (section === '加工成本' && (nk === 'processfee' || rawKey === 'process_fee')) continue + if (nk === 'partid' || rawKey === 'part_id') continue + + const uiKeys: string[] = keyMap[nk] || keyMap[String(f.key).toLowerCase()] || [] + const uiKey = uiKeys.length ? uiKeys[0] : rawKey + if (seen.has(uiKey)) continue + seen.add(uiKey) + out.push({ key: uiKey, label: templateFieldDisplayLabel(f, rawKey) }) + } + + for (const fc of fixedFallback) { + if (!seen.has(fc.key)) { + seen.add(fc.key) + out.push({ + key: fc.key, + label: labelFallbacks[fc.key] || fc.label + }) + } + } + + return out.length ? out : fixedFallback } const costTemplates: Record = { @@ -1639,8 +1647,11 @@ export function useQuoteCrud(options?: { } catch (e) { console.warn('同步已过期报价单状态失败', e) } - const res = await api.getList({ page: 1, page_size: 200 }) - const list = res?.data?.results || res?.data?.data?.results || res?.data?.list || res?.data || [] + /** 与后端 dvadmin CustomPagination 一致:每页条数参数为 `limit`(非 page_size),默认 10 会导致列表只拉取 10 条 */ + const res: any = await api.getList({ page: 1, limit: 999 }) + const list = Array.isArray(res?.data) + ? res.data + : res?.data?.results || res?.data?.list || res?.results || res?.list || [] const mapped = (Array.isArray(list) ? list : []).map(mapBackendQuote) await enrichQuotesWithInquiryData(mapped) quotes.value = mapped @@ -1849,7 +1860,7 @@ export function useQuoteCrud(options?: { const countSection = (sec: string) => costRows.value.filter((r) => r.section === sec).length allowed.forEach((sec) => { if (countSection(sec) > 0) return - const cols = FIXED_QUOTATION_SECTION_COLUMNS[sec] + const cols = mergeQuotationSectionColumns(sec, sections) if (!cols?.length) return const values: Record = {} const labels: Record = {}