diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py
index 2df9492..691998a 100644
--- a/backend/apps/pisadmin/miscprocurement/models.py
+++ b/backend/apps/pisadmin/miscprocurement/models.py
@@ -1,5 +1,5 @@
import logging
-from typing import Optional
+from typing import Iterable, Optional
from django.db import models
from django.utils import timezone
@@ -175,8 +175,13 @@ class CostEstimateTemplateBody(models.Model):
class Inquiry(CoreModel):
- """杂采询价单主表"""
-
+ """杂采询价单主表
+
+ 与供应商端报价联动:当全部报价单都不再处于待报价/报价中时,「发布」「报价中」可经
+ `Inquiry.sync_to_quote_closed_when_no_open_quotations` 收口为「报价结束」;
+ 当供应商名单中每家均有一条「已报价」报价单时,可经 `Inquiry.sync_to_quote_closed_when_all_suppliers_quoted` 收口(见 `submit`)。
+ """
+
# (1开立;2确认;3发布;4报价中;5报价结束;6比议价中;7价格审核;8核价通过(结束);9落标(结束);0作废)
STATUS_CHOICES = (
(1, "开立"),
@@ -269,6 +274,111 @@ class Inquiry(CoreModel):
def __str__(self) -> str: # pragma: no cover - simple repr
return self.inquiry_no
+ @classmethod
+ def sync_to_quote_closed_when_no_open_quotations(
+ cls,
+ inquiry_nos: Iterable[str],
+ *,
+ actor_username: Optional[str] = None,
+ ) -> int:
+ """
+ 当某询价单下已不存在「待报价」(1) 或「报价中」(2) 的报价单时,若询价单仍为「发布」(3) 或「报价中」(4),
+ 则置为「报价结束」(5),并写操作日志。
+
+ 典型调用:供应商报价单因超过截止时间被批量置为「已过期」后,判断询价维度是否可收口为报价结束。
+ """
+ from apps.pissupplier.models import QuotationMaster # 避免 apps 间循环 import
+
+ now = timezone.now()
+ username = (str(actor_username).strip()[:20] if actor_username else None) or None
+ unique = {str(x).strip() for x in inquiry_nos if x and str(x).strip()}
+ n = 0
+ for inq_no in unique:
+ if QuotationMaster.objects.filter(inquiry_no=inq_no, status__in=(1, 2)).exists():
+ continue
+ inq = cls.objects.filter(inquiry_no=inq_no, status__in=(3, 4)).first()
+ if not inq:
+ continue
+ old_status = int(inq.status if inq.status is not None else 0)
+ inq.status = 5
+ inq.update_time = now
+ inq.update_datetime = now
+ if username:
+ inq.update_user = username
+ inq.save(update_fields=["status", "update_time", "update_user", "update_datetime"])
+ RFQOperationLogs.try_append(
+ inquiry_no=inq.inquiry_no,
+ purchase_type=int(inq.purchase_type),
+ operation_type=6,
+ operation_user=username,
+ quotation_no="-",
+ per_status=old_status,
+ cur_status=5,
+ operation_desc="全部报价单已结束待报价/报价中,询价单同步为报价结束",
+ )
+ n += 1
+ return n
+
+ @classmethod
+ def sync_to_quote_closed_when_all_suppliers_quoted(
+ cls,
+ inquiry_no: str,
+ *,
+ actor_username: Optional[str] = None,
+ quotation_no: Optional[str] = None,
+ ) -> bool:
+ """
+ 若询价单供应商名单(InquirySupplier)中每个供应商在 `QuotationMaster` 上均有一条「已报价」(3) 记录,
+ 且询价单仍为「发布」(3) 或「报价中」(4),则将询价单置为「报价结束」(5)。
+
+ 用于最后一户提交正式报价后收口,不依赖是否已超过报价截止时间。
+ """
+ from apps.pissupplier.models import QuotationMaster # 避免 apps 间循环 import
+
+ inq_no = (inquiry_no or "").strip()
+ if not inq_no:
+ return False
+ inq = cls.objects.filter(inquiry_no=inq_no, status__in=(3, 4)).first()
+ if not inq:
+ return False
+
+ raw_codes = (
+ InquirySupplier.objects.filter(inquiry_no=inq_no)
+ .values_list("supplier_code", flat=True)
+ .distinct()
+ )
+ supplier_codes = {str(c).strip() for c in raw_codes if c and str(c).strip()}
+ if not supplier_codes:
+ return False
+
+ for code in supplier_codes:
+ qm = QuotationMaster.objects.filter(inquiry_no=inq_no, supplier_code=code).first()
+ st = int(qm.status) if qm is not None and qm.status is not None else None
+ if st != 3:
+ return False
+
+ old_status = int(inq.status if inq.status is not None else 0)
+ now = timezone.now()
+ username = (str(actor_username).strip()[:20] if actor_username else None) or None
+ inq.status = 5
+ inq.update_time = now
+ inq.update_datetime = now
+ if username:
+ inq.update_user = username
+ inq.save(update_fields=["status", "update_time", "update_user", "update_datetime"])
+ qn = (quotation_no or "-").strip()[:20] or "-"
+ RFQOperationLogs.try_append(
+ inquiry_no=inq.inquiry_no,
+ purchase_type=int(inq.purchase_type),
+ operation_type=6,
+ operation_user=username,
+ quotation_no=qn,
+ per_status=old_status,
+ cur_status=5,
+ operation_desc="全部供应商已提交报价,询价单同步为报价结束",
+ )
+ return True
+
class InquirySupplier(models.Model):
"""杂采询价单-供应商关联表"""
@@ -600,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 6bdfe42..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
@@ -247,6 +401,14 @@ class CostEstimateTemplateViewSet(CustomModelViewSet):
class InquiryViewSet(CustomModelViewSet):
+ """
+ 杂采询价单 CRUD/发布等。
+
+ 与供应商报价单联动:当 `pissupplier.QuotationMasterViewSet.sync_expired` 将超时报价置为已过期后,
+ 若该询价下已无待报价/报价中的报价单,且主表状态仍为「发布」(3) 或「报价中」(4),
+ 会由 `Inquiry.sync_to_quote_closed_when_no_open_quotations` 将询价单置为「报价结束」(5)。
+ """
+
queryset = Inquiry.objects.all().prefetch_related(
"suppliers",
"attachments",
@@ -572,7 +734,7 @@ class InquiryViewSet(CustomModelViewSet):
20,
) or None
current_time = timezone.now()
- quote_deadline = inquiry.quote_deadline.strftime("%Y-%m-%d %H:%M:%S") if inquiry.quote_deadline else None
+ quote_deadline = inquiry.quote_deadline
inquiry_materials = list(inquiry.material_costs.all())
inquiry_processes = list(inquiry.process_costs.all())
@@ -862,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/models.py b/backend/apps/pissupplier/models.py
index 669ef57..153102d 100644
--- a/backend/apps/pissupplier/models.py
+++ b/backend/apps/pissupplier/models.py
@@ -38,7 +38,7 @@ class QuotationMaster(models.Model):
contact_person = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人" )
contact_phone = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人电话" )
contact_email = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人邮件" )
- quote_deadline = models.CharField( max_length=20, null=True, blank=True, verbose_name="报价截止时间" )
+ quote_deadline = models.DateTimeField( null=True, blank=True, verbose_name="报价截止时间" )
validity_days = models.IntegerField( null=True, blank=True, verbose_name="有效天数" )
delivery_days = models.IntegerField( null=True, blank=True, verbose_name="交货周期" )
payment_method = models.IntegerField( choices=PAYMENT_METHOD_CHOICES, null=True, blank=True, verbose_name="付款方式" )
@@ -48,7 +48,12 @@ class QuotationMaster(models.Model):
null=True,
blank=True,
verbose_name="状态",
- help_text="1 待报价 2 已报价 3 已过期;置为已报价请走供应商端 POST quotation_master/{id}/submit/",
+ help_text=(
+ "1 待报价 2 报价中 3 已报价 4 已过期;"
+ "置为已报价请走 POST quotation_master/{id}/submit/;"
+ "超时批量置过期及询价单收口见 sync_expired / Inquiry.sync_to_quote_closed_when_no_open_quotations;"
+ "名单内供应商均已提交见 submit 与 Inquiry.sync_to_quote_closed_when_all_suppliers_quoted。"
+ ),
)
is_awarded = models.IntegerField( choices=AWARD_STATUS_CHOICES, default=0, null=True, blank=True, verbose_name="报价中标否" )
createuser = models.CharField( max_length=20, null=True, blank=True, verbose_name="创建人员" )
diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py
index 0ad4857..4f29777 100644
--- a/backend/apps/pissupplier/serializers.py
+++ b/backend/apps/pissupplier/serializers.py
@@ -376,13 +376,24 @@ class SupplierInquiryAttachmentSerializer(serializers.ModelSerializer):
class QuotationMasterSerializer(BusinessAuditSerializer):
- """杂采报价单主表序列化器"""
+ """杂采报价单主表序列化器
+
+ `status`:1 待报价、2 报价中、3 已报价、4 已过期;超时由 POST `quotation_master/sync_expired/` 批量更新。
+ 同步过期后,若询价单 `Inquiry` 仍为「发布」(3) 或「报价中」(4) 且该询价下已无任何待报价/报价中单,
+ 则询价单会置为「报价结束」(5),见 `Inquiry.sync_to_quote_closed_when_no_open_quotations`。
+ 全部供应商名单均已正式提交报价(均为已报价)时由 `POST …/submit/` 内 `Inquiry.sync_to_quote_closed_when_all_suppliers_quoted` 收口。
+ """
creattime = serializers.DateTimeField(
format="%Y-%m-%d %H:%M:%S",
required=False,
allow_null=True,
)
+ quote_deadline = serializers.DateTimeField(
+ format="%Y-%m-%d %H:%M:%S",
+ required=False,
+ allow_null=True,
+ )
bid_start_time = serializers.DateTimeField(
format="%Y-%m-%d %H:%M:%S",
required=False,
@@ -503,7 +514,11 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer):
contact_person = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True)
contact_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True)
contact_email = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True)
- quote_deadline = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True)
+ quote_deadline = serializers.DateTimeField(
+ format="%Y-%m-%d %H:%M:%S",
+ required=False,
+ allow_null=True,
+ )
validity_days = serializers.IntegerField(required=False, allow_null=True)
delivery_days = serializers.IntegerField(required=False, allow_null=True)
payment_method = serializers.IntegerField(required=False, allow_null=True)
diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py
index b74e834..ba85652 100644
--- a/backend/apps/pissupplier/views.py
+++ b/backend/apps/pissupplier/views.py
@@ -4,7 +4,7 @@ from django.db import transaction
from django.utils import timezone
from rest_framework.decorators import action
-from dvadmin.utils.json_response import DetailResponse, ErrorResponse
+from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse
from dvadmin.utils.viewset import CustomModelViewSet
from apps.pisadmin.miscprocurement.models import Inquiry, RFQOperationLogs
@@ -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
@@ -60,6 +66,32 @@ class QuotationMasterViewSet(CustomModelViewSet):
)
ordering = ("-creattime", "-autoid")
+ @action(methods=["post"], detail=False, url_path="sync_expired")
+ def sync_expired(self, request):
+ """
+ 将当前用户数据权限范围内、状态为待报价(1)或报价中(2)、且已超过报价截止时间的报价单
+ 更新为已过期(4)。若某询价单下已无任何待报价/报价中的报价单,且询价单仍为「发布」或「报价中」,
+ 则将该询价单置为「报价结束」(5)。供供应商端列表加载前调用。
+ """
+ now = timezone.now()
+ qs = self.filter_queryset(self.get_queryset()).filter(
+ status__in=(1, 2),
+ quote_deadline__isnull=False,
+ quote_deadline__lt=now,
+ )
+ inquiry_nos = list(qs.values_list("inquiry_no", flat=True).distinct())
+ username = getattr(getattr(request, "user", None), "username", None)
+ with transaction.atomic():
+ updated = qs.update(status=4)
+ inquiries_closed = Inquiry.sync_to_quote_closed_when_no_open_quotations(
+ inquiry_nos,
+ actor_username=username,
+ )
+ return SuccessResponse(
+ data={"updated": updated, "inquiries_closed": inquiries_closed},
+ msg="同步成功",
+ )
+
def perform_create(self, serializer):
serializer.save()
@@ -126,6 +158,9 @@ class QuotationMasterViewSet(CustomModelViewSet):
instance = self.get_object()
if instance.status not in (1, 2):
return ErrorResponse(msg="仅未报价状态可进入报价中")
+ dl = getattr(instance, "quote_deadline", None)
+ if dl is not None and dl < timezone.now():
+ return ErrorResponse(msg="已超过报价截止时间")
instance.status = 2
instance.quotetime = timezone.now()
username = getattr(getattr(request, "user", None), "username", None)
@@ -147,14 +182,27 @@ class QuotationMasterViewSet(CustomModelViewSet):
instance = self.get_object()
if instance.status != 2:
return ErrorResponse(msg="仅报价中状态可提交报价")
+ dl = getattr(instance, "quote_deadline", None)
+ if dl is not None and dl < timezone.now():
+ return ErrorResponse(msg="已超过报价截止时间")
instance.status = 3
instance.quotetime = timezone.now()
username = getattr(getattr(request, "user", None), "username", None)
if username:
instance.quoteuser = username
- instance.save(update_fields=["status", "quotetime", "quoteuser"])
+ inquiry_quote_closed = False
+ with transaction.atomic():
+ instance.save(update_fields=["status", "quotetime", "quoteuser"])
+ inquiry_quote_closed = Inquiry.sync_to_quote_closed_when_all_suppliers_quoted(
+ instance.inquiry_no,
+ actor_username=username,
+ quotation_no=getattr(instance, "quotation_no", None),
+ )
serializer = self.get_serializer(instance)
- return DetailResponse(data=serializer.data, msg="提交成功")
+ payload = dict(serializer.data)
+ payload["inquiry_quote_closed"] = inquiry_quote_closed
+ msg = "提交成功" + (",询价单已进入报价结束" if inquiry_quote_closed else "")
+ return DetailResponse(data=payload, msg=msg)
class QuotationAttachmentViewSet(CustomModelViewSet):
diff --git a/web/src/views/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 @@
-
+
+
+
+
+
+
+
-