forked from erp-dev/erp
feat: shipment change && version modelize
This commit is contained in:
@@ -14,6 +14,41 @@ from shipment.models import (
|
||||
)
|
||||
|
||||
|
||||
SHIPMENT_STAGE_MISSING_ADDRESS = "missing_address"
|
||||
SHIPMENT_STAGE_DELIVERABLE = "deliverable"
|
||||
SHIPMENT_STAGE_SCHEDULED = "scheduled"
|
||||
SHIPMENT_STAGE_DELIVERED = "delivered"
|
||||
SHIPMENT_STAGE_CANCELLED = "cancelled"
|
||||
|
||||
SHIPMENT_STAGE_DISPLAY = {
|
||||
SHIPMENT_STAGE_MISSING_ADDRESS: "待补地址",
|
||||
SHIPMENT_STAGE_DELIVERABLE: "可送货",
|
||||
SHIPMENT_STAGE_SCHEDULED: "已排车",
|
||||
SHIPMENT_STAGE_DELIVERED: "已送达",
|
||||
SHIPMENT_STAGE_CANCELLED: "已取消",
|
||||
}
|
||||
|
||||
|
||||
def resolve_shipment_stage(shipment: Shipment) -> str | None:
|
||||
if shipment.status == ShipmentStatus.CANCELLED:
|
||||
return SHIPMENT_STAGE_CANCELLED
|
||||
|
||||
delivery = getattr(shipment, "delivery", None)
|
||||
if delivery is not None:
|
||||
if delivery.status == ShipmentDeliveryStatus.DELIVERED:
|
||||
return SHIPMENT_STAGE_DELIVERED
|
||||
if delivery.status in {
|
||||
ShipmentDeliveryStatus.PENDING,
|
||||
ShipmentDeliveryStatus.IN_TRANSIT,
|
||||
}:
|
||||
return SHIPMENT_STAGE_SCHEDULED
|
||||
return None
|
||||
|
||||
if (shipment.address or "").strip():
|
||||
return SHIPMENT_STAGE_DELIVERABLE
|
||||
return SHIPMENT_STAGE_MISSING_ADDRESS
|
||||
|
||||
|
||||
def _build_nested_sales_item_context(items):
|
||||
customer_ids = {item.customer_id for item in items if item.customer_id}
|
||||
printing_job_ids = {item.printing_job_id for item in items if item.printing_job_id}
|
||||
@@ -128,6 +163,8 @@ class ShipmentSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
approved_by_name = serializers.SerializerMethodField()
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
shipment_stage = serializers.SerializerMethodField()
|
||||
shipment_stage_display = serializers.SerializerMethodField()
|
||||
external_finished_products_count = serializers.SerializerMethodField()
|
||||
merchant_id = serializers.IntegerField(source="merchant.id", read_only=True)
|
||||
merchant_name = serializers.CharField(source="merchant.name", read_only=True)
|
||||
@@ -157,6 +194,8 @@ class ShipmentSerializer(serializers.ModelSerializer):
|
||||
"remark",
|
||||
"status",
|
||||
"status_display",
|
||||
"shipment_stage",
|
||||
"shipment_stage_display",
|
||||
"external_id",
|
||||
"geo_coordinates",
|
||||
"extra",
|
||||
@@ -191,6 +230,13 @@ class ShipmentSerializer(serializers.ModelSerializer):
|
||||
def get_items_count(self, obj):
|
||||
return obj.items.filter(delete_at__isnull=True).count()
|
||||
|
||||
def get_shipment_stage(self, obj):
|
||||
return resolve_shipment_stage(obj)
|
||||
|
||||
def get_shipment_stage_display(self, obj):
|
||||
stage = resolve_shipment_stage(obj)
|
||||
return SHIPMENT_STAGE_DISPLAY.get(stage)
|
||||
|
||||
def get_order_description(self, obj):
|
||||
return _resolve_shipment_order_description(obj)
|
||||
|
||||
|
||||
@@ -2430,6 +2430,79 @@ class ShipmentQueryAPITestCase(TestCase):
|
||||
self.assertEqual(data["results"][0]["id"], self.shipment1.id)
|
||||
self.assertEqual(data["results"][0]["delivery_id"], delivery.id)
|
||||
|
||||
def test_list_shipments_supports_shipment_stage_filter_and_fields(self):
|
||||
self.shipment1.address = ""
|
||||
self.shipment1.save(update_fields=["address", "updated_at"])
|
||||
|
||||
deliverable = shipment_models.Shipment.objects.create(
|
||||
merchant=self.merchant1,
|
||||
customer=self.customer1,
|
||||
shipment_date="2026-01-20",
|
||||
created_by=self.user1,
|
||||
address="绍兴市测试路 8 号",
|
||||
)
|
||||
scheduled_delivery = shipment_models.ShipmentDelivery.objects.create(
|
||||
merchant=self.merchant1,
|
||||
driver_name="排车司机",
|
||||
vehicle_trip="STAGE-SCHEDULED",
|
||||
status=shipment_models.ShipmentDeliveryStatus.PENDING,
|
||||
created_by=self.user1,
|
||||
)
|
||||
scheduled = shipment_models.Shipment.objects.create(
|
||||
merchant=self.merchant1,
|
||||
customer=self.customer1,
|
||||
shipment_date="2026-01-21",
|
||||
created_by=self.user1,
|
||||
address="已排车地址",
|
||||
delivery=scheduled_delivery,
|
||||
)
|
||||
delivered_delivery = shipment_models.ShipmentDelivery.objects.create(
|
||||
merchant=self.merchant1,
|
||||
driver_name="送达司机",
|
||||
vehicle_trip="STAGE-DELIVERED",
|
||||
status=shipment_models.ShipmentDeliveryStatus.DELIVERED,
|
||||
created_by=self.user1,
|
||||
)
|
||||
delivered = shipment_models.Shipment.objects.create(
|
||||
merchant=self.merchant1,
|
||||
customer=self.customer1,
|
||||
shipment_date="2026-01-22",
|
||||
created_by=self.user1,
|
||||
address="已送达地址",
|
||||
delivery=delivered_delivery,
|
||||
)
|
||||
cancelled = shipment_models.Shipment.objects.create(
|
||||
merchant=self.merchant1,
|
||||
customer=self.customer1,
|
||||
shipment_date="2026-01-23",
|
||||
created_by=self.user1,
|
||||
address="取消地址",
|
||||
status=shipment_models.ShipmentStatus.CANCELLED,
|
||||
)
|
||||
|
||||
cases = [
|
||||
("missing_address", self.shipment1.id, "待补地址"),
|
||||
("deliverable", deliverable.id, "可送货"),
|
||||
("scheduled", scheduled.id, "已排车"),
|
||||
("delivered", delivered.id, "已送达"),
|
||||
("cancelled", cancelled.id, "已取消"),
|
||||
]
|
||||
for stage, expected_id, expected_display in cases:
|
||||
with self.subTest(stage=stage):
|
||||
resp = self.client.get(f"/api/v1/shipment/shipments/?shipment_stage={stage}&limit=1")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
data = resp.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["results"][0]["id"], expected_id)
|
||||
self.assertEqual(data["results"][0]["shipment_stage"], stage)
|
||||
self.assertEqual(data["results"][0]["shipment_stage_display"], expected_display)
|
||||
|
||||
def test_list_shipments_rejects_invalid_shipment_stage(self):
|
||||
resp = self.client.get("/api/v1/shipment/shipments/?shipment_stage=unknown")
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("shipment_stage", resp.json())
|
||||
|
||||
def test_retrieve_shipment_success(self):
|
||||
resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -3,6 +3,7 @@ Shipment API ViewSet
|
||||
"""
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -11,7 +12,7 @@ from rest_framework.views import APIView
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
|
||||
from flower.viewsets import LimitedLimitOffsetPagination
|
||||
from shipment.models import Shipment, ShipmentDelivery
|
||||
from shipment.models import Shipment, ShipmentDelivery, ShipmentDeliveryStatus, ShipmentStatus
|
||||
|
||||
from .serializers import (
|
||||
SalesItemDetailSerializer,
|
||||
@@ -30,9 +31,23 @@ from .serializers import (
|
||||
ShipmentCreateExternalSerializer,
|
||||
ShipmentSalesItemCustomerSerializer,
|
||||
ShipmentUpdateSerializer,
|
||||
SHIPMENT_STAGE_CANCELLED,
|
||||
SHIPMENT_STAGE_DELIVERABLE,
|
||||
SHIPMENT_STAGE_DELIVERED,
|
||||
SHIPMENT_STAGE_MISSING_ADDRESS,
|
||||
SHIPMENT_STAGE_SCHEDULED,
|
||||
)
|
||||
|
||||
|
||||
SHIPMENT_STAGE_CHOICES = {
|
||||
SHIPMENT_STAGE_MISSING_ADDRESS,
|
||||
SHIPMENT_STAGE_DELIVERABLE,
|
||||
SHIPMENT_STAGE_SCHEDULED,
|
||||
SHIPMENT_STAGE_DELIVERED,
|
||||
SHIPMENT_STAGE_CANCELLED,
|
||||
}
|
||||
|
||||
|
||||
def _build_sales_item_serializer_context(items):
|
||||
customer_ids = {item.customer_id for item in items if item.customer_id}
|
||||
printing_job_ids = {item.printing_job_id for item in items if item.printing_job_id}
|
||||
@@ -125,13 +140,14 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
|
||||
- status: 状态(1=草稿, 2=已发布, 3=已取消, 4=已驳回, 5=已审核)
|
||||
- delivery_id: 送货单ID(仅当传入具体ID时过滤;null/空值不触发过滤)
|
||||
- delivery_isnull: 是否仅查询未绑定/已绑定送货单的出货单(true/false)
|
||||
- shipment_stage: 逻辑状态(missing_address/deliverable/scheduled/delivered/cancelled)
|
||||
- external_id: 外部订单号(精确匹配)
|
||||
- shipment_date_from: 出货日期起始(YYYY-MM-DD)
|
||||
- shipment_date_to: 出货日期结束(YYYY-MM-DD,包含整天)
|
||||
"""
|
||||
qs = (
|
||||
Shipment.objects.all()
|
||||
.select_related("merchant", "customer", "created_by", "cancelled_by")
|
||||
.select_related("merchant", "customer", "created_by", "cancelled_by", "delivery")
|
||||
.prefetch_related(
|
||||
"items",
|
||||
"external_finished_products",
|
||||
@@ -190,6 +206,35 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
|
||||
elif normalized in {"0", "false", "no"}:
|
||||
qs = qs.exclude(address="")
|
||||
|
||||
shipment_stage = self.request.query_params.get("shipment_stage")
|
||||
if shipment_stage:
|
||||
normalized_stage = shipment_stage.strip()
|
||||
if normalized_stage not in SHIPMENT_STAGE_CHOICES:
|
||||
allowed = ", ".join(sorted(SHIPMENT_STAGE_CHOICES))
|
||||
raise ValidationError({"shipment_stage": f"shipment_stage 必须是 {allowed} 之一"})
|
||||
|
||||
if normalized_stage == SHIPMENT_STAGE_CANCELLED:
|
||||
qs = qs.filter(status=ShipmentStatus.CANCELLED)
|
||||
else:
|
||||
qs = qs.exclude(status=ShipmentStatus.CANCELLED)
|
||||
if normalized_stage == SHIPMENT_STAGE_MISSING_ADDRESS:
|
||||
qs = qs.filter(delivery_id__isnull=True, address="")
|
||||
elif normalized_stage == SHIPMENT_STAGE_DELIVERABLE:
|
||||
qs = qs.filter(delivery_id__isnull=True).exclude(address="")
|
||||
elif normalized_stage == SHIPMENT_STAGE_SCHEDULED:
|
||||
qs = qs.filter(
|
||||
delivery_id__isnull=False,
|
||||
delivery__status__in=[
|
||||
ShipmentDeliveryStatus.PENDING,
|
||||
ShipmentDeliveryStatus.IN_TRANSIT,
|
||||
],
|
||||
)
|
||||
elif normalized_stage == SHIPMENT_STAGE_DELIVERED:
|
||||
qs = qs.filter(
|
||||
delivery_id__isnull=False,
|
||||
delivery__status=ShipmentDeliveryStatus.DELIVERED,
|
||||
)
|
||||
|
||||
return qs.order_by("-created_at", "-id")
|
||||
|
||||
def get(self, request):
|
||||
|
||||
Reference in New Issue
Block a user