diff --git a/backend/apps/pisadmin/basicinfo/views/email_template.py b/backend/apps/pisadmin/basicinfo/views/email_template.py index e744258..ef629f5 100644 --- a/backend/apps/pisadmin/basicinfo/views/email_template.py +++ b/backend/apps/pisadmin/basicinfo/views/email_template.py @@ -71,6 +71,9 @@ def build_context_rfs_publish( material_info = ";".join(lines) if lines else (title or "—") qd = getattr(inquiry, "quote_deadline", None) + # 招标:主表不存报价截止时,发布邮件展示投标截止时间 + if qd is None and int(getattr(inquiry, "buying_method", 1) or 1) == 2: + qd = getattr(inquiry, "bid_end_time", None) if qd: local_qd = _for_local_display(qd) deadline_time = local_qd.strftime("%Y-%m-%d %H:%M") diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 691998a..796fbc2 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -672,9 +672,9 @@ class InquiryRfqItem(models.Model): class MiscLowPriceHeader(CoreModel): - """比价-制程最低价记录主表。""" + """比价-制程最低价记录主表。按询价单上阶物料料号分套;材料按规格、加工按工站各占一行(无整单合并行)。""" - # 成本类别: 1-材料; 2-加工; 3-包装费; 4-运输费; 5-管销研费用; 6-利润率(与库表 CostType varchar 一致) + # 成本类别: 1-材料; 2-加工; 3-包装费; 4-运输费; 5-管销研费用(杂采制程最低价不落库); 6-利润率 COST_TYPE_CHOICES = ( ("1", "材料"), ("2", "加工"), diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index be4ce81..5128677 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 写入)。""" + """比价-制程最低价主表:开启/确认比价时写入;含材料规格、工站、包装费(3)、运输费(4)、利润率(6);杂采不写管销研(5)。""" class Meta: model = MiscLowPriceHeader @@ -395,9 +395,9 @@ class MiscNegotiationRecordBatchItemSerializer(serializers.Serializer): class MiscNegotiationSaveSerializer(serializers.Serializer): - """比价/议价结果批量写入杂采议价记录表""" + """比价/议价结果批量写入杂采议价记录表。part_id 可选,缺省时由服务端按 InquiryRfqItem 首行解析。""" - part_id = serializers.CharField(max_length=50) + part_id = serializers.CharField(max_length=50, required=False, allow_blank=True, allow_null=True) records = MiscNegotiationRecordBatchItemSerializer(many=True) @@ -1034,6 +1034,11 @@ class InquirySerializer(CustomModelSerializer): part_name = serializers.SerializerMethodField(read_only=True) # 税率:优先上阶物料行 tax_rate,否则同料号税费利润行(proc_inquiry_profit_cost.tax_rate) tax_rate = serializers.SerializerMethodField(read_only=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, @@ -1143,20 +1148,57 @@ class InquirySerializer(CustomModelSerializer): raise serializers.ValidationError("采购方式(寻源方式)取值不合法") return value - def _apply_inquiry_bid_times_for_buying_method(self, attrs, instance): - """采购方式为询价(1)时,投标开始/截止时间不入库。""" + @staticmethod + def _effective_inquiry_field(attrs, instance, field_name): + """合并局部更新:未出现在 attrs 中的字段沿用 instance。""" + if field_name in attrs: + return attrs[field_name] + if instance is not None: + return getattr(instance, field_name, None) + return None + + def _resolve_buying_method(self, attrs, instance): bm = attrs.get("buying_method") if bm is None and instance is not None: bm = getattr(instance, "buying_method", None) if bm is None: - bm = 1 + return 1 try: - bm = int(bm) + return int(bm) except (TypeError, ValueError): - return + return 1 + + def _validate_inquiry_deadline_fields(self, attrs, instance): + """与前端一致:询价(1)必填报价截止时;招标(2)必填投标起止时间且结束晚于开始。""" + bm = self._resolve_buying_method(attrs, instance) + errors = {} + if bm == 1: + qd = self._effective_inquiry_field(attrs, instance, "quote_deadline") + if qd is None: + errors["quote_deadline"] = "询价方式下须填写报价截止时间" + elif bm == 2: + bs = self._effective_inquiry_field(attrs, instance, "bid_start_time") + be = self._effective_inquiry_field(attrs, instance, "bid_end_time") + if bs is None: + errors["bid_start_time"] = "招标方式下须填写投标开始时间" + if be is None: + errors["bid_end_time"] = "招标方式下须填写投标截止时间" + if bs is not None and be is not None and bs >= be: + errors["bid_end_time"] = "投标截止时间须晚于投标开始时间" + if errors: + raise serializers.ValidationError(errors) + + def _apply_inquiry_deadline_fields_for_buying_method(self, attrs, instance): + """ + 询价(1):投标时间不入库。 + 招标(2):报价截止时不入库(与前端「投标开始/截止」互斥)。 + """ + bm = self._resolve_buying_method(attrs, instance) if bm == 1: attrs["bid_start_time"] = None attrs["bid_end_time"] = None + elif bm == 2: + attrs["quote_deadline"] = None def validate(self, attrs): instance = getattr(self, "instance", None) @@ -1164,9 +1206,10 @@ class InquirySerializer(CustomModelSerializer): 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: - self._apply_inquiry_bid_times_for_buying_method(attrs, instance) + self._validate_inquiry_deadline_fields(attrs, instance) + self._apply_inquiry_deadline_fields_for_buying_method(attrs, instance) return attrs template_no = attrs.get("template") @@ -1178,6 +1221,8 @@ class InquirySerializer(CustomModelSerializer): template_no = "" if not template_no: + self._validate_inquiry_deadline_fields(attrs, instance) + self._apply_inquiry_deadline_fields_for_buying_method(attrs, instance) return attrs preferred = attrs["template_version"] if tv_in_attrs else None @@ -1189,7 +1234,8 @@ class InquirySerializer(CustomModelSerializer): } ) attrs["template_version"] = resolved - self._apply_inquiry_bid_times_for_buying_method(attrs, instance) + self._validate_inquiry_deadline_fields(attrs, instance) + self._apply_inquiry_deadline_fields_for_buying_method(attrs, instance) 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 f308e44..4d96f16 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -89,6 +89,23 @@ def _negotiation_totals_from_quotation_item(quotation_no: str, part_id: str): return item.total_price_excl_tax, item.total_price_incl_tax +def _safe_decimal(v): + if v is None: + return None + try: + return Decimal(str(v)) + except Exception: + return None + + +def _resolve_inquiry_primary_part_id(inquiry: Inquiry) -> str: + """与采购端比价弹窗一致:上阶物料首行料号。""" + first = InquiryRfqItem.objects.filter(inquiry_no=inquiry).order_by("id").first() + if not first: + return "" + return (first.part_id or "").strip() + + def _clip_price_str(value, max_len: int = 10) -> str: s = str(value).strip() if value is not None else "" return s[:max_len] @@ -100,142 +117,202 @@ def _clip_field(value, max_len: int) -> str: def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: """ - 确认比价时:按当前询价单下各供应商报价单,汇总材料/加工/包装/运输的「制程最低价」, - 写入 MiscLowPriceHeader;材料规格下重量、单价的最小值写入 MiscLowPriceDetail(与次表设计一致)。 + 制程最低价落库(与比价展开明细一致): + - 询价单下**每个上阶物料料号**单独一套主/次表数据(多料号互不合并)。 + - **材料**:按「材料规格」各一行,取各供应商该规格 material_cost 的 min;次表为同规格下重量、单价 min。 + - **加工**:按「工站」各一行,取各供应商该工站 process_price 的 min(多工站互不合并)。 + - **其它**:包装费、运输费分列取 min(优先 sup_quotation_other;无则用上阶物料 total_other_expense 回退,两列同 min)。 + - **利润率**:cost_type=6,各报价单该料号利润率取 min。杂采不落库管销研(cost_type=5)。 + 不写「整单材料成本/加工成本」汇总行,避免多规格/多工站被合并成一条。 """ inquiry_no = (inquiry.inquiry_no or "").strip() - part_id = (part_id or "").strip() - if not inquiry_no or not part_id: + if not inquiry_no: return - qn_active = QuotationMaster.objects.filter(inquiry_no=inquiry_no).exclude(status=4) - qn_list = list(qn_active.values_list("quotation_no", flat=True)) + qm_qs = QuotationMaster.objects.filter(inquiry_no=inquiry_no).exclude(status=4) + qn_list = list(qm_qs.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() + raw_parts = [ + str(x).strip() + for x in InquiryRfqItem.objects.filter(inquiry_no=inquiry) + .values_list("part_id", flat=True) + .distinct() + if x is not None and str(x).strip() != "" + ] + fallback = (part_id or "").strip() + part_ids = raw_parts if raw_parts else ([fallback] if fallback else []) + if not part_ids: + return - # —— 主表:材料行(按材料规格)—— - 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 pid in part_ids: + MiscLowPriceHeader.objects.filter(inquiry_no=inquiry_no, part_id=pid).delete() + MiscLowPriceDetail.objects.filter(inquiry_no=inquiry_no, part_id=pid).delete() - for spec, rows in by_spec.items(): - costs = [] - for r in rows: - if r.material_cost is not None: + # —— 材料:按材料规格一行;次表重量/单价 —— + materials = QuotationMaterial.objects.filter(quotation_no__in=qm_qs, part_id=pid) + 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=pid, + 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=pid, + 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=pid, + 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=qm_qs, part_id=pid) + 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=pid, + souce_no=souce_ref or None, + cost_type="2", + item_no=_clip_field(station, 100), + min_price=_clip_price_str(min(prices)), + ) + + # —— 包装费 / 运输费(FK 用报价主表 QuerySet;兼容 part_id 大小写;无子表时回退上阶物料 total_other_expense)—— + others = QuotationOther.objects.filter(quotation_no__in=qm_qs).filter( + Q(part_id=pid) | Q(part_id__iexact=(pid or "").strip()) + ) + pkg_vals = [] + tr_vals = [] + for o in others: + if o.packaging_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))) + pkg_vals.append(Decimal(str(o.packaging_cost))) except Exception: pass - if r.unit_price is not None: + if o.transportation_cost is not None: try: - unit_prices.append(Decimal(str(r.unit_price))) + tr_vals.append(Decimal(str(o.transportation_cost))) 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: + if not pkg_vals and not tr_vals: + for qn in qn_list: + it = ( + QuotationItem.objects.filter(quotation_no=qn, part_id=pid).order_by("autoid").first() + or QuotationItem.objects.filter(quotation_no=qn).order_by("autoid").first() + ) + if it is None or it.total_other_expense is None: + continue try: - prices.append(Decimal(str(r.process_price))) + v = Decimal(str(it.total_other_expense)) except Exception: continue - if prices: + pkg_vals.append(v) + tr_vals.append(v) + + if pkg_vals: MiscLowPriceHeader.objects.create( inquiry_no=inquiry_no, - part_id=part_id, + part_id=pid, souce_no=souce_ref or None, - cost_type="2", - item_no=_clip_field(station, 100), - min_price=_clip_price_str(min(prices)), + 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=pid, + souce_no=souce_ref or None, + cost_type="4", + item_no="运输费", + min_price=_clip_price_str(min(tr_vals)), ) - # —— 主表:包装费 / 运输费 —— - 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)), - ) + # —— 利润率 cost_type=6(杂采无管销研 cost_type=5,不落库)—— + pr_vals = [] + for qn in qn_list: + it = QuotationItem.objects.filter(quotation_no=qn, part_id=pid).order_by("autoid").first() + if it is None: + it = QuotationItem.objects.filter(quotation_no=qn).order_by("autoid").first() + if it is not None and it.profit_rate is not None and str(it.profit_rate).strip() != "": + v = _safe_decimal(it.profit_rate) + if v is not None: + pr_vals.append(v) + continue + pf = QuotationProfit.objects.filter(quotation_no=qn, part_id=pid).order_by("autoid").first() + if pf is not None and pf.profit_rate is not None: + v = _safe_decimal(pf.profit_rate) + if v is not None: + pr_vals.append(v) + if pr_vals: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=pid, + souce_no=souce_ref or None, + cost_type="6", + item_no="利润率", + min_price=_clip_price_str(min(pr_vals)), + ) class MiscMaterialViewSet(CustomModelViewSet): @@ -734,7 +811,10 @@ class InquiryViewSet(CustomModelViewSet): 20, ) or None current_time = timezone.now() + # 招标(2):供应商端报价单沿用「投标截止时间」作为有效报价截止;询价(1)用主表 quote_deadline。 quote_deadline = inquiry.quote_deadline + if int(getattr(inquiry, "buying_method", 1) or 1) == 2: + quote_deadline = getattr(inquiry, "bid_end_time", None) or quote_deadline inquiry_materials = list(inquiry.material_costs.all()) inquiry_processes = list(inquiry.process_costs.all()) @@ -1003,45 +1083,50 @@ class InquiryViewSet(CustomModelViewSet): @action(methods=["put"], detail=True) def start_bargaining(self, request, pk=None): - """开启比价议价:从报价结束状态进入比议价中""" + """开启比价议价:从报价结束状态进入比议价中;并写入比价-制程最低价主/次表。""" instance = self.get_object() if int(instance.status or self.STATUS_PUBLISHED) not in (self.STATUS_PUBLISHED, self.STATUS_QUOTING, self.STATUS_QUOTE_ENDED): return ErrorResponse(msg='只有“发布”或“报价结束”状态的询价单才能开启比价') current_user = self._get_request_username() or None current_time = timezone.now() - return self._save_status( - instance, - status=self.STATUS_BARGaining, - 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=current_user, - comparison_time=current_time, - operation_type=7, - operation_desc="开启比议价", - ) - - @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='只有“比议价中”状态的询价单才能确认议价') - 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() + part_id = _resolve_inquiry_primary_part_id(instance) 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", + "start_bargaining: 询价单无上阶物料料号,跳过制程最低价落库 inquiry_no=%s", instance.inquiry_no, ) + return self._save_status( + instance, + status=self.STATUS_BARGaining, + 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=current_user, + comparison_time=current_time, + operation_type=7, + operation_desc="开启比议价", + ) + except Exception as exc: + logger.exception("start_bargaining 写入制程最低价失败") + return ErrorResponse(msg=f"开启比价失败:{exc}") + + @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='只有“比议价中”状态的询价单才能确认议价') + part_id = _resolve_inquiry_primary_part_id(instance) + if not part_id: + return ErrorResponse(msg="询价单无上阶物料料号,无法确认比价") + try: + with transaction.atomic(): + _sync_misc_low_price_records(instance, part_id) return self._save_status( instance, status=self.STATUS_NEGOTIATED, @@ -1055,7 +1140,6 @@ class InquiryViewSet(CustomModelViewSet): operation_desc="议价审核提交(进入价格审核)", ) except Exception as exc: - logger.exception("confirm_negotiation 写入制程最低价失败") return ErrorResponse(msg=f"确认比价失败:{exc}") @action(methods=["put"], detail=True) @@ -1082,6 +1166,8 @@ class InquiryViewSet(CustomModelViewSet): """按询价单号查询杂采议价记录(可选 part_id)。""" instance = self.get_object() part_id = (request.query_params.get("part_id") or "").strip() + if not part_id: + part_id = _resolve_inquiry_primary_part_id(instance) qs = MiscNegotiationRecords.objects.filter(inquiry_no=instance.inquiry_no) if part_id: qs = qs.filter(part_id=part_id) @@ -1096,7 +1182,9 @@ class InquiryViewSet(CustomModelViewSet): serializer.is_valid(raise_exception=True) part_id = (serializer.validated_data.get("part_id") or "").strip() if not part_id: - return ErrorResponse(msg="part_id 不能为空") + part_id = _resolve_inquiry_primary_part_id(instance) + if not part_id: + return ErrorResponse(msg="询价单无上阶物料料号,无法保存议价记录") records = serializer.validated_data.get("records") or [] username = self._get_request_username() or None now = timezone.now() diff --git a/backend/apps/pissupplier/models.py b/backend/apps/pissupplier/models.py index 153102d..3227b28 100644 --- a/backend/apps/pissupplier/models.py +++ b/backend/apps/pissupplier/models.py @@ -4,7 +4,7 @@ from dvadmin.utils.models import table_prefix class QuotationMaster(models.Model): - """杂采报价单主表""" + """杂采报价单主表(采购端比价弹窗按 autoid 拉详情用于供应商报价预览)""" PAYMENT_METHOD_CHOICES = ( (1, "月结30天"), diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py index 4f29777..e40bd39 100644 --- a/backend/apps/pissupplier/serializers.py +++ b/backend/apps/pissupplier/serializers.py @@ -378,6 +378,8 @@ 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`。 diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index ba85652..a0feaff 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -30,7 +30,10 @@ from apps.pissupplier.serializers import ( class QuotationMasterViewSet(CustomModelViewSet): - """杂采报价单主表管理接口""" + """杂采报价单主表管理接口 + + 详情 GET 与采购端比价弹窗「供应商报价预览」共用:返回 `QuotationMasterSerializer` 及嵌套材料/加工/其它/利润/上阶物料等。 + """ queryset = QuotationMaster.objects.prefetch_related( "rfq_items", diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index 5022a21..d30e18e 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -11,18 +11,18 @@ 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' }) -/** 确认比价:后端写入制程最低价主/次表时需带 part_id(与议价记录一致) */ -export const ConfirmNegotiationObj = (id: string | number, data?: { part_id?: string }) => +/** 确认比价:料号由后端从上阶物料表解析 */ +export const ConfirmNegotiationObj = (id: string | number, data?: Record) => request({ url: `${baseUrl}${id}/confirm_negotiation/`, method: 'put', data: data ?? {} }) export const SubmitPriceAuditObj = (id: string | number) => request({ url: `${baseUrl}${id}/submit_price_audit/`, method: 'put' }) /** 查询杂采议价记录(比价议价价格存此表,非报价明细「中标价格」) */ -export const GetNegotiationRecordsObj = (id: string | number, params: { part_id?: string }) => - request({ url: `${baseUrl}${id}/negotiation_records/`, method: 'get', params }) +export const GetNegotiationRecordsObj = (id: string | number, params?: { part_id?: string }) => + request({ url: `${baseUrl}${id}/negotiation_records/`, method: 'get', params: params ?? {} }) /** 保存比价中的议价后价格、中标否至杂采议价记录表 */ export const SaveNegotiationRecordsObj = ( id: string | number, data: { - part_id: string + part_id?: string records: { quotation_no: string supplier_code?: string diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx index e9ec0d2..8c171eb 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx @@ -64,7 +64,10 @@ type ExtraHooks = { onAdd?: () => void onEdit?: (row: any) => void onView?: (row: any) => void - /** 打开比价/议价弹窗(比议价中、价格审核、核价通过);保存时按报价单写入杂采议价记录(议价前总价快照等) */ + /** + * 打开比价/议价弹窗(比议价中、价格审核、核价通过);保存时按报价单写入杂采议价记录(议价前总价快照等)。 + * 弹窗内表头供应商名称可点击,预览对应 GET quotation_master/{autoid}/ 报价明细。 + */ onComparison?: (row: any) => void /** 列表多选变化(用于后续多询价单比价等) */ onTableSelectionChange?: (rows: any[]) => void @@ -75,6 +78,13 @@ type ExtraHooks = { const STATUS_OPEN = 1 const STATUS_CONFIRMED = 2 const STATUS_PUBLISHED = 3 +const STATUS_QUOTING = 4 +const STATUS_QUOTING_ENDED = 5 +const STATUS_BARGaining = 6 +const STATUS_NEGOTIATED = 7 +const STATUS_PRICE_AUDITED = 8 +const STATUS_FINISHED = 9 +const STATUS_CANCELLED = 0 /** 询价单接口错误文案:将数据库唯一约束等转为可读提示 */ export const formatRfqApiErrorMessage = (err: any, fallback: string) => { @@ -107,7 +117,9 @@ const isOpenStatus = (row: any) => getRowStatus(row) === STATUS_OPEN const isConfirmedStatus = (row: any) => getRowStatus(row) === STATUS_CONFIRMED const canPublishStatus = (row: any) => isConfirmedStatus(row) /** 报价中(4)、报价结束(5) 可开启比价 */ -const isQuotingOrEndedStatus = (row: any) => [4, 5].includes(getRowStatus(row)) +// const isQuotingOrEndedStatus = (row: any) => [4, 5].includes(getRowStatus(row)) +/** 改为仅状态:报价结束(5) 可开启比价 */ +const isQuotingOrEndedStatus = (row: any) => getRowStatus(row) === STATUS_QUOTING_ENDED /** 比议价中(6)、价格审核(7)、核价通过(8) 可打开比价窗口 */ const canOpenComparison = (row: any) => [6, 7, 8].includes(getRowStatus(row)) const getErrorMessage = (err: any, fallback: string) => formatRfqApiErrorMessage(err, fallback) @@ -127,6 +139,128 @@ export const formatCostTemplateVersion = (v: unknown) => { return String(Math.trunc(n)) } +/** 数据库数值型空值:展示 0.00(含占位符 -) */ +export const displayNumericEmpty = (v: unknown): string => { + if (v === null || v === undefined || v === '' || v === '-') return '0.00' + const n = Number(v) + if (Number.isFinite(n)) return n.toFixed(2) + const s = String(v).trim() + return s === '' ? '0.00' : s +} + +/** 文本型空值:展示 - */ +export const displayTextEmpty = (v: unknown): string => { + if (v === null || v === undefined) return '-' + const s = String(v).trim() + return s === '' ? '-' : s +} + +/** 利润率、税率等:空值 0.00%,数值保留两位小数并加 % */ +export const displayPercentRate = (v: unknown): string => { + if (v === null || v === undefined || v === '') return '0.00%' + const raw = String(v).trim() + if (raw === '') return '0.00%' + const stripped = raw.replace(/%/g, '').trim() + const n = Number(stripped) + if (Number.isFinite(n)) return `${n.toFixed(2)}%` + return raw.endsWith('%') ? raw : '0.00%' +} + +/** 比价展开明细:与模板 fields 顺序一致 */ +export type ComparisonDetailMetric = { + label: string + get: (r: any) => unknown + isText: boolean +} + +const normCmpTplKey = (k: string) => + String(k || '') + .trim() + .toLowerCase() + .replace(/_/g, '') + +const toSnake = (s: string) => + String(s || '') + .replace(/([A-Z])/g, '_$1') + .replace(/^_/, '') + .toLowerCase() + +const materialGetterByNormKey: Record unknown> = { + material: (r) => r?.material_spec ?? r?.material, + length: (r) => r?.length, + width: (r) => r?.width, + height: (r) => r?.height, + specificgravity: (r) => r?.specific_gravity ?? r?.specificgravity, + qty: (r) => r?.qty, + quantity: (r) => r?.qty, + weight: (r) => r?.weight, + unitprice: (r) => r?.unit_price ?? r?.unitPrice, + materialcost: (r) => r?.material_cost, + lossrate: (r) => r?.loss_rate ?? r?.lossRate, + remark: (r) => r?.remark, + unit: (r) => r?.unit +} + +const processGetterByNormKey: Record unknown> = { + processstation: (r) => r?.process_station, + unit: (r) => r?.unit, + unitrate: (r) => r?.unit_rate ?? r?.unitRate, + processqty: (r) => r?.process_qty ?? r?.processQty, + processprice: (r) => r?.process_price ?? r?.processPrice, + processtime: (r) => r?.process_time ?? r?.processTime, + remark: (r) => r?.remark, + unitprice: (r) => r?.unit_rate ?? r?.unit_price, + processcost: (r) => r?.process_price, + lossrate: (r) => r?.loss_rate ?? r?.lossRate, + cavitycount: (r) => r?.cavity_count ?? r?.cavityCount +} + +const materialTextNormKeys = new Set(['material', 'remark', 'unit']) +const processTextNormKeys = new Set(['processstation', 'unit', 'remark']) + +function fallbackRowGetter(rawKey: string): (r: any) => unknown { + const k = String(rawKey || '').trim() + return (r: any) => { + if (r == null) return undefined + const nk = normCmpTplKey(k) + return r[k] ?? r[toSnake(k)] ?? r[nk] + } +} + +/** 材料成本:按成本模板 fields 顺序生成展开行 */ +export function buildMaterialComparisonMetricsFromTemplateFields(fields: Array<{ key?: string; label?: string; nameCn?: string; name_cn?: string }>): ComparisonDetailMetric[] { + const out: ComparisonDetailMetric[] = [] + if (!Array.isArray(fields)) return out + for (const f of fields) { + const rawKey = String(f?.key ?? '').trim() + if (!rawKey) continue + const nk = normCmpTplKey(rawKey) + const label = + String(f.label || f.nameCn || f.name_cn || '').trim() || rawKey + const getter = materialGetterByNormKey[nk] ?? fallbackRowGetter(rawKey) + const isText = materialTextNormKeys.has(nk) + out.push({ label, get: getter, isText }) + } + return out +} + +/** 加工成本:按成本模板 fields 顺序生成展开行 */ +export function buildProcessComparisonMetricsFromTemplateFields(fields: Array<{ key?: string; label?: string; nameCn?: string; name_cn?: string }>): ComparisonDetailMetric[] { + const out: ComparisonDetailMetric[] = [] + if (!Array.isArray(fields)) return out + for (const f of fields) { + const rawKey = String(f?.key ?? '').trim() + if (!rawKey) continue + const nk = normCmpTplKey(rawKey) + const label = + String(f.label || f.nameCn || f.name_cn || '').trim() || rawKey + const getter = processGetterByNormKey[nk] ?? fallbackRowGetter(rawKey) + const isText = processTextNormKeys.has(nk) + out.push({ label, get: getter, isText }) + } + return out +} + export const createCrudOptions = function ({ context, crudExpose, @@ -237,6 +371,8 @@ export const createCrudOptions = function ({ cancelButtonText: '取消' }) await api.StartBargainingObj(row.id) + await api.SaveNegotiationRecordsObj(row.id, { records: [] }) + onComparison && onComparison(row) crudExpose?.doRefresh?.() } catch (err: any) { diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue index bbea8ae..b0bac3a 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue @@ -71,7 +71,7 @@ - +
- +
- - + @@ -426,14 +430,14 @@
-
询价单号{{ comparisonDialog.baseInfo.code || '-' }}
-
采购件料号{{ comparisonDialog.baseInfo.partNo || '-' }}
-
采购件名称{{ comparisonDialog.baseInfo.partName || '-' }}
-
目标价格{{ comparisonDialog.baseInfo.targetPrice || '-' }}
-
交易币别{{ comparisonDialog.baseInfo.currency || '-' }}
-
税率{{ comparisonDialog.baseInfo.taxRate || '-' }}
-
当前成交价{{ comparisonDialog.baseInfo.dealPrice || '-' }}
-
制程最低价{{ comparisonDialog.baseInfo.lowestProcessPrice || '-' }}
+
询价单号{{ displayTextEmpty(comparisonDialog.baseInfo.code) }}
+
采购件料号{{ displayTextEmpty(comparisonDialog.baseInfo.partNo) }}
+
采购件名称{{ displayTextEmpty(comparisonDialog.baseInfo.partName) }}
+
目标价格{{ displayNumericEmpty(comparisonDialog.baseInfo.targetPrice) }}
+
交易币别{{ displayTextEmpty(comparisonDialog.baseInfo.currency) }}
+
税率{{ displayPercentRate(comparisonDialog.baseInfo.taxRate) }}
+
当前成交价{{ displayNumericEmpty(comparisonDialog.baseInfo.dealPrice) }}
+
制程最低价{{ displayNumericEmpty(comparisonDialog.baseInfo.lowestProcessPrice) }}
+ - + - +
@@ -486,23 +494,27 @@ > + - + - +
暂无明细
@@ -522,12 +534,16 @@
+ - + - +
+ + +
+ +
暂无报价数据
+
+ +
@@ -592,7 +726,13 @@ import { createCrudOptions, normalizeDict, formatCostTemplateVersion, - formatRfqApiErrorMessage + formatRfqApiErrorMessage, + displayNumericEmpty, + displayTextEmpty, + displayPercentRate, + buildMaterialComparisonMetricsFromTemplateFields, + buildProcessComparisonMetricsFromTemplateFields, + type ComparisonDetailMetric } from './crud' import * as api from './api' import * as costTemplateApi from '../cost_template/api' @@ -1181,6 +1321,8 @@ watch( if (Number(v) === 1) { form.bid_start_time = '' form.bid_end_time = '' + } else if (Number(v) === 2) { + form.quote_deadline = '' } } ) @@ -1240,11 +1382,11 @@ const normalizeDateTime = (value: unknown) => { } const quoteDeadlineHourOptions = Array.from({ length: 24 }, (_, index) => padTwoDigits(index)) -const quoteDeadlineOpenBaseDate = ref(null) +const openBaseDate = ref(null) const getDayStart = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()) -const parseQuoteDeadlineDate = (value: unknown) => { +const parseDate = (value: unknown) => { const normalized = normalizeQuoteDeadline(value) if (!normalized) return null const parsed = new Date(normalized.replace(' ', 'T')) @@ -1252,25 +1394,45 @@ const parseQuoteDeadlineDate = (value: unknown) => { } const captureQuoteDeadlineOpenBaseDate = () => { - quoteDeadlineOpenBaseDate.value = getDayStart(new Date()) + openBaseDate.value = getDayStart(new Date()) } const isQuoteDeadlineDateDisabled = (date: Date) => { - if (!quoteDeadlineOpenBaseDate.value) return false - return getDayStart(date).getTime() <= quoteDeadlineOpenBaseDate.value.getTime() + if (!openBaseDate.value) return false + return getDayStart(date).getTime() <= openBaseDate.value.getTime() } const validateQuoteDeadlineAfterOpenDay = () => { - const selectedDate = parseQuoteDeadlineDate(form.quote_deadline) - if (!selectedDate) { - ElMessage.error('请选择报价截止时') - activeTab.value = 'base' - return false - } - if (quoteDeadlineOpenBaseDate.value && getDayStart(selectedDate).getTime() <= quoteDeadlineOpenBaseDate.value.getTime()) { - ElMessage.error('报价截止时只能选择大于打开创建/编辑当天的日期') - activeTab.value = 'base' - return false + if (Number(form.buying_method) === 1) { + const selectedDate = parseDate(form.quote_deadline) + if (!selectedDate) { + ElMessage.error('请选择报价截止时') + activeTab.value = 'base' + return false + } + if (openBaseDate.value && getDayStart(selectedDate).getTime() <= openBaseDate.value.getTime()) { + ElMessage.error('报价截止时只能选择大于打开创建/编辑当天的日期') + activeTab.value = 'base' + return false + } + } else if (Number(form.buying_method) === 2) { + const selectedBSDate = parseDate(form.bid_start_time) + const selectedBEDate = parseDate(form.bid_end_time) + if (!selectedBSDate || !selectedBEDate) { + ElMessage.error('请完善投标起止时间') + activeTab.value = 'base' + return false + } + if (selectedBSDate >= selectedBEDate) { + ElMessage.error('投标截止时间须晚于投标开始时间') + activeTab.value = 'base' + return false + } + if (openBaseDate.value && getDayStart(selectedBSDate).getTime() <= openBaseDate.value.getTime()) { + ElMessage.error('投标开始时间只能选择大于打开创建/编辑当天的日期') + activeTab.value = 'base' + return false + } } return true } @@ -1305,6 +1467,66 @@ const quoteDeadlineHour = computed({ } }) +const bidStartTimeDate = computed({ + get: () => { + const normalized = normalizeDateTime(form.bid_start_time) + return normalized ? normalized.slice(0, 10) : '' + }, + set: (value: string) => { + if (!value) { + form.bid_start_time = '' + return + } + const currentHour = bidStartTimeHour.value || '23' + form.bid_start_time = `${value} ${currentHour}:00:00` + } +}) + +const bidStartTimeHour = computed({ + get: () => { + const normalized = normalizeDateTime(form.bid_start_time) + return normalized ? normalized.slice(11, 13) : '23' + }, + set: (value: string) => { + const date = bidStartTimeDate.value + if (!date) { + form.bid_start_time = '' + return + } + form.bid_start_time = `${date} ${value || '00'}:00:00` + } +}) + +const bidEndTimeDate = computed({ + get: () => { + const normalized = normalizeDateTime(form.bid_end_time) + return normalized ? normalized.slice(0, 10) : '' + }, + set: (value: string) => { + if (!value) { + form.bid_end_time = '' + return + } + const currentHour = bidEndTimeHour.value || '23' + form.bid_end_time = `${value} ${currentHour}:00:00` + } +}) + +const bidEndTimeHour = computed({ + get: () => { + const normalized = normalizeDateTime(form.bid_end_time) + return normalized ? normalized.slice(11, 13) : '23' + }, + set: (value: string) => { + const date = bidEndTimeDate.value + if (!date) { + form.bid_end_time = '' + return + } + form.bid_end_time = `${date} ${value || '00'}:00:00` + } +}) + const currentTemplate = computed(() => templates.value.find((t: any) => t.template_no === form.template)) const templateSectionMap = computed(() => { const map = new Map() @@ -1776,7 +1998,14 @@ const openDetail = async (row: any, mode: 'edit' | 'view') => { const openEdit = (row: any) => openDetail(row, 'edit') const openView = (row: any) => openDetail(row, 'view') -type ComparisonDetailRow = { label: string; values: Record; avg?: number; min?: number } +type ComparisonDetailRow = { + label: string + values: Record + avg?: number + min?: number + /** 与成本模板字段一致:文本列走 displayTextEmpty */ + isText?: boolean +} /** 材料/加工:按材质或工站分组,组内多行明细(对齐 bargain_price.html 展开结构) */ type ComparisonDetailGroup = { title: string; lines: ComparisonDetailRow[] } type ComparisonRow = ComparisonDetailRow & { @@ -1806,14 +2035,139 @@ const comparisonDialog = reactive({ dealPrice: '', lowestProcessPrice: '' }, - suppliers: [] as { name: string; code: string }[], + suppliers: [] as { name: string; code: string; quotationId?: number | string }[], rows: [] as ComparisonRow[] }) +const QUOTATION_STATUS_LABELS: Record = { + 1: '待报价', + 2: '报价中', + 3: '已报价', + 4: '已过期' +} + +const quotationPreview = reactive({ + visible: false, + loading: false, + title: '', + quote: null as any +}) + +const quotationPreviewRfqItem = computed(() => { + const q = quotationPreview.quote + if (!q?.rfq_items?.length) return null + return q.rfq_items[0] +}) + +const quotationPreviewCurrency = computed(() => { + const q = quotationPreview.quote as any + const c = q?.currency ?? q?.transaction_currency + if (c != null && c !== '') return String(c) + return displayTextEmpty(comparisonDialog.baseInfo.currency) +}) + +/** + * 基本信息「税费」:金额(含税−不含税,或上阶物料明细 tax_rate 字段——库中存的是税费金额,非比例) + */ +const quotationPreviewTaxDisplay = computed(() => { + const it = quotationPreviewRfqItem.value as any + if (!it) return displayNumericEmpty(null) + const inc = it.total_price_incl_tax + const ex = it.total_price_excl_tax + if (inc != null && inc !== '' && ex != null && ex !== '') { + const ni = Number(inc) + const ne = Number(ex) + if (Number.isFinite(ni) && Number.isFinite(ne)) return displayNumericEmpty(ni - ne) + } + return displayNumericEmpty(it.tax_rate) +}) + +/** ⑤ 税金「税率」:仅来自 profit_costs(税率比例);勿与 rfq_items.tax_rate(税费金额)混用 */ +const quotationPreviewTaxTableRows = computed(() => { + const q = quotationPreview.quote as any + if (!q) return [] + const list = q.tax_costs + if (Array.isArray(list) && list.length) return list + const pid = quotationPreviewRfqItem.value?.part_id + const profits = q.profit_costs + const pc = + pid && Array.isArray(profits) + ? profits.find((p: any) => p.part_id === pid) || profits[0] + : profits?.[0] + return [{ tax_rate: pc?.tax_rate }] +}) + +const quotationPreviewStatusLabel = computed(() => { + const s = Number((quotationPreview.quote as any)?.status) + if (!Number.isFinite(s)) return '-' + return QUOTATION_STATUS_LABELS[s] ?? String(s) +}) + +const quotationPreviewTotalPrice = computed(() => { + const q = quotationPreview.quote as any + if (!q) return '0.00' + const a = q.quote_amount + if (a !== undefined && a !== null && a !== '') { + return displayNumericEmpty(a) + } + const it = q.rfq_items?.[0] + if (it?.total_price_incl_tax != null && it?.total_price_incl_tax !== '') { + return displayNumericEmpty(it.total_price_incl_tax) + } + return '0.00' +}) + +const openQuotationPreview = async (supplierIndex: number) => { + let quote: any = comparisonDialog.quotes[supplierIndex] + const sup = comparisonDialog.suppliers[supplierIndex] + if (!quote && sup?.quotationId != null) { + quotationPreview.loading = true + quotationPreview.quote = null + quotationPreview.title = `报价单预览 · ${sup?.name || ''}` + quotationPreview.visible = true + try { + const res = await getQuotationDetail(sup.quotationId) + quote = unwrapQuotationDetail(res) + } catch (e: any) { + ElMessage.error(e?.message || '加载报价单失败') + quotationPreview.visible = false + return + } finally { + quotationPreview.loading = false + } + } + if (!quote) { + ElMessage.warning('暂无该供应商报价单数据') + return + } + quotationPreview.quote = quote + quotationPreview.title = `报价单预览 · ${quote.supplier_name || sup?.name || ''}(${quote.quotation_no || ''})` + quotationPreview.visible = true +} + const comparisonInquiryStatus = computed(() => Number(comparisonDialog.currentRow?.status)) -const formatCompareAvg = (v: unknown) => { - if (v === undefined || v === null) return '-' +/** 比价主表:利润/税金行为金额合计,与材料成本等同用两位小数,不加 %(仅表头「税率」用 displayPercentRate) */ +const formatComparisonMainCell = (row: ComparisonRow, v: unknown) => { + if (row.key === 'rank') { + if (v === null || v === undefined || v === '') return '-' + return String(v) + } + if (v === null || v === undefined || v === '' || v === '-') return '0.00' + const n = Number(v) + if (Number.isFinite(n)) return n.toFixed(2) + return String(v) +} + +const formatCompareAvg = (v: unknown, _rowKey?: string) => { + if (v === undefined || v === null) return '0.00' + const n = Number(v) + if (Number.isFinite(n)) return n.toFixed(2) + return String(v) +} + +const formatCompareMin = (v: unknown, _rowKey?: string) => { + if (v === undefined || v === null) return '0.00' const n = Number(v) if (Number.isFinite(n)) return n.toFixed(2) return String(v) @@ -1839,16 +2193,6 @@ const quotationSupplierKey = (quote: any, idx: number) => quote?.id || `sup-${idx}` -/** 比价弹窗:与杂采议价记录表 part_id 对齐 */ -const resolveComparisonPartId = (row: any, quotes: any[]) => { - const fromRow = String(row?.part_no || '').trim() - if (fromRow) return fromRow - const q0 = quotes[0] - const fromRfq = String(q0?.rfq_items?.[0]?.part_id || '').trim() - if (fromRfq) return fromRfq - return String(q0?.part_no || q0?.part_id || '').trim() -} - /** 议价金额回显:保留接口字符串精度,避免 Number 化丢小数位 */ const formatBargainDisplayValue = (bp: any): string => { if (bp === null || bp === undefined || bp === '') return '' @@ -2056,7 +2400,7 @@ const buildRanksByTotal = (quotes: any[], supplierKeys: string[]): Record { if (!row || typeof row !== 'object') return row const oj = row.option_json @@ -2075,11 +2419,14 @@ const mergeOptionJsonIntoRow = (row: any): any => { return { ...row, ...extra } } -const formatMetricCell = (v: unknown): string => { - if (v === null || v === undefined || v === '') return '-' - const n = Number(v) - if (Number.isFinite(n)) return String(v) - return String(v) +/** 比价展开明细:按行标签区分数值(空→0.00)与文本(空→-) */ +const formatMetricDetailCell = (v: unknown, label: string, isText?: boolean) => { + if (isText === true || label === '备注') return displayTextEmpty(v) + return displayNumericEmpty(v) +} + +const formatMetricCell = (v: unknown, isText: boolean) => { + return isText ? displayTextEmpty(v) : displayNumericEmpty(v) } const findMaterialRowBySpec = (q: any, spec: string) => { @@ -2092,8 +2439,34 @@ const findProcessRowByStation = (q: any, station: string) => { return (q.process_costs || []).find((r: any) => String(r.process_station || '').trim() === t) } -/** 材料成本展开:按材料规格分组,组内展示重量/尺寸/单价/费用等(与报价单 material_costs 一致) */ -const buildMaterialDetailGroups = (quotes: any[], supplierKeys: string[]): ComparisonDetailGroup[] => { +/** 无模板或模板无字段时的回退顺序(与旧版硬编码一致) */ +const DEFAULT_MATERIAL_COMPARISON_METRICS: ComparisonDetailMetric[] = [ + { label: '用量(重量)', get: (r) => r?.weight, isText: false }, + { label: '长', get: (r) => r?.length, isText: false }, + { label: '宽', get: (r) => r?.width, isText: false }, + { label: '高', get: (r) => r?.height, isText: false }, + { label: '材料单价', get: (r) => r?.unit_price, isText: false }, + { label: '比重', get: (r) => r?.specific_gravity, isText: false }, + { label: '数量', get: (r) => r?.qty, isText: false }, + { label: '材料费用', get: (r) => r?.material_cost, isText: false }, + { label: '备注', get: (r) => r?.remark, isText: true } +] + +const DEFAULT_PROCESS_COMPARISON_METRICS: ComparisonDetailMetric[] = [ + { label: '加工计量', get: (r) => r?.process_qty, isText: false }, + { label: '单位', get: (r) => r?.unit, isText: true }, + { label: '费率', get: (r) => r?.unit_rate, isText: false }, + { label: '加工时间', get: (r) => r?.process_time ?? r?.processTime, isText: false }, + { label: '加工费用', get: (r) => r?.process_price, isText: false }, + { label: '备注', get: (r) => r?.remark, isText: true } +] + +/** 材料成本展开:按材料规格分组;组内行顺序按成本结构模板 fields */ +const buildMaterialDetailGroups = ( + quotes: any[], + supplierKeys: string[], + metrics: ComparisonDetailMetric[] = DEFAULT_MATERIAL_COMPARISON_METRICS +): ComparisonDetailGroup[] => { const specs = new Set() quotes.forEach((q) => { ;(q.material_costs || []).forEach((r: any) => { @@ -2101,18 +2474,6 @@ const buildMaterialDetailGroups = (quotes: any[], supplierKeys: string[]): Compa }) }) const sortedSpecs = [...specs].sort() - const metrics: { label: string; get: (r: any) => unknown }[] = [ - { label: '用量(重量)', get: (r) => r?.weight }, - { label: '长', get: (r) => r?.length }, - { label: '宽', get: (r) => r?.width }, - { label: '高', get: (r) => r?.height }, - { label: '材料单价', get: (r) => r?.unit_price }, - { label: '比重', get: (r) => r?.specific_gravity }, - { label: '数量', get: (r) => r?.qty }, - { label: '损耗率(%)', get: (r) => r?.loss_rate ?? r?.lossRate }, - { label: '材料费用', get: (r) => r?.material_cost }, - { label: '备注', get: (r) => r?.remark } - ] const groups: ComparisonDetailGroup[] = [] for (const spec of sortedSpecs) { const lines: ComparisonDetailRow[] = [] @@ -2121,19 +2482,23 @@ const buildMaterialDetailGroups = (quotes: any[], supplierKeys: string[]): Compa supplierKeys.forEach((sup, idx) => { const raw = findMaterialRowBySpec(quotes[idx], spec) const merged = mergeOptionJsonIntoRow(raw || {}) - values[sup] = formatMetricCell(m.get(merged)) + values[sup] = formatMetricCell(m.get(merged), m.isText) }) const allDash = supplierKeys.every((sup) => values[sup] === '-') if (allDash) continue - lines.push({ label: m.label, values, ...calcCompareStats(values) }) + lines.push({ label: m.label, values, isText: m.isText, ...calcCompareStats(values) }) } if (lines.length) groups.push({ title: spec || '材料', lines }) } return groups } -/** 加工成本展开:按工站分组,组内展示计量/费率/加工费等 */ -const buildProcessDetailGroups = (quotes: any[], supplierKeys: string[]): ComparisonDetailGroup[] => { +/** 加工成本展开:按工站分组;组内行顺序按成本结构模板 fields */ +const buildProcessDetailGroups = ( + quotes: any[], + supplierKeys: string[], + metrics: ComparisonDetailMetric[] = DEFAULT_PROCESS_COMPARISON_METRICS +): ComparisonDetailGroup[] => { const stations = new Set() quotes.forEach((q) => { ;(q.process_costs || []).forEach((r: any) => { @@ -2141,15 +2506,6 @@ const buildProcessDetailGroups = (quotes: any[], supplierKeys: string[]): Compar }) }) const sorted = [...stations].sort() - const metrics: { label: string; get: (r: any) => unknown }[] = [ - { label: '加工计量', get: (r) => r?.process_qty }, - { label: '单位', get: (r) => r?.unit }, - { label: '费率', get: (r) => r?.unit_rate }, - { label: '加工时间', get: (r) => r?.process_time ?? r?.processTime }, - { label: '模穴数', get: (r) => r?.cavity_count ?? r?.cavityCount }, - { label: '加工费用', get: (r) => r?.process_price }, - { label: '备注', get: (r) => r?.remark } - ] const groups: ComparisonDetailGroup[] = [] for (const station of sorted) { const lines: ComparisonDetailRow[] = [] @@ -2158,11 +2514,11 @@ const buildProcessDetailGroups = (quotes: any[], supplierKeys: string[]): Compar supplierKeys.forEach((sup, idx) => { const raw = findProcessRowByStation(quotes[idx], station) const merged = mergeOptionJsonIntoRow(raw || {}) - values[sup] = formatMetricCell(m.get(merged)) + values[sup] = formatMetricCell(m.get(merged), m.isText) }) const allDash = supplierKeys.every((s) => values[s] === '-') if (allDash) continue - lines.push({ label: m.label, values, ...calcCompareStats(values) }) + lines.push({ label: m.label, values, isText: m.isText, ...calcCompareStats(values) }) } if (lines.length) groups.push({ title: station || '工站', lines }) } @@ -2197,7 +2553,13 @@ const buildOtherCostDetails = (quotes: any[], supplierKeys: string[]): Compariso })) } -const buildComparisonRowsFromPisQuotes = (quotes: any[]) => { +const buildComparisonRowsFromPisQuotes = ( + quotes: any[], + detailOpts?: { + materialMetrics?: ComparisonDetailMetric[] + processMetrics?: ComparisonDetailMetric[] + } +) => { const supplierKeys = quotes.map((q, idx) => quotationSupplierKey(q, idx)) const rows: ComparisonRow[] = [] @@ -2267,8 +2629,8 @@ const buildComparisonRowsFromPisQuotes = (quotes: any[]) => { }) rows.push({ key: 'award', label: '中标否', values: winValues }) - const materialGroups = buildMaterialDetailGroups(quotes, supplierKeys) - const processGroups = buildProcessDetailGroups(quotes, supplierKeys) + const materialGroups = buildMaterialDetailGroups(quotes, supplierKeys, detailOpts?.materialMetrics) + const processGroups = buildProcessDetailGroups(quotes, supplierKeys, detailOpts?.processMetrics) const otherDetails = buildOtherCostDetails(quotes, supplierKeys) const matRow = rows.find((r) => r.key === 'material') @@ -2280,7 +2642,8 @@ const buildComparisonRowsFromPisQuotes = (quotes: any[]) => { const suppliers = supplierKeys.map((name, idx) => ({ name: name || `供应商${idx + 1}`, - code: quotes[idx]?.supplier_code || quotes[idx]?.supplierCode || '' + code: quotes[idx]?.supplier_code || quotes[idx]?.supplierCode || '', + quotationId: quotes[idx]?.autoid ?? quotes[idx]?.id })) return { suppliers, rows } @@ -2336,7 +2699,29 @@ const openComparison = async (row: any) => { list.map((q: any) => getQuotationDetail(q.autoid ?? q.id)) ) const quotes = details.map((r: any) => unwrapQuotationDetail(r)).filter(Boolean) - const { suppliers, rows } = buildComparisonRowsFromPisQuotes(quotes) + await ensureTemplatesLoaded() + const tplCode = String(row?.template_code || row?.template || '').trim() + let tplResolved: any = null + if (tplCode) { + tplResolved = templates.value.find((t: any) => t.template_no === tplCode) ?? null + if (tplResolved) tplResolved = await fetchTemplateDetailIfNeeded(tplResolved) + } + let materialMetrics: ComparisonDetailMetric[] | undefined + let processMetrics: ComparisonDetailMetric[] | undefined + if (tplResolved) { + const secs = normalizeSections(tplResolved.sections) + const matSec = secs.find((s: any) => (s.title || s.name) === '材料成本') + const procSec = secs.find((s: any) => (s.title || s.name) === '加工成本') + if (matSec?.fields?.length) { + const built = buildMaterialComparisonMetricsFromTemplateFields(matSec.fields) + if (built.length) materialMetrics = built + } + if (procSec?.fields?.length) { + const built = buildProcessComparisonMetricsFromTemplateFields(procSec.fields) + if (built.length) processMetrics = built + } + } + const { suppliers, rows } = buildComparisonRowsFromPisQuotes(quotes, { materialMetrics, processMetrics }) comparisonDialog.quotes = quotes comparisonDialog.suppliers = suppliers comparisonDialog.rows = rows @@ -2358,17 +2743,14 @@ const openComparison = async (row: any) => { const c = quotes[0].currency ?? quotes[0].transaction_currency if (c != null && c !== '') comparisonDialog.baseInfo.currency = String(c).trim() } - const partId = resolveComparisonPartId(row, quotes) - if (partId) { - try { - const negRes = await api.GetNegotiationRecordsObj(row.id, { part_id: partId }) - const raw = negRes?.data?.data ?? negRes?.data - const negList = Array.isArray(raw) ? raw : [] - mergeNegotiationIntoComparisonRows(rows, quotes, negList) - if (negList.length) await applyComparisonRowsAfterNegotiationMerge(rows) - } catch { - /* 无议价记录时沿用报价单展示 */ - } + try { + const negRes = await api.GetNegotiationRecordsObj(row.id, {}) + const raw = negRes?.data?.data ?? negRes?.data + const negList = Array.isArray(raw) ? raw : [] + mergeNegotiationIntoComparisonRows(rows, quotes, negList) + if (negList.length) await applyComparisonRowsAfterNegotiationMerge(rows) + } catch { + /* 无议价记录时沿用报价单展示 */ } /** 与表格「制程最低价」列一致:取「总价」行各供应商报价中的最小值,勿用「加工成本」或「议价价格」行 */ const totalRow = rows.find((r) => r.key === 'total') @@ -2500,12 +2882,6 @@ const saveComparison = async (target: 'draft' | 'negotiated' | 'audit') => { } if (target !== 'draft' && !(await validateComparisonAwardAndBargain())) return - const partId = resolveComparisonPartId(comparisonDialog.currentRow, comparisonDialog.quotes) - if (!partId) { - ElMessage.warning('缺少产品料号,无法保存议价记录') - return - } - const updates = syncQuotesFromComparisonRows() if (!updates.length) { ElMessage.warning('无可保存的报价数据') @@ -2550,9 +2926,9 @@ const saveComparison = async (target: 'draft' | 'negotiated' | 'audit') => { comparisonDialog.loading = true try { - await api.SaveNegotiationRecordsObj(comparisonDialog.currentRow.id, { part_id: partId, records }) + await api.SaveNegotiationRecordsObj(comparisonDialog.currentRow.id, { records }) try { - const negRes = await api.GetNegotiationRecordsObj(comparisonDialog.currentRow.id, { part_id: partId }) + const negRes = await api.GetNegotiationRecordsObj(comparisonDialog.currentRow.id, {}) const raw = negRes?.data?.data ?? negRes?.data const negList = Array.isArray(raw) ? raw : [] mergeNegotiationIntoComparisonRows(comparisonDialog.rows, comparisonDialog.quotes, negList) @@ -2568,7 +2944,7 @@ const saveComparison = async (target: 'draft' | 'negotiated' | 'audit') => { }) ) if (target === 'negotiated') { - await api.ConfirmNegotiationObj(comparisonDialog.currentRow.id, { part_id: partId }) + await api.ConfirmNegotiationObj(comparisonDialog.currentRow.id) comparisonDialog.currentRow.status = 7 ElMessage.success('已确认比价') crudExpose.doRefresh() @@ -2943,7 +3319,10 @@ const saveForm = async () => { company_code: form.plant || undefined, purchase_dept: clipPurchaseDept(form.purchase_dept) || undefined, buyer: form.buyer, - quote_deadline: normalizeQuoteDeadline(form.quote_deadline) || undefined, + quote_deadline: + Number(form.buying_method) === 1 + ? normalizeQuoteDeadline(form.quote_deadline) || undefined + : null, buying_method: Number.isFinite(Number(form.buying_method)) ? Number(form.buying_method) : 1, bid_start_time: Number(form.buying_method) === 1 @@ -3066,9 +3445,6 @@ onMounted(() => { color: #6b7280; font-size: 13px; } -.bid-time-placeholder { - color: #6b7280; -} .actions { display: flex; gap: 8px; @@ -3270,4 +3646,38 @@ onMounted(() => { .compare-table :deep(.el-table__header .hidden-expand .cell) { display: none; } + +.supplier-header-link { + cursor: pointer; + color: var(--el-color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} +.supplier-header-link:hover { + opacity: 0.85; +} + +.quotation-preview-dialog .quote-preview-body { + max-height: 72vh; + overflow: auto; +} +.quote-preview-section { + margin-bottom: 16px; +} +.quote-preview-section-title { + font-weight: 600; + font-size: 14px; + color: #92400e; + background: linear-gradient(90deg, #fde68a 0%, #fffbeb 50%, transparent 100%); + padding: 8px 10px; + margin-bottom: 8px; + border-radius: 4px; + border-left: 3px solid #f59e0b; +} +.quote-preview-total .quote-preview-total-value { + font-size: 18px; + font-weight: 700; + color: #0f766e; + padding: 8px 0; +}