1
0
forked from erp-dev/erp

feat: shipment change && version modelize

This commit is contained in:
2026-07-09 23:03:22 +08:00
parent 36e4bb6de6
commit 48e4782e1e
23 changed files with 947 additions and 36 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):