From 0f0d40588d7d2ed90fafc03bb7579fd7b33142a8 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Fri, 27 Mar 2026 19:15:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=83=A8=E5=88=86ui=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/pisadmin/miscprocurement/models.py | 2 +- .../pisadmin/miscprocurement/serializers.py | 56 ++- .../apps/pisadmin/miscprocurement/views.py | 199 +++++++++- backend/apps/pissupplier/views.py | 8 +- .../miscprocurement/rfqmiscellaneous/api.ts | 4 +- .../rfqmiscellaneous/index.vue | 366 ++++++++++++++---- 6 files changed, 550 insertions(+), 85 deletions(-) diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index fe6bc61..691998a 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -710,7 +710,7 @@ class MiscLowPriceHeader(CoreModel): max_length=100, db_column="ItemNo", verbose_name="项次名", - help_text="如:铝等项次名称", + help_text="根据成本类别获取询价单对应子表数据行,材料类别 - 材料成本的产品料号 part_id, 加工类别 - 加工工站 process_station", ) min_price = models.CharField( max_length=10, diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index f32dc16..be4ce81 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -267,7 +267,7 @@ class MiscPartCreateUpdateSerializer(CustomModelSerializer): class MiscLowPriceHeaderSerializer(CustomModelSerializer): - """比价-制程最低价记录主表""" + """比价-制程最低价记录主表(采购端「确认比价」时由 InquiryViewSet.confirm_negotiation 写入)。""" class Meta: model = MiscLowPriceHeader @@ -283,7 +283,7 @@ class MiscLowPriceHeaderSerializer(CustomModelSerializer): class MiscLowPriceDetailSerializer(CustomModelSerializer): - """比价-制程最低价记录次表""" + """比价-制程最低价记录次表(材料规格下重量/单价最小值;与 confirm_negotiation 同步写入)。""" class Meta: model = MiscLowPriceDetail @@ -1029,6 +1029,11 @@ class InquirySerializer(CustomModelSerializer): rfq_items = InquiryRfqItemSerializer(many=True, required=False) # 列表/详情展示:company_code → 公司信息简称(同请求内按代码缓存) company_short_name = serializers.SerializerMethodField(read_only=True) + # 上阶物料首行:与 misc_rfq_items 一致,供列表/比价弹窗回显采购件料号、名称(主表无独立字段) + part_no = serializers.SerializerMethodField(read_only=True) + part_name = serializers.SerializerMethodField(read_only=True) + # 税率:优先上阶物料行 tax_rate,否则同料号税费利润行(proc_inquiry_profit_cost.tax_rate) + tax_rate = serializers.SerializerMethodField(read_only=True) bid_start_time = serializers.DateTimeField( format="%Y-%m-%d %H:%M:%S", required=False, @@ -1054,6 +1059,53 @@ class InquirySerializer(CustomModelSerializer): cache[code] = short or code return cache[code] + def _first_rfq_item(self, obj: Inquiry): + qs = getattr(obj, "rfq_items", None) + if qs is None: + return None + return qs.order_by("id").first() + + def get_part_no(self, obj): + item = self._first_rfq_item(obj) + if not item: + return "" + v = getattr(item, "part_id", None) + return "" if v is None else str(v).strip() + + def get_part_name(self, obj): + item = self._first_rfq_item(obj) + if not item: + return "" + v = getattr(item, "product_name", None) + return "" if v is None else str(v).strip() + + def _profit_cost_for_first_part(self, obj: Inquiry): + """首条上阶物料对应料号的税费利润行;无料号时取首条利润行。""" + rfq = self._first_rfq_item(obj) + qs = getattr(obj, "profit_costs", None) + if qs is None: + return None + if rfq: + pid = getattr(rfq, "part_id", None) + if pid is not None and str(pid).strip() != "": + row = qs.filter(part_id=pid).order_by("id").first() + if row: + return row + return qs.order_by("id").first() + + def get_tax_rate(self, obj): + item = self._first_rfq_item(obj) + if item: + v = getattr(item, "tax_rate", None) + if v is not None and str(v).strip() != "": + return str(v).strip() + pc = self._profit_cost_for_first_part(obj) + if pc: + v = getattr(pc, "tax_rate", None) + if v is not None and str(v).strip() != "": + return str(v).strip() + return "" + def to_internal_value(self, data): if hasattr(data, "copy"): data = data.copy() diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index d3a5c59..f308e44 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1,3 +1,5 @@ +import logging +from collections import defaultdict from decimal import Decimal from django.db import transaction @@ -25,6 +27,9 @@ from apps.pissupplier.models import ( QuotationItem, ) +logger = logging.getLogger(__name__) + + from .models import ( MiscProcurementMaterialInfo, MiscProcurementStationInfo, @@ -84,6 +89,155 @@ def _negotiation_totals_from_quotation_item(quotation_no: str, part_id: str): return item.total_price_excl_tax, item.total_price_incl_tax +def _clip_price_str(value, max_len: int = 10) -> str: + s = str(value).strip() if value is not None else "" + return s[:max_len] + + +def _clip_field(value, max_len: int) -> str: + return str(value or "").strip()[:max_len] + + +def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: + """ + 确认比价时:按当前询价单下各供应商报价单,汇总材料/加工/包装/运输的「制程最低价」, + 写入 MiscLowPriceHeader;材料规格下重量、单价的最小值写入 MiscLowPriceDetail(与次表设计一致)。 + """ + inquiry_no = (inquiry.inquiry_no or "").strip() + part_id = (part_id or "").strip() + if not inquiry_no or not part_id: + return + + qn_active = QuotationMaster.objects.filter(inquiry_no=inquiry_no).exclude(status=4) + qn_list = list(qn_active.values_list("quotation_no", flat=True)) + if not qn_list: + return + + souce_ref = inquiry_no[:20] + + MiscLowPriceHeader.objects.filter(inquiry_no=inquiry_no, part_id=part_id).delete() + MiscLowPriceDetail.objects.filter(inquiry_no=inquiry_no, part_id=part_id).delete() + + # —— 主表:材料行(按材料规格)—— + materials = QuotationMaterial.objects.filter(quotation_no__in=qn_list, part_id=part_id) + by_spec: dict[str, list] = defaultdict(list) + for m in materials: + spec = (m.material_spec or "").strip() or "材料" + by_spec[spec].append(m) + + for spec, rows in by_spec.items(): + costs = [] + for r in rows: + if r.material_cost is not None: + try: + costs.append(Decimal(str(r.material_cost))) + except Exception: + continue + if costs: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="1", + item_no=_clip_field(spec, 100), + min_price=_clip_price_str(min(costs)), + ) + + weights = [] + unit_prices = [] + for r in rows: + if r.weight is not None: + try: + weights.append(Decimal(str(r.weight))) + except Exception: + pass + if r.unit_price is not None: + try: + unit_prices.append(Decimal(str(r.unit_price))) + except Exception: + pass + spec_key = _clip_field(spec, 50) + if weights: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + cost_type="1", + material_spec=spec_key, + item_no="1", + value=_clip_price_str(min(weights)), + souce_no=souce_ref, + ) + if unit_prices: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + cost_type="1", + material_spec=spec_key, + item_no="2", + value=_clip_price_str(min(unit_prices)), + souce_no=souce_ref, + ) + + # —— 主表:加工(按工站)—— + processes = QuotationProcess.objects.filter(quotation_no__in=qn_list, part_id=part_id) + by_station: dict[str, list] = defaultdict(list) + for p in processes: + st = (p.process_station or "").strip() or "工站" + by_station[st].append(p) + + for station, rows in by_station.items(): + prices = [] + for r in rows: + if r.process_price is not None: + try: + prices.append(Decimal(str(r.process_price))) + except Exception: + continue + if prices: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="2", + item_no=_clip_field(station, 100), + min_price=_clip_price_str(min(prices)), + ) + + # —— 主表:包装费 / 运输费 —— + others = QuotationOther.objects.filter(quotation_no__in=qn_list, part_id=part_id) + pkg_vals = [] + tr_vals = [] + for o in others: + if o.packaging_cost is not None: + try: + pkg_vals.append(Decimal(str(o.packaging_cost))) + except Exception: + pass + if o.transportation_cost is not None: + try: + tr_vals.append(Decimal(str(o.transportation_cost))) + except Exception: + pass + if pkg_vals: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="3", + item_no="包装费", + min_price=_clip_price_str(min(pkg_vals)), + ) + if tr_vals: + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=part_id, + souce_no=souce_ref or None, + cost_type="4", + item_no="运输费", + min_price=_clip_price_str(min(tr_vals)), + ) + + class MiscMaterialViewSet(CustomModelViewSet): queryset = MiscProcurementMaterialInfo.objects.all() serializer_class = MiscMaterialSerializer @@ -870,24 +1024,39 @@ class InquiryViewSet(CustomModelViewSet): @action(methods=["put"], detail=True) def confirm_negotiation(self, request, pk=None): - """确认议价:从比议价中进入议价确认/价格审核""" + """确认议价:从比议价中进入议价确认/价格审核;并写入比价-制程最低价主/次表。""" instance = self.get_object() if int(instance.status or self.STATUS_BARGaining) != self.STATUS_BARGaining: return ErrorResponse(msg='只有“比议价中”状态的询价单才能确认议价') - current_user = self._get_request_username() or None - current_time = timezone.now() - return self._save_status( - instance, - status=self.STATUS_NEGOTIATED, - confirm_user=getattr(instance, "confirm_user", None), - confirm_time=getattr(instance, "confirm_time", None), - release_user=getattr(instance, "release_user", None), - release_time=getattr(instance, "release_time", None), - comparison_user=getattr(instance, "comparison_user", None), - comparison_time=getattr(instance, "comparison_time", None), - operation_type=8, - operation_desc="议价审核提交(进入价格审核)", - ) + part_id = (request.data.get("part_id") or request.query_params.get("part_id") or "").strip() + if not part_id: + first = InquiryRfqItem.objects.filter(inquiry_no=instance).order_by("id").first() + if first: + part_id = (first.part_id or "").strip() + try: + with transaction.atomic(): + if part_id: + _sync_misc_low_price_records(instance, part_id) + else: + logger.warning( + "confirm_negotiation: 缺少 part_id,跳过制程最低价落库 inquiry_no=%s", + instance.inquiry_no, + ) + return self._save_status( + instance, + status=self.STATUS_NEGOTIATED, + confirm_user=getattr(instance, "confirm_user", None), + confirm_time=getattr(instance, "confirm_time", None), + release_user=getattr(instance, "release_user", None), + release_time=getattr(instance, "release_time", None), + comparison_user=getattr(instance, "comparison_user", None), + comparison_time=getattr(instance, "comparison_time", None), + operation_type=8, + operation_desc="议价审核提交(进入价格审核)", + ) + except Exception as exc: + logger.exception("confirm_negotiation 写入制程最低价失败") + return ErrorResponse(msg=f"确认比价失败:{exc}") @action(methods=["put"], detail=True) def submit_price_audit(self, request, pk=None): diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index afd9b76..ba85652 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -32,7 +32,13 @@ from apps.pissupplier.serializers import ( class QuotationMasterViewSet(CustomModelViewSet): """杂采报价单主表管理接口""" - queryset = QuotationMaster.objects.prefetch_related("rfq_items") + queryset = QuotationMaster.objects.prefetch_related( + "rfq_items", + "material_costs", + "process_costs", + "other_costs", + "profit_costs", + ) serializer_class = QuotationMasterSerializer create_serializer_class = QuotationMasterCreateUpdateSerializer update_serializer_class = QuotationMasterCreateUpdateSerializer diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index ed352d4..5022a21 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -11,7 +11,9 @@ export const ConfirmObj = (id: string | number) => request({ url: `${baseUrl}${i export const RestoreObj = (id: string | number) => request({ url: `${baseUrl}${id}/restore/`, method: 'put' }) export const PublishObj = (id: string | number) => request({ url: `${baseUrl}${id}/publish/`, method: 'put' }) export const StartBargainingObj = (id: string | number) => request({ url: `${baseUrl}${id}/start_bargaining/`, method: 'put' }) -export const ConfirmNegotiationObj = (id: string | number) => request({ url: `${baseUrl}${id}/confirm_negotiation/`, method: 'put' }) +/** 确认比价:后端写入制程最低价主/次表时需带 part_id(与议价记录一致) */ +export const ConfirmNegotiationObj = (id: string | number, data?: { part_id?: string }) => + request({ url: `${baseUrl}${id}/confirm_negotiation/`, method: 'put', data: data ?? {} }) export const SubmitPriceAuditObj = (id: string | number) => request({ url: `${baseUrl}${id}/submit_price_audit/`, method: 'put' }) /** 查询杂采议价记录(比价议价价格存此表,非报价明细「中标价格」) */ export const GetNegotiationRecordsObj = (id: string | number, params: { part_id?: string }) => diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue index 5a620d2..bbea8ae 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue @@ -40,7 +40,7 @@ - + + + + + + +
-