新增 杂采比议价、询价履历记录 实现;调整部分ui效果

This commit is contained in:
nebula_chen
2026-03-27 14:32:50 +08:00
parent 7af8dbde95
commit 6f2000041f
12 changed files with 2004 additions and 73 deletions
@@ -1,7 +1,13 @@
import logging
from typing import Optional
from django.db import models
from django.utils import timezone
from dvadmin.utils.models import CoreModel, table_prefix
logger = logging.getLogger(__name__)
class MiscProcurementMaterialInfo(CoreModel):
"""杂采材料信息"""
@@ -205,6 +211,10 @@ class Inquiry(CoreModel):
(3, "模具/夹具"),
(4, "管"),
)
BUYING_METHOD_CHOICES = (
(1, "询价"),
(2, "招标"),
)
inquiry_no = models.CharField(max_length=20, unique=True, db_index=True, verbose_name="询价单号")
title = models.CharField(max_length=20, verbose_name="询价单名称")
@@ -246,6 +256,9 @@ class Inquiry(CoreModel):
create_user = models.CharField(max_length=20, db_column="createuser", null=True, blank=True, verbose_name="创建人")
update_user = models.CharField(max_length=20, db_column="UpdateUser", null=True, blank=True, verbose_name="最后更新人")
update_time = models.DateTimeField(db_column="UpdateTime", null=True, blank=True, verbose_name="最后更新时间")
buying_method = models.IntegerField(choices=BUYING_METHOD_CHOICES, null=True, blank=True, verbose_name="采购方式(寻源方式)")
bid_start_time = models.DateTimeField(null=True, blank=True, verbose_name="投标开始时间")
bid_end_time = models.DateTimeField(null=True, blank=True, verbose_name="投标截止时间")
class Meta:
db_table = table_prefix + "proc_inquiry_master"
@@ -546,3 +559,240 @@ class InquiryRfqItem(models.Model):
def __str__(self) -> str: # pragma: no cover - simple repr
return f"{self.inquiry_no_id}-{self.part_id}"
class MiscLowPriceHeader(CoreModel):
"""比价-制程最低价记录主表。"""
# 成本类别: 1-材料; 2-加工; 3-包装费; 4-运输费; 5-管销研费用; 6-利润率(与库表 CostType varchar 一致)
COST_TYPE_CHOICES = (
("1", "材料"),
("2", "加工"),
("3", "包装费"),
("4", "运输费"),
("5", "管销研费用"),
("6", "利润率"),
)
inquiry_no = models.CharField(
max_length=20,
db_index=True,
db_column="inquiry_no",
verbose_name="询价单号",
help_text="业务主键列之一;逻辑主键与 id 并存",
)
part_id = models.CharField(max_length=50, db_column="PartId", verbose_name="产品料号")
# 来源单号(报价单&询价单);设计库字段名为 SouceNo
souce_no = models.CharField(
max_length=20,
null=True,
blank=True,
db_column="SouceNo",
verbose_name="来源单号(报价单&询价单)",
)
cost_type = models.CharField(
max_length=50,
choices=COST_TYPE_CHOICES,
db_column="CostType",
verbose_name="成本类别",
)
item_no = models.CharField(
max_length=100,
db_column="ItemNo",
verbose_name="项次名",
help_text="如:铝等项次名称",
)
min_price = models.CharField(
max_length=10,
db_column="MinPrice",
verbose_name="最低价格",
help_text="表结构为 varchar,若需参与运算可在业务层转换",
)
class Meta:
db_table = table_prefix + "misc_low_price_header"
verbose_name = "比价-制程最低价记录主表"
verbose_name_plural = verbose_name
ordering = ("-create_datetime", "id")
indexes = [
models.Index(fields=["inquiry_no", "part_id"]),
]
def __str__(self) -> str:
return f"{self.inquiry_no}-{self.part_id}-{self.cost_type}"
class MiscLowPriceDetail(CoreModel):
"""比价-制程最低价记录次表"""
ITEM_NO_CHOICES = (
(1, "重量"),
(2, "单价"),
)
inquiry_no = models.CharField(max_length=20, db_column="inquiry_no", db_index=True, verbose_name="询价单号")
part_id = models.CharField(max_length=50, db_column="PartId", verbose_name="产品料号")
cost_type = models.CharField(
max_length=50,
db_column="CostType",
verbose_name="成本类别",
help_text="仅支持1-材料",
)
material_spec = models.CharField(max_length=50, db_column="materialspec", verbose_name="材料规格")
item_no = models.CharField(
max_length=100,
db_column="ItemNo",
verbose_name="项次名",
help_text="策采:重量、损耗、单价;杂采:重量、单价",
choices=ITEM_NO_CHOICES,
)
value = models.CharField(max_length=10, db_column="Value", verbose_name="最小值")
souce_no = models.CharField(
max_length=20,
db_column="SouceNo",
verbose_name="来源单号(报价单&询价单)",
)
class Meta:
db_table = table_prefix + "misc_low_price_detail"
verbose_name = "比价-制程最低价记录次表"
verbose_name_plural = verbose_name
ordering = ("-create_datetime", "id")
indexes = [
models.Index(fields=["inquiry_no", "part_id"]),
]
def __str__(self) -> str:
return f"{self.inquiry_no}-{self.part_id}-{self.item_no}"
class MiscNegotiationRecords(CoreModel):
"""杂采议价记录表"""
inquiry_no = models.CharField(max_length=20, db_column="inquiry_no", db_index=True, verbose_name="询价单号")
part_id = models.CharField(max_length=50, db_column="PartId", verbose_name="产品料号")
supplier_code = models.CharField(max_length=50, null=True, blank=True, db_column="SupplierCode", verbose_name="供应商代码")
bargaining_price = models.DecimalField(max_digits=12, decimal_places=4, null=True, blank=True, default=0, verbose_name="议价后价格")
bargaining_time = models.DateTimeField(null=True, blank=True, verbose_name="议价时间")
bargaining_user = models.CharField(max_length=20, null=True, blank=True, db_column="BargainingUser", verbose_name="议价人")
is_awarded = models.IntegerField(default=0, null=True, blank=True, verbose_name="是否中标", help_text="是否中标(1:是 0:否)")
quotation_no = models.CharField(max_length=20, null=True, blank=True, db_column="QuotationNo", verbose_name="报价单号")
total_price_excl_tax = models.DecimalField(max_digits=12, decimal_places=4, null=True, blank=True, default=0, verbose_name="议价前不含税总价")
total_price_incl_tax = models.DecimalField(max_digits=12, decimal_places=4, null=True, blank=True, default=0, verbose_name="议价前含税总价")
class Meta:
db_table = table_prefix + "misc_negotiation_records"
verbose_name = "杂采议价记录表"
verbose_name_plural = verbose_name
ordering = ("-create_datetime", "id")
indexes = [
models.Index(fields=["inquiry_no", "part_id"]),
models.Index(
fields=["inquiry_no", "part_id", "quotation_no"],
name="pis_misc_ne_inq_part_qtn_idx",
),
]
def __str__(self) -> str:
return f"{self.inquiry_no}-{self.part_id}-{self.quotation_no or self.supplier_code}"
class RFQOperationLogs(CoreModel):
"""询价单操作日志表"""
# (01询价单创;02询价单确认;03询价单发布;04询价单还原;05询价发送通知;06报价;07比议价;08议价审核提交;09议价审核完成;10议价审核驳回)
OPERATION_TYPE_CHOICES = (
(1, "询价单创建"),
(2, "询价单确认"),
(3, "询价单发布"),
(4, "询价单还原"),
(5, "询价发送通知"),
(6, "报价"),
(7, "比议价"),
(8, "议价审核提交"),
(9, "议价审核完成"),
(10, "议价审核驳回"),
)
PURCHASE_TYPE_CHOICES = (
(1, "策采"),
(2, "杂采"),
)
operation_type = models.CharField(max_length=20, db_column="OperationType", verbose_name="操作类型", choices=OPERATION_TYPE_CHOICES)
operation_user = models.CharField(max_length=20, null=True, blank=True, db_column="OperationUser", verbose_name="操作人")
operation_time = models.DateTimeField(null=True, blank=True, db_column="OperationTime", verbose_name="操作时间")
operation_desc = models.TextField(max_length=200, null=True, blank=True, db_column="OperationDesc", verbose_name="操作描述")
inquiry_no = models.CharField(max_length=20, db_column="InquiryNo", db_index=True, verbose_name="询价单号")
quotation_no = models.CharField(max_length=20, null=False, blank=False, db_column="QuotationNo", db_index=True, verbose_name="报价单号")
per_status = models.CharField(max_length=50, null=True, blank=True, db_column="PerStatus", verbose_name="作业前状态")
cur_status = models.TextField(max_length=100, null=True, blank=True, db_column="CurStatus", verbose_name="作业后状态")
is_show_user = models.IntegerField(null=True, blank=True, db_column="IsShowUser", verbose_name="履历显示否", help_text="履历显示否(1:是 0:否)")
purchase_type = models.IntegerField(choices=PURCHASE_TYPE_CHOICES, null=False, blank=False, db_column="PurchaseType", verbose_name="采购类别", help_text="采购类别(1:策采 2:杂采)")
class Meta:
db_table = table_prefix + "rfq_operation_logs"
verbose_name = "询价单操作日志"
verbose_name_plural = verbose_name
ordering = ("-create_datetime", "id")
indexes = [
models.Index(fields=["inquiry_no", "quotation_no"]),
]
@classmethod
def inquiry_status_display(cls, code: Optional[int]) -> str:
"""询价单状态码 → 与 Inquiry.STATUS_CHOICES 一致的可读文案。"""
if code is None:
return ""
try:
c = int(code)
except (TypeError, ValueError):
return str(code)
return dict(Inquiry.STATUS_CHOICES).get(c, str(c))
@classmethod
def append(
cls,
*,
inquiry_no: str,
purchase_type: int,
operation_type: int,
operation_user: Optional[str] = None,
quotation_no: Optional[str] = None,
per_status: Optional[int] = None,
cur_status: Optional[int] = None,
operation_desc: Optional[str] = None,
is_show_user: int = 1,
) -> None:
"""
写入一条询价操作日志。quotation_no 无关联报价单时使用 \"-\"。
operation_type 与 OPERATION_TYPE_CHOICES 取值 1–10 一致。
"""
qn = (quotation_no or "-").strip()[:20] or "-"
op_user = (operation_user or "").strip()[:20] if operation_user else None
now = timezone.now()
per_str = cls.inquiry_status_display(per_status) if per_status is not None else None
cur_str = cls.inquiry_status_display(cur_status) if cur_status is not None else None
desc = (operation_desc or "").strip()[:200] if operation_desc else None
cls.objects.create(
operation_type=str(int(operation_type)),
operation_user=op_user,
operation_time=now,
operation_desc=desc,
inquiry_no=(inquiry_no or "")[:20],
quotation_no=qn,
per_status=(per_str[:50] if per_str else None),
cur_status=(cur_str[:100] if cur_str else None),
is_show_user=is_show_user,
purchase_type=int(purchase_type),
)
@classmethod
def try_append(cls, **kwargs) -> None:
"""写入失败不影响主流程,仅记录异常日志。"""
try:
cls.append(**kwargs)
except Exception:
logger.exception("写入询价操作日志失败")
def __str__(self) -> str:
return f"{self.inquiry_no}-{self.quotation_no}"
@@ -9,6 +9,10 @@ from .models import (
MiscProcurementMaterialInfo,
MiscProcurementStationInfo,
MiscProcMaterial,
MiscLowPriceHeader,
MiscLowPriceDetail,
MiscNegotiationRecords,
RFQOperationLogs,
Inquiry,
InquirySupplier,
InquiryAttachment,
@@ -262,6 +266,141 @@ class MiscPartCreateUpdateSerializer(CustomModelSerializer):
fields = "__all__"
class MiscLowPriceHeaderSerializer(CustomModelSerializer):
"""比价-制程最低价记录主表"""
class Meta:
model = MiscLowPriceHeader
fields = "__all__"
read_only_fields = [
"id",
"create_datetime",
"update_datetime",
"creator",
"modifier",
"dept_belong_id",
]
class MiscLowPriceDetailSerializer(CustomModelSerializer):
"""比价-制程最低价记录次表"""
class Meta:
model = MiscLowPriceDetail
fields = "__all__"
read_only_fields = [
"id",
"create_datetime",
"update_datetime",
"creator",
"modifier",
"dept_belong_id",
]
class MiscNegotiationRecordsSerializer(CustomModelSerializer):
"""杂采议价记录表"""
class Meta:
model = MiscNegotiationRecords
fields = "__all__"
read_only_fields = [
"id",
"create_datetime",
"update_datetime",
"creator",
"modifier",
"dept_belong_id",
]
class RFQOperationLogsSerializer(CustomModelSerializer):
"""询价单操作日志(rfq_operation_logs)"""
operation_type = serializers.IntegerField(required=True)
operation_time = serializers.DateTimeField(
format="%Y-%m-%d %H:%M:%S",
required=False,
allow_null=True,
)
class Meta:
model = RFQOperationLogs
fields = "__all__"
read_only_fields = [
"id",
"create_datetime",
"update_datetime",
"creator",
"modifier",
"dept_belong_id",
]
extra_kwargs = {
"inquiry_no": {"required": True, "allow_blank": False},
"quotation_no": {"required": True, "allow_blank": False},
"purchase_type": {"required": True},
}
def validate_operation_type(self, value):
valid_values = {choice[0] for choice in RFQOperationLogs.OPERATION_TYPE_CHOICES}
if value not in valid_values:
raise serializers.ValidationError("操作类型取值不合法")
return value
def validate_purchase_type(self, value):
valid_values = {choice[0] for choice in RFQOperationLogs.PURCHASE_TYPE_CHOICES}
if value not in valid_values:
raise serializers.ValidationError("采购类别取值不合法")
return value
def _normalize_operation_type(self, validated_data: dict) -> None:
ot = validated_data.get("operation_type")
if ot is not None and not isinstance(ot, str):
validated_data["operation_type"] = str(int(ot))
def create(self, validated_data):
self._normalize_operation_type(validated_data)
return super().create(validated_data)
def update(self, instance, validated_data):
self._normalize_operation_type(validated_data)
return super().update(instance, validated_data)
def to_representation(self, instance):
data = super().to_representation(instance)
ot = data.get("operation_type")
if ot is not None and ot != "":
try:
data["operation_type"] = int(ot)
except (TypeError, ValueError):
pass
return data
class MiscNegotiationRecordBatchItemSerializer(serializers.Serializer):
"""比价保存时按报价单维度的议价行"""
quotation_no = serializers.CharField(max_length=20)
supplier_code = serializers.CharField(max_length=50, required=False, allow_blank=True)
is_awarded = serializers.IntegerField(required=False, default=0)
bargaining_price = serializers.DecimalField(
max_digits=12, decimal_places=4, required=False, allow_null=True
)
total_price_excl_tax = serializers.DecimalField(
max_digits=12, decimal_places=4, required=False, allow_null=True
)
total_price_incl_tax = serializers.DecimalField(
max_digits=12, decimal_places=4, required=False, allow_null=True
)
class MiscNegotiationSaveSerializer(serializers.Serializer):
"""比价/议价结果批量写入杂采议价记录表"""
part_id = serializers.CharField(max_length=50)
records = MiscNegotiationRecordBatchItemSerializer(many=True)
class CostEstimateTemplateBodySerializer(serializers.ModelSerializer):
supplier_behavior = serializers.SerializerMethodField()
item_name_en = serializers.CharField(required=False, allow_blank=True, allow_null=True)
@@ -890,6 +1029,16 @@ class InquirySerializer(CustomModelSerializer):
rfq_items = InquiryRfqItemSerializer(many=True, required=False)
# 列表/详情展示:company_code → 公司信息简称(同请求内按代码缓存)
company_short_name = serializers.SerializerMethodField(read_only=True)
bid_start_time = serializers.DateTimeField(
format="%Y-%m-%d %H:%M:%S",
required=False,
allow_null=True,
)
bid_end_time = serializers.DateTimeField(
format="%Y-%m-%d %H:%M:%S",
required=False,
allow_null=True,
)
def get_company_short_name(self, obj):
code = (getattr(obj, "company_code", None) or "").strip()
@@ -931,8 +1080,32 @@ class InquirySerializer(CustomModelSerializer):
"inquiry_no": {"required": False, "allow_blank": True, "allow_null": True},
# 由 resolve_inquiry_template_version 根据 template 从 CostEstimateTemplateHead 写入
"template_version": {"required": False, "allow_null": True},
"buying_method": {"required": False, "allow_null": True},
}
def validate_buying_method(self, value):
if value is None:
return value
valid_values = {choice[0] for choice in Inquiry.BUYING_METHOD_CHOICES}
if value not in valid_values:
raise serializers.ValidationError("采购方式(寻源方式)取值不合法")
return value
def _apply_inquiry_bid_times_for_buying_method(self, attrs, instance):
"""采购方式为询价(1)时,投标开始/截止时间不入库。"""
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
try:
bm = int(bm)
except (TypeError, ValueError):
return
if bm == 1:
attrs["bid_start_time"] = None
attrs["bid_end_time"] = None
def validate(self, attrs):
instance = getattr(self, "instance", None)
@@ -941,6 +1114,7 @@ class InquirySerializer(CustomModelSerializer):
# 局部更新且未改模板/版本:保持库中原值
if instance and not template_in_attrs and not tv_in_attrs:
self._apply_inquiry_bid_times_for_buying_method(attrs, instance)
return attrs
template_no = attrs.get("template")
@@ -963,6 +1137,7 @@ class InquirySerializer(CustomModelSerializer):
}
)
attrs["template_version"] = resolved
self._apply_inquiry_bid_times_for_buying_method(attrs, instance)
return attrs
def _generate_code(self, validated_data: dict) -> str:
@@ -4,6 +4,8 @@ from .views import (
MiscMaterialViewSet,
MiscStationViewSet,
MiscPartViewSet,
MiscLowPriceHeaderViewSet,
MiscLowPriceDetailViewSet,
InquiryViewSet,
InquirySupplierViewSet,
InquiryAttachmentViewSet,
@@ -13,6 +15,7 @@ from .views import (
InquiryProfitCostViewSet,
InquiryRfqItemViewSet,
CostEstimateTemplateViewSet,
RFQOperationLogsViewSet,
)
router = routers.SimpleRouter()
@@ -27,7 +30,10 @@ router.register(r'inquiry_process_cost', InquiryProcessCostViewSet)
router.register(r'inquiry_other_cost', InquiryOtherCostViewSet)
router.register(r'inquiry_profit_cost', InquiryProfitCostViewSet)
router.register(r'inquiry_rfq_item', InquiryRfqItemViewSet)
router.register(r'proc_low_price_header', MiscLowPriceHeaderViewSet)
router.register(r'proc_low_price_detail', MiscLowPriceDetailViewSet)
router.register(r'cost_template', CostEstimateTemplateViewSet)
router.register(r'rfq_operation_logs', RFQOperationLogsViewSet)
urlpatterns = []
urlpatterns += router.urls
+284 -3
View File
@@ -1,3 +1,5 @@
from decimal import Decimal
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
@@ -27,6 +29,10 @@ from .models import (
MiscProcurementMaterialInfo,
MiscProcurementStationInfo,
MiscProcMaterial,
MiscLowPriceHeader,
MiscLowPriceDetail,
MiscNegotiationRecords,
RFQOperationLogs,
Inquiry,
InquirySupplier,
InquiryAttachment,
@@ -45,6 +51,11 @@ from .serializers import (
MiscStationCreateUpdateSerializer,
MiscPartSerializer,
MiscPartCreateUpdateSerializer,
MiscLowPriceHeaderSerializer,
MiscLowPriceDetailSerializer,
MiscNegotiationRecordsSerializer,
MiscNegotiationSaveSerializer,
RFQOperationLogsSerializer,
InquirySerializer,
InquirySupplierSerializer,
InquiryAttachmentSerializer,
@@ -59,6 +70,20 @@ from .serializers import (
)
def _negotiation_totals_from_quotation_item(quotation_no: str, part_id: str):
"""从杂采报价单上阶物料明细取议价前含税/不含税总价(与 part_id 匹配行)。"""
if not quotation_no or not part_id:
return None, None
item = (
QuotationItem.objects.filter(quotation_no=quotation_no, part_id=part_id)
.order_by("autoid")
.first()
)
if not item:
return None, None
return item.total_price_excl_tax, item.total_price_incl_tax
class MiscMaterialViewSet(CustomModelViewSet):
queryset = MiscProcurementMaterialInfo.objects.all()
serializer_class = MiscMaterialSerializer
@@ -232,7 +257,16 @@ class InquiryViewSet(CustomModelViewSet):
"rfq_items",
)
serializer_class = InquirySerializer
filter_fields = ("inquiry_no", "title", "purchase_type", "template", "template_version", "status", "buyer")
filter_fields = (
"inquiry_no",
"title",
"purchase_type",
"template",
"template_version",
"status",
"buyer",
"buying_method",
)
search_fields = ("inquiry_no", "title", "material_type", "buyer", "remark")
ordering = ("-update_datetime",)
@@ -326,7 +360,21 @@ class InquiryViewSet(CustomModelViewSet):
return ErrorResponse(msg="当前询价单状态仅允许查看,不允许编辑或删除;如需修改请先还原为“开立”")
return None
def _save_status(self, instance, *, status, confirm_user=None, confirm_time=None, release_user=None, release_time=None, comparison_user=None, comparison_time=None):
def _save_status(
self,
instance,
*,
status,
confirm_user=None,
confirm_time=None,
release_user=None,
release_time=None,
comparison_user=None,
comparison_time=None,
operation_type=None,
operation_desc=None,
):
old_status = int(instance.status if instance.status is not None else 0)
current_time = timezone.now()
current_user = self._get_request_username() or getattr(instance, "update_user", None)
instance.status = status
@@ -353,6 +401,18 @@ class InquiryViewSet(CustomModelViewSet):
"update_time",
]
)
new_status = int(status)
if operation_type is not None and old_status != new_status:
RFQOperationLogs.try_append(
inquiry_no=instance.inquiry_no,
purchase_type=int(instance.purchase_type),
operation_type=operation_type,
operation_user=current_user or None,
quotation_no="-",
per_status=old_status,
cur_status=new_status,
operation_desc=operation_desc,
)
serializer = self.get_serializer(instance)
return DetailResponse(data=serializer.data, msg="状态更新成功")
@@ -537,6 +597,9 @@ class InquiryViewSet(CustomModelViewSet):
contact_phone=supplier["contact_phone"] or None,
contact_email=supplier["contact_email"] or None,
quote_deadline=quote_deadline,
buying_method=getattr(inquiry, "buying_method", None),
bid_start_time=getattr(inquiry, "bid_start_time", None),
bid_end_time=getattr(inquiry, "bid_end_time", None),
delivery_days=getattr(inquiry, "lead_time_days", None),
payment_method=getattr(inquiry, "payment_method", None),
status=1,
@@ -655,7 +718,17 @@ class InquiryViewSet(CustomModelViewSet):
QuotationItem.objects.bulk_create(item_bulk)
def perform_create(self, serializer):
serializer.save()
inquiry = serializer.save()
RFQOperationLogs.try_append(
inquiry_no=inquiry.inquiry_no,
purchase_type=int(inquiry.purchase_type),
operation_type=1,
operation_user=self._get_request_username() or None,
quotation_no="-",
per_status=None,
cur_status=int(inquiry.status) if inquiry.status is not None else 1,
operation_desc="询价单创建",
)
def perform_update(self, serializer):
serializer.save()
@@ -717,6 +790,8 @@ class InquiryViewSet(CustomModelViewSet):
confirm_time=current_time,
release_user=getattr(instance, "release_user", None),
release_time=getattr(instance, "release_time", None),
operation_type=2,
operation_desc="询价单确认",
)
@action(methods=["put"], detail=True)
@@ -731,6 +806,8 @@ class InquiryViewSet(CustomModelViewSet):
confirm_time=None,
release_user=None,
release_time=None,
operation_type=4,
operation_desc="询价单还原为开立",
)
@action(methods=["put"], detail=True)
@@ -751,6 +828,8 @@ class InquiryViewSet(CustomModelViewSet):
confirm_time=getattr(instance, "confirm_time", None),
release_user=current_user,
release_time=current_time,
operation_type=3,
operation_desc="询价单发布",
)
except serializers.ValidationError as exc:
detail = getattr(exc, "detail", None)
@@ -760,6 +839,160 @@ class InquiryViewSet(CustomModelViewSet):
msg = str(detail or exc)
return ErrorResponse(msg=msg)
@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='只有“比议价中”状态的询价单才能确认议价')
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="议价审核提交(进入价格审核)",
)
@action(methods=["put"], detail=True)
def submit_price_audit(self, request, pk=None):
"""价格审核提交:由「价格审核」(7) 进入「核价通过」(8)。"""
instance = self.get_object()
if int(instance.status or 0) != self.STATUS_NEGOTIATED:
return ErrorResponse(msg='只有「价格审核」状态的询价单才能提交核价')
return self._save_status(
instance,
status=self.STATUS_APPROVED,
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=9,
operation_desc="议价审核完成(核价通过)",
)
@action(methods=["get"], detail=True, url_path="negotiation_records")
def negotiation_records(self, request, pk=None):
"""按询价单号查询杂采议价记录(可选 part_id)。"""
instance = self.get_object()
part_id = (request.query_params.get("part_id") or "").strip()
qs = MiscNegotiationRecords.objects.filter(inquiry_no=instance.inquiry_no)
if part_id:
qs = qs.filter(part_id=part_id)
data = MiscNegotiationRecordsSerializer(qs.order_by("id"), many=True).data
return DetailResponse(data=data, msg="success")
@action(methods=["put"], detail=True, url_path="save_negotiation_records")
def save_negotiation_records(self, request, pk=None):
"""按报价单写入杂采议价记录:议价结果 + 该报价单议价前含税/不含税总价快照(来自上阶物料明细)。"""
instance = self.get_object()
serializer = MiscNegotiationSaveSerializer(data=request.data)
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 不能为空")
records = serializer.validated_data.get("records") or []
username = self._get_request_username() or None
now = timezone.now()
with transaction.atomic():
for row in records:
qn = (row.get("quotation_no") or "").strip()
if not qn:
return ErrorResponse(msg="records 中每条须包含 quotation_no(报价单号)")
code = (row.get("supplier_code") or "").strip()
if not code:
qm = QuotationMaster.objects.filter(quotation_no=qn).only("supplier_code").first()
if qm:
code = (qm.supplier_code or "").strip()
is_awarded = int(row.get("is_awarded") or 0)
# 议价价格独立于是否中标:用户填写即落库(清空时前端传 null)
bp = row.get("bargaining_price")
snap_ex, snap_in = _negotiation_totals_from_quotation_item(qn, part_id)
tex = row.get("total_price_excl_tax")
tin = row.get("total_price_incl_tax")
if tex is None:
tex = snap_ex
if tin is None:
tin = snap_in
if tex is None:
tex = Decimal("0")
if tin is None:
tin = Decimal("0")
defaults = {
"supplier_code": code or None,
"is_awarded": is_awarded,
"bargaining_user": username,
"bargaining_time": now,
"bargaining_price": bp,
"quotation_no": qn,
"total_price_excl_tax": tex,
"total_price_incl_tax": tin,
}
qs = MiscNegotiationRecords.objects.filter(
inquiry_no=instance.inquiry_no,
part_id=part_id,
quotation_no=qn,
)
if qs.count() > 1:
keep_id = qs.order_by("id").first().id
qs.exclude(id=keep_id).delete()
obj = (
MiscNegotiationRecords.objects.filter(
inquiry_no=instance.inquiry_no,
part_id=part_id,
quotation_no=qn,
)
.order_by("id")
.first()
)
if obj:
for k, v in defaults.items():
setattr(obj, k, v)
obj.save()
else:
MiscNegotiationRecords.objects.create(
inquiry_no=instance.inquiry_no,
part_id=part_id,
**defaults,
)
out_qs = MiscNegotiationRecords.objects.filter(
inquiry_no=instance.inquiry_no,
part_id=part_id,
).order_by("id")
return DetailResponse(
data=MiscNegotiationRecordsSerializer(out_qs, many=True).data,
msg="议价记录已保存",
)
def _notify_vendors_on_publish(self, inquiry: Inquiry):
"""
按询价单供应商子表汇总(与 `_create_supplier_quotations` 相同的 `_group_inquiry_suppliers`),
@@ -924,3 +1157,51 @@ class InquiryRfqItemViewSet(CustomModelViewSet):
def perform_update(self, serializer):
serializer.save()
class MiscLowPriceHeaderViewSet(CustomModelViewSet):
"""比价-制程最低价记录主表"""
queryset = MiscLowPriceHeader.objects.all()
serializer_class = MiscLowPriceHeaderSerializer
filter_fields = ("inquiry_no", "part_id", "souce_no", "cost_type", "item_no")
search_fields = ("inquiry_no", "part_id", "souce_no", "item_no")
ordering = ("-create_datetime", "id")
class MiscLowPriceDetailViewSet(CustomModelViewSet):
"""比价-制程最低价记录次表"""
queryset = MiscLowPriceDetail.objects.all()
serializer_class = MiscLowPriceDetailSerializer
filter_fields = ("inquiry_no", "part_id", "cost_type", "material_spec", "item_no", "souce_no")
search_fields = ("inquiry_no", "part_id", "material_spec", "item_no", "souce_no")
ordering = ("-create_datetime", "id")
class MiscNegotiationRecordsViewSet(CustomModelViewSet):
"""杂采议价记录表"""
queryset = MiscNegotiationRecords.objects.all()
serializer_class = MiscNegotiationRecordsSerializer
filter_fields = ("inquiry_no", "part_id", "supplier_code")
search_fields = ("inquiry_no", "part_id", "supplier_code")
ordering = ("-create_datetime", "id")
class RFQOperationLogsViewSet(CustomModelViewSet):
"""询价单操作日志(rfq_operation_logs)"""
queryset = RFQOperationLogs.objects.all()
serializer_class = RFQOperationLogsSerializer
create_serializer_class = RFQOperationLogsSerializer
update_serializer_class = RFQOperationLogsSerializer
filter_fields = (
"inquiry_no",
"quotation_no",
"operation_type",
"operation_user",
"purchase_type",
)
search_fields = ("inquiry_no", "quotation_no", "operation_user", "operation_desc")
ordering = ("-create_datetime", "-id")
+8
View File
@@ -25,6 +25,11 @@ class QuotationMaster(models.Model):
(1, "已中标"),
)
BUYING_METHOD_CHOICES = (
(1, "询价"),
(2, "招标"),
)
autoid = models.BigAutoField( primary_key=True, db_column="autoId", verbose_name="自增ID" )
quotation_no = models.CharField( max_length=20, unique=True, db_index=True, verbose_name="报价单单号" )
inquiry_no = models.CharField( max_length=20, db_index=True, verbose_name="询价单单号", help_text="报价单关联的询价单单号" )
@@ -53,6 +58,9 @@ class QuotationMaster(models.Model):
null=True, blank=True, verbose_name="报价时间", help_text="正式提交报价时由 submit 接口写入当前时间"
)
remark = models.TextField( null=True, blank=True, verbose_name="报价说明及备注" )
buying_method = models.IntegerField(choices=BUYING_METHOD_CHOICES, null=True, blank=True, verbose_name="采购方式(寻源方式)")
bid_start_time = models.DateTimeField(null=True, blank=True, verbose_name="投标开始时间")
bid_end_time = models.DateTimeField(null=True, blank=True, verbose_name="投标截止时间")
class Meta:
db_table = table_prefix + "sup_quotation_master"
+21
View File
@@ -383,6 +383,16 @@ class QuotationMasterSerializer(BusinessAuditSerializer):
required=False,
allow_null=True,
)
bid_start_time = serializers.DateTimeField(
format="%Y-%m-%d %H:%M:%S",
required=False,
allow_null=True,
)
bid_end_time = serializers.DateTimeField(
format="%Y-%m-%d %H:%M:%S",
required=False,
allow_null=True,
)
# 列表/详情:询价主表 company_code + 公司信息简称(同请求内按 inquiry_no 缓存,减轻重复查询)
inquiry_company_code = serializers.SerializerMethodField(read_only=True)
inquiry_company_short_name = serializers.SerializerMethodField(read_only=True)
@@ -504,6 +514,9 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer):
quoteuser = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True)
quotetime = serializers.DateTimeField(required=False, allow_null=True)
remark = serializers.CharField(required=False, allow_blank=True, allow_null=True)
buying_method = serializers.IntegerField(required=False, allow_null=True)
bid_start_time = serializers.DateTimeField(required=False, allow_null=True)
bid_end_time = serializers.DateTimeField(required=False, allow_null=True)
audit_create_user_field = "createuser"
audit_create_time_field = "creattime"
@@ -548,6 +561,14 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer):
raise serializers.ValidationError("中标状态值不合法")
return value
def validate_buying_method(self, value):
if value is None:
return value
valid_values = {choice[0] for choice in QuotationMaster.BUYING_METHOD_CHOICES}
if value not in valid_values:
raise serializers.ValidationError("采购方式(寻源方式)取值不合法")
return value
def _upsert_attachments(self, quotation, attachments):
QuotationAttachment.objects.filter(quotation_no=quotation).delete()
if not attachments:
+23 -3
View File
@@ -7,7 +7,7 @@ from rest_framework.decorators import action
from dvadmin.utils.json_response import DetailResponse, ErrorResponse
from dvadmin.utils.viewset import CustomModelViewSet
from apps.pisadmin.miscprocurement.models import Inquiry
from apps.pisadmin.miscprocurement.models import Inquiry, RFQOperationLogs
from apps.pissupplier.models import (
QuotationMaster,
QuotationAttachment,
@@ -84,7 +84,12 @@ class QuotationMasterViewSet(CustomModelViewSet):
return super().partial_update(request, *args, **kwargs)
@staticmethod
def _sync_inquiry_when_quotation_quoting(inquiry_no: str, *, actor_username: Optional[str] = None):
def _sync_inquiry_when_quotation_quoting(
inquiry_no: str,
*,
actor_username: Optional[str] = None,
quotation_no: Optional[str] = None,
):
"""
任意报价单进入「报价中」(status=2) 时,若询价单仍为「发布」(3),同步为「报价中」(4)。
已进入报价中(4) 的询价单无需再改;不回退报价结束及之后状态。
@@ -95,6 +100,7 @@ class QuotationMasterViewSet(CustomModelViewSet):
inq = Inquiry.objects.filter(inquiry_no=inq_no, status=3).first()
if not inq:
return
old_status = int(inq.status if inq.status is not None else 0)
now = timezone.now()
update_user = (str(actor_username).strip()[:20] if actor_username else None) or None
inq.status = 4
@@ -103,6 +109,16 @@ class QuotationMasterViewSet(CustomModelViewSet):
if update_user:
inq.update_user = update_user
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=update_user,
quotation_no=(quotation_no or "-")[:20],
per_status=old_status,
cur_status=4,
operation_desc="供应商进入报价中,询价单同步为报价中",
)
@action(methods=["post"], detail=True, url_path="quote")
def quote(self, request, pk=None):
@@ -117,7 +133,11 @@ class QuotationMasterViewSet(CustomModelViewSet):
instance.quoteuser = username
with transaction.atomic():
instance.save(update_fields=["status", "quotetime", "quoteuser"])
self._sync_inquiry_when_quotation_quoting(instance.inquiry_no, actor_username=username)
self._sync_inquiry_when_quotation_quoting(
instance.inquiry_no,
actor_username=username,
quotation_no=getattr(instance, "quotation_no", None),
)
serializer = self.get_serializer(instance)
return DetailResponse(data=serializer.data, msg="报价中状态更新成功")