diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 47f41b2..ba1ad50 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -217,6 +217,7 @@ class Inquiry(CoreModel): ) template_version = models.IntegerField( db_column="TemplateVersion", + default=1, verbose_name="模板版本号", help_text="与 template 共同锁定成本估算模板主表版本(CostEstimateTemplateHead.version);发布与供应商报价结构均以此为准。", ) diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 6ff4ee1..2f46b2c 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -20,6 +20,7 @@ from .models import ( CostEstimateTemplateHead, CostEstimateTemplateBody, ) +from apps.pisadmin.basicinfo.models import Company from apps.pisadmin.basicinfo.models import Unit from apps.pisadmin.basicinfo.models import SystemNoRule from dvadmin.utils.serializers import CustomModelSerializer @@ -39,6 +40,31 @@ def get_request_username(serializer) -> str: ) +def resolve_inquiry_template_version(template_no: str, preferred_version=None): + """ + 从 t_CostEstimate_Template_Head(CostEstimateTemplateHead)解析询价单应锁定的模板版本号。 + 与 InquiryViewSet._get_template_prefill_fields 一致:仅 status=1(已确认);若 preferred_version + 存在且对应主表行存在则用之,否则取该 template_no 下最高 version。 + """ + tn = str(template_no or "").strip() + if not tn: + return None + base_qs = CostEstimateTemplateHead.objects.filter(template_no=tn, status=1) + head = None + if preferred_version is not None and preferred_version != "": + try: + ver = int(preferred_version) + except (TypeError, ValueError): + ver = None + if ver is not None: + head = base_qs.filter(version=ver).first() + if head is None: + head = base_qs.order_by("-version", "-id").first() + if not head: + return None + return int(head.version) + + def get_request_username_from_request(request) -> str: if not request: return "" @@ -862,6 +888,22 @@ class InquirySerializer(CustomModelSerializer): other_costs = InquiryOtherCostSerializer(many=True, required=False) profit_costs = InquiryProfitCostSerializer(many=True, required=False) rfq_items = InquiryRfqItemSerializer(many=True, required=False) + # 列表/详情展示:company_code → 公司信息简称(同请求内按代码缓存) + company_short_name = serializers.SerializerMethodField(read_only=True) + + def get_company_short_name(self, obj): + code = (getattr(obj, "company_code", None) or "").strip() + if not code: + return "" + cache = self.context.setdefault("_company_short_by_code", {}) + if code not in cache: + row = Company.objects.filter(company_code=code).only("company_short_name", "company_name").first() + if row: + short = (row.company_short_name or row.company_name or "").strip() + else: + short = "" + cache[code] = short or code + return cache[code] def to_internal_value(self, data): if hasattr(data, "copy"): @@ -885,7 +927,43 @@ class InquirySerializer(CustomModelSerializer): "update_time", "update_user", ] - extra_kwargs = {"inquiry_no": {"required": False, "allow_blank": True, "allow_null": True}} + extra_kwargs = { + "inquiry_no": {"required": False, "allow_blank": True, "allow_null": True}, + # 由 resolve_inquiry_template_version 根据 template 从 CostEstimateTemplateHead 写入 + "template_version": {"required": False, "allow_null": True}, + } + + def validate(self, attrs): + instance = getattr(self, "instance", None) + + template_in_attrs = "template" in attrs + tv_in_attrs = "template_version" in attrs + + # 局部更新且未改模板/版本:保持库中原值 + if instance and not template_in_attrs and not tv_in_attrs: + return attrs + + template_no = attrs.get("template") + if template_no is not None: + template_no = str(template_no).strip() + elif instance is not None: + template_no = (instance.template or "").strip() + else: + template_no = "" + + if not template_no: + return attrs + + preferred = attrs["template_version"] if tv_in_attrs else None + resolved = resolve_inquiry_template_version(template_no, preferred_version=preferred) + if resolved is None: + raise serializers.ValidationError( + { + "template": "所选询价模板无已确认版本,请先在「成本结构模板」中确认后再保存", + } + ) + attrs["template_version"] = resolved + return attrs def _generate_code(self, validated_data: dict) -> str: """ diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index f607bfa..18c3554 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -239,6 +239,14 @@ class InquiryViewSet(CustomModelViewSet): STATUS_OPEN = 1 STATUS_CONFIRMED = 2 STATUS_PUBLISHED = 3 + STATUS_QUOTING = 4 + STATUS_QUOTE_ENDED = 5 + STATUS_BARGaining = 6 + STATUS_NEGOTIATED = 7 + STATUS_PRICE_REVIEW = 7 + STATUS_APPROVED = 8 + STATUS_AWARDED = 9 + STATUS_VOID = 0 TEMPLATE_PREFILL_FIELD_MAP = { "1": { "material": "material_spec", @@ -318,7 +326,7 @@ class InquiryViewSet(CustomModelViewSet): return ErrorResponse(msg="当前询价单状态仅允许查看,不允许编辑或删除;如需修改请先还原为“开立”") return None - def _save_status(self, instance, *, status, confirm_user=None, confirm_time=None, release_user=None, release_time=None): + def _save_status(self, instance, *, status, confirm_user=None, confirm_time=None, release_user=None, release_time=None, comparison_user=None, comparison_time=None): current_time = timezone.now() current_user = self._get_request_username() or getattr(instance, "update_user", None) instance.status = status @@ -326,6 +334,10 @@ class InquiryViewSet(CustomModelViewSet): instance.confirm_time = confirm_time instance.release_user = release_user instance.release_time = release_time + if comparison_user is not None: + instance.comparison_user = comparison_user + if comparison_time is not None: + instance.comparison_time = comparison_time instance.update_user = current_user instance.update_time = current_time instance.save( @@ -335,6 +347,8 @@ class InquiryViewSet(CustomModelViewSet): "confirm_time", "release_user", "release_time", + "comparison_user", + "comparison_time", "update_user", "update_time", ] diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py index 8fabbbe..00ce279 100644 --- a/backend/apps/pissupplier/serializers.py +++ b/backend/apps/pissupplier/serializers.py @@ -7,6 +7,7 @@ from django.utils import timezone from rest_framework import serializers from dvadmin.utils.serializers import CustomModelSerializer +from apps.pisadmin.basicinfo.models import Company from apps.pisadmin.miscprocurement.models import ( CostEstimateTemplateBody, CostEstimateTemplateHead, @@ -382,6 +383,9 @@ class QuotationMasterSerializer(BusinessAuditSerializer): required=False, allow_null=True, ) + # 列表/详情:询价主表 company_code + 公司信息简称(同请求内按 inquiry_no 缓存,减轻重复查询) + inquiry_company_code = serializers.SerializerMethodField(read_only=True) + inquiry_company_short_name = serializers.SerializerMethodField(read_only=True) # 列表/详情展示:上阶物料明细 total_price_incl_tax 之和(主表无报价金额列) quote_amount = serializers.SerializerMethodField(read_only=True) template_sections = serializers.SerializerMethodField(read_only=True) @@ -398,6 +402,35 @@ class QuotationMasterSerializer(BusinessAuditSerializer): audit_update_user_field = "quoteuser" audit_update_time_field = "quotetime" + def _inquiry_plant_tuple(self, inquiry_no): + """(company_code, 展示用简称);简称优先 company_short_name,否则 company_name,再否则代码。""" + if not inquiry_no: + return None, "" + cache = self.context.setdefault("_inquiry_plant_by_inquiry_no", {}) + if inquiry_no in cache: + return cache[inquiry_no] + inq = Inquiry.objects.filter(inquiry_no=inquiry_no).only("company_code").first() + code = (getattr(inq, "company_code", None) or "").strip() if inq else "" + if not code: + cache[inquiry_no] = (None, "") + return cache[inquiry_no] + co = Company.objects.filter(company_code=code).only("company_short_name", "company_name").first() + short = "" + if co: + short = (co.company_short_name or co.company_name or "").strip() + if not short: + short = code + cache[inquiry_no] = (code, short) + return cache[inquiry_no] + + def get_inquiry_company_code(self, obj): + code, _ = self._inquiry_plant_tuple(getattr(obj, "inquiry_no", None)) + return code + + def get_inquiry_company_short_name(self, obj): + _, short = self._inquiry_plant_tuple(getattr(obj, "inquiry_no", None)) + return short or "" + def get_quote_amount(self, obj): total = Decimal("0") has_value = False diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index 4bee49c..89b17ba 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -1,9 +1,13 @@ +from typing import Optional + +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.viewset import CustomModelViewSet +from apps.pisadmin.miscprocurement.models import Inquiry from apps.pissupplier.models import ( QuotationMaster, QuotationAttachment, @@ -64,28 +68,56 @@ class QuotationMasterViewSet(CustomModelViewSet): def update(self, request, *args, **kwargs): instance = self.get_object() - if instance.status not in (1, 2): - return ErrorResponse(msg="仅未报价/报价中状态可保存报价内容") + # if instance.status not in (1, 2): + # return ErrorResponse(msg="仅未报价/报价中状态可保存报价内容") + # 含已报价(3):采购端比价窗口可回写中标/议价等(仍不可改 status/quotetime,由序列化器剥离) + if instance.status not in (1, 2, 3): + return ErrorResponse(msg="当前报价单状态不允许保存") return super().update(request, *args, **kwargs) def partial_update(self, request, *args, **kwargs): instance = self.get_object() - if instance.status not in (1, 2): - return ErrorResponse(msg="仅未报价/报价中状态可保存报价内容") + # if instance.status not in (1, 2): + # return ErrorResponse(msg="仅未报价/报价中状态可保存报价内容") + if instance.status not in (1, 2, 3): + return ErrorResponse(msg="当前报价单状态不允许保存") return super().partial_update(request, *args, **kwargs) + @staticmethod + def _sync_inquiry_when_quotation_quoting(inquiry_no: str, *, actor_username: Optional[str] = None): + """ + 任意报价单进入「报价中」(status=2) 时,若询价单仍为「发布」(3),同步为「报价中」(4)。 + 已进入报价中(4) 的询价单无需再改;不回退报价结束及之后状态。 + """ + inq_no = (inquiry_no or "").strip() + if not inq_no: + return + inq = Inquiry.objects.filter(inquiry_no=inq_no, status=3).first() + if not inq: + return + now = timezone.now() + update_user = (str(actor_username).strip()[:20] if actor_username else None) or None + inq.status = 4 + inq.update_time = now + inq.update_datetime = now + if update_user: + inq.update_user = update_user + inq.save(update_fields=["status", "update_time", "update_user", "update_datetime"]) + @action(methods=["post"], detail=True, url_path="quote") def quote(self, request, pk=None): """进入报价中:写入当前时间为报价时间,状态为报价中(2)。仅未报价(status=1)可报价。""" instance = self.get_object() - if instance.status != 1: + if instance.status not in (1, 2): return ErrorResponse(msg="仅未报价状态可进入报价中") instance.status = 2 instance.quotetime = timezone.now() username = getattr(getattr(request, "user", None), "username", None) if username: instance.quoteuser = username - instance.save(update_fields=["status", "quotetime", "quoteuser"]) + with transaction.atomic(): + instance.save(update_fields=["status", "quotetime", "quoteuser"]) + self._sync_inquiry_when_quotation_quoting(instance.inquiry_no, actor_username=username) serializer = self.get_serializer(instance) return DetailResponse(data=serializer.data, msg="报价中状态更新成功") diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx index 1813cd6..5b982a2 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx @@ -42,17 +42,6 @@ const formatQuoteDeadlineDisplay = (value: unknown) => { return text } -const normalizeList = (value: unknown, label: string) => { - if (value === null || value === undefined || value === '') return [] - if (typeof value === 'string') { - const parsed = JSON.parse(value) - if (!Array.isArray(parsed)) throw new Error(`${label} 需为 JSON 数组`) - return parsed - } - if (Array.isArray(value)) return value - throw new Error(`${label} 需为 JSON 数组`) -} - export const normalizeDict = (value: unknown, label: string) => { if (value === null || value === undefined || value === '') return {} if (typeof value === 'string') { @@ -134,6 +123,49 @@ export const createCrudOptions = function ({ onTableSelectionChange }: Partial & ExtraHooks): CreateCrudOptionsRet { void context + + const normalizeSelection = (raw: unknown): any[] => { + if (Array.isArray(raw)) return raw + if (raw != null && typeof raw === 'object') return [raw as any] + return [] + } + + const getSelectedRows = () => { + if (typeof getTableSelection === 'function') { + const rows = normalizeSelection(getTableSelection()) + if (rows.length > 0) return rows + } + const expose = crudExpose as any + // Element Plus:当前勾选行(与列表第一列选择列一致) + const baseTable = expose?.getBaseTableRef?.() + if (baseTable?.getSelectionRows) { + const fromEl = normalizeSelection(baseTable.getSelectionRows()) + if (fromEl.length) return fromEl + } + const tableRef: any = expose?.getTableRef?.() || expose?.tableRef + const selection = + tableRef?.getSelection?.() || + tableRef?.getSelectionRows?.() || + tableRef?.getSelections?.() || + tableRef?.getSelected?.() || + [] + return normalizeSelection(selection) + } + + /** 工具栏批量操作:取当前勾选的全部行(与列表选择列一致) */ + const pickSelectedRows = (): any[] | null => { + const rows = getSelectedRows() + if (!rows.length) { + ElMessage.warning('请先选择询价单') + return null + } + // if (rows.length > 1) { + // ElMessage.warning('仅支持单条操作') + // return null + // } + return rows[0] + } + return { crudOptions: { form: { @@ -305,6 +337,20 @@ export const createCrudOptions = function ({ columnSetDisabled: true } }, +company_short_name: { + title: '交易厂区', + type: 'text', + form: { show: false }, + search: { show: false }, + column: { + minWidth: 100, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => { + const v = row?.company_short_name ?? row?.companyShortName + return v != null && v !== '' ? String(v) : '' + } + } + }, inquiry_no: { title: '询价单号', type: 'input', diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index b37d40e..0010ad5 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -32,6 +32,10 @@ type Quote = { quoteNo: string inquiryCode: string inquiryTitle: string + /** 询价主表 `Inquiry.company_code` */ + inquiryCompanyCode?: string + /** 公司信息简称(交易厂区/询价厂区展示) */ + companyShortName?: string inquiryStatus: string /** 询价主表 `Inquiry.status`(数字),用于中标列与 status=9 判断 */ inquiryStatusCode?: number | null @@ -1038,6 +1042,8 @@ function blankQuote(): Quote { quoteNo: '', inquiryCode: '', inquiryTitle: '', + inquiryCompanyCode: '', + companyShortName: '', inquiryStatus: '', inquiryStatusCode: undefined, template: '', @@ -1064,7 +1070,7 @@ function blankQuote(): Quote { export function useQuoteCrud(options?: { onChange?: () => void }) { const filters = reactive<{ - status: string + inquiryPlant: string isAwarded: string | number | '' quoteNo?: string inquiryCode?: string @@ -1072,7 +1078,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { currency?: string dateRange: [Date, Date] | [] }>({ - status: '', + inquiryPlant: '', isAwarded: '', quoteNo: '', inquiryCode: '', @@ -1297,6 +1303,8 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { if (tpl) q.template = tpl if (inv.currency) q.currency = inv.currency if (inv.quote_deadline) q.quoteDeadline = inv.quote_deadline + const cc = inv.company_code != null && inv.company_code !== '' ? String(inv.company_code).trim() : '' + if (cc) q.inquiryCompanyCode = cc const invSt = inv.status ?? inv.inquiry_status if (invSt !== undefined && invSt !== null && invSt !== '') { q.inquiryStatusCode = Number(invSt) @@ -1312,6 +1320,14 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { inquiryCode: item.inquiry_no || item.inquiryCode || '', inquiryTitle: item.inquiry_name || item.inquiry_title || item.title || item.inquiryTitle || '', + inquiryCompanyCode: + item.inquiry_company_code != null && item.inquiry_company_code !== '' + ? String(item.inquiry_company_code).trim() + : '', + companyShortName: + item.inquiry_company_short_name != null && item.inquiry_company_short_name !== '' + ? String(item.inquiry_company_short_name).trim() + : '', inquiryStatus: item.inquiry_status || item.inquiryStatus || '', inquiryStatusCode: item.inquiry_status_code != null && item.inquiry_status_code !== '' @@ -1480,7 +1496,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { }) const resetFilter = () => { - filters.status = '' + filters.inquiryPlant = '' filters.isAwarded = '' filters.quoteNo = '' filters.inquiryCode = '' @@ -1496,13 +1512,17 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { const matchInquiryCode = !filters.inquiryCode || q.inquiryCode.toLowerCase().includes(filters.inquiryCode.trim().toLowerCase()) const matchInquiryTitle = !filters.inquiryTitle || q.inquiryTitle.toLowerCase().includes(filters.inquiryTitle.trim().toLowerCase()) const matchCurrency = !filters.currency || q.currency.toLowerCase().includes(filters.currency.trim().toLowerCase()) - const matchStatus = !filters.status || q.status === filters.status + const plantQ = (filters.inquiryPlant || '').trim().toLowerCase() + const matchPlant = + !plantQ || + (q.companyShortName || '').toLowerCase().includes(plantQ) || + (q.inquiryCompanyCode || '').toLowerCase().includes(plantQ) const matchAwarded = filters.isAwarded === '' || filters.isAwarded === undefined || filters.isAwarded === null ? true : Number(q.isAwarded) === Number(filters.isAwarded) const matchDate = !start || !end || (new Date(q.quoteDeadline) >= start && new Date(q.quoteDeadline) <= end) - return matchQuoteNo && matchInquiryCode && matchInquiryTitle && matchCurrency && matchStatus && matchAwarded && matchDate + return matchQuoteNo && matchInquiryCode && matchInquiryTitle && matchCurrency && matchPlant && matchAwarded && matchDate }) }) @@ -1727,6 +1747,8 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { quoteData.template = inquiry.template || inquiry.template_code || quoteData.template quoteData.currency = inquiry.currency || quoteData.currency quoteData.quoteDeadline = inquiry.quote_deadline || quoteData.quoteDeadline + 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 // const inqSt = inquiry.status ?? inquiry.inquiry_status // if (inqSt !== undefined && inqSt !== null && inqSt !== '') { diff --git a/web/src/views/pissupplier/quotation/index.vue b/web/src/views/pissupplier/quotation/index.vue index 9325017..1b93522 100644 --- a/web/src/views/pissupplier/quotation/index.vue +++ b/web/src/views/pissupplier/quotation/index.vue @@ -36,7 +36,7 @@ {{ templateLabel(current.template) }} {{ current.currency }} {{ current.quoteDeadline }} - {{ formatMiscInquiryStatus(current) }} + {{ current.companyShortName || current.inquiryCompanyCode || '—' }} 询价附件
@@ -299,7 +299,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { Flag } from '@element-plus/icons-vue' import { compute, dict, useCrud, useExpose } from '@fast-crud/fast-crud' import { getBaseURL } from '/@/utils/baseUrl' -import { useQuoteCrud, formatAwardBidStatus, formatMiscInquiryStatus, type InquiryAttachmentRow } from './crud' +import { useQuoteCrud, formatAwardBidStatus, type InquiryAttachmentRow } from './crud' const INQUIRY_FILE_TYPE_ORDER = [1, 2, 3] as const const INQUIRY_FILE_TYPE_LABELS: Record = { @@ -474,7 +474,7 @@ onUnmounted(() => { }) const syncFilters = (form: any = {}) => { - filters.status = form.status || '' + filters.inquiryPlant = form.inquiryPlant || '' filters.quoteNo = form.quoteNo || '' filters.inquiryCode = form.inquiryCode || '' filters.inquiryTitle = form.inquiryTitle || '' @@ -543,6 +543,21 @@ const crudOptions = { } }, columns: { + inquiryPlant: { + title: '交易厂区', + type: 'text', + search: { + show: true, + component: { props: { clearable: true, placeholder: '公司代码或简称' } } + }, + column: { show: false } + }, + companyShortName: { + title: '交易厂区', + type: 'text', + search: { show: false }, + column: { minWidth: 100, showOverflowTooltip: true } + }, quoteNo: { title: '报价单号', type: 'text', @@ -592,7 +607,7 @@ const crudOptions = { title: '报价状态', type: 'dict-select', dict: dict({ data: statusOptions }), - search: { show: true }, + search: { show: false }, column: { width: 100, slots: { default: 'cell_status' } } }, isAwarded: {