diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 689ff32..47f41b2 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -215,6 +215,11 @@ class Inquiry(CoreModel): verbose_name="询价模版", help_text="对应成本估算模板编号;发布生成供应商报价单时,子表字段是否从询价单带入由该模板明细 is_computed=1 或 supplier_required 为 1/2 决定。", ) + template_version = models.IntegerField( + db_column="TemplateVersion", + verbose_name="模板版本号", + help_text="与 template 共同锁定成本估算模板主表版本(CostEstimateTemplateHead.version);发布与供应商报价结构均以此为准。", + ) is_bom = models.IntegerField(default=0, verbose_name="是否BOM否") currency = models.CharField(max_length=20, default="CNY", verbose_name="交易币别") company_code = models.CharField(max_length=20, null=True, blank=True, verbose_name="公司代码") @@ -283,7 +288,7 @@ class InquirySupplier(models.Model): class InquiryAttachment(models.Model): - """杂采询价单-附件关联表""" + """杂采询价单-附件关联表(采购方上传)。发布询价生成报价单时不写入 `pissupplier.QuotationAttachment`,供应商通过详情中的询价附件只读展示。""" FILE_TYPE_CHOICES = ( (1, "产品图纸"), @@ -341,6 +346,14 @@ class InquiryMaterialCost(models.Model): material_cost = models.DecimalField( max_digits=12, decimal_places=4, db_column="material_cost", null=True, blank=True, verbose_name="材料费用" ) + weight = models.DecimalField( + max_digits=12, + decimal_places=4, + db_column="Weight", + null=True, + blank=True, + verbose_name="重量", + ) remark = models.CharField(max_length=100, db_column="remark", null=True, blank=True, verbose_name="备注") option_json = models.TextField(db_column="OptionJson", null=True, blank=True, verbose_name="可选扩展信息") create_time = models.DateTimeField(db_column="createtime", auto_now_add=True, verbose_name="创建时间") diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 247f311..6ff4ee1 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -641,7 +641,7 @@ class NestedInquirySupplierSerializer(InquirySupplierSerializer): class InquiryAttachmentSerializer(serializers.ModelSerializer): - """采购端询价单附件读写;供应商端只读见 `apps.pissupplier.serializers.QuotationMasterSerializer.inquiry_attachments`。""" + """采购端询价单附件读写;供应商在报价详情中通过 `QuotationMasterSerializer.inquiry_attachments` 只读查看。发布时不复制到报价单附件子表。""" file_type = serializers.CharField() @@ -689,6 +689,7 @@ class InquiryMaterialCostSerializer(serializers.ModelSerializer): "qty", "specific_gravity", "material_cost", + "weight", "remark", "option_json", "create_time", @@ -987,6 +988,7 @@ class InquirySerializer(CustomModelSerializer): qty=row.get("qty"), specific_gravity=row.get("specific_gravity"), material_cost=row.get("material_cost"), + weight=row.get("weight"), remark=row.get("remark"), option_json=self._normalize_material_option_json(row), create_user=row.get("create_user") or current_user or None, diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 965892f..f607bfa 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -16,7 +16,6 @@ from apps.pisadmin.basicinfo.views.email_utils import send_email_notice from apps.pissupplier.models import ( QuotationMaster, - QuotationAttachment, QuotationMaterial, QuotationProcess, QuotationOther, @@ -114,6 +113,19 @@ class CostEstimateTemplateViewSet(CustomModelViewSet): def get_queryset(self): qs = super().get_queryset() + # 仅列表默认隐藏作废;retrieve/update 等需能按 id 加载作废行(否则筛选作废后无法查看详情) + if getattr(self, "action", None) != "list": + return qs + params = getattr(self.request, "query_params", getattr(self.request, "GET", {})) + status_raw = params.get("status") + # 列表默认不展示作废;查询区显式选「状态=作废」时传 status=2,不过滤以便列出作废行 + if status_raw is not None and str(status_raw).strip() != "": + try: + st = int(status_raw) + except (TypeError, ValueError): + return qs.exclude(status=self.TEMPLATE_STATUS_VOID) + if st == self.TEMPLATE_STATUS_VOID: + return qs return qs.exclude(status=self.TEMPLATE_STATUS_VOID) def perform_create(self, serializer): @@ -220,7 +232,7 @@ class InquiryViewSet(CustomModelViewSet): "rfq_items", ) serializer_class = InquirySerializer - filter_fields = ("inquiry_no", "title", "purchase_type", "template", "status", "buyer") + filter_fields = ("inquiry_no", "title", "purchase_type", "template", "template_version", "status", "buyer") search_fields = ("inquiry_no", "title", "material_type", "buyer", "remark") ordering = ("-update_datetime",) @@ -238,6 +250,7 @@ class InquiryViewSet(CustomModelViewSet): "qty": "qty", "unitprice": "unit_price", "materialcost": "material_cost", + "weight": "weight", "remark": "remark", }, "2": { @@ -346,24 +359,32 @@ class InquiryViewSet(CustomModelViewSet): now=timezone.now(), ) - def _get_template_prefill_fields(self, template_no): + def _get_template_prefill_fields(self, template_no, template_version=None): """ - 根据询价单关联的成本模板(Inquiry.template = CostEstimateTemplateHead.template_no), + 根据询价单关联的成本模板(Inquiry.template = template_no,Inquiry.template_version = 主表 version), 找出「发布生成报价单时」允许从询价单带入到报价子表的字段名集合。 满足以下任一条件时,对应映射字段进入集合(从询价单带入报价子表),否则为空值(见 _pick_prefill_value): - is_computed == 1(系统自动计算/带出) - supplier_required == 1(带出不可修改)或 2(带出可修改) + + template_version 为空时兼容旧数据:取该编号下「已确认」主表的最高 version。 """ if not template_no: return {} - # 与 pissupplier.build_cost_template_sections_for_quotation 一致:仅已确认主表,避免草稿/作废版本与供应商端展示脱节 - head = ( - CostEstimateTemplateHead.objects.filter(template_no=str(template_no).strip(), status=1) - .order_by("-version", "-id") - .first() - ) + tn = str(template_no).strip() + base_qs = CostEstimateTemplateHead.objects.filter(template_no=tn, status=1) + head = None + if template_version is not None and template_version != "": + try: + ver = int(template_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 {} @@ -468,7 +489,10 @@ class InquiryViewSet(CustomModelViewSet): QuotationMaster.objects.filter(inquiry_no=inquiry.inquiry_no).delete() quotation_numbers = self._generate_quotation_numbers(len(supplier_groups), inquiry) - prefill_fields = self._get_template_prefill_fields(getattr(inquiry, "template", None)) + prefill_fields = self._get_template_prefill_fields( + getattr(inquiry, "template", None), + getattr(inquiry, "template_version", None), + ) current_user = self._clip( self._get_request_username() or getattr(inquiry, "release_user", None), 20, @@ -476,14 +500,12 @@ class InquiryViewSet(CustomModelViewSet): current_time = timezone.now() quote_deadline = inquiry.quote_deadline.strftime("%Y-%m-%d %H:%M:%S") if inquiry.quote_deadline else None - inquiry_attachments = list(inquiry.attachments.all()) inquiry_materials = list(inquiry.material_costs.all()) inquiry_processes = list(inquiry.process_costs.all()) inquiry_others = list(inquiry.other_costs.all()) inquiry_profits = list(inquiry.profit_costs.all()) inquiry_items = list(inquiry.rfq_items.all()) - attachment_bulk = [] material_bulk = [] process_bulk = [] other_bulk = [] @@ -511,19 +533,6 @@ class InquiryViewSet(CustomModelViewSet): ) part_ids = supplier["part_ids"] - for row in inquiry_attachments: - if row.part_id in part_ids: - attachment_bulk.append( - QuotationAttachment( - quotation_no=quotation, - part_id=row.part_id, - file_name=self._clip(row.file_name, 20), - file_path=self._clip(row.file_path, 20) or None, - uploadtime=None, - uploaduser=None, - ) - ) - for row in inquiry_materials: if row.part_id in part_ids: material_spec_src = self._pick_prefill_value( @@ -543,6 +552,7 @@ class InquiryViewSet(CustomModelViewSet): prefill_fields, "1", "specific_gravity", row.specific_gravity ), material_cost=self._pick_prefill_value(prefill_fields, "1", "material_cost", row.material_cost), + weight=self._pick_prefill_value(prefill_fields, "1", "weight", row.weight), remark=self._pick_prefill_value(prefill_fields, "1", "remark", row.remark), option_json=row.option_json if prefill_fields.get("1") else None, ) @@ -619,8 +629,6 @@ class InquiryViewSet(CustomModelViewSet): ) ) - if attachment_bulk: - QuotationAttachment.objects.bulk_create(attachment_bulk) if material_bulk: QuotationMaterial.objects.bulk_create(material_bulk) if process_bulk: diff --git a/backend/apps/pissupplier/models.py b/backend/apps/pissupplier/models.py index 2bf297a..2e8bbfc 100644 --- a/backend/apps/pissupplier/models.py +++ b/backend/apps/pissupplier/models.py @@ -15,8 +15,9 @@ class QuotationMaster(models.Model): STATUS_CHOICES = ( (1, "待报价"), - (2, "已报价"), - (3, "已过期"), + (2, "报价中"), + (3, "已报价"), + (4, "已过期"), ) AWARD_STATUS_CHOICES = ( @@ -76,8 +77,8 @@ class QuotationAttachment(models.Model): verbose_name="报价单单号", ) part_id = models.CharField(max_length=50, db_column="Partid", verbose_name="产品料号") - file_name = models.CharField(max_length=20, db_column="file_name", verbose_name="文件名称") - file_path = models.CharField(max_length=20, db_column="file_path", null=True, blank=True, verbose_name="文件路径") + file_name = models.CharField(max_length=100, db_column="file_name", verbose_name="文件名称") + file_path = models.CharField(max_length=200, db_column="file_path", null=True, blank=True, verbose_name="文件路径") uploadtime = models.CharField(max_length=20, null=True, blank=True, verbose_name="上传时间") uploaduser = models.CharField(max_length=20, null=True, blank=True, verbose_name="上传人员") @@ -133,6 +134,14 @@ class QuotationMaterial(models.Model): blank=True, verbose_name="材料费用", ) + weight = models.DecimalField( + max_digits=12, + decimal_places=4, + db_column="Weight", + null=True, + blank=True, + verbose_name="重量", + ) remark = models.CharField(max_length=100, db_column="remark", null=True, blank=True, verbose_name="备注") option_json = models.TextField(db_column="OptionJson", null=True, blank=True, verbose_name="可选扩展信息") diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py index 12a0183..8fabbbe 100644 --- a/backend/apps/pissupplier/serializers.py +++ b/backend/apps/pissupplier/serializers.py @@ -1,4 +1,6 @@ import json +from decimal import Decimal +from typing import Optional from django.db import models, transaction from django.utils import timezone @@ -93,18 +95,27 @@ _COST_CATEGORY_TO_TITLE = { _COST_SECTION_ORDER = ("产品明细", "材料成本", "加工成本", "其它成本", "管销研费用", "利润", "税金") -def build_cost_template_sections_for_quotation(template_no: str): +def build_cost_template_sections_for_quotation(template_no: str, template_version: Optional[int] = None): """ 将 `CostEstimateTemplateBody` 扁平行展开为前端 `sections[]`(含 fields[].is_computed / supplier_required)。 - 使用询价单 `Inquiry.template` 作为 template_no。 + 使用询价单 `Inquiry.template` 作为 template_no,`Inquiry.template_version` 锁定主表 version。 + + template_version 为 None 时兼容旧数据:取该编号下已确认主表的最高 version。 """ if not template_no: return [] - head = ( - CostEstimateTemplateHead.objects.filter(template_no=str(template_no).strip(), status=1) - .order_by("-version", "-id") - .first() - ) + tn = str(template_no).strip() + base_qs = CostEstimateTemplateHead.objects.filter(template_no=tn, status=1) + head = None + if template_version is not None and template_version != "": + try: + ver = int(template_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 [] items = list( @@ -210,6 +221,7 @@ class QuotationMaterialSerializer(serializers.ModelSerializer): "qty", "specific_gravity", "material_cost", + "weight", "remark", "option_json", ] @@ -370,6 +382,8 @@ class QuotationMasterSerializer(BusinessAuditSerializer): required=False, allow_null=True, ) + # 列表/详情展示:上阶物料明细 total_price_incl_tax 之和(主表无报价金额列) + quote_amount = serializers.SerializerMethodField(read_only=True) template_sections = serializers.SerializerMethodField(read_only=True) inquiry_attachments = serializers.SerializerMethodField(read_only=True) attachments = QuotationAttachmentSerializer(many=True, required=False) @@ -384,6 +398,16 @@ class QuotationMasterSerializer(BusinessAuditSerializer): audit_update_user_field = "quoteuser" audit_update_time_field = "quotetime" + def get_quote_amount(self, obj): + total = Decimal("0") + has_value = False + for row in obj.rfq_items.all(): + v = getattr(row, "total_price_incl_tax", None) + if v is not None: + total += v + has_value = True + return total if has_value else None + def get_template_sections(self, obj): """列表接口不展开,避免 N+1;详情(retrieve)返回与询价成本模板一致的 sections。""" if self.context.get("quotation_skip_template_sections"): @@ -393,10 +417,13 @@ class QuotationMasterSerializer(BusinessAuditSerializer): inquiry_no = getattr(obj, "inquiry_no", None) if not inquiry_no: return [] - inq = Inquiry.objects.filter(inquiry_no=inquiry_no).only("template").first() + inq = Inquiry.objects.filter(inquiry_no=inquiry_no).only("template", "template_version").first() if not inq or not inq.template: return [] - return build_cost_template_sections_for_quotation(inq.template) + return build_cost_template_sections_for_quotation( + inq.template, + getattr(inq, "template_version", None), + ) def get_inquiry_attachments(self, obj): """详情接口返回询价单附件;列表不查,避免 N+1。""" @@ -495,18 +522,28 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer): bulk = [] username = self.get_request_username() now_text = timezone.now().strftime(self.audit_datetime_format) + fn_max = QuotationAttachment._meta.get_field("file_name").max_length + fp_max = QuotationAttachment._meta.get_field("file_path").max_length + part_max = QuotationAttachment._meta.get_field("part_id").max_length for row in attachments: + fn = (row.get("file_name") or "").strip() + fp = row.get("file_path") + fp = (fp or "").strip() if fp is not None else "" + pid = (row.get("part_id") or "").strip() + if not fn and not fp: + continue bulk.append( QuotationAttachment( quotation_no=quotation, - part_id=row.get("part_id", ""), - file_name=row.get("file_name", ""), - file_path=row.get("file_path"), + part_id=pid[:part_max], + file_name=fn[:fn_max], + file_path=(fp[:fp_max] if fp else None) or None, uploadtime=row.get("uploadtime") or now_text, uploaduser=row.get("uploaduser") or username or None, ) ) - QuotationAttachment.objects.bulk_create(bulk) + if bulk: + QuotationAttachment.objects.bulk_create(bulk) def _upsert_material_costs(self, quotation, material_costs): QuotationMaterial.objects.filter(quotation_no=quotation).delete() @@ -526,6 +563,7 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer): qty=row.get("qty"), specific_gravity=row.get("specific_gravity"), material_cost=row.get("material_cost"), + weight=row.get("weight"), remark=row.get("remark"), option_json=normalize_option_json(row.get("option_json")), ) diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index 2265cb9..4bee49c 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -28,7 +28,7 @@ from apps.pissupplier.serializers import ( class QuotationMasterViewSet(CustomModelViewSet): """杂采报价单主表管理接口""" - queryset = QuotationMaster.objects.all() + queryset = QuotationMaster.objects.prefetch_related("rfq_items") serializer_class = QuotationMasterSerializer create_serializer_class = QuotationMasterCreateUpdateSerializer update_serializer_class = QuotationMasterCreateUpdateSerializer @@ -64,23 +64,38 @@ class QuotationMasterViewSet(CustomModelViewSet): def update(self, request, *args, **kwargs): instance = self.get_object() - if instance.status != 1: - return ErrorResponse(msg="仅未报价状态可保存报价内容") + if instance.status not in (1, 2): + return ErrorResponse(msg="仅未报价/报价中状态可保存报价内容") return super().update(request, *args, **kwargs) def partial_update(self, request, *args, **kwargs): instance = self.get_object() - if instance.status != 1: - return ErrorResponse(msg="仅未报价状态可保存报价内容") + if instance.status not in (1, 2): + return ErrorResponse(msg="仅未报价/报价中状态可保存报价内容") return super().partial_update(request, *args, **kwargs) + @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: + 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"]) + serializer = self.get_serializer(instance) + return DetailResponse(data=serializer.data, msg="报价中状态更新成功") + @action(methods=["post"], detail=True, url_path="submit") def submit(self, request, pk=None): - """正式提交报价:写入当前时间为报价时间,状态为已报价(2)。仅未报价(status=1)可提交。""" + """正式提交报价:写入当前时间为报价时间,状态为已报价(3)。仅报价中(status=2)可提交。""" instance = self.get_object() - if instance.status != 1: - return ErrorResponse(msg="仅未报价状态可提交报价") - instance.status = 2 + if instance.status != 2: + return ErrorResponse(msg="仅报价中状态可提交报价") + instance.status = 3 instance.quotetime = timezone.now() username = getattr(getattr(request, "user", None), "username", None) if username: diff --git a/backend/dvadmin/utils/exception.py b/backend/dvadmin/utils/exception.py index d1dbedc..dfbf02c 100644 --- a/backend/dvadmin/utils/exception.py +++ b/backend/dvadmin/utils/exception.py @@ -20,6 +20,49 @@ from dvadmin.utils.json_response import ErrorResponse logger = logging.getLogger(__name__) +def _format_drf_detail(detail): + """ + 将 DRF ValidationError.detail 转为可读字符串。 + 避免嵌套列表(如 items 多行明细)在简单拼接时只剩「items:{}」等无效提示。 + """ + if detail is None: + return "" + if isinstance(detail, dict): + if not detail: + return "" + parts = [] + for k, v in detail.items(): + if isinstance(v, (list, tuple)): + subs = [] + for item in v: + if isinstance(item, dict): + inner = _format_drf_detail(item) + if inner: + subs.append(inner) + else: + subs.append(str(item)) + if subs: + parts.append("%s: %s" % (k, ";".join(subs))) + elif isinstance(v, dict): + inner = _format_drf_detail(v) + if inner: + parts.append("%s: %s" % (k, inner)) + else: + parts.append("%s: %s" % (k, v)) + return ";".join(parts) + if isinstance(detail, (list, tuple)): + subs = [] + for item in detail: + if isinstance(item, dict): + inner = _format_drf_detail(item) + if inner: + subs.append(inner) + else: + subs.append(str(item)) + return ";".join(subs) + return str(detail) + + class CustomAuthenticationFailed(NotAuthenticated): # 设置 status_code 属性为 400 status_code = 400 @@ -53,11 +96,13 @@ def CustomExceptionHandler(ex, context): msg = "接口地址不正确" elif isinstance(ex, DRFAPIException): set_rollback() - msg = ex.detail - if isinstance(msg,dict): - for k, v in msg.items(): - for i in v: - msg = "%s:%s" % (k, i) + detail = ex.detail + if isinstance(detail, dict): + msg = _format_drf_detail(detail) or str(detail) + elif isinstance(detail, (list, tuple)): + msg = _format_drf_detail(detail) or str(detail) + else: + msg = str(detail) elif isinstance(ex, ProtectedError): set_rollback() msg = "删除失败:该条数据与其他数据有相关绑定" diff --git a/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue b/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue index 052de2e..ba03557 100644 --- a/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue +++ b/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue @@ -39,7 +39,7 @@ diff --git a/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx index 698d7e8..64f5e3a 100644 --- a/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx @@ -409,10 +409,8 @@ const resolveSectionsForSubmit = (form: any) => { const currentSections = normalizeSections(form.sections) if (!draftSections.length) return currentSections if (!currentSections.length) return draftSections - // 优先使用 latestSectionDraft(SectionBuilder 实时回传的用户编辑),避免 valueResolve 用旧 items 覆盖后提交 - const draftCount = countSectionFields(draftSections) - const currentCount = countSectionFields(currentSections) - return draftCount >= currentCount ? draftSections : currentSections + // 与「字段数比较选 form/draft」相比:行数相同仅改 key、或同段内重复 key 时,draft 才正确;一律以 SectionBuilder 回传的 latestSectionDraft 为准。 + return draftSections } const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[], headVersion?: number | null) => { @@ -525,6 +523,80 @@ const templateStatusUnconfirmed = (row: any) => Number(row?.status) === 0 const templateStatusConfirmed = (row: any) => Number(row?.status) === 1 /** 提交用载荷(不含业务分流字段);初始添加与「版本变更」共用结构,后者走独立 API。 */ +/** + * 同一成本分段内字段 Key(item_no)不可重复;与 SectionBuilder 中「加工成本」等同一段多行同 key 的场景一致。 + * 同时扫描 latestSectionDraft 与 form.sections,避免仅一份数据源时漏检。 + */ +const validateDuplicateFieldKeys = ( + form: any +): { ok: true } | { ok: false; message: string } => { + const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') + const scan = (sectionsRaw: any): string[] => { + const msgs: string[] = [] + for (const sec of normalizeSections(sectionsRaw)) { + if (!sec || sec.enabled === false) continue + const title = sec.title || sec.name || '' + if (allowTitles.length && !allowTitles.includes(title)) continue + const keys: string[] = [] + for (const f of sec.fields || []) { + const k = String(f?.key ?? '').trim() + if (!k) continue + keys.push(k) + } + const cnt = new Map() + for (const k of keys) cnt.set(k, (cnt.get(k) || 0) + 1) + const dups = [...cnt.entries()].filter(([, c]) => c > 1).map(([k]) => k) + if (dups.length) msgs.push(`「${title}」内字段 Key 重复:${dups.join('、')}`) + } + return msgs + } + const merged = new Set([...scan(latestSectionDraft), ...scan(form?.sections)]) + const parts = [...merged].filter(Boolean) + if (parts.length) { + return { ok: false, message: `${parts.join(';')},请修改后再保存` } + } + return { ok: true } +} + +/** 与 sectionsToItems 一致:中文名取自 nameCn / label,任一为空则后端 item_name_cn 校验失败 */ +const fieldDisplayNameCn = (f: any) => String(f?.nameCn ?? f?.label ?? '').trim() + +const validateItemNamesCn = (form: any): { ok: true } | { ok: false; message: string } => { + const sections = normalizeSections(resolveSectionsForSubmit(form)) + const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') + const msgs: string[] = [] + for (const sec of sections) { + if (!sec || sec.enabled === false) continue + const title = sec.title || sec.name || '' + if (allowTitles.length && !allowTitles.includes(title)) continue + const badKeys: string[] = [] + for (const f of sec.fields || []) { + if (!fieldDisplayNameCn(f)) { + const k = String(f?.key ?? '').trim() + badKeys.push(k || '(未填 Key)') + } + } + if (badKeys.length) msgs.push(`「${title}」字段中文名未填写(字段 Key:${badKeys.join('、')})`) + } + if (msgs.length) { + return { ok: false, message: `${msgs.join(';')},请补全后再保存` } + } + return { ok: true } +} + +/** 新建 / 编辑 / 版本变更 提交前共用 */ +const validateCostTemplateBeforeSubmit = (form: any): { ok: true } | { ok: false; message: string } => { + const issues: string[] = [] + const dup = validateDuplicateFieldKeys(form) + if (!dup.ok) issues.push(dup.message.replace(/,请修改后再保存$/, '')) + const cn = validateItemNamesCn(form) + if (!cn.ok) issues.push(cn.message.replace(/,请补全后再保存$/, '')) + if (issues.length) { + return { ok: false, message: `${issues.join(';')},请修改后再保存` } + } + return { ok: true } +} + const buildCostTemplateSubmitPayload = (form: any) => { const sections = resolveSectionsForSubmit(form) const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') @@ -615,6 +687,11 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp request: { pageRequest: async (query) => api.GetList(query), addRequest: async ({ form }) => { + const pre = validateCostTemplateBeforeSubmit(form) + if (!pre.ok) { + ElMessage.error(pre.message) + return Promise.reject(new Error(pre.message)) + } const payload = buildCostTemplateSubmitPayload(form) const src = form.__newVersionSourceId if (src != null && src !== '') { @@ -638,6 +715,11 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp }) }, editRequest: async ({ form, row }) => { + const pre = validateCostTemplateBeforeSubmit(form) + if (!pre.ok) { + ElMessage.error(pre.message) + return Promise.reject(new Error(pre.message)) + } const base = buildCostTemplateSubmitPayload(form) const payload: any = { ...base, @@ -864,12 +946,13 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp dict: dict({ data: [ { value: '0', label: '未确认' }, - { value: '1', label: '已确认' } + { value: '1', label: '已确认' }, + { value: '2', label: '作废' } ] }), search: { show: true, - component: { props: { clearable: true, placeholder: '状态' } } + component: { props: { clearable: true, placeholder: '状态(默认不含作废)' } } }, form: { show: false }, column: { width: 100 } diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx index 32f1c3b..1813cd6 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx @@ -70,17 +70,45 @@ type ExtraHooks = { onAdd?: () => void onEdit?: (row: any) => void onView?: (row: any) => void + /** 列表多选变化(用于后续多询价单比价等) */ + onTableSelectionChange?: (rows: any[]) => void } const STATUS_OPEN = 1 const STATUS_CONFIRMED = 2 const STATUS_PUBLISHED = 3 +/** 询价单接口错误文案:将数据库唯一约束等转为可读提示 */ +export const formatRfqApiErrorMessage = (err: any, fallback: string) => { + const d = err?.response?.data + const pick = () => { + if (typeof d?.msg === 'string' && d.msg.trim()) return d.msg + if (typeof d?.message === 'string' && d.message.trim()) return d.message + if (typeof d?.detail === 'string' && d.detail.trim()) return d.detail + if (Array.isArray(d?.non_field_errors) && d.non_field_errors.length) return String(d.non_field_errors[0]) + return '' + } + const raw = pick() || (typeof err?.msg === 'string' ? err.msg : '') || (typeof err?.message === 'string' ? err.message : '') + const s = typeof raw === 'string' ? raw : String(raw) + if ( + /重复键违反唯一约束|unique constraint|UniqueViolation|duplicate key|already exists|pis_proc_inquiry_supplie/i.test( + s + ) + ) { + if (/supplier|suppli|PartId|part_id|inquiry_no/i.test(s)) { + return '供应商名单重复:同一询价单、同一料号下不能添加相同供应商,请删除重复行或更换供应商后再保存' + } + return '保存失败:存在与数据库冲突的重复数据,请检查供应商名单或其它唯一项' + } + if (s) return s.length > 280 ? `${s.slice(0, 280)}…` : s + return fallback +} + const getRowStatus = (row: any) => Number(row?.status) const isOpenStatus = (row: any) => getRowStatus(row) === STATUS_OPEN const isConfirmedStatus = (row: any) => getRowStatus(row) === STATUS_CONFIRMED const canPublishStatus = (row: any) => isConfirmedStatus(row) -const getErrorMessage = (err: any, fallback: string) => err?.msg || err?.message || err?.response?.data?.msg || fallback +const getErrorMessage = (err: any, fallback: string) => formatRfqApiErrorMessage(err, fallback) /** 列表行含 `suppliers` 时可先做提示;未返回嵌套时交由接口校验 */ const rowHasSuppliersList = (row: any): boolean | null => { @@ -97,7 +125,14 @@ export const formatCostTemplateVersionTwoDigits = (v: unknown) => { return String(Math.trunc(n)).padStart(2, '0') } -export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, onView }: Partial & ExtraHooks): CreateCrudOptionsRet { +export const createCrudOptions = function ({ + context, + crudExpose, + onAdd, + onEdit, + onView, + onTableSelectionChange +}: Partial & ExtraHooks): CreateCrudOptionsRet { void context return { crudOptions: { @@ -128,7 +163,10 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, } }, table: { - rowKey: 'id' + rowKey: 'id', + onSelectionChange: (changed: any[]) => { + onTableSelectionChange?.(changed || []) + } }, actionbar: { buttons: { @@ -143,19 +181,21 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, }, rowHandle: { fixed: 'right', - width: 320, + width: 420, buttons: { view: { show: false }, edit: { show: false }, remove: { text: '删除', - type: 'danger', + // 禁用时不沿用 danger 的淡红底,改为 info 灰底 + disabled + type: compute(({ row }) => (isOpenStatus(row) ? 'danger' : 'info')), order: 1, - show: compute(({ row }) => isOpenStatus(row)) + show: true, + disabled: compute(({ row }) => !isOpenStatus(row)) }, customView: { text: '查看', - type: 'info', + type: 'default', order: 0, show: true, click({ row }) { @@ -164,19 +204,23 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, }, customEdit: { text: '编辑', - type: 'primary', + type: compute(({ row }) => (isOpenStatus(row) ? 'primary' : 'info')), order: 1.5, - show: compute(({ row }) => isOpenStatus(row)), + show: true, + disabled: compute(({ row }) => !isOpenStatus(row)), click({ row }) { + if (!isOpenStatus(row)) return onEdit && onEdit(row) } }, confirm: { text: '确认', - type: 'success', + type: compute(({ row }) => (isOpenStatus(row) ? 'success' : 'info')), order: 2, - show: compute(({ row }) => isOpenStatus(row)), + show: true, + disabled: compute(({ row }) => !isOpenStatus(row)), async click({ row }) { + if (!isOpenStatus(row)) return try { const supplierOk = rowHasSuppliersList(row) if (supplierOk === false) { @@ -200,10 +244,12 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, }, restore: { text: '还原', - type: 'primary', + type: compute(({ row }) => (isConfirmedStatus(row) ? 'primary' : 'info')), order: 2.5, - show: compute(({ row }) => isConfirmedStatus(row)), + show: true, + disabled: compute(({ row }) => !isConfirmedStatus(row)), async click({ row }) { + if (!isConfirmedStatus(row)) return try { await ElMessageBox.confirm('确认将状态还原为【开立】?', '提示', { type: 'warning', @@ -222,10 +268,12 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, }, publish: { text: '发布', - type: 'warning', + type: compute(({ row }) => (canPublishStatus(row) ? 'warning' : 'info')), order: 3, - show: compute(({ row }) => canPublishStatus(row)), + show: true, + disabled: compute(({ row }) => !canPublishStatus(row)), async click({ row }) { + if (!canPublishStatus(row)) return try { await ElMessageBox.confirm('确认将状态改为【发布】?', '提示', { type: 'warning', @@ -245,6 +293,18 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, } }, columns: { + $checked: { + title: '', + form: { show: false }, + search: { show: false }, + column: { + type: 'selection', + align: 'center', + width: 52, + fixed: 'left', + columnSetDisabled: true + } + }, inquiry_no: { title: '询价单号', type: 'input', diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue index c0fd79b..426c452 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue @@ -307,7 +307,7 @@ @change="(val: string) => handleVendorSelect(row, val)" > ([]) + const categoryDict = [ { value: 'tooling', label: '模治具' }, { value: 'stamping', label: '冲压' }, @@ -939,7 +947,7 @@ const currentUserName = computed( const getStatusCode = (value: unknown) => Number(value) const isOpenStatus = (value: unknown) => getStatusCode(value) === STATUS_OPEN const isReadonlyStatus = (value: unknown) => [STATUS_CONFIRMED, STATUS_PUBLISHED].includes(getStatusCode(value)) -const getErrorMessage = (err: any, fallback: string) => err?.msg || err?.message || err?.response?.data?.msg || fallback +const getErrorMessage = formatRfqApiErrorMessage const emptyForm = () => ({ id: null, @@ -1288,6 +1296,27 @@ const updateProcessCalc = (row: CostRow) => { v[feeKey] = Number((rate * qty).toFixed(4)) } +/** 与下拉 option.value、v-model(row.name) 一致,用于去重 */ +const vendorRowSelectKey = (row: any) => + String(row?.name ?? row?.supplier_id ?? row?.supplier_code ?? '').trim() + +/** 其它行已选中的供应商不再出现在本行下拉中(避免重复违反唯一约束) */ +const supplierOptionsForVendorRow = (row: any) => { + const taken = new Set() + for (const v of form.vendors || []) { + if (v === row) continue + const k = vendorRowSelectKey(v) + if (k) taken.add(k) + } + const self = vendorRowSelectKey(row) + return supplierOptions.value.filter((s) => { + const k = String(s.value ?? '').trim() + if (!k) return false + if (self && k === self) return true + return !taken.has(k) + }) +} + const handleVendorSelect = (row: any, value?: string) => { row.name = value || '' const supplier = supplierOptions.value.find((s) => s.value === value) @@ -1432,6 +1461,7 @@ const openDetail = async (row: any, mode: 'edit' | 'view') => { values.specificgravity = m.specific_gravity ?? '' values.qty = m.qty ?? '' values.unitPrice = m.unit_price ?? '' + values.weight = m.weight ?? '' values.material_cost = m.material_cost ?? '' const row = { id: `mat-${idx}-${Date.now()}`, section: '材料成本', field: `mat-${idx}`, values: { ...values, partNo: partId } } updateMaterialCalc(row) @@ -1768,6 +1798,16 @@ const saveForm = async () => { if (dialog.mode === 'create') { form.purchase_dept = loginPurchaseDept() } + const seenVendorKeys = new Set() + for (const v of form.vendors || []) { + const k = vendorRowSelectKey(v) + if (!k) continue + if (seenVendorKeys.has(k)) { + ElMessage.warning('供应商名单中存在重复供应商,请删除重复行或更换供应商后再保存') + return + } + seenVendorKeys.add(k) + } try { const uploadedAttachments = await prepareAttachmentsForSubmit() form.attachments = uploadedAttachments @@ -1788,6 +1828,12 @@ const saveForm = async () => { ), specific_gravity: r.values?.specificgravity ?? r.values?.specific_gravity ?? '', material_cost: r.values?.material_cost ?? r.values?.materialCost ?? null, + weight: (() => { + const w = r.values?.weight ?? r.values?.Weight + if (w === '' || w === null || w === undefined) return null + const n = Number(w) + return Number.isFinite(n) ? n : null + })(), remark: r.values?.remark ?? '', option_json: JSON.stringify(r.values || {}) })) @@ -1911,7 +1957,15 @@ const saveForm = async () => { } } -const { crudOptions } = createCrudOptions({ crudExpose, onAdd: openCreate, onEdit: openEdit, onView: openView }) +const { crudOptions } = createCrudOptions({ + crudExpose, + onAdd: openCreate, + onEdit: openEdit, + onView: openView, + onTableSelectionChange: (rows) => { + selectedInquiryRows.value = rows || [] + } +}) useCrud({ crudExpose, crudOptions }) diff --git a/web/src/views/pissupplier/quotation/api.ts b/web/src/views/pissupplier/quotation/api.ts index 779b850..2ba5504 100644 --- a/web/src/views/pissupplier/quotation/api.ts +++ b/web/src/views/pissupplier/quotation/api.ts @@ -41,8 +41,13 @@ export const getDetail = (id: string | number) => request({ url: `${baseUrl}${id export const create = (data: any) => request({ url: baseUrl, method: 'post', data }) export const update = (id: string | number, data: any) => request({ url: `${baseUrl}${id}/`, method: 'put', data }) -export const submit = (id: string | number, data: any) => request({ url: `${baseUrl}${id}/`, method: 'put', data }) +/** @deprecated 与 `update` 相同;保存报价请使用 `update` */ +export const submit = (id: string | number, data: any) => update(id, data) -/** 正式提交报价:写入 quotetime、status=2 */ +/** 进入报价中:写入 quotetime、status=2 */ +export const quoteOfficial = (id: string | number) => + request({ url: `${baseUrl}${id}/quote/`, method: 'post', data: {} }) + +/** 正式提交报价:写入 quotetime、status=3 */ export const submitOfficial = (id: string | number) => request({ url: `${baseUrl}${id}/submit/`, method: 'post', data: {} }) diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index adbc6f9..b37d40e 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -14,7 +14,7 @@ const extractPagedList = (res: any): any[] => { return Array.isArray(raw) ? raw : [] } -export type QuoteStatus = 'pending' | 'quoted' | 'expired' +export type QuoteStatus = 'pending' | 'quoted' | 'completed' | 'expired' type CostAttr = { key: string; label: string; value: string | number; type?: string } type CostTemplateItem = { section: string; attrs: CostAttr[]; span?: 'wide'; allowAdd?: boolean } type CostItem = { id: string; section: string; span?: 'wide'; attrs: CostAttr[]; field?: string } @@ -82,31 +82,126 @@ export type InquiryAttachmentRow = { upload_user?: string } +/** 与 `pis_proc_inquiry_attachment` / 询价单 `attachments` 嵌套结构一致(勿用报价单 `attachments`) */ +const INQUIRY_FILE_TYPE_LABELS: Record = { + 1: '产品图纸', + 2: '招标文件', + 3: '其它文件' +} + +export function mapInquiryAttachmentsFromInquiryApi(rows: any[] | null | undefined): InquiryAttachmentRow[] { + if (!Array.isArray(rows) || !rows.length) return [] + return rows.map((r) => { + const ft = Number(r.file_type) + const t = ft === 1 || ft === 2 || ft === 3 ? ft : 3 + return { + id: r.id, + part_id: r.part_id, + file_type: t, + file_type_label: r.file_type_label || INQUIRY_FILE_TYPE_LABELS[t], + file_name: r.file_name, + file_path: r.file_path, + upload_time: r.upload_time, + upload_user: r.upload_user + } + }) +} + +/** el-upload 的 file-list → 后端 `QuotationAttachment` 行 */ +function buildQuotationAttachmentsForSave(files: any[] | null | undefined, defaultPartId: string): any[] { + if (!Array.isArray(files) || !files.length) return [] + const pid = String(defaultPartId || '').trim() + const out: any[] = [] + for (const f of files) { + const name = String(f?.name ?? f?.file_name ?? '').trim() + const path = String( + f?.url ?? f?.file_path ?? f?.response?.url ?? f?.response?.data?.url ?? f?.response?.data?.file ?? '' + ).trim() + if (!name && !path) continue + out.push({ + part_id: String(f?.part_id ?? f?.partId ?? pid).trim() || pid, + file_name: name, + file_path: path || null + }) + } + return out +} + +const unwrapUploadResponse = (res: any) => res?.data?.data ?? res?.data ?? res + +/** + * 新选文件仅有 `raw`,须先走 `/api/system/file/` 上传拿到路径(与询价单附件保存一致)。 + */ +async function uploadQuotationAttachmentFile(fileItem: any) { + const existing = String(fileItem?.url ?? fileItem?.file_path ?? '').trim() + if (existing) { + return { + ...fileItem, + url: existing, + file_path: existing, + status: 'success' + } + } + const rawFile = fileItem?.raw + if (!rawFile) { + return { ...fileItem, url: '', file_path: '', status: fileItem?.status || 'ready' } + } + const formData = new FormData() + formData.append('file', rawFile) + formData.append('upload_method', '1') + const res = await inquiryApi.UploadFile(formData) + const uploaded = unwrapUploadResponse(res) || {} + const filePath = String(uploaded.url || uploaded.file_url || '').trim() + return { + ...fileItem, + name: fileItem?.name || uploaded.name || rawFile.name || '', + url: filePath, + file_path: filePath, + status: 'success' + } +} + +/** 详情嵌套 `attachments`(file_name/file_path)→ el-upload `file-list` */ +function normalizeQuotationAttachmentsForUpload(rows: any[] | null | undefined): any[] { + if (!Array.isArray(rows) || !rows.length) return [] + return rows.map((r, i) => ({ + uid: r.uid ?? (r.autoid != null ? `a-${r.autoid}` : `ex-${i}`), + name: r.name ?? r.file_name ?? '', + url: r.url ?? r.file_path ?? '', + part_id: r.part_id, + status: r.status ?? 'success' + })) +} + const statusOptions = [ - { label: '未报价', value: 'pending' }, - { label: '已报价', value: 'quoted' }, + { label: '待报价', value: 'pending' }, + { label: '报价中', value: 'quoted' }, + { label: '已报价', value: 'completed' }, { label: '已过期', value: 'expired' } ] const statusMapBackendToFront: Record = { 1: 'pending', 2: 'quoted', - 3: 'expired' + 3: 'completed', + 4: 'expired' } const statusMapFrontToBackend: Record = { pending: 1, quoted: 2, - expired: 3 + completed: 3, + expired: 4 } /** * 列表「中标状态」列:is_awarded + 报价 status + 询价 status * - is_awarded==1 → 文案「中标」+ 旗帜图标(由模板渲染) - * - is_awarded==0 且 status==1 → 未报价 - * - is_awarded==0 且 status==2 且询价 status!=9 → 评标中 - * - is_awarded==0 且 status==2 且询价 status==9 → 未中标 - * - status==3 → 空 + * - is_awarded==0 且 status==1 → 待报价 + * - is_awarded==0 且 status==2 → 报价中 + * - is_awarded==0 且 status==3 且询价 status!=9 → 评标中 + * - is_awarded==0 且 status==3 且询价 status==9 → 未中标 + * - status==4 → 空 */ export function formatAwardBidStatus(row: { isAwarded?: number | string @@ -125,10 +220,11 @@ export function formatAwardBidStatus(row: { const inqRaw = row.inquiryStatusCode const inqNum = inqRaw === undefined || inqRaw === null || inqRaw === '' ? NaN : Number(inqRaw) - if (sc === 3) return { mode: 'text', text: '' } + if (sc === 4) return { mode: 'text', text: '' } if (ia === 1) return { mode: 'flag', text: '中标' } - if (ia === 0 && sc === 1) return { mode: 'text', text: '未报价' } - if (ia === 0 && sc === 2) { + if (ia === 0 && sc === 1) return { mode: 'text', text: '待报价' } + if (ia === 0 && sc === 2) return { mode: 'text', text: '报价中' } + if (ia === 0 && sc === 3) { if (!Number.isFinite(inqNum)) return { mode: 'text', text: '评标中' } if (inqNum !== 9) return { mode: 'text', text: '评标中' } return { mode: 'text', text: '未中标' } @@ -224,6 +320,7 @@ export const FIXED_QUOTATION_SECTION_COLUMNS: Record { { key: 'height', label: '高', value: m.height ?? '' }, { key: 'specificgravity', label: '比重', value: m.specific_gravity ?? '' }, { key: 'qty', label: '数量', value: m.qty ?? '' }, + { key: 'weight', label: '重量', value: m.weight ?? '' }, { key: 'unitPrice', label: '单价', value: m.unit_price ?? '' }, { key: 'material_fee', label: '材料费用', value: m.material_cost ?? '' }, { key: 'remark', label: '备注', value: m.remark ?? '' } @@ -837,6 +935,7 @@ const costRowsToNestedPayload = (rows: CostRow[]) => { ? String(v.specific_gravity).slice(0, 10) : null, material_cost: numOrUndef(v.material_fee ?? v.material_cost), + weight: numOrUndef(v.weight), remark: v.remark ? String(v.remark).slice(0, 100) : null, option_json: Object.keys(extra).length ? JSON.stringify(extra) : null }) @@ -910,11 +1009,29 @@ const otherCostPackagingKeys = ['packageFee', 'packaging_cost', 'packagingCost', const otherCostTransportKeys = ['transportFee', 'transportation_cost', 'transportationCost', 'transport_fee', '运输费'] const formatMoney = (v: number | string) => { + if (v === '' || v === null || v === undefined) return '-' const n = typeof v === 'string' ? Number(v) : v if (!Number.isFinite(n)) return '-' return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +/** 主表无报价金额字段时,由上阶物料明细含税总价汇总 */ +const totalInclTaxFromRfqItems = (rfqItems: unknown): number | '' => { + if (!Array.isArray(rfqItems) || !rfqItems.length) return '' + let sum = 0 + let any = false + for (const r of rfqItems) { + const raw = (r as any)?.total_price_incl_tax ?? (r as any)?.totalPriceInclTax + if (raw === null || raw === undefined || raw === '') continue + const n = Number(raw) + if (Number.isFinite(n)) { + sum += n + any = true + } + } + return any ? sum : '' +} + function blankQuote(): Quote { return { id: '', @@ -1073,8 +1190,24 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { }) const normalizeStatus = (s: any): QuoteStatus => { + if (s === null || s === undefined || s === '') return 'pending' if (typeof s === 'number') return statusMapBackendToFront[s] || 'pending' - return (s as QuoteStatus) || 'pending' + + // 兼容后端返回 "1" / "2" 这种字符串数字 + const str = String(s).trim() + if (/^\d+$/.test(str)) return statusMapBackendToFront[Number(str)] || 'pending' + + // 已是前端枚举值 + if (str === 'pending' || str === 'quoted' || str === 'completed' || str === 'expired') return str as QuoteStatus + + // 兼容后端/历史数据直接返回中文文案的情况 + const labelToStatus: Record = { + 待报价: 'pending', + 报价中: 'quoted', + 已报价: 'completed', + 已过期: 'expired' + } + return labelToStatus[str] || 'pending' } const normalizePayment = (p: any) => { @@ -1190,7 +1323,11 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { template: item.template || item.template_code || '', currency: item.currency || 'CNY', quoteDeadline: item.quote_deadline || item.quoteDeadline || '', - quoteAmount: item.quote_amount || item.quoteAmount || '', + quoteAmount: + item.quote_amount ?? + item.quoteAmount ?? + totalInclTaxFromRfqItems(item.rfq_items) ?? + '', quoteTime: item.quotetime || item.quoteTime || '', status: normalizeStatus(item.status), statusCode: (() => { @@ -1221,7 +1358,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { (item.enable_cost_structure ?? item.is_bom ?? item.isBom ?? item.template?.enable_cost_structure) !== false ), rfqItems: Array.isArray(item.rfq_items) ? item.rfq_items : [], - attachments: Array.isArray(item.attachments) ? item.attachments : [], + attachments: normalizeQuotationAttachmentsForUpload(item.attachments), inquiryAttachments: Array.isArray(item.inquiry_attachments) ? item.inquiry_attachments : Array.isArray(item.inquiryAttachments) @@ -1492,7 +1629,10 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { return rows }) - /** 各成本段金额之和(不含小计行,避免重复累计) */ + /** 与表格「税前合计」一致:成本合计 + 利润(未税) */ + const quoteAmountPreTax = computed(() => quoteRollupForRfq.value.preTax) + + /** 各启用成本段 map 金额之和(一般等于 postTax;保留供兼容) */ const quoteTotal = computed(() => enabledSections.value.reduce((sum, section) => sum + (sectionAmountMap.value[section] ?? 0), 0) ) @@ -1547,6 +1687,10 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { let quoteData: Quote = { ...row } let rawDetail: any = null if (row.id) { + // 点击「报价」后先进入报价中(2),以便后续保存/编辑仍可通过后端校验 + if (isPendingQuotation(row)) { + await api.quoteOfficial(row.id) + } try { const detailRes = await api.getDetail(row.id) rawDetail = unwrapQuotationDetail(detailRes) @@ -1584,10 +1728,11 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { quoteData.currency = inquiry.currency || quoteData.currency quoteData.quoteDeadline = inquiry.quote_deadline || quoteData.quoteDeadline quoteData.inquiryStatus = inquiry.status || inquiry.inquiry_status || quoteData.inquiryStatus - const inqSt = inquiry.status ?? inquiry.inquiry_status - if (inqSt !== undefined && inqSt !== null && inqSt !== '') { - quoteData.inquiryStatusCode = Number(inqSt) - } + // const inqSt = inquiry.status ?? inquiry.inquiry_status + // if (inqSt !== undefined && inqSt !== null && inqSt !== '') { + // quoteData.inquiryStatusCode = Number(inqSt) + // } + quoteData.inquiryStatusCode = 2 inquirySections = inquiry.sections || inquiry.template_sections || inquiry.template?.sections || quoteData.templateSections if (Array.isArray(inquirySections) && inquirySections.length) { quoteData.templateSections = inquirySections @@ -1640,8 +1785,21 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { quoteData.templateSections = rawDetail.template_sections } + // 询价附件必须以询价单子表为准(`Inquiry.attachments`),勿用报价单 `attachments` 回显 + if (inquiry?.attachments?.length) { + quoteData.inquiryAttachments = mapInquiryAttachmentsFromInquiryApi(inquiry.attachments) + } else if (rawDetail?.inquiry_attachments?.length) { + quoteData.inquiryAttachments = mapInquiryAttachmentsFromInquiryApi(rawDetail.inquiry_attachments) + } + quoteData.inquiryStatus = formatMiscInquiryStatus(quoteData) fillCurrent(quoteData, effectiveRows) + + // 让列表行立即反映「报价(2)」状态,保证表格里的「提交」按钮可用 + if (quoteData?.id) { + const idx = quotes.value.findIndex((q) => q.id === quoteData.id) + if (idx >= 0) quotes.value.splice(idx, 1, quoteData) + } dialog.visible = true } finally { loading.value = false @@ -1652,22 +1810,43 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { dialog.mode = 'view' dialog.quoteId = row.id dialog.visible = true - fillCurrent(row) + loading.value = true ;(async () => { try { - if (!row.id) return await ensureTemplateNameLookup() - const detailRes = await api.getDetail(row.id) - const data = unwrapQuotationDetail(detailRes) - if (data) { - const mapped = mapBackendQuote(data) - await enrichQuotesWithInquiryData([mapped]) - if (!mapped.supplierCode && row.supplierCode) mapped.supplierCode = row.supplierCode - if (!mapped.supplierName && row.supplierName) mapped.supplierName = row.supplierName - fillCurrent(mapped) + let merged: Quote = { ...row } + if (row.id) { + const detailRes = await api.getDetail(row.id) + const data = unwrapQuotationDetail(detailRes) + if (data) merged = mapBackendQuote(data) } + await enrichQuotesWithInquiryData([merged]) + if (!merged.supplierCode && row.supplierCode) merged.supplierCode = row.supplierCode + if (!merged.supplierName && row.supplierName) merged.supplierName = row.supplierName + try { + const code = (merged.inquiryCode || '').trim() + if (code) { + const res = await inquiryApi.GetList({ + inquiry_no: code, + page: 1, + page_size: 1, + pageSize: 1 + }) + const list = extractPagedList(res) + const inv = list[0] + if (inv?.attachments?.length) { + merged.inquiryAttachments = mapInquiryAttachmentsFromInquiryApi(inv.attachments) + } + } + } catch (e) { + console.warn('加载询价附件失败', e) + } + fillCurrent(merged) } catch (e) { console.warn('加载报价详情失败', e) + fillCurrent(row) + } finally { + loading.value = false } })() } @@ -1788,8 +1967,8 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { } function saveQuote() { - if (dialog.quoteId && !isPendingQuotation(current)) { - ElMessage.warning('仅未报价状态可保存') + if (dialog.quoteId && !isQuotationEditable(current)) { + ElMessage.warning('仅未报价/报价中状态可保存') return } const c = (current.base.contact || '').trim() @@ -1802,12 +1981,27 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { ;(async () => { current.costItems = costItemsForCurrent.value current.quoteAmount = formatMoney(quoteTotal.value) - const payload = buildSavePayload({ ...current, costItems: costItemsForCurrent.value }) loading.value = true try { + const uploadedList = await Promise.all((current.attachments || []).map(uploadQuotationAttachmentFile)) + for (const f of uploadedList) { + const path = String(f?.url ?? f?.file_path ?? '').trim() + const name = String(f?.name ?? f?.file_name ?? '').trim() + if (!name && !path) continue + if (!path) { + if (f?.raw) { + ElMessage.error(`附件上传失败:${name || '未命名文件'},请检查网络后重试`) + return + } + ElMessage.error(`附件「${name || '未命名'}」缺少存储路径,请删除后重新上传`) + return + } + } + current.attachments = uploadedList + const payload = buildSavePayload({ ...current, costItems: costItemsForCurrent.value }) let res: any if (dialog.quoteId) { - res = await api.submit(dialog.quoteId, payload) + res = await api.update(dialog.quoteId, payload) } else { res = await api.create(payload) } @@ -1830,8 +2024,8 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { } function submitQuotationFromRow(row: Quote) { - if (!isPendingQuotation(row)) { - ElMessage.warning('仅未报价状态可提交报价') + if (!isQuotedQuotation(row)) { + ElMessage.warning('仅报价中状态可提交报价') return } const c = (row.base?.contact || '').trim() @@ -1946,6 +2140,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { nested.other_costs = fillPart(nested.other_costs) nested.profit_costs = fillPart(nested.profit_costs) } + const attachmentRows = buildQuotationAttachmentsForSave(q.attachments, defaultPartId) // 仅提交 QuotationMaster / 嵌套子表存在的字段(询价标题、模板、币别等由询价主表维护,不在报价主表模型上) const payload: any = { quotation_no: q.quoteNo || undefined, @@ -1964,7 +2159,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { process_costs: nested.process_costs, other_costs: nested.other_costs, profit_costs: nested.profit_costs, - attachments: q.attachments || [], + attachments: attachmentRows, remark: q.remark || '' } if (Array.isArray(q.rfqItems) && q.rfqItems.length) { @@ -1977,12 +2172,25 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { function isPendingQuotation(q: Quote | Record) { const row = q as any const fromCode = Number(row.statusCode) - if (Number.isFinite(fromCode)) return fromCode === 1 + if (Number.isFinite(fromCode)) return fromCode === 1 || fromCode === 2 const fromRaw = Number(row.status) - if (Number.isFinite(fromRaw)) return fromRaw === 1 + if (Number.isFinite(fromRaw)) return fromRaw === 1 || fromRaw === 2 return row.status === 'pending' } + function isQuotedQuotation(q: Quote | Record) { + const row = q as any + const fromCode = Number(row.statusCode) + if (Number.isFinite(fromCode)) return fromCode === 2 + const fromRaw = Number(row.status) + if (Number.isFinite(fromRaw)) return fromRaw === 2 + return row.status === 'quoted' + } + + function isQuotationEditable(q: Quote | Record) { + return isPendingQuotation(q) || isQuotedQuotation(q) + } + const templateLabel = (t: string) => { if (t == null || t === '') return '' const key = String(t).trim() @@ -1991,8 +2199,19 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { const legacy: Record = { tooling: '模治具', equipment: '设备', plastic: '塑胶件' } return legacy[key] || key } - const statusLabel = (s: QuoteStatus) => ({ pending: '未报价', quoted: '已报价', expired: '已过期' }[s] || s) - const statusTagType = (s: QuoteStatus) => ({ pending: 'warning', quoted: 'success', expired: 'info' }[s] || 'info') + const statusLabel = (s: QuoteStatus) => ({ + pending: '待报价', + quoted: '报价中', + completed: '已报价', + expired: '已过期' + }[s] || s) + + const statusTagType = (s: QuoteStatus) => ({ + pending: 'warning', + quoted: 'primary', + completed: 'success', + expired: 'info' + }[s] || 'info') return { filters, @@ -2001,6 +2220,8 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { filteredQuotes, loadQuotes, isPendingQuotation, + isQuotedQuotation, + isQuotationEditable, viewQuote, openQuote, dialog, @@ -2018,6 +2239,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { profitTaxSections, loadCostRowsFromTemplate, quoteSummaryRows, + quoteAmountPreTax, quoteTotal, addCostRow, removeCostRow, diff --git a/web/src/views/pissupplier/quotation/index.vue b/web/src/views/pissupplier/quotation/index.vue index 6e35e68..9325017 100644 --- a/web/src/views/pissupplier/quotation/index.vue +++ b/web/src/views/pissupplier/quotation/index.vue @@ -18,11 +18,14 @@ - -
-
- 报价状态: - {{ statusLabel(current.status) }} + +
+ +
+
+ 报价状态: + {{ statusLabel(current.status) }} +
@@ -40,16 +43,21 @@
{{ group.label }}
暂无询价附件
@@ -100,6 +108,8 @@
+
未税价
+
{{ formatMoney(quoteAmountPreTax) }}
最终报价
{{ formatMoney(quoteTotal) }}
@@ -110,11 +120,39 @@ +
拖拽或点击上传 (PDF/DOC/JPG/PNG)
@@ -248,7 +286,7 @@