From e7ad616152d61a735086b684c75adaff1bde4415 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Fri, 27 Mar 2026 19:12:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20-=20=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E6=88=AA=E6=AD=A2=E6=97=B6=E9=97=B4=E6=A0=A1=E9=AA=8C=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=9B=E6=8A=A5=E4=BB=B7=E5=8D=95=E6=B5=81=E8=BD=AC?= =?UTF-8?q?=E5=88=B0=E2=80=9D=E5=B7=B2=E8=BF=87=E6=9C=9F=E2=80=9C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=80=BB=E8=BE=91=EF=BC=9B=E5=AE=8C=E5=96=84=E8=AF=A2?= =?UTF-8?q?=E4=BB=B7=E5=8D=95=E6=B5=81=E8=BD=AC=E5=88=B0=E2=80=9D=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E7=BB=93=E6=9D=9F=E2=80=9C=E7=8A=B6=E6=80=81=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/pisadmin/miscprocurement/models.py | 116 +++++++++++++++++- .../apps/pisadmin/miscprocurement/views.py | 10 +- backend/apps/pissupplier/models.py | 9 +- backend/apps/pissupplier/serializers.py | 19 ++- backend/apps/pissupplier/views.py | 48 +++++++- web/src/views/pissupplier/quotation/api.ts | 6 +- web/src/views/pissupplier/quotation/crud.tsx | 90 ++++++++++++-- web/src/views/pissupplier/quotation/index.vue | 16 +-- 8 files changed, 284 insertions(+), 30 deletions(-) diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 2df9492..fe6bc61 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Iterable, Optional from django.db import models from django.utils import timezone @@ -175,8 +175,13 @@ class CostEstimateTemplateBody(models.Model): class Inquiry(CoreModel): - """杂采询价单主表""" - + """杂采询价单主表 + + 与供应商端报价联动:当全部报价单都不再处于待报价/报价中时,「发布」「报价中」可经 + `Inquiry.sync_to_quote_closed_when_no_open_quotations` 收口为「报价结束」; + 当供应商名单中每家均有一条「已报价」报价单时,可经 `Inquiry.sync_to_quote_closed_when_all_suppliers_quoted` 收口(见 `submit`)。 + """ + # (1开立;2确认;3发布;4报价中;5报价结束;6比议价中;7价格审核;8核价通过(结束);9落标(结束);0作废) STATUS_CHOICES = ( (1, "开立"), @@ -269,6 +274,111 @@ class Inquiry(CoreModel): def __str__(self) -> str: # pragma: no cover - simple repr return self.inquiry_no + @classmethod + def sync_to_quote_closed_when_no_open_quotations( + cls, + inquiry_nos: Iterable[str], + *, + actor_username: Optional[str] = None, + ) -> int: + """ + 当某询价单下已不存在「待报价」(1) 或「报价中」(2) 的报价单时,若询价单仍为「发布」(3) 或「报价中」(4), + 则置为「报价结束」(5),并写操作日志。 + + 典型调用:供应商报价单因超过截止时间被批量置为「已过期」后,判断询价维度是否可收口为报价结束。 + """ + from apps.pissupplier.models import QuotationMaster # 避免 apps 间循环 import + + now = timezone.now() + username = (str(actor_username).strip()[:20] if actor_username else None) or None + unique = {str(x).strip() for x in inquiry_nos if x and str(x).strip()} + n = 0 + for inq_no in unique: + if QuotationMaster.objects.filter(inquiry_no=inq_no, status__in=(1, 2)).exists(): + continue + inq = cls.objects.filter(inquiry_no=inq_no, status__in=(3, 4)).first() + if not inq: + continue + old_status = int(inq.status if inq.status is not None else 0) + inq.status = 5 + inq.update_time = now + inq.update_datetime = now + if username: + inq.update_user = username + inq.save(update_fields=["status", "update_time", "update_user", "update_datetime"]) + RFQOperationLogs.try_append( + inquiry_no=inq.inquiry_no, + purchase_type=int(inq.purchase_type), + operation_type=6, + operation_user=username, + quotation_no="-", + per_status=old_status, + cur_status=5, + operation_desc="全部报价单已结束待报价/报价中,询价单同步为报价结束", + ) + n += 1 + return n + + @classmethod + def sync_to_quote_closed_when_all_suppliers_quoted( + cls, + inquiry_no: str, + *, + actor_username: Optional[str] = None, + quotation_no: Optional[str] = None, + ) -> bool: + """ + 若询价单供应商名单(InquirySupplier)中每个供应商在 `QuotationMaster` 上均有一条「已报价」(3) 记录, + 且询价单仍为「发布」(3) 或「报价中」(4),则将询价单置为「报价结束」(5)。 + + 用于最后一户提交正式报价后收口,不依赖是否已超过报价截止时间。 + """ + from apps.pissupplier.models import QuotationMaster # 避免 apps 间循环 import + + inq_no = (inquiry_no or "").strip() + if not inq_no: + return False + inq = cls.objects.filter(inquiry_no=inq_no, status__in=(3, 4)).first() + if not inq: + return False + + raw_codes = ( + InquirySupplier.objects.filter(inquiry_no=inq_no) + .values_list("supplier_code", flat=True) + .distinct() + ) + supplier_codes = {str(c).strip() for c in raw_codes if c and str(c).strip()} + if not supplier_codes: + return False + + for code in supplier_codes: + qm = QuotationMaster.objects.filter(inquiry_no=inq_no, supplier_code=code).first() + st = int(qm.status) if qm is not None and qm.status is not None else None + if st != 3: + return False + + old_status = int(inq.status if inq.status is not None else 0) + now = timezone.now() + username = (str(actor_username).strip()[:20] if actor_username else None) or None + inq.status = 5 + inq.update_time = now + inq.update_datetime = now + if username: + inq.update_user = username + inq.save(update_fields=["status", "update_time", "update_user", "update_datetime"]) + qn = (quotation_no or "-").strip()[:20] or "-" + RFQOperationLogs.try_append( + inquiry_no=inq.inquiry_no, + purchase_type=int(inq.purchase_type), + operation_type=6, + operation_user=username, + quotation_no=qn, + per_status=old_status, + cur_status=5, + operation_desc="全部供应商已提交报价,询价单同步为报价结束", + ) + return True + class InquirySupplier(models.Model): """杂采询价单-供应商关联表""" diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 6bdfe42..d3a5c59 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -247,6 +247,14 @@ class CostEstimateTemplateViewSet(CustomModelViewSet): class InquiryViewSet(CustomModelViewSet): + """ + 杂采询价单 CRUD/发布等。 + + 与供应商报价单联动:当 `pissupplier.QuotationMasterViewSet.sync_expired` 将超时报价置为已过期后, + 若该询价下已无待报价/报价中的报价单,且主表状态仍为「发布」(3) 或「报价中」(4), + 会由 `Inquiry.sync_to_quote_closed_when_no_open_quotations` 将询价单置为「报价结束」(5)。 + """ + queryset = Inquiry.objects.all().prefetch_related( "suppliers", "attachments", @@ -572,7 +580,7 @@ class InquiryViewSet(CustomModelViewSet): 20, ) or None current_time = timezone.now() - quote_deadline = inquiry.quote_deadline.strftime("%Y-%m-%d %H:%M:%S") if inquiry.quote_deadline else None + quote_deadline = inquiry.quote_deadline inquiry_materials = list(inquiry.material_costs.all()) inquiry_processes = list(inquiry.process_costs.all()) diff --git a/backend/apps/pissupplier/models.py b/backend/apps/pissupplier/models.py index 669ef57..153102d 100644 --- a/backend/apps/pissupplier/models.py +++ b/backend/apps/pissupplier/models.py @@ -38,7 +38,7 @@ class QuotationMaster(models.Model): contact_person = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人" ) contact_phone = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人电话" ) contact_email = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人邮件" ) - quote_deadline = models.CharField( max_length=20, null=True, blank=True, verbose_name="报价截止时间" ) + quote_deadline = models.DateTimeField( null=True, blank=True, verbose_name="报价截止时间" ) validity_days = models.IntegerField( null=True, blank=True, verbose_name="有效天数" ) delivery_days = models.IntegerField( null=True, blank=True, verbose_name="交货周期" ) payment_method = models.IntegerField( choices=PAYMENT_METHOD_CHOICES, null=True, blank=True, verbose_name="付款方式" ) @@ -48,7 +48,12 @@ class QuotationMaster(models.Model): null=True, blank=True, verbose_name="状态", - help_text="1 待报价 2 已报价 3 已过期;置为已报价请走供应商端 POST quotation_master/{id}/submit/", + help_text=( + "1 待报价 2 报价中 3 已报价 4 已过期;" + "置为已报价请走 POST quotation_master/{id}/submit/;" + "超时批量置过期及询价单收口见 sync_expired / Inquiry.sync_to_quote_closed_when_no_open_quotations;" + "名单内供应商均已提交见 submit 与 Inquiry.sync_to_quote_closed_when_all_suppliers_quoted。" + ), ) is_awarded = models.IntegerField( choices=AWARD_STATUS_CHOICES, default=0, null=True, blank=True, verbose_name="报价中标否" ) createuser = models.CharField( max_length=20, null=True, blank=True, verbose_name="创建人员" ) diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py index 0ad4857..4f29777 100644 --- a/backend/apps/pissupplier/serializers.py +++ b/backend/apps/pissupplier/serializers.py @@ -376,13 +376,24 @@ class SupplierInquiryAttachmentSerializer(serializers.ModelSerializer): class QuotationMasterSerializer(BusinessAuditSerializer): - """杂采报价单主表序列化器""" + """杂采报价单主表序列化器 + + `status`:1 待报价、2 报价中、3 已报价、4 已过期;超时由 POST `quotation_master/sync_expired/` 批量更新。 + 同步过期后,若询价单 `Inquiry` 仍为「发布」(3) 或「报价中」(4) 且该询价下已无任何待报价/报价中单, + 则询价单会置为「报价结束」(5),见 `Inquiry.sync_to_quote_closed_when_no_open_quotations`。 + 全部供应商名单均已正式提交报价(均为已报价)时由 `POST …/submit/` 内 `Inquiry.sync_to_quote_closed_when_all_suppliers_quoted` 收口。 + """ creattime = serializers.DateTimeField( format="%Y-%m-%d %H:%M:%S", required=False, allow_null=True, ) + quote_deadline = serializers.DateTimeField( + format="%Y-%m-%d %H:%M:%S", + required=False, + allow_null=True, + ) bid_start_time = serializers.DateTimeField( format="%Y-%m-%d %H:%M:%S", required=False, @@ -503,7 +514,11 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer): contact_person = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True) contact_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True) contact_email = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True) - quote_deadline = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True) + quote_deadline = serializers.DateTimeField( + format="%Y-%m-%d %H:%M:%S", + required=False, + allow_null=True, + ) validity_days = serializers.IntegerField(required=False, allow_null=True) delivery_days = serializers.IntegerField(required=False, allow_null=True) payment_method = serializers.IntegerField(required=False, allow_null=True) diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index b74e834..afd9b76 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -4,7 +4,7 @@ from django.db import transaction from django.utils import timezone from rest_framework.decorators import action -from dvadmin.utils.json_response import DetailResponse, ErrorResponse +from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse from dvadmin.utils.viewset import CustomModelViewSet from apps.pisadmin.miscprocurement.models import Inquiry, RFQOperationLogs @@ -60,6 +60,32 @@ class QuotationMasterViewSet(CustomModelViewSet): ) ordering = ("-creattime", "-autoid") + @action(methods=["post"], detail=False, url_path="sync_expired") + def sync_expired(self, request): + """ + 将当前用户数据权限范围内、状态为待报价(1)或报价中(2)、且已超过报价截止时间的报价单 + 更新为已过期(4)。若某询价单下已无任何待报价/报价中的报价单,且询价单仍为「发布」或「报价中」, + 则将该询价单置为「报价结束」(5)。供供应商端列表加载前调用。 + """ + now = timezone.now() + qs = self.filter_queryset(self.get_queryset()).filter( + status__in=(1, 2), + quote_deadline__isnull=False, + quote_deadline__lt=now, + ) + inquiry_nos = list(qs.values_list("inquiry_no", flat=True).distinct()) + username = getattr(getattr(request, "user", None), "username", None) + with transaction.atomic(): + updated = qs.update(status=4) + inquiries_closed = Inquiry.sync_to_quote_closed_when_no_open_quotations( + inquiry_nos, + actor_username=username, + ) + return SuccessResponse( + data={"updated": updated, "inquiries_closed": inquiries_closed}, + msg="同步成功", + ) + def perform_create(self, serializer): serializer.save() @@ -126,6 +152,9 @@ class QuotationMasterViewSet(CustomModelViewSet): instance = self.get_object() if instance.status not in (1, 2): return ErrorResponse(msg="仅未报价状态可进入报价中") + dl = getattr(instance, "quote_deadline", None) + if dl is not None and dl < timezone.now(): + return ErrorResponse(msg="已超过报价截止时间") instance.status = 2 instance.quotetime = timezone.now() username = getattr(getattr(request, "user", None), "username", None) @@ -147,14 +176,27 @@ class QuotationMasterViewSet(CustomModelViewSet): instance = self.get_object() if instance.status != 2: return ErrorResponse(msg="仅报价中状态可提交报价") + dl = getattr(instance, "quote_deadline", None) + if dl is not None and dl < timezone.now(): + return ErrorResponse(msg="已超过报价截止时间") instance.status = 3 instance.quotetime = timezone.now() username = getattr(getattr(request, "user", None), "username", None) if username: instance.quoteuser = username - instance.save(update_fields=["status", "quotetime", "quoteuser"]) + inquiry_quote_closed = False + with transaction.atomic(): + instance.save(update_fields=["status", "quotetime", "quoteuser"]) + inquiry_quote_closed = Inquiry.sync_to_quote_closed_when_all_suppliers_quoted( + instance.inquiry_no, + actor_username=username, + quotation_no=getattr(instance, "quotation_no", None), + ) serializer = self.get_serializer(instance) - return DetailResponse(data=serializer.data, msg="提交成功") + payload = dict(serializer.data) + payload["inquiry_quote_closed"] = inquiry_quote_closed + msg = "提交成功" + (",询价单已进入报价结束" if inquiry_quote_closed else "") + return DetailResponse(data=payload, msg=msg) class QuotationAttachmentViewSet(CustomModelViewSet): diff --git a/web/src/views/pissupplier/quotation/api.ts b/web/src/views/pissupplier/quotation/api.ts index 2ba5504..0878479 100644 --- a/web/src/views/pissupplier/quotation/api.ts +++ b/web/src/views/pissupplier/quotation/api.ts @@ -22,7 +22,7 @@ export const PISSUPPLIER_ROUTES = { const baseUrl = PISSUPPLIER_ROUTES.quotation_master -export type QuotationStatusCode = 1 | 2 | 3 +export type QuotationStatusCode = 1 | 2 | 3 | 4 export type PaymentCode = 1 | 2 | 3 | 4 /** @@ -36,6 +36,10 @@ export type PaymentCode = 1 | 2 | 3 | 4 * 成本结构须提交 material_costs、process_costs、other_costs、profit_costs(与 QuotationMasterCreateUpdateSerializer), * 勿使用 cost_items——后端不识别,子表不会更新。 */ +/** 将待报价/报价中且已超过截止时间的报价单置为已过期(4),与列表权限范围一致 */ +export const syncExpiredQuotations = () => + request({ url: `${baseUrl}sync_expired/`, method: 'post', data: {} }) + export const getList = (params: any) => request({ url: baseUrl, method: 'get', params }) export const getDetail = (id: string | number) => request({ url: `${baseUrl}${id}/`, method: 'get' }) export const create = (data: any) => request({ url: baseUrl, method: 'post', data }) diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index bb074d3..c977367 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -46,6 +46,7 @@ type Quote = { bidEndTime?: string template: string currency: string + /** 与后端 `quote_deadline`(DateTime)对应的展示用字符串,经 `formatQuoteDeadlineDisplay` 规范化 */ quoteDeadline: string quoteAmount: string quoteTime: string @@ -90,10 +91,17 @@ export function formatQuoteDeadlineDisplay(value: unknown) { if (!value) return '' const text = String(value).trim() if (!text) return '' - const matched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2})/) - if (matched) { - return `${matched[1]} ${matched[2]}:00` + // 匹配完整日期时间:yyyy-mm-dd HH:MM:SS 或 yyyy-mm-ddTHH:MM:SS + const fullMatched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})/) + if (fullMatched) { + return `${fullMatched[1]} ${fullMatched[2]}:${fullMatched[3]}` } + // 匹配只到小时:yyyy-mm-dd HH + const hourMatched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2})/) + if (hourMatched) { + return `${hourMatched[1]} ${hourMatched[2]}:00` + } + // 只有日期:yyyy-mm-dd const dateOnly = text.match(/^(\d{4}-\d{2}-\d{2})$/) if (dateOnly) { return `${dateOnly[1]} 00:00` @@ -101,6 +109,47 @@ export function formatQuoteDeadlineDisplay(value: unknown) { return text } +/** 将后端返回的 `quote_deadline`(ISO 或 `YYYY-MM-DD HH:mm:ss`)规范为列表/表单展示用字符串 */ +export function normalizeQuoteDeadlineFromApi(value: unknown): string { + return formatQuoteDeadlineDisplay(value) +} + +/** 解析报价截止时间用于本地筛选(与 `YYYY-MM-DD HH:mm:ss` / ISO 字符串兼容) */ +export function parseQuoteDeadlineToMs(value: unknown): number { + if (value == null || value === '') return NaN + const text = String(value).trim() + if (!text) return NaN + const normalized = formatQuoteDeadlineDisplay(text) + if (!normalized) return NaN + const forDate = normalized.replace(/^(\d{4}-\d{2}-\d{2})\s+/, '$1T') + const d = new Date(forDate) + const t = d.getTime() + return Number.isNaN(t) ? NaN : t +} + +/** + * 保存主表时提交 `quote_deadline`:后端为 DateTimeField,提交 `YYYY-MM-DD HH:mm:ss` 或省略 + */ +export function quoteDeadlineToApiPayload(value: unknown): string | undefined { + if (value == null || value === '') return undefined + const raw = String(value).trim() + if (!raw) return undefined + const normalized = formatQuoteDeadlineDisplay(raw) + if (!normalized) return undefined + const m = normalized.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/) + if (m) { + const sec = m[4] ?? '00' + return `${m[1]} ${m[2]}:${m[3]}:${sec}` + } + const m2 = normalized.match(/^(\d{4}-\d{2}-\d{2})$/) + if (m2) return `${m2[1]} 00:00:00` + const isoCandidate = raw.includes('T') || raw.includes('Z') ? raw : raw.replace(/^(\d{4}-\d{2}-\d{2})\s+/, '$1T') + const d = new Date(isoCandidate) + if (Number.isNaN(d.getTime())) return undefined + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + /** 列表「投标开始/截止」列:采购方式=询价 时显示「-」 */ export function formatBidTimeColumn(row: any, value: unknown) { if (Number(row?.buyingMethod ?? row?.buying_method) === 1) return '-' @@ -1339,7 +1388,9 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { const tpl = inv.template || inv.template_code if (tpl) q.template = tpl if (inv.currency) q.currency = inv.currency - if (inv.quote_deadline) q.quoteDeadline = inv.quote_deadline + if (inv.quote_deadline != null && String(inv.quote_deadline).trim() !== '') { + q.quoteDeadline = normalizeQuoteDeadlineFromApi(inv.quote_deadline) + } if (q.buyingMethod == null && inv.buying_method != null && inv.buying_method !== '') { const n = Number(inv.buying_method) if (Number.isFinite(n)) q.buyingMethod = n @@ -1397,7 +1448,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { const raw = item.bid_end_time ?? item.bidEndTime return raw != null && String(raw).trim() !== '' ? String(raw).trim() : '' })(), - quoteDeadline: item.quote_deadline || item.quoteDeadline || '', + quoteDeadline: normalizeQuoteDeadlineFromApi(item.quote_deadline ?? item.quoteDeadline ?? ''), quoteAmount: item.quote_amount ?? item.quoteAmount ?? @@ -1534,6 +1585,11 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { loading.value = true try { await ensureTemplateNameLookup() + try { + await api.syncExpiredQuotations() + } 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 || [] const mapped = (Array.isArray(list) ? list : []).map(mapBackendQuote) @@ -1585,7 +1641,13 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { filters.buyingMethod === '' || filters.buyingMethod === undefined || filters.buyingMethod === null ? true : Number(q.buyingMethod) === Number(filters.buyingMethod) - const matchDate = !start || !end || (new Date(q.quoteDeadline) >= start && new Date(q.quoteDeadline) <= end) + const matchDate = + !start || + !end || + (() => { + const t = parseQuoteDeadlineToMs(q.quoteDeadline) + return !Number.isNaN(t) && t >= start.getTime() && t <= end.getTime() + })() return ( matchQuoteNo && matchInquiryCode && @@ -1819,7 +1881,9 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { quoteData.inquiryTitle = inquiry.title || quoteData.inquiryTitle quoteData.template = inquiry.template || inquiry.template_code || quoteData.template quoteData.currency = inquiry.currency || quoteData.currency - quoteData.quoteDeadline = inquiry.quote_deadline || quoteData.quoteDeadline + if (inquiry.quote_deadline != null && String(inquiry.quote_deadline).trim() !== '') { + quoteData.quoteDeadline = normalizeQuoteDeadlineFromApi(inquiry.quote_deadline) + } const icc = inquiry.company_code != null && inquiry.company_code !== '' ? String(inquiry.company_code).trim() : '' if (icc) quoteData.inquiryCompanyCode = icc quoteData.inquiryStatus = inquiry.status || inquiry.inquiry_status || quoteData.inquiryStatus @@ -2138,13 +2202,17 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { loading.value = true try { const res: any = await api.submitOfficial(row.id) - const data = res?.data?.data ?? res?.data ?? res - const updated = mapBackendQuote(data) + const raw = res?.data?.data ?? res?.data ?? res + const inquiryClosed = raw?.inquiry_quote_closed === true + const updated = mapBackendQuote(raw) await enrichQuotesWithInquiryData([updated]) const idx = quotes.value.findIndex((q) => q.id === updated.id) if (idx >= 0) quotes.value.splice(idx, 1, updated) else quotes.value.unshift(updated) - ElMessage.success('报价已提交') + const tip = + res?.data?.msg || + (inquiryClosed ? '报价已提交,询价单已进入报价结束' : '报价已提交') + ElMessage.success(tip) options?.onChange?.() } catch (err) { console.error('提交报价失败', err) @@ -2240,7 +2308,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { const payload: any = { quotation_no: q.quoteNo || undefined, inquiry_no: q.inquiryCode || undefined, - quote_deadline: q.quoteDeadline || undefined, + quote_deadline: quoteDeadlineToApiPayload(q.quoteDeadline), supplier_code: q.supplierCode || undefined, supplier_name: q.supplierName || undefined, contact_person: q.base.contact || undefined, diff --git a/web/src/views/pissupplier/quotation/index.vue b/web/src/views/pissupplier/quotation/index.vue index 5cfbf49..9365059 100644 --- a/web/src/views/pissupplier/quotation/index.vue +++ b/web/src/views/pissupplier/quotation/index.vue @@ -45,8 +45,8 @@ - - + + @@ -320,9 +320,11 @@ import { formatAwardBidStatus, type InquiryAttachmentRow, buyingMethodDict, - formatBidTimeColumn + formatBidTimeColumn, + formatQuoteDeadlineDisplay } from './crud' + const INQUIRY_FILE_TYPE_ORDER = [1, 2, 3] as const const INQUIRY_FILE_TYPE_LABELS: Record = { 1: '产品图纸', @@ -539,10 +541,10 @@ const crudOptions = { form: { labelWidth: '110px' }, search: { show: true, labelWidth: '90px' }, actionbar: { show: false }, - table: { rowKey: 'id', size: 'small' }, + table: { rowKey: 'id', size: 'medium' }, rowHandle: { fixed: 'right', - width: 320, + width: 240, buttons: { // Hide fast-crud default actions; use custom actions below instead view: { show: false }, @@ -630,7 +632,7 @@ const crudOptions = { } }, quoteDeadline: { - title: '报价截止日', + title: '报价截止时间', type: 'datetime', search: { show: true, @@ -645,7 +647,7 @@ const crudOptions = { } } }, - column: { width: 150 } + column: { width: 150, formatter: ({ value }: any) => formatQuoteDeadlineDisplay(value) } }, bidStartTime: { title: '投标开始时间', From 0f0d40588d7d2ed90fafc03bb7579fd7b33142a8 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Fri, 27 Mar 2026 19:15:44 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E9=83=A8=E5=88=86ui=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/pisadmin/miscprocurement/models.py | 2 +- .../pisadmin/miscprocurement/serializers.py | 56 ++- .../apps/pisadmin/miscprocurement/views.py | 199 +++++++++- backend/apps/pissupplier/views.py | 8 +- .../miscprocurement/rfqmiscellaneous/api.ts | 4 +- .../rfqmiscellaneous/index.vue | 366 ++++++++++++++---- 6 files changed, 550 insertions(+), 85 deletions(-) diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index fe6bc61..691998a 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -710,7 +710,7 @@ class MiscLowPriceHeader(CoreModel): max_length=100, db_column="ItemNo", verbose_name="项次名", - help_text="如:铝等项次名称", + help_text="根据成本类别获取询价单对应子表数据行,材料类别 - 材料成本的产品料号 part_id, 加工类别 - 加工工站 process_station", ) min_price = models.CharField( max_length=10, diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index f32dc16..be4ce81 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -267,7 +267,7 @@ class MiscPartCreateUpdateSerializer(CustomModelSerializer): class MiscLowPriceHeaderSerializer(CustomModelSerializer): - """比价-制程最低价记录主表""" + """比价-制程最低价记录主表(采购端「确认比价」时由 InquiryViewSet.confirm_negotiation 写入)。""" class Meta: model = MiscLowPriceHeader @@ -283,7 +283,7 @@ class MiscLowPriceHeaderSerializer(CustomModelSerializer): class MiscLowPriceDetailSerializer(CustomModelSerializer): - """比价-制程最低价记录次表""" + """比价-制程最低价记录次表(材料规格下重量/单价最小值;与 confirm_negotiation 同步写入)。""" class Meta: model = MiscLowPriceDetail @@ -1029,6 +1029,11 @@ class InquirySerializer(CustomModelSerializer): rfq_items = InquiryRfqItemSerializer(many=True, required=False) # 列表/详情展示:company_code → 公司信息简称(同请求内按代码缓存) company_short_name = serializers.SerializerMethodField(read_only=True) + # 上阶物料首行:与 misc_rfq_items 一致,供列表/比价弹窗回显采购件料号、名称(主表无独立字段) + part_no = serializers.SerializerMethodField(read_only=True) + part_name = serializers.SerializerMethodField(read_only=True) + # 税率:优先上阶物料行 tax_rate,否则同料号税费利润行(proc_inquiry_profit_cost.tax_rate) + tax_rate = serializers.SerializerMethodField(read_only=True) bid_start_time = serializers.DateTimeField( format="%Y-%m-%d %H:%M:%S", required=False, @@ -1054,6 +1059,53 @@ class InquirySerializer(CustomModelSerializer): cache[code] = short or code return cache[code] + def _first_rfq_item(self, obj: Inquiry): + qs = getattr(obj, "rfq_items", None) + if qs is None: + return None + return qs.order_by("id").first() + + def get_part_no(self, obj): + item = self._first_rfq_item(obj) + if not item: + return "" + v = getattr(item, "part_id", None) + return "" if v is None else str(v).strip() + + def get_part_name(self, obj): + item = self._first_rfq_item(obj) + if not item: + return "" + v = getattr(item, "product_name", None) + return "" if v is None else str(v).strip() + + def _profit_cost_for_first_part(self, obj: Inquiry): + """首条上阶物料对应料号的税费利润行;无料号时取首条利润行。""" + rfq = self._first_rfq_item(obj) + qs = getattr(obj, "profit_costs", None) + if qs is None: + return None + if rfq: + pid = getattr(rfq, "part_id", None) + if pid is not None and str(pid).strip() != "": + row = qs.filter(part_id=pid).order_by("id").first() + if row: + return row + return qs.order_by("id").first() + + def get_tax_rate(self, obj): + item = self._first_rfq_item(obj) + if item: + v = getattr(item, "tax_rate", None) + if v is not None and str(v).strip() != "": + return str(v).strip() + pc = self._profit_cost_for_first_part(obj) + if pc: + v = getattr(pc, "tax_rate", None) + if v is not None and str(v).strip() != "": + return str(v).strip() + return "" + def to_internal_value(self, data): if hasattr(data, "copy"): data = data.copy() diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index d3a5c59..f308e44 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1,3 +1,5 @@ +import logging +from collections import defaultdict from decimal import Decimal from django.db import transaction @@ -25,6 +27,9 @@ from apps.pissupplier.models import ( QuotationItem, ) +logger = logging.getLogger(__name__) + + from .models import ( MiscProcurementMaterialInfo, MiscProcurementStationInfo, @@ -84,6 +89,155 @@ def _negotiation_totals_from_quotation_item(quotation_no: str, part_id: str): return item.total_price_excl_tax, item.total_price_incl_tax +def _clip_price_str(value, max_len: int = 10) -> str: + s = str(value).strip() if value is not None else "" + return s[:max_len] + + +def _clip_field(value, max_len: int) -> str: + return str(value or "").strip()[:max_len] + + +def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: + """ + 确认比价时:按当前询价单下各供应商报价单,汇总材料/加工/包装/运输的「制程最低价」, + 写入 MiscLowPriceHeader;材料规格下重量、单价的最小值写入 MiscLowPriceDetail(与次表设计一致)。 + """ + inquiry_no = (inquiry.inquiry_no or "").strip() + part_id = (part_id or "").strip() + if not inquiry_no or not part_id: + return + + qn_active = QuotationMaster.objects.filter(inquiry_no=inquiry_no).exclude(status=4) + qn_list = list(qn_active.values_list("quotation_no", flat=True)) + if not qn_list: + return + + souce_ref = inquiry_no[:20] + + MiscLowPriceHeader.objects.filter(inquiry_no=inquiry_no, part_id=part_id).delete() + MiscLowPriceDetail.objects.filter(inquiry_no=inquiry_no, part_id=part_id).delete() + + # —— 主表:材料行(按材料规格)—— + materials = QuotationMaterial.objects.filter(quotation_no__in=qn_list, part_id=part_id) + by_spec: dict[str, list] = defaultdict(list) + for m in materials: + spec = (m.material_spec or "").strip() or "材料" + by_spec[spec].append(m) + + for spec, rows in by_spec.items(): + costs = [] + for r in rows: + if r.material_cost is not None: + try: + costs.append(Decimal(str(r.material_cost))) + except Exception: + continue + if costs: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="1", + item_no=_clip_field(spec, 100), + min_price=_clip_price_str(min(costs)), + ) + + weights = [] + unit_prices = [] + for r in rows: + if r.weight is not None: + try: + weights.append(Decimal(str(r.weight))) + except Exception: + pass + if r.unit_price is not None: + try: + unit_prices.append(Decimal(str(r.unit_price))) + except Exception: + pass + spec_key = _clip_field(spec, 50) + if weights: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + cost_type="1", + material_spec=spec_key, + item_no="1", + value=_clip_price_str(min(weights)), + souce_no=souce_ref, + ) + if unit_prices: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + cost_type="1", + material_spec=spec_key, + item_no="2", + value=_clip_price_str(min(unit_prices)), + souce_no=souce_ref, + ) + + # —— 主表:加工(按工站)—— + processes = QuotationProcess.objects.filter(quotation_no__in=qn_list, part_id=part_id) + by_station: dict[str, list] = defaultdict(list) + for p in processes: + st = (p.process_station or "").strip() or "工站" + by_station[st].append(p) + + for station, rows in by_station.items(): + prices = [] + for r in rows: + if r.process_price is not None: + try: + prices.append(Decimal(str(r.process_price))) + except Exception: + continue + if prices: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="2", + item_no=_clip_field(station, 100), + min_price=_clip_price_str(min(prices)), + ) + + # —— 主表:包装费 / 运输费 —— + others = QuotationOther.objects.filter(quotation_no__in=qn_list, part_id=part_id) + pkg_vals = [] + tr_vals = [] + for o in others: + if o.packaging_cost is not None: + try: + pkg_vals.append(Decimal(str(o.packaging_cost))) + except Exception: + pass + if o.transportation_cost is not None: + try: + tr_vals.append(Decimal(str(o.transportation_cost))) + except Exception: + pass + if pkg_vals: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="3", + item_no="包装费", + min_price=_clip_price_str(min(pkg_vals)), + ) + if tr_vals: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="4", + item_no="运输费", + min_price=_clip_price_str(min(tr_vals)), + ) + + class MiscMaterialViewSet(CustomModelViewSet): queryset = MiscProcurementMaterialInfo.objects.all() serializer_class = MiscMaterialSerializer @@ -870,24 +1024,39 @@ class InquiryViewSet(CustomModelViewSet): @action(methods=["put"], detail=True) def confirm_negotiation(self, request, pk=None): - """确认议价:从比议价中进入议价确认/价格审核""" + """确认议价:从比议价中进入议价确认/价格审核;并写入比价-制程最低价主/次表。""" instance = self.get_object() if int(instance.status or self.STATUS_BARGaining) != self.STATUS_BARGaining: return ErrorResponse(msg='只有“比议价中”状态的询价单才能确认议价') - current_user = self._get_request_username() or None - current_time = timezone.now() - return self._save_status( - instance, - status=self.STATUS_NEGOTIATED, - confirm_user=getattr(instance, "confirm_user", None), - confirm_time=getattr(instance, "confirm_time", None), - release_user=getattr(instance, "release_user", None), - release_time=getattr(instance, "release_time", None), - comparison_user=getattr(instance, "comparison_user", None), - comparison_time=getattr(instance, "comparison_time", None), - operation_type=8, - operation_desc="议价审核提交(进入价格审核)", - ) + part_id = (request.data.get("part_id") or request.query_params.get("part_id") or "").strip() + if not part_id: + first = InquiryRfqItem.objects.filter(inquiry_no=instance).order_by("id").first() + if first: + part_id = (first.part_id or "").strip() + try: + with transaction.atomic(): + if part_id: + _sync_misc_low_price_records(instance, part_id) + else: + logger.warning( + "confirm_negotiation: 缺少 part_id,跳过制程最低价落库 inquiry_no=%s", + instance.inquiry_no, + ) + return self._save_status( + instance, + status=self.STATUS_NEGOTIATED, + confirm_user=getattr(instance, "confirm_user", None), + confirm_time=getattr(instance, "confirm_time", None), + release_user=getattr(instance, "release_user", None), + release_time=getattr(instance, "release_time", None), + comparison_user=getattr(instance, "comparison_user", None), + comparison_time=getattr(instance, "comparison_time", None), + operation_type=8, + operation_desc="议价审核提交(进入价格审核)", + ) + except Exception as exc: + logger.exception("confirm_negotiation 写入制程最低价失败") + return ErrorResponse(msg=f"确认比价失败:{exc}") @action(methods=["put"], detail=True) def submit_price_audit(self, request, pk=None): diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index afd9b76..ba85652 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -32,7 +32,13 @@ from apps.pissupplier.serializers import ( class QuotationMasterViewSet(CustomModelViewSet): """杂采报价单主表管理接口""" - queryset = QuotationMaster.objects.prefetch_related("rfq_items") + queryset = QuotationMaster.objects.prefetch_related( + "rfq_items", + "material_costs", + "process_costs", + "other_costs", + "profit_costs", + ) serializer_class = QuotationMasterSerializer create_serializer_class = QuotationMasterCreateUpdateSerializer update_serializer_class = QuotationMasterCreateUpdateSerializer diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index ed352d4..5022a21 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -11,7 +11,9 @@ export const ConfirmObj = (id: string | number) => request({ url: `${baseUrl}${i export const RestoreObj = (id: string | number) => request({ url: `${baseUrl}${id}/restore/`, method: 'put' }) export const PublishObj = (id: string | number) => request({ url: `${baseUrl}${id}/publish/`, method: 'put' }) export const StartBargainingObj = (id: string | number) => request({ url: `${baseUrl}${id}/start_bargaining/`, method: 'put' }) -export const ConfirmNegotiationObj = (id: string | number) => request({ url: `${baseUrl}${id}/confirm_negotiation/`, method: 'put' }) +/** 确认比价:后端写入制程最低价主/次表时需带 part_id(与议价记录一致) */ +export const ConfirmNegotiationObj = (id: string | number, data?: { part_id?: string }) => + 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' }) /** 查询杂采议价记录(比价议价价格存此表,非报价明细「中标价格」) */ export const GetNegotiationRecordsObj = (id: string | number, params: { part_id?: string }) => diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue index 5a620d2..bbea8ae 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue @@ -40,7 +40,7 @@ - + + + + + + +
-