diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index d1eb6f9..a4e875c 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -832,7 +832,7 @@ class RFQOperationLogs(CoreModel): (2, "询价单确认"), (3, "询价单发布"), (4, "询价单还原"), - # (5, "询价发送通知"), + (5, "报价截止"), (6, "供应商报价"), (7, "比议价"), (8, "议价审核提交"), @@ -921,5 +921,67 @@ 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/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 7a5084a..3cd62af 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -371,6 +371,44 @@ class RFQOperationLogsSerializer(CustomModelSerializer): self._normalize_operation_type(validated_data) return super().update(instance, validated_data) + def _supplier_count_for_inquiry_publish(self, inquiry_no: str) -> int: + """询价单发布日志行:当前受邀供应商家数(distinct supplier_code)。""" + from apps.pisadmin.miscprocurement.models import InquirySupplier + + inq = (inquiry_no or "").strip() + if not inq: + return 0 + return ( + InquirySupplier.objects.filter(inquiry_no=inq) + .values_list("supplier_code", flat=True) + .distinct() + .count() + ) + + def _quote_ended_supplier_submit_and_overdue_counts(self, inquiry_no: str): + """ + 询价单进入「报价结束」后:已提交报价(3)与已过期/逾期未报(4)的供应商家数(按 supplier_code 去重)。 + """ + from apps.pissupplier.models import QuotationMaster + + inq = (inquiry_no or "").strip() + if not inq: + return 0, 0 + qs = QuotationMaster.objects.filter(inquiry_no=inq) + submitted = ( + qs.filter(status=3) + .values_list("supplier_code", flat=True) + .distinct() + .count() + ) + overdue = ( + qs.filter(status=4) + .values_list("supplier_code", flat=True) + .distinct() + .count() + ) + return submitted, overdue + def to_representation(self, instance): data = super().to_representation(instance) ot = data.get("operation_type") @@ -379,6 +417,24 @@ class RFQOperationLogsSerializer(CustomModelSerializer): data["operation_type"] = int(ot) except (TypeError, ValueError): pass + try: + iot = int(data.get("operation_type")) if data.get("operation_type") is not None else None + except (TypeError, ValueError): + iot = None + inq_no = str(getattr(instance, "inquiry_no", "") or "") + if iot == 3: + data["supplier_count"] = self._supplier_count_for_inquiry_publish(inq_no) + else: + data["supplier_count"] = None + + cur_label = (data.get("cur_status") or getattr(instance, "cur_status", None) or "").strip() + if iot == 6 and cur_label == "报价结束": + sub_n, ovd_n = self._quote_ended_supplier_submit_and_overdue_counts(inq_no) + data["quote_ended_submitted_supplier_count"] = sub_n + data["quote_ended_overdue_supplier_count"] = ovd_n + else: + data["quote_ended_submitted_supplier_count"] = None + data["quote_ended_overdue_supplier_count"] = None return data diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index a41ba24..d4c8114 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -103,10 +103,23 @@ class QuotationMasterViewSet(CustomModelViewSet): quote_deadline__isnull=False, quote_deadline__lt=now, ) - inquiry_nos = list(qs.values_list("inquiry_no", flat=True).distinct()) + quotation_rows = list(qs.values("quotation_no", "inquiry_no", "status", "supplier_name")) + inquiry_nos = list({str(r.get("inquiry_no") or "").strip() for r in quotation_rows if r.get("inquiry_no")}) + inquiry_purchase_type = {} + if inquiry_nos: + inquiry_purchase_type = { + x.inquiry_no: int(x.purchase_type) + for x in Inquiry.objects.filter(inquiry_no__in=inquiry_nos).only("inquiry_no", "purchase_type") + } 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/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue index f127aac..c7561f9 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue @@ -472,10 +472,18 @@ - + @@ -575,6 +583,7 @@ const OPERATION_TYPE_LABELS: Record = { 2: '询价单确认', 3: '询价单发布', 4: '询价单还原', + 5: '报价截止', 6: '供应商报价', 7: '比议价', 8: '议价审核提交', @@ -1068,6 +1077,11 @@ const skipTemplateWatch = ref(false) type RfqOperationLogRow = { id?: number operation_type?: number + /** 仅操作类型为「询价单发布」(3) 时后端返回受邀供应商家数 */ + supplier_count?: number | null + /** 仅「供应商报价」且作业后为「报价结束」时:已提交报价(3)、已过期(4) 的供应商家数 */ + quote_ended_submitted_supplier_count?: number | null + quote_ended_overdue_supplier_count?: number | null operation_user?: string | null operation_time?: string | null operation_desc?: string | null @@ -1459,12 +1473,24 @@ const statusTagType = (s: unknown) => { } const statusLabel = (s: unknown) => statusDict.find((i) => i.value === getStatusCode(s))?.label || String(s ?? '') +const toOptionalInt = (v: unknown): number | null => { + if (v === null || v === undefined || v === '') return null + const n = typeof v === 'number' ? v : Number(v) + return Number.isFinite(n) ? n : null +} + const normalizeOperationLogRow = (raw: Record): RfqOperationLogRow => { const ot = raw.operation_type const opType = typeof ot === 'number' ? ot : Number(ot) + const supplierCount = toOptionalInt(raw.supplier_count) + const qSub = toOptionalInt(raw.quote_ended_submitted_supplier_count) + const qOvd = toOptionalInt(raw.quote_ended_overdue_supplier_count) return { ...raw, - operation_type: Number.isFinite(opType) ? opType : 0 + operation_type: Number.isFinite(opType) ? opType : 0, + supplier_count: supplierCount, + quote_ended_submitted_supplier_count: qSub, + quote_ended_overdue_supplier_count: qOvd } as RfqOperationLogRow } @@ -1548,6 +1574,25 @@ const operationTypeLabel = (t: unknown) => { return OPERATION_TYPE_LABELS[n] || `类型${n}` } +/** 「询价单发布」时色块外展示受邀供应商家数(与后端 supplier_count 一致) */ +const operationPublishSupplierSuffix = (row: RfqOperationLogRow) => { + if (Number(row.operation_type) !== 3) return '' + const n = row.supplier_count + if (n === null || n === undefined || !Number.isFinite(Number(n))) return '' + return `(共 ${Number(n)} 家供应商)` +} + +/** 「供应商报价」且状态变为「报价结束」时色块外展示提交数与逾期数(与后端两个 count 一致) */ +const operationSupplierQuoteEndedSuffix = (row: RfqOperationLogRow) => { + if (Number(row.operation_type) !== 6) return '' + if ((row.cur_status || '').trim() !== '报价结束') return '' + const s = row.quote_ended_submitted_supplier_count + const o = row.quote_ended_overdue_supplier_count + if (s === null || s === undefined || o === null || o === undefined) return '' + if (!Number.isFinite(Number(s)) || !Number.isFinite(Number(o))) return '' + return `(报价结束,共 ${Number(s)} 家供应商提交报价,${Number(o)} 家逾期未报价)` +} + /** 与参考稿 HTML 色块含义接近的样式类 */ const operationTypeBadgeClass = (t: unknown) => { const n = typeof t === 'number' ? t : Number(t) @@ -1555,6 +1600,7 @@ const operationTypeBadgeClass = (t: unknown) => { if (n === 2) return 'op-type--confirm' if (n === 3) return 'op-type--publish' if (n === 4) return 'op-type--restore' + if (n === 5) return 'op-type--deadline' if (n === 6) return 'op-type--quote' if (n === 7) return 'op-type--compare' if (n === 8 || n === 9) return 'op-type--audit' @@ -2961,6 +3007,18 @@ onMounted(() => { font-size: 12px; color: var(--el-text-color-secondary); } +.op-type-cell { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + line-height: 1.4; +} +.op-type-suffix { + font-size: 12px; + font-weight: 400; + color: var(--el-text-color-regular); +} .op-type-badge { display: inline-block; padding: 2px 8px; @@ -2982,6 +3040,9 @@ onMounted(() => { .op-type--restore { background: #909399; } +.op-type--deadline { + background: #b45309; +} .op-type--quote { background: #e6a23c; } diff --git a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx index 269ae48..db3c1e9 100644 --- a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx @@ -7,6 +7,7 @@ const OPERATION_TYPE_LABELS: Record = { 2: '询价单确认', 3: '询价单发布', 4: '询价单还原', + 5: '报价截止', 6: '供应商报价', 7: '比议价', 8: '议价审核提交',