1
0
forked from erp-dev/erp

feat: shipment_delivery

This commit is contained in:
2026-04-03 16:55:22 +08:00
parent 371cd796e7
commit 76b30cf4f4
16 changed files with 2513 additions and 9 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-04-03 03:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api_v1', '0013_printingexternalsyncfailure_and_more'),
]
operations = [
migrations.AlterField(
model_name='printingexternalsyncfailure',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
]

View File

@@ -37,6 +37,11 @@ from .views.shipment import (
SalesItemByCustomerView, SalesItemByCustomerView,
SalesItemByPrintingOrderView, SalesItemByPrintingOrderView,
SalesItemCreateView, SalesItemCreateView,
ShipmentDeliveryBindShipmentsView,
ShipmentDeliveryCancelView,
ShipmentDeliveryDetailView,
ShipmentDeliveryListCreateView,
ShipmentDeliveryStatusUpdateView,
ShipmentSalesItemCustomerListView, ShipmentSalesItemCustomerListView,
ShipmentListCreateView, ShipmentListCreateView,
ShipmentDetailView, ShipmentDetailView,
@@ -308,6 +313,31 @@ urlpatterns = [
ShipmentExternalCreateView.as_view(), ShipmentExternalCreateView.as_view(),
name="shipment_create_external", name="shipment_create_external",
), ),
path(
"shipment/deliveries/",
ShipmentDeliveryListCreateView.as_view(),
name="shipment_delivery_list_create",
),
path(
"shipment/deliveries/<int:pk>/",
ShipmentDeliveryDetailView.as_view(),
name="shipment_delivery_detail",
),
path(
"shipment/deliveries/<int:pk>/status/",
ShipmentDeliveryStatusUpdateView.as_view(),
name="shipment_delivery_status_update",
),
path(
"shipment/deliveries/<int:pk>/cancel/",
ShipmentDeliveryCancelView.as_view(),
name="shipment_delivery_cancel",
),
path(
"shipment/deliveries/<int:pk>/bind-shipments/",
ShipmentDeliveryBindShipmentsView.as_view(),
name="shipment_delivery_bind_shipments",
),
path( path(
"shipment/sales-items/customers/", "shipment/sales-items/customers/",
ShipmentSalesItemCustomerListView.as_view(), ShipmentSalesItemCustomerListView.as_view(),

View File

@@ -8,6 +8,11 @@ from .views import (
SalesItemByCustomerView, SalesItemByCustomerView,
SalesItemByPrintingOrderView, SalesItemByPrintingOrderView,
SalesItemCreateView, SalesItemCreateView,
ShipmentDeliveryBindShipmentsView,
ShipmentDeliveryDetailView,
ShipmentDeliveryListCreateView,
ShipmentDeliveryCancelView,
ShipmentDeliveryStatusUpdateView,
ShipmentSalesItemCustomerListView, ShipmentSalesItemCustomerListView,
ShipmentListCreateView, ShipmentListCreateView,
ShipmentDetailView, ShipmentDetailView,
@@ -19,6 +24,11 @@ __all__ = [
'SalesItemByCustomerView', 'SalesItemByCustomerView',
'SalesItemByPrintingOrderView', 'SalesItemByPrintingOrderView',
'SalesItemCreateView', 'SalesItemCreateView',
'ShipmentDeliveryBindShipmentsView',
'ShipmentDeliveryDetailView',
'ShipmentDeliveryListCreateView',
'ShipmentDeliveryCancelView',
'ShipmentDeliveryStatusUpdateView',
'ShipmentSalesItemCustomerListView', 'ShipmentSalesItemCustomerListView',
'ShipmentListCreateView', 'ShipmentListCreateView',
'ShipmentDetailView', 'ShipmentDetailView',

View File

@@ -4,7 +4,61 @@ Shipment API 序列化器
from rest_framework import serializers from rest_framework import serializers
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment from shipment.models import (
ExternalFinishedProduct,
SalesItem,
Shipment,
ShipmentDelivery,
ShipmentDeliveryStatus,
)
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}
customer_name_map = {}
if customer_ids:
from basic_info.models import Customer
customer_name_map = dict(
Customer.objects.filter(id__in=customer_ids).values_list("id", "name")
)
printing_order_map = {}
external_order_id_map = {}
product_image_map = {}
if printing_job_ids:
from printing.models import PrintingJob
printing_jobs = list(
PrintingJob.objects.filter(id__in=printing_job_ids).select_related(
"printing_order", "product"
)
)
printing_order_map = {job.id: job.printing_order_id for job in printing_jobs}
external_order_id_map = {
job.id: getattr(job.printing_order, "external_order_id", None)
for job in printing_jobs
}
for job in printing_jobs:
if not getattr(job, "product", None):
product_image_map[job.id] = None
continue
primary_url = job.product.get_primary_image_url()
if primary_url:
product_image_map[job.id] = primary_url
elif job.product.image:
product_image_map[job.id] = job.product.image.url
else:
product_image_map[job.id] = None
return {
"customer_name_map": customer_name_map,
"printing_order_map": printing_order_map,
"external_order_id_map": external_order_id_map,
"product_image_map": product_image_map,
}
class ShipmentSerializer(serializers.ModelSerializer): class ShipmentSerializer(serializers.ModelSerializer):
@@ -42,6 +96,9 @@ class ShipmentSerializer(serializers.ModelSerializer):
"customer", "customer",
"customer_name", "customer_name",
"shipment_date", "shipment_date",
"address",
"contact_name",
"contact_phone",
"area", "area",
"remark", "remark",
"status", "status",
@@ -100,7 +157,13 @@ class ShipmentSerializer(serializers.ModelSerializer):
# 优先使用 prefetch 的 related manager兜底为 none() # 优先使用 prefetch 的 related manager兜底为 none()
rel = getattr(obj, "items", None) rel = getattr(obj, "items", None)
items = list(rel.all()) if rel is not None else [] items = list(rel.all()) if rel is not None else []
return SalesItemSerializer(items, many=True).data serializer_context = dict(self.context)
serializer_context.update(_build_nested_sales_item_context(items))
return SalesItemDetailSerializer(
items,
many=True,
context=serializer_context,
).data
def get_external_finished_products(self, obj): def get_external_finished_products(self, obj):
""" """
@@ -150,6 +213,27 @@ class ShipmentCreateNormalSerializer(serializers.Serializer):
customer = serializers.IntegerField(help_text="客户ID") customer = serializers.IntegerField(help_text="客户ID")
shipment_date = serializers.DateField(help_text="出货日期") shipment_date = serializers.DateField(help_text="出货日期")
address = serializers.CharField(
max_length=255,
required=False,
default="",
allow_blank=True,
help_text="地址(可选)",
)
contact_name = serializers.CharField(
max_length=100,
required=False,
default="",
allow_blank=True,
help_text="联系人(可选)",
)
contact_phone = serializers.CharField(
max_length=50,
required=False,
default="",
allow_blank=True,
help_text="联系电话(可选)",
)
area = serializers.CharField( area = serializers.CharField(
max_length=30, max_length=30,
required=False, required=False,
@@ -194,6 +278,27 @@ class ShipmentCreateExternalSerializer(serializers.Serializer):
customer = serializers.IntegerField(help_text="客户ID") customer = serializers.IntegerField(help_text="客户ID")
shipment_date = serializers.DateField(help_text="出货日期") shipment_date = serializers.DateField(help_text="出货日期")
address = serializers.CharField(
max_length=255,
required=False,
default="",
allow_blank=True,
help_text="地址(可选)",
)
contact_name = serializers.CharField(
max_length=100,
required=False,
default="",
allow_blank=True,
help_text="联系人(可选)",
)
contact_phone = serializers.CharField(
max_length=50,
required=False,
default="",
allow_blank=True,
help_text="联系电话(可选)",
)
area = serializers.CharField( area = serializers.CharField(
max_length=30, max_length=30,
required=False, required=False,
@@ -232,6 +337,15 @@ class ShipmentUpdateSerializer(serializers.Serializer):
customer = serializers.IntegerField(required=False, help_text="客户ID可选") customer = serializers.IntegerField(required=False, help_text="客户ID可选")
shipment_date = serializers.DateField(required=False, help_text="出货日期(可选)") shipment_date = serializers.DateField(required=False, help_text="出货日期(可选)")
address = serializers.CharField(
max_length=255, required=False, allow_blank=True, help_text="地址(可选)"
)
contact_name = serializers.CharField(
max_length=100, required=False, allow_blank=True, help_text="联系人(可选)"
)
contact_phone = serializers.CharField(
max_length=50, required=False, allow_blank=True, help_text="联系电话(可选)"
)
area = serializers.CharField( area = serializers.CharField(
max_length=30, required=False, allow_blank=True, help_text="出货地区(可选)" max_length=30, required=False, allow_blank=True, help_text="出货地区(可选)"
) )
@@ -360,6 +474,194 @@ class ShipmentSalesItemCustomerSerializer(serializers.Serializer):
unshipped_sales_items_count = serializers.IntegerField(read_only=True) unshipped_sales_items_count = serializers.IntegerField(read_only=True)
class ShipmentDeliveryShipmentSummarySerializer(serializers.ModelSerializer):
customer_name = serializers.CharField(source="customer.name", read_only=True)
status_display = serializers.CharField(source="get_status_display", read_only=True)
class Meta:
model = Shipment
fields = [
"id",
"customer",
"customer_name",
"shipment_date",
"status",
"status_display",
"external_id",
]
read_only_fields = fields
class ShipmentDeliverySerializer(serializers.ModelSerializer):
status_display = serializers.CharField(source="get_status_display", read_only=True)
merchant_id = serializers.IntegerField(source="merchant.id", read_only=True)
merchant_name = serializers.CharField(source="merchant.name", read_only=True)
created_by_id = serializers.IntegerField(
source="created_by.id", read_only=True, allow_null=True
)
created_by_name = serializers.SerializerMethodField()
operator_id = serializers.IntegerField(
source="operator.id", read_only=True, allow_null=True
)
operator_name = serializers.CharField(source="operator.name", read_only=True, allow_null=True)
cancelled_by_id = serializers.IntegerField(
source="cancelled_by.id", read_only=True, allow_null=True
)
cancelled_by_name = serializers.SerializerMethodField()
shipments_count = serializers.SerializerMethodField()
shipments = serializers.SerializerMethodField()
class Meta:
model = ShipmentDelivery
fields = [
"id",
"merchant_id",
"merchant_name",
"driver_name",
"vehicle_trip",
"contact_phone",
"vehicle_capacity",
"remark",
"internal_remark",
"status",
"status_display",
"started_at",
"delivered_at",
"cancelled_at",
"shipments_count",
"shipments",
"created_by_id",
"created_by_name",
"operator_id",
"operator_name",
"cancelled_by_id",
"cancelled_by_name",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_created_by_name(self, obj):
if obj.created_by:
employee = getattr(obj.created_by, "employee", None)
if employee:
return employee.name
return obj.created_by.username
return None
def get_cancelled_by_name(self, obj):
if obj.cancelled_by:
employee = getattr(obj.cancelled_by, "employee", None)
if employee:
return employee.name
return obj.cancelled_by.username
return None
def get_shipments_count(self, obj):
return obj.shipments.count()
def get_shipments(self, obj):
return ShipmentDeliveryShipmentSummarySerializer(
obj.shipments.order_by("id"),
many=True,
).data
class ShipmentDeliveryCreateSerializer(serializers.Serializer):
driver_name = serializers.CharField(max_length=100, help_text="司机名")
vehicle_trip = serializers.CharField(max_length=100, help_text="车次")
contact_phone = serializers.CharField(
max_length=50,
required=False,
default="",
allow_blank=True,
help_text="联系电话(可选)",
)
vehicle_capacity = serializers.CharField(
max_length=100,
required=False,
default="",
allow_blank=True,
help_text="车辆容量(可选)",
)
remark = serializers.CharField(
max_length=200,
required=False,
default="",
allow_blank=True,
help_text="备注(可选)",
)
internal_remark = serializers.CharField(
max_length=200,
required=False,
default="",
allow_blank=True,
help_text="内部备注(可选)",
)
shipments = serializers.ListField(
child=serializers.IntegerField(),
required=False,
default=list,
help_text="要关联的出货单ID列表",
)
def validate_shipments(self, value):
return list(dict.fromkeys(value)) if value else []
class ShipmentDeliveryUpdateSerializer(serializers.Serializer):
driver_name = serializers.CharField(
max_length=100, required=False, allow_blank=False, help_text="司机名(可选)"
)
vehicle_trip = serializers.CharField(
max_length=100, required=False, allow_blank=False, help_text="车次(可选)"
)
contact_phone = serializers.CharField(
max_length=50, required=False, allow_blank=True, help_text="联系电话(可选)"
)
vehicle_capacity = serializers.CharField(
max_length=100, required=False, allow_blank=True, help_text="车辆容量(可选)"
)
remark = serializers.CharField(
max_length=200, required=False, allow_blank=True, help_text="备注(可选)"
)
internal_remark = serializers.CharField(
max_length=200, required=False, allow_blank=True, help_text="内部备注(可选)"
)
shipments = serializers.ListField(
child=serializers.IntegerField(),
required=False,
help_text="要绑定的出货单ID列表可选传入即视为替换",
)
def validate_shipments(self, value):
return list(dict.fromkeys(value)) if value else []
class ShipmentDeliveryStatusUpdateSerializer(serializers.Serializer):
status = serializers.ChoiceField(
choices=[
(ShipmentDeliveryStatus.PENDING, ShipmentDeliveryStatus.PENDING.label),
(ShipmentDeliveryStatus.IN_TRANSIT, ShipmentDeliveryStatus.IN_TRANSIT.label),
(ShipmentDeliveryStatus.DELIVERED, ShipmentDeliveryStatus.DELIVERED.label),
],
help_text="送货单状态1=待送货, 2=送货中, 3=已送达)",
)
class ShipmentDeliveryBindShipmentsSerializer(serializers.Serializer):
shipments = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text="要追加绑定到当前送货单的出货单ID列表",
)
def validate_shipments(self, value):
if not value:
raise serializers.ValidationError("shipments 不能为空")
return list(dict.fromkeys(value))
class SalesItemCreateSerializer(serializers.Serializer): class SalesItemCreateSerializer(serializers.Serializer):
""" """
销售品创建序列化器 销售品创建序列化器

View File

@@ -6,6 +6,7 @@ from decimal import Decimal
from django.test import TestCase from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from rest_framework.test import APIClient, APITestCase from rest_framework.test import APIClient, APITestCase
from rest_framework import status from rest_framework import status
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -935,6 +936,9 @@ class ShipmentCreateAPITestCase(TestCase):
data = { data = {
"customer": self.customer.id, "customer": self.customer.id,
"shipment_date": "2026-01-14", "shipment_date": "2026-01-14",
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "华东", "area": "华东",
"remark": "测试备注", "remark": "测试备注",
"sales_items": [self.sales_item1.id, self.sales_item2.id], "sales_items": [self.sales_item1.id, self.sales_item2.id],
@@ -950,6 +954,9 @@ class ShipmentCreateAPITestCase(TestCase):
self.assertEqual(result["customer"], self.customer.id) self.assertEqual(result["customer"], self.customer.id)
self.assertEqual(result["customer_name"], self.customer.name) self.assertEqual(result["customer_name"], self.customer.name)
self.assertEqual(result["shipment_date"], "2026-01-14") self.assertEqual(result["shipment_date"], "2026-01-14")
self.assertEqual(result["address"], "杭州市测试路 1 号")
self.assertEqual(result["contact_name"], "张三")
self.assertEqual(result["contact_phone"], "13800138000")
self.assertEqual(result.get("area", ""), "华东") self.assertEqual(result.get("area", ""), "华东")
self.assertEqual(result["remark"], "测试备注") self.assertEqual(result["remark"], "测试备注")
self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT) self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT)
@@ -1106,6 +1113,9 @@ class ShipmentExternalCreateAPITestCase(TestCase):
data = { data = {
"customer": self.customer.id, "customer": self.customer.id,
"shipment_date": "2026-01-14", "shipment_date": "2026-01-14",
"address": "绍兴市仓库 2 号",
"contact_name": "李四",
"contact_phone": "13900139000",
"area": "华南", "area": "华南",
"remark": "external 备注", "remark": "external 备注",
"external_id": "EXT-ORDER-001", "external_id": "EXT-ORDER-001",
@@ -1123,6 +1133,9 @@ class ShipmentExternalCreateAPITestCase(TestCase):
self.assertEqual(result["customer"], self.customer.id) self.assertEqual(result["customer"], self.customer.id)
self.assertEqual(result["external_id"], "EXT-ORDER-001") self.assertEqual(result["external_id"], "EXT-ORDER-001")
self.assertEqual(result["address"], "绍兴市仓库 2 号")
self.assertEqual(result["contact_name"], "李四")
self.assertEqual(result["contact_phone"], "13900139000")
self.assertEqual(result.get("area", ""), "华南") self.assertEqual(result.get("area", ""), "华南")
self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT) self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT)
self.assertEqual(result["status_display"], "草稿(未发布)") self.assertEqual(result["status_display"], "草稿(未发布)")
@@ -1234,6 +1247,38 @@ class ShipmentQueryAPITestCase(TestCase):
merchant=self.merchant2, name="客户2", mobile="13900139011", area="B" merchant=self.merchant2, name="客户2", mobile="13900139011", area="B"
) )
self.category1 = basic_models.ProductCategory.objects.create(
merchant=self.merchant1,
name="分类1",
)
self.product1 = basic_models.Product.objects.create(
merchant=self.merchant1,
category=self.category1,
name="产品1",
human_id="SHIPMENTQ001",
mdy_image_url="https://example.com/shipment-list-image.jpg",
)
self.state1 = stateflow_models.State.objects.create(name="查询状态")
self.process1 = stateflow_models.Process.objects.create(name="查询流程")
self.process1.replace_nodes([self.state1])
self.printing_order1 = printing_models.PrintingOrder.objects.create(
merchant=self.merchant1,
customer=self.customer1,
fabric="查询面料",
width="150cm",
process=self.process1,
created_by=self.user1,
external_order_id="QUERY-PO-001",
)
self.printing_job1 = printing_models.PrintingJob.objects.create(
merchant=self.merchant1,
printing_order=self.printing_order1,
product=self.product1,
quantity=20,
unit="",
created_by=self.user1,
)
self.shipment1 = shipment_models.Shipment.objects.create( self.shipment1 = shipment_models.Shipment.objects.create(
merchant=self.merchant1, merchant=self.merchant1,
customer=self.customer1, customer=self.customer1,
@@ -1250,6 +1295,16 @@ class ShipmentQueryAPITestCase(TestCase):
remark="s2", remark="s2",
area="B1", area="B1",
) )
self.sales_item1 = shipment_models.SalesItem.objects.create(
merchant=self.merchant1,
shipment=self.shipment1,
name="列表销售品",
quantity=Decimal("12.00"),
unit=shipment_models.UnitChoices.METER,
printing_job_id=self.printing_job1.id,
customer_id=self.customer1.id,
created_by=self.user1,
)
self.client.force_authenticate(user=self.user1) self.client.force_authenticate(user=self.user1)
@@ -1267,12 +1322,16 @@ class ShipmentQueryAPITestCase(TestCase):
# 关键:列表序列化器必须稳定输出明细数组字段(即使为空) # 关键:列表序列化器必须稳定输出明细数组字段(即使为空)
item = next(it for it in data["results"] if it["id"] == self.shipment1.id) item = next(it for it in data["results"] if it["id"] == self.shipment1.id)
self.assertIn("address", item)
self.assertIn("contact_name", item)
self.assertIn("contact_phone", item)
self.assertIn("area", item) self.assertIn("area", item)
self.assertEqual(item["area"], "A1") self.assertEqual(item["area"], "A1")
self.assertEqual(item["status"], shipment_models.ShipmentStatus.DRAFT) self.assertEqual(item["status"], shipment_models.ShipmentStatus.DRAFT)
self.assertEqual(item["status_display"], "草稿(未发布)") self.assertEqual(item["status_display"], "草稿(未发布)")
self.assertIn("sales_items", item) self.assertIn("sales_items", item)
self.assertIsInstance(item["sales_items"], list) self.assertIsInstance(item["sales_items"], list)
self.assertEqual(item["sales_items"][0]["product_image_url"], "https://example.com/shipment-list-image.jpg")
self.assertIn("external_finished_products", item) self.assertIn("external_finished_products", item)
self.assertIsInstance(item["external_finished_products"], list) self.assertIsInstance(item["external_finished_products"], list)
@@ -1280,6 +1339,9 @@ class ShipmentQueryAPITestCase(TestCase):
resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/") resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
result = resp.json() result = resp.json()
self.assertIn("address", result)
self.assertIn("contact_name", result)
self.assertIn("contact_phone", result)
self.assertEqual(result["id"], self.shipment1.id) self.assertEqual(result["id"], self.shipment1.id)
self.assertIn("area", result) self.assertIn("area", result)
self.assertEqual(result["area"], "A1") self.assertEqual(result["area"], "A1")
@@ -1287,6 +1349,7 @@ class ShipmentQueryAPITestCase(TestCase):
self.assertEqual(result["status_display"], "草稿(未发布)") self.assertEqual(result["status_display"], "草稿(未发布)")
self.assertIn("sales_items", result) self.assertIn("sales_items", result)
self.assertIsInstance(result["sales_items"], list) self.assertIsInstance(result["sales_items"], list)
self.assertEqual(result["sales_items"][0]["product_image_url"], "https://example.com/shipment-list-image.jpg")
self.assertIn("external_finished_products", result) self.assertIn("external_finished_products", result)
self.assertIsInstance(result["external_finished_products"], list) self.assertIsInstance(result["external_finished_products"], list)
@@ -1296,16 +1359,27 @@ class ShipmentQueryAPITestCase(TestCase):
""" """
resp = self.client.patch( resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment1.id}/", f"/api/v1/shipment/shipments/{self.shipment1.id}/",
data={"area": "更新地区"}, data={
"area": "更新地区",
"address": "宁波市滨海路 9 号",
"contact_name": "王五",
"contact_phone": "13700137000",
},
format="json", format="json",
) )
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
result = resp.json() result = resp.json()
self.assertEqual(result["id"], self.shipment1.id) self.assertEqual(result["id"], self.shipment1.id)
self.assertEqual(result.get("area", ""), "更新地区") self.assertEqual(result.get("area", ""), "更新地区")
self.assertEqual(result["address"], "宁波市滨海路 9 号")
self.assertEqual(result["contact_name"], "王五")
self.assertEqual(result["contact_phone"], "13700137000")
self.shipment1.refresh_from_db() self.shipment1.refresh_from_db()
self.assertEqual(getattr(self.shipment1, "area", ""), "更新地区") self.assertEqual(getattr(self.shipment1, "area", ""), "更新地区")
self.assertEqual(self.shipment1.address, "宁波市滨海路 9 号")
self.assertEqual(self.shipment1.contact_name, "王五")
self.assertEqual(self.shipment1.contact_phone, "13700137000")
def test_patch_shipment_rejects_non_draft(self): def test_patch_shipment_rejects_non_draft(self):
""" """
@@ -1773,3 +1847,530 @@ class SalesItemCreateAPITestCase(APITestCase):
resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
class ShipmentDeliveryAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
self.merchant = basic_models.Merchant.objects.create(
name="送货测试商户", type=basic_models.MerchantTypeEnum.FACTORY
)
self.other_merchant = basic_models.Merchant.objects.create(
name="其它送货商户", type=basic_models.MerchantTypeEnum.FACTORY
)
self.user = User.objects.create_user(
username="shipment_delivery_user",
password="testpass123",
)
self.employee = basic_models.Employee.objects.create(
sys_user=self.user,
merchant=self.merchant,
name="送货测试员工",
mobile="13800138111",
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name="送货客户A",
mobile="13900139111",
area="杭州",
)
self.other_customer = basic_models.Customer.objects.create(
merchant=self.other_merchant,
name="送货客户B",
mobile="13900139112",
area="绍兴",
)
self.shipment1 = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date="2026-04-03",
created_by=self.user,
)
self.shipment2 = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date="2026-04-04",
created_by=self.user,
external_id="EXT-SHP-001",
)
self.shipment3 = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date="2026-04-05",
created_by=self.user,
)
self.foreign_shipment = shipment_models.Shipment.objects.create(
merchant=self.other_merchant,
customer=self.other_customer,
shipment_date="2026-04-06",
created_by=self.user,
)
self.other_delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="旧司机",
vehicle_trip="TRIP-OLD",
created_by=self.user,
)
self.shipment3.delivery = self.other_delivery
self.shipment3.save(update_fields=["delivery", "updated_at"])
self.client.force_authenticate(user=self.user)
from shipment.services import modify_status
modify_status(
self.shipment1,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
modify_status(
self.shipment1,
target_status=shipment_models.ShipmentStatus.APPROVED,
operator=self.user,
approved_by=self.user,
)
modify_status(
self.shipment2,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
modify_status(
self.shipment2,
target_status=shipment_models.ShipmentStatus.APPROVED,
operator=self.user,
approved_by=self.user,
)
def test_create_delivery_with_shipments(self):
resp = self.client.post(
"/api/v1/shipment/deliveries/",
{
"driver_name": "张司机",
"vehicle_trip": "KD-001",
"contact_phone": "13800138000",
"vehicle_capacity": "9.6米厢车",
"remark": "先装车",
"internal_remark": "注意对账",
"shipments": [self.shipment1.id, self.shipment2.id],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
data = resp.json()
self.assertEqual(data["driver_name"], "张司机")
self.assertEqual(data["vehicle_trip"], "KD-001")
self.assertEqual(data["contact_phone"], "13800138000")
self.assertEqual(data["vehicle_capacity"], "9.6米厢车")
self.assertEqual(data["remark"], "先装车")
self.assertEqual(data["internal_remark"], "注意对账")
self.assertEqual(data["status"], shipment_models.ShipmentDeliveryStatus.PENDING)
self.assertEqual(data["shipments_count"], 2)
self.assertEqual(data["merchant_id"], self.merchant.id)
self.assertEqual(data["created_by_id"], self.user.id)
self.assertEqual(data["operator_id"], self.employee.id)
self.assertEqual(data["operator_name"], self.employee.name)
self.shipment1.refresh_from_db()
self.shipment2.refresh_from_db()
self.assertIsNotNone(self.shipment1.delivery_id)
self.assertEqual(self.shipment1.delivery_id, self.shipment2.delivery_id)
def test_create_delivery_rejects_shipment_bound_to_other_delivery(self):
resp = self.client.post(
"/api/v1/shipment/deliveries/",
{
"driver_name": "张司机",
"vehicle_trip": "KD-002",
"shipments": [self.shipment3.id],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("已关联到其他送货单", resp.json()["detail"])
def test_create_delivery_rejects_non_approved_shipment(self):
unapproved_shipment = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date="2026-04-07",
created_by=self.user,
)
resp = self.client.post(
"/api/v1/shipment/deliveries/",
{
"driver_name": "张司机",
"vehicle_trip": "KD-002-A",
"shipments": [unapproved_shipment.id],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("未处于已审核状态", resp.json()["detail"])
def test_create_delivery_rejects_foreign_merchant_shipment(self):
resp = self.client.post(
"/api/v1/shipment/deliveries/",
{
"driver_name": "张司机",
"vehicle_trip": "KD-003",
"shipments": [self.foreign_shipment.id],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("不属于当前商户", resp.json()["detail"])
def test_list_deliveries_supports_status_driver_and_vehicle_filters(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="李司机",
vehicle_trip="TRIP-001",
status=shipment_models.ShipmentDeliveryStatus.IN_TRANSIT,
created_by=self.user,
)
shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="王司机",
vehicle_trip="TRIP-002",
status=shipment_models.ShipmentDeliveryStatus.PENDING,
created_by=self.user,
)
resp = self.client.get(
f"/api/v1/shipment/deliveries/?status={shipment_models.ShipmentDeliveryStatus.IN_TRANSIT}&driver_name=李&vehicle_trip=001"
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = resp.json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], delivery.id)
def test_detail_returns_nested_shipment_summaries(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-004",
created_by=self.user,
)
self.shipment1.delivery = delivery
self.shipment1.save(update_fields=["delivery", "updated_at"])
self.shipment2.delivery = delivery
self.shipment2.save(update_fields=["delivery", "updated_at"])
resp = self.client.get(f"/api/v1/shipment/deliveries/{delivery.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = resp.json()
self.assertEqual(data["shipments_count"], 2)
shipment_ids = [item["id"] for item in data["shipments"]]
self.assertEqual(shipment_ids, [self.shipment1.id, self.shipment2.id])
def test_patch_delivery_updates_fields_and_replaces_shipments(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-005",
created_by=self.user,
)
self.shipment1.delivery = delivery
self.shipment1.save(update_fields=["delivery", "updated_at"])
resp = self.client.patch(
f"/api/v1/shipment/deliveries/{delivery.id}/",
{
"driver_name": "李司机",
"vehicle_trip": "KD-005-B",
"contact_phone": "13700137000",
"vehicle_capacity": "13米高栏",
"remark": "改派车辆",
"internal_remark": "已电话确认",
"shipments": [self.shipment2.id],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = resp.json()
self.assertEqual(data["driver_name"], "李司机")
self.assertEqual(data["vehicle_trip"], "KD-005-B")
self.assertEqual(data["contact_phone"], "13700137000")
self.assertEqual(data["vehicle_capacity"], "13米高栏")
self.assertEqual(data["remark"], "改派车辆")
self.assertEqual(data["internal_remark"], "已电话确认")
self.assertEqual(data["operator_id"], self.employee.id)
self.assertEqual(data["shipments_count"], 1)
self.assertEqual(data["shipments"][0]["id"], self.shipment2.id)
self.shipment1.refresh_from_db()
self.shipment2.refresh_from_db()
self.assertIsNone(self.shipment1.delivery_id)
self.assertEqual(self.shipment2.delivery_id, delivery.id)
def test_modify_delivery_status_sets_timestamps(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-006",
created_by=self.user,
)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/status/",
{"status": shipment_models.ShipmentDeliveryStatus.IN_TRANSIT},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
delivery.refresh_from_db()
self.assertEqual(
delivery.status, shipment_models.ShipmentDeliveryStatus.IN_TRANSIT
)
self.assertIsNotNone(delivery.started_at)
self.assertIsNone(delivery.delivered_at)
self.assertEqual(delivery.operator_id, self.employee.id)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/status/",
{"status": shipment_models.ShipmentDeliveryStatus.DELIVERED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
delivery.refresh_from_db()
self.assertEqual(
delivery.status, shipment_models.ShipmentDeliveryStatus.DELIVERED
)
self.assertIsNotNone(delivery.delivered_at)
self.assertEqual(delivery.operator_id, self.employee.id)
def test_modify_delivery_status_rejects_invalid_transition(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-007",
created_by=self.user,
)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/status/",
{"status": shipment_models.ShipmentDeliveryStatus.DELIVERED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("不允许", resp.json()["detail"])
def test_modify_delivery_status_rejects_cancelled_target(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-007-B",
created_by=self.user,
)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/status/",
{"status": shipment_models.ShipmentDeliveryStatus.CANCELLED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("不是合法选项", str(resp.json()))
def test_cancel_delivery_requires_permission(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-007-C",
created_by=self.user,
)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/cancel/",
{},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
self.assertIn("没有权限", resp.json()["detail"])
def test_cancel_delivery_success(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-007-D",
created_by=self.user,
)
permission = Permission.objects.get(codename="cancel_shipmentdelivery")
self.user.user_permissions.add(permission)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/cancel/",
{},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = resp.json()
self.assertEqual(data["status"], shipment_models.ShipmentDeliveryStatus.CANCELLED)
self.assertEqual(data["status_display"], "已取消")
self.assertEqual(data["cancelled_by_id"], self.user.id)
self.assertEqual(data["cancelled_by_name"], self.employee.name)
self.assertEqual(data["operator_id"], self.employee.id)
self.assertIsNotNone(data["cancelled_at"])
delivery.refresh_from_db()
self.assertEqual(delivery.status, shipment_models.ShipmentDeliveryStatus.CANCELLED)
self.assertEqual(delivery.cancelled_by_id, self.user.id)
self.assertIsNotNone(delivery.cancelled_at)
def test_bind_shipments_to_existing_delivery_success(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-007-E",
created_by=self.user,
)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/bind-shipments/",
{"shipments": [self.shipment1.id, self.shipment2.id]},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = resp.json()
self.assertEqual(data["shipments_count"], 2)
shipment_ids = [item["id"] for item in data["shipments"]]
self.assertEqual(shipment_ids, [self.shipment1.id, self.shipment2.id])
self.assertEqual(data["operator_id"], self.employee.id)
self.shipment1.refresh_from_db()
self.shipment2.refresh_from_db()
self.assertEqual(self.shipment1.delivery_id, delivery.id)
self.assertEqual(self.shipment2.delivery_id, delivery.id)
def test_bind_shipments_rejects_non_approved_shipment(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-007-F",
created_by=self.user,
)
unapproved_shipment = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date="2026-04-08",
created_by=self.user,
)
resp = self.client.post(
f"/api/v1/shipment/deliveries/{delivery.id}/bind-shipments/",
{"shipments": [unapproved_shipment.id]},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("未处于已审核状态", resp.json()["detail"])
def test_delete_delivery_unbinds_shipments(self):
delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="张司机",
vehicle_trip="KD-008",
created_by=self.user,
)
self.shipment1.delivery = delivery
self.shipment1.save(update_fields=["delivery", "updated_at"])
resp = self.client.delete(f"/api/v1/shipment/deliveries/{delivery.id}/")
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
self.shipment1.refresh_from_db()
self.assertIsNone(self.shipment1.delivery_id)
self.assertFalse(
shipment_models.ShipmentDelivery.objects.filter(id=delivery.id).exists()
)
class ShipmentDeliveryServiceTestCase(TestCase):
def setUp(self):
self.merchant = basic_models.Merchant.objects.create(
name="送货服务测试商户", type=basic_models.MerchantTypeEnum.FACTORY
)
self.user = User.objects.create_user(
username="shipment_delivery_service_user",
password="testpass123",
)
basic_models.Employee.objects.create(
sys_user=self.user,
merchant=self.merchant,
name="送货服务测试员工",
mobile="13800138112",
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
self.delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name="服务司机",
vehicle_trip="SERVICE-001",
created_by=self.user,
)
def test_modify_status_is_idempotent(self):
from shipment.services import modify_shipment_delivery_status
result = modify_shipment_delivery_status(
self.delivery,
target_status=shipment_models.ShipmentDeliveryStatus.PENDING,
)
self.assertEqual(result.id, self.delivery.id)
self.delivery.refresh_from_db()
self.assertEqual(
self.delivery.status, shipment_models.ShipmentDeliveryStatus.PENDING
)
self.assertIsNone(self.delivery.started_at)
def test_modify_status_cannot_rollback(self):
from shipment.services import modify_shipment_delivery_status
modify_shipment_delivery_status(
self.delivery,
target_status=shipment_models.ShipmentDeliveryStatus.IN_TRANSIT,
)
with self.assertRaisesMessage(
ValueError, "不允许将送货单状态从 送货中 修改为 待送货"
):
modify_shipment_delivery_status(
self.delivery,
target_status=shipment_models.ShipmentDeliveryStatus.PENDING,
)
def test_cancel_delivery_is_idempotent(self):
from shipment.services import cancel_shipment_delivery
result = cancel_shipment_delivery(
self.delivery,
cancelled_by=self.user,
operator=self.user,
)
first_cancelled_at = result.cancelled_at
result = cancel_shipment_delivery(
self.delivery,
cancelled_by=self.user,
operator=self.user,
)
self.assertEqual(result.status, shipment_models.ShipmentDeliveryStatus.CANCELLED)
self.assertEqual(result.cancelled_by_id, self.user.id)
self.assertEqual(result.cancelled_at, first_cancelled_at)

View File

@@ -10,11 +10,16 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from flower.viewsets import LimitedLimitOffsetPagination from flower.viewsets import LimitedLimitOffsetPagination
from shipment.models import Shipment from shipment.models import Shipment, ShipmentDelivery
from .serializers import ( from .serializers import (
SalesItemDetailSerializer, SalesItemDetailSerializer,
SalesItemSerializer, SalesItemSerializer,
ShipmentDeliveryBindShipmentsSerializer,
ShipmentDeliveryCreateSerializer,
ShipmentDeliverySerializer,
ShipmentDeliveryStatusUpdateSerializer,
ShipmentDeliveryUpdateSerializer,
ShipmentSerializer, ShipmentSerializer,
ShipmentCreateNormalSerializer, ShipmentCreateNormalSerializer,
ShipmentCreateExternalSerializer, ShipmentCreateExternalSerializer,
@@ -178,6 +183,9 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
shipment_date=data["shipment_date"], shipment_date=data["shipment_date"],
sales_item_ids=data["sales_items"], sales_item_ids=data["sales_items"],
created_by=request.user, created_by=request.user,
address=data.get("address", ""),
contact_name=data.get("contact_name", ""),
contact_phone=data.get("contact_phone", ""),
remark=data.get("remark", ""), remark=data.get("remark", ""),
area=data.get("area", ""), area=data.get("area", ""),
) )
@@ -246,6 +254,9 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
shipment, shipment,
customer_id=data.get("customer"), customer_id=data.get("customer"),
shipment_date=data.get("shipment_date"), shipment_date=data.get("shipment_date"),
address=data.get("address"),
contact_name=data.get("contact_name"),
contact_phone=data.get("contact_phone"),
area=data.get("area"), area=data.get("area"),
remark=data.get("remark"), remark=data.get("remark"),
external_id=data.get("external_id"), external_id=data.get("external_id"),
@@ -263,6 +274,313 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
return self.patch(request, pk=pk) return self.patch(request, pk=pk)
class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView):
"""
送货单:查询列表 / 创建
- GET /api/v1/shipment/deliveries/
- POST /api/v1/shipment/deliveries/
"""
permission_classes = [IsAuthenticated]
serializer_class = ShipmentDeliverySerializer
pagination_class = LimitedLimitOffsetPagination
def get_queryset(self):
qs = ShipmentDelivery.objects.all().select_related(
"merchant", "created_by", "operator", "cancelled_by"
).prefetch_related(
"shipments",
"shipments__customer",
)
user = self.request.user
if not getattr(user, "is_superuser", False):
emp = getattr(user, "employee", None)
merchant = getattr(emp, "merchant", None) if emp else None
if not merchant:
return ShipmentDelivery.objects.none()
qs = qs.filter(merchant=merchant)
status_val = self.request.query_params.get("status")
if status_val:
qs = qs.filter(status=status_val)
driver_name = (self.request.query_params.get("driver_name") or "").strip()
if driver_name:
qs = qs.filter(driver_name__icontains=driver_name)
vehicle_trip = (self.request.query_params.get("vehicle_trip") or "").strip()
if vehicle_trip:
qs = qs.filter(vehicle_trip__icontains=vehicle_trip)
return qs.order_by("-created_at", "-id")
def get(self, request):
return self.list(request)
def post(self, request):
serializer = ShipmentDeliveryCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.services import create_shipment_delivery
try:
delivery = create_shipment_delivery(
driver_name=serializer.validated_data["driver_name"],
vehicle_trip=serializer.validated_data["vehicle_trip"],
contact_phone=serializer.validated_data.get("contact_phone", ""),
vehicle_capacity=serializer.validated_data.get("vehicle_capacity", ""),
shipment_ids=serializer.validated_data.get("shipments", []),
created_by=request.user,
remark=serializer.validated_data.get("remark", ""),
internal_remark=serializer.validated_data.get("internal_remark", ""),
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
delivery = ShipmentDelivery.objects.select_related(
"merchant", "created_by", "operator", "cancelled_by"
).prefetch_related(
"shipments",
"shipments__customer",
).get(id=delivery.id)
return Response(
ShipmentDeliverySerializer(delivery).data,
status=status.HTTP_201_CREATED,
)
class ShipmentDeliveryDetailView(RetrieveModelMixin, GenericAPIView):
"""
送货单详情 / 修改 / 删除
- GET /api/v1/shipment/deliveries/<id>/
- PATCH /api/v1/shipment/deliveries/<id>/
- PUT /api/v1/shipment/deliveries/<id>/
- DELETE /api/v1/shipment/deliveries/<id>/
"""
permission_classes = [IsAuthenticated]
serializer_class = ShipmentDeliverySerializer
def get_queryset(self):
qs = ShipmentDelivery.objects.all().select_related(
"merchant", "created_by", "operator", "cancelled_by"
).prefetch_related(
"shipments",
"shipments__customer",
)
user = self.request.user
if getattr(user, "is_superuser", False):
return qs
emp = getattr(user, "employee", None)
merchant = getattr(emp, "merchant", None) if emp else None
if not merchant:
return ShipmentDelivery.objects.none()
return qs.filter(merchant=merchant)
def get(self, request, pk: int):
return self.retrieve(request, pk=pk)
def patch(self, request, pk: int):
delivery = self.get_object()
serializer = ShipmentDeliveryUpdateSerializer(data=request.data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.services import update_shipment_delivery
try:
delivery = update_shipment_delivery(
delivery,
driver_name=serializer.validated_data.get("driver_name"),
vehicle_trip=serializer.validated_data.get("vehicle_trip"),
contact_phone=serializer.validated_data.get("contact_phone"),
vehicle_capacity=serializer.validated_data.get("vehicle_capacity"),
shipment_ids=serializer.validated_data.get("shipments"),
remark=serializer.validated_data.get("remark"),
internal_remark=serializer.validated_data.get("internal_remark"),
operator=request.user,
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
delivery = self.get_queryset().get(id=delivery.id)
return Response(
ShipmentDeliverySerializer(delivery).data,
status=status.HTTP_200_OK,
)
def put(self, request, pk: int):
return self.patch(request, pk=pk)
def delete(self, request, pk: int):
delivery = self.get_object()
from shipment.services import delete_shipment_delivery
delete_shipment_delivery(delivery)
return Response(status=status.HTTP_204_NO_CONTENT)
class ShipmentDeliveryStatusUpdateView(APIView):
"""
修改送货单状态
POST /api/v1/shipment/deliveries/<id>/status/
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk: int):
user = request.user
queryset = ShipmentDelivery.objects.all()
if not getattr(user, "is_superuser", False):
emp = getattr(user, "employee", None)
merchant = getattr(emp, "merchant", None) if emp else None
if not merchant:
return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND)
queryset = queryset.filter(merchant=merchant)
delivery = queryset.filter(id=pk).first()
if delivery is None:
return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND)
serializer = ShipmentDeliveryStatusUpdateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.services import modify_shipment_delivery_status
try:
delivery = modify_shipment_delivery_status(
delivery,
target_status=serializer.validated_data["status"],
operator=request.user,
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
delivery = ShipmentDelivery.objects.select_related(
"merchant", "created_by", "operator", "cancelled_by"
).prefetch_related(
"shipments",
"shipments__customer",
).get(id=delivery.id)
return Response(
ShipmentDeliverySerializer(delivery).data,
status=status.HTTP_200_OK,
)
class ShipmentDeliveryCancelView(APIView):
"""
取消送货单
POST /api/v1/shipment/deliveries/<id>/cancel/
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk: int):
if not request.user.has_perm("shipment.cancel_shipmentdelivery"):
return Response(
{"detail": "没有权限取消送货单"},
status=status.HTTP_403_FORBIDDEN,
)
user = request.user
queryset = ShipmentDelivery.objects.all()
if not getattr(user, "is_superuser", False):
emp = getattr(user, "employee", None)
merchant = getattr(emp, "merchant", None) if emp else None
if not merchant:
return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND)
queryset = queryset.filter(merchant=merchant)
delivery = queryset.filter(id=pk).first()
if delivery is None:
return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND)
from shipment.services import cancel_shipment_delivery
delivery = cancel_shipment_delivery(
delivery,
cancelled_by=request.user,
operator=request.user,
)
delivery = ShipmentDelivery.objects.select_related(
"merchant", "created_by", "operator", "cancelled_by"
).prefetch_related(
"shipments",
"shipments__customer",
).get(id=delivery.id)
return Response(
ShipmentDeliverySerializer(delivery).data,
status=status.HTTP_200_OK,
)
class ShipmentDeliveryBindShipmentsView(APIView):
"""
追加绑定出货单到现有送货单
POST /api/v1/shipment/deliveries/<id>/bind-shipments/
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk: int):
user = request.user
queryset = ShipmentDelivery.objects.all()
if not getattr(user, "is_superuser", False):
emp = getattr(user, "employee", None)
merchant = getattr(emp, "merchant", None) if emp else None
if not merchant:
return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND)
queryset = queryset.filter(merchant=merchant)
delivery = queryset.filter(id=pk).first()
if delivery is None:
return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND)
serializer = ShipmentDeliveryBindShipmentsSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.services import bind_shipments_to_delivery
try:
delivery = bind_shipments_to_delivery(
delivery,
shipment_ids=serializer.validated_data["shipments"],
operator=request.user,
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
delivery = ShipmentDelivery.objects.select_related(
"merchant", "created_by", "operator", "cancelled_by"
).prefetch_related(
"shipments",
"shipments__customer",
).get(id=delivery.id)
return Response(
ShipmentDeliverySerializer(delivery).data,
status=status.HTTP_200_OK,
)
class ShipmentExternalCreateView(APIView): class ShipmentExternalCreateView(APIView):
""" """
创建出货单external 版) 创建出货单external 版)
@@ -293,6 +611,9 @@ class ShipmentExternalCreateView(APIView):
external_id=data["external_id"], external_id=data["external_id"],
external_finished_products=data["external_finished_products"], external_finished_products=data["external_finished_products"],
created_by=request.user, created_by=request.user,
address=data.get("address", ""),
contact_name=data.get("contact_name", ""),
contact_phone=data.get("contact_phone", ""),
remark=data.get("remark", ""), remark=data.get("remark", ""),
area=data.get("area", ""), area=data.get("area", ""),
) )

View File

@@ -0,0 +1,161 @@
# 2026-04-02 RDS PgBouncer 命名游标问题记录
## 背景
生产环境在 Django Admin 页面访问 `SalesItem` 编辑页时出现如下错误:
```text
ProgrammingError at /admin/shipment/salesitem/1/change/
cursor "_django_curs_128437320623808_sync_1" already exists
```
本地开发/测试环境未复现该问题。
## 现象
- 出错页面:`/admin/shipment/salesitem/<id>/change/`
- 错误类型PostgreSQL `ProgrammingError`
- 关键错误信息:`cursor "... already exists"`
- 特征:属于数据库连接/游标状态错误,而不是表结构错误
## 排查结论
### 1. 不是 migrate 未执行导致
本次错误不符合迁移缺失的典型表现。
如果是 migration 未执行,通常会看到:
- 某字段不存在
- 某表不存在
- 约束不存在
- 类型不匹配
而本次是“命名游标已经存在”,属于运行时数据库连接状态问题。
### 2. 当前 `SalesItem` Admin 实现本身没有明显异常
`shipment` 模块中的 admin 配置较为简单,没有明显会主动触发复杂命名游标行为的自定义逻辑:
- 参考:[shipment/admin.py](/home/f/coding/flower/shipment/admin.py)
因此本问题更像是生产环境数据库接入方式引发,而不是该页面逻辑本身写坏。
### 3. 根因判断:阿里云 RDS 自带 PgBouncer/连接池 与 Django server-side cursors 兼容性问题
生产环境已确认使用“阿里云 RDS 自带代理/连接池”。
Django 在 PostgreSQL 下会在部分场景使用 server-side cursors尤其与 `QuerySet.iterator()` 相关)。
当应用经过连接池/代理访问数据库时,连接池不能稳定保证命名游标依赖的“同一物理连接上下文”,从而可能触发:
- 命名游标残留
- 游标状态错乱
- `cursor "... already exists"`
这类问题在连接池场景下比直连 PostgreSQL 更容易出现。
## 代码与配置侧观察
### 数据库配置
项目数据库配置位于:
- [flower/settings.py](/home/f/coding/flower/flower/settings.py)
原有配置特征:
- 使用 PostgreSQL
- 支持 `CONN_MAX_AGE`
- 未显式设置 `DISABLE_SERVER_SIDE_CURSORS`
### 项目内的连接池配置
仓库自带的 PgBouncer 配置位于:
- [deploy/pgbouncer/pgbouncer.ini](/home/f/coding/flower/deploy/pgbouncer/pgbouncer.ini)
仓库内自建 PgBouncer 使用的是 `session` 模式。
但生产环境此次问题来自阿里云 RDS 自带代理/连接池,实际行为应以云侧代理为准,不能简单等同于本仓库自带 PgBouncer 配置。
## 修复决策
考虑到:
- 生产环境不能放弃连接池
- 该系统存在较多大查询
- 生产已经出现与命名游标相关的实际错误
最终决定采用 Django 官方支持的兼容方案:
在数据库配置中加入:
```python
'DISABLE_SERVER_SIDE_CURSORS': True,
```
并保留:
```python
'CONN_MAX_AGE': env.int('CONN_MAX_AGE', default=0),
```
## 修复位置
已在以下位置加入配置:
- [flower/settings.py](/home/f/coding/flower/flower/settings.py)
配置位置为 `DATABASES['default']` 顶层,与 `ENGINE``HOST``PORT``CONN_MAX_AGE` 同级,不放在 `OPTIONS` 中。
## 采用该修复的原因
### 为什么不优先怀疑 migration
因为错误类型明显属于连接/游标状态,不属于 schema 不一致。
### 为什么不取消连接池
生产环境仍需要连接池承载系统中的大查询与整体数据库连接管理。
### 为什么选择禁用 server-side cursors
这是 Django 官方针对连接池/代理场景给出的标准兼容方向之一。
对当前项目而言,这一决策的风险低于继续保留命名游标并承受生产不稳定性。
## 可能的副作用
启用 `DISABLE_SERVER_SIDE_CURSORS=True` 后:
- 普通页面和常规 ORM 查询通常不会受功能性影响
- 依赖 `QuerySet.iterator()` 的超大结果集遍历,可能失去 server-side cursor 带来的部分性能/内存优势
项目中需要后续重点观察的批量遍历代码包括:
- [printing/tasks.py](/home/f/coding/flower/printing/tasks.py)
- [api_v1/external_product_image_backfill.py](/home/f/coding/flower/api_v1/external_product_image_backfill.py)
也就是说,该修复更可能带来“部分后台任务性能特征变化”,而不是“开发环境或生产环境无法正确运行”。
## 对开发环境的影响判断
开发环境同样可以启用 `DISABLE_SERVER_SIDE_CURSORS=True`,不会导致系统无法运行。
主要影响仍然是:
- 大结果集遍历时的性能和内存行为可能更保守
但这不属于严重副作用。
## 结论
本次问题定性为:
- **生产环境数据库连接池代理与 Django server-side cursors 的兼容性问题**
本次修复决策为:
- **保留连接池**
- **在 Django 数据库配置中启用 `DISABLE_SERVER_SIDE_CURSORS=True`**
该决策用于降低生产环境中命名游标相关错误的出现概率,并保持系统整体连接池架构不变。

View File

@@ -70,6 +70,12 @@
} }
``` ```
说明:
- `sales_items` 现在返回的是带图片字段的销售品详情结构
- 若销售品关联的 `PrintingJob.product` 存在主图,则会返回 `product_image_url`
- 若无关联图片,则 `product_image_url``null`
### 出货单详情 ### 出货单详情
- **URL**: `/api/v1/shipment/shipments/<id>/` - **URL**: `/api/v1/shipment/shipments/<id>/`
@@ -82,6 +88,10 @@
|------|------|------| |------|------|------|
| status | int | 可选状态过滤;取值为 1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核。传入后仅当该出货单状态匹配时才返回详情 | | status | int | 可选状态过滤;取值为 1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核。传入后仅当该出货单状态匹配时才返回详情 |
说明:
- 详情中的 `sales_items` 同样返回带 `product_image_url` 的销售品详情结构
--- ---
### 更新出货单 ### 更新出货单
@@ -92,7 +102,7 @@
说明: 说明:
- 仅允许修改业务数据字段:`customer``shipment_date``area``remark``external_id` - 仅允许修改业务数据字段:`customer``shipment_date``address``contact_name``contact_phone``area``remark``external_id`
- 该接口不允许修改 `status` - 该接口不允许修改 `status`
-`草稿(未发布)` 状态的出货单允许修改 -`草稿(未发布)` 状态的出货单允许修改
- 非草稿状态会返回 `400 Bad Request` - 非草稿状态会返回 `400 Bad Request`
@@ -101,6 +111,9 @@
```json ```json
{ {
"address": "宁波市滨海路 9 号",
"contact_name": "王五",
"contact_phone": "13700137000",
"area": "更新地区", "area": "更新地区",
"remark": "更新备注" "remark": "更新备注"
} }
@@ -139,6 +152,9 @@
|------|------|------|------| |------|------|------|------|
| customer | int | 是 | 客户ID | | customer | int | 是 | 客户ID |
| shipment_date | string | 是 | 出货日期YYYY-MM-DD | | shipment_date | string | 是 | 出货日期YYYY-MM-DD |
| address | string | 否 | 地址(可空字符串,长度<=255 |
| contact_name | string | 否 | 联系人(可空字符串,长度<=100 |
| contact_phone | string | 否 | 联系电话(可空字符串,长度<=50 |
| area | string | 否 | 出货地区(可空字符串,长度<=30 | | area | string | 否 | 出货地区(可空字符串,长度<=30 |
| remark | string | 否 | 备注 | | remark | string | 否 | 备注 |
| sales_items | array[int] | 否 | 要关联的销售品ID列表 | | sales_items | array[int] | 否 | 要关联的销售品ID列表 |
@@ -149,6 +165,9 @@
{ {
"customer": 1, "customer": 1,
"shipment_date": "2026-01-14", "shipment_date": "2026-01-14",
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "华东", "area": "华东",
"remark": "备注信息", "remark": "备注信息",
"sales_items": [1, 2, 3] "sales_items": [1, 2, 3]
@@ -165,6 +184,9 @@
"customer": 1, "customer": 1,
"customer_name": "客户A", "customer_name": "客户A",
"shipment_date": "2026-01-14", "shipment_date": "2026-01-14",
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "华东", "area": "华东",
"remark": "备注信息", "remark": "备注信息",
"status": 1, "status": 1,
@@ -194,6 +216,9 @@
| customer | int | 客户ID | | customer | int | 客户ID |
| customer_name | string | 客户名称 | | customer_name | string | 客户名称 |
| shipment_date | string | 出货日期 | | shipment_date | string | 出货日期 |
| address | string | 地址 |
| contact_name | string | 联系人 |
| contact_phone | string | 联系电话 |
| area | string | 出货地区 | | area | string | 出货地区 |
| remark | string | 备注 | | remark | string | 备注 |
| status | int | 状态枚举1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核) | | status | int | 状态枚举1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核) |
@@ -287,6 +312,9 @@
|------|------|------|------| |------|------|------|------|
| customer | int | 是 | 客户ID | | customer | int | 是 | 客户ID |
| shipment_date | string | 是 | 出货日期YYYY-MM-DD | | shipment_date | string | 是 | 出货日期YYYY-MM-DD |
| address | string | 否 | 地址(可空字符串,长度<=255 |
| contact_name | string | 否 | 联系人(可空字符串,长度<=100 |
| contact_phone | string | 否 | 联系电话(可空字符串,长度<=50 |
| area | string | 否 | 出货地区(可空字符串,长度<=30 | | area | string | 否 | 出货地区(可空字符串,长度<=30 |
| remark | string | 否 | 备注 | | remark | string | 否 | 备注 |
| external_id | string | 是 | 外部订单号(长度<=120 | | external_id | string | 是 | 外部订单号(长度<=120 |
@@ -306,6 +334,9 @@
{ {
"customer": 1, "customer": 1,
"shipment_date": "2026-01-14", "shipment_date": "2026-01-14",
"address": "绍兴市仓库 2 号",
"contact_name": "李四",
"contact_phone": "13900139000",
"area": "华南", "area": "华南",
"remark": "external 备注(可选)", "remark": "external 备注(可选)",
"external_id": "EXT-ORDER-001", "external_id": "EXT-ORDER-001",
@@ -326,6 +357,9 @@
"customer": 1, "customer": 1,
"customer_name": "客户A", "customer_name": "客户A",
"shipment_date": "2026-01-14", "shipment_date": "2026-01-14",
"address": "绍兴市仓库 2 号",
"contact_name": "李四",
"contact_phone": "13900139000",
"area": "华南", "area": "华南",
"remark": "external 备注(可选)", "remark": "external 备注(可选)",
"status": 1, "status": 1,

319
docs/shipment_delivery.md Normal file
View File

@@ -0,0 +1,319 @@
# Shipment Delivery API
本文档说明 `shipment` 模块中的“送货单”模型及相关 API。
## 业务说明
送货单用于表示一次具体的送货行为。
特点:
- 一个送货单可关联多个出货单
- 一个出货单最多属于一个送货单
- 送货单与出货单关系为一对多
- 送货单使用独立状态流转,不复用出货单状态
## 状态枚举
送货单状态定义如下:
- `1 = 待送货`
- `2 = 送货中`
- `3 = 已送达`
- `4 = 已取消`
状态机规则:
- `待送货 -> 送货中`
- `送货中 -> 已送达`
- 不允许回退
- 重复设置同一状态时保持幂等,直接返回当前对象
- `已取消` 为终态
时间字段规则:
- 进入 `送货中` 时写入 `started_at`
- 进入 `已送达` 时写入 `delivered_at`
## 模型字段
送货单模型包含以下核心字段:
- `id`
- `merchant`
- `driver_name`
- `vehicle_trip`
- `contact_phone`
- `vehicle_capacity`
- `remark`
- `internal_remark`
- `status`
- `started_at`
- `delivered_at`
- `created_by`
- `operator`
- `cancelled_at`
- `cancelled_by`
- `created_at`
- `updated_at`
说明:
- `driver_name` 已加索引
- `vehicle_trip` 已加索引
- `status` 已加索引
- `created_by` 为系统用户 `User`
- `operator` 为业务员工 `Employee`
- `merchant` 用于多商户隔离
- 创建、修改、状态流转时,会根据 `request.user.employee` 自动写入 `operator`
- 取消送货单时会写入 `cancelled_at``cancelled_by`
## API 列表
### 1. 查询送货单列表
- `GET /api/v1/shipment/deliveries/`
支持查询参数:
- `status`
- `driver_name`
- `vehicle_trip`
- `limit`
- `offset`
查询说明:
- `status` 为精确匹配
- `driver_name` 为包含匹配
- `vehicle_trip` 为包含匹配
示例:
```http
GET /api/v1/shipment/deliveries/?status=2&driver_name=&vehicle_trip=001&limit=20&offset=0
```
返回示例:
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"merchant_id": 1,
"merchant_name": "测试印花厂",
"driver_name": "张司机",
"vehicle_trip": "KD-001",
"contact_phone": "13800138000",
"vehicle_capacity": "9.6米厢车",
"remark": "先装车",
"internal_remark": "注意对账",
"status": 1,
"status_display": "待送货",
"started_at": null,
"delivered_at": null,
"cancelled_at": null,
"shipments_count": 2,
"shipments": [
{
"id": 10,
"customer": 5,
"customer_name": "客户A",
"shipment_date": "2026-04-03",
"status": 2,
"status_display": "已发布",
"external_id": null
}
],
"created_by_id": 8,
"created_by_name": "测试员工",
"operator_id": 3,
"operator_name": "测试员工",
"cancelled_by_id": null,
"cancelled_by_name": null,
"created_at": "2026-04-03T10:00:00+08:00",
"updated_at": "2026-04-03T10:00:00+08:00"
}
]
}
```
### 2. 创建送货单
- `POST /api/v1/shipment/deliveries/`
请求体:
```json
{
"driver_name": "张司机",
"vehicle_trip": "KD-001",
"contact_phone": "13800138000",
"vehicle_capacity": "9.6米厢车",
"remark": "先装车",
"internal_remark": "注意对账",
"shipments": [10, 11]
}
```
字段说明:
- `driver_name`: 必填,司机名
- `vehicle_trip`: 必填,车次
- `contact_phone`: 可选,联系电话
- `vehicle_capacity`: 可选,车辆容量
- `remark`: 可选,备注
- `internal_remark`: 可选,内部备注
- `shipments`: 可选,出货单 ID 列表
创建规则:
- 只能绑定当前用户所属商户的出货单
- 只能绑定状态为 `已审核` 的出货单
- 已绑定到其他送货单的出货单不能重复绑定
- 创建接口不支持直接传入 `status`
### 3. 查询送货单详情
- `GET /api/v1/shipment/deliveries/{id}/`
返回字段与列表单项一致,但会返回完整 `shipments` 摘要数组。
### 4. 修改送货单
- `PATCH /api/v1/shipment/deliveries/{id}/`
- `PUT /api/v1/shipment/deliveries/{id}/`
请求体示例:
```json
{
"driver_name": "李司机",
"vehicle_trip": "KD-001-B",
"contact_phone": "13700137000",
"vehicle_capacity": "13米高栏",
"remark": "改派车辆",
"internal_remark": "已电话确认",
"shipments": [11, 12]
}
```
修改规则:
- `driver_name``vehicle_trip``contact_phone``vehicle_capacity``remark``internal_remark` 可单独修改
- `shipments` 如果传入,则视为“整体替换当前绑定的出货单集合”
- 替换绑定时,出货单仍然必须满足“当前商户、已审核、未绑定到其他送货单”
- 更新接口不支持直接修改 `status`
### 5. 删除送货单
- `DELETE /api/v1/shipment/deliveries/{id}/`
行为说明:
- 删除送货单时,会先解除与其关联的出货单绑定
- 删除成功返回 `204 No Content`
### 6. 修改送货单状态
- `POST /api/v1/shipment/deliveries/{id}/status/`
请求体:
```json
{
"status": 2
}
```
状态取值:
- `1 = 待送货`
- `2 = 送货中`
- `3 = 已送达`
规则说明:
- 只允许按状态机顺序流转
- 不允许回退
- 相同状态重复提交保持幂等
- 成功改到 `送货中` 时写入 `started_at`
- 成功改到 `已送达` 时写入 `delivered_at`
- 该接口不用于取消送货单
### 7. 取消送货单
- `POST /api/v1/shipment/deliveries/{id}/cancel/`
权限要求:
- 需要 Django 权限:`shipment.cancel_shipmentdelivery`
请求体:
```json
{}
```
行为说明:
- 将送货单状态改为 `已取消`
- 写入 `cancelled_at`
- 写入 `cancelled_by`
- 同时会根据 `request.user.employee` 更新 `operator`
- 已取消的送货单再次取消保持幂等
### 8. 追加绑定出货单到已有送货单
- `POST /api/v1/shipment/deliveries/{id}/bind-shipments/`
请求体:
```json
{
"shipments": [12, 13]
}
```
规则说明:
- 该接口是“追加绑定”,不会替换当前已有绑定
- 只允许绑定当前用户所属商户的出货单
- 出货单必须处于 `已审核` 状态
- 已绑定到其他送货单的出货单不能再次绑定
- 成功后会根据 `request.user.employee` 更新 `operator`
## Service 设计
送货单的业务写入逻辑统一放在 `shipment/services.py` 中:
- `create_shipment_delivery(...)`
- `update_shipment_delivery(...)`
- `modify_shipment_delivery_status(...)`
- `cancel_shipment_delivery(...)`
- `delete_shipment_delivery(...)`
说明:
- list / detail 属于简单只读查询,直接由 API 查询集处理
- create / update / delete / status modify 均通过 service 完成
## 测试覆盖
已补充以下测试方向:
- 创建送货单并绑定多个出货单
- 绑定已属于其他送货单的出货单时报错
- 绑定非当前商户出货单时报错
- 列表支持 `status / driver_name / vehicle_trip` 查询
- 详情返回关联出货单摘要
- 更新送货单并替换绑定出货单
- 创建、修改、状态流转会写入 `operator`
- 取消送货单需要权限校验,并写入 `cancelled_at / cancelled_by`
- 状态流转写入 `started_at / delivered_at`
- 非法状态流转报错
- 删除送货单后解除出货单绑定

View File

@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import ExternalFinishedProduct, Shipment, SalesItem from .models import ExternalFinishedProduct, Shipment, SalesItem, ShipmentDelivery
class SalesItemInline(admin.TabularInline): class SalesItemInline(admin.TabularInline):
@@ -18,10 +18,29 @@ class ExternalFinishedProductInline(admin.TabularInline):
@admin.register(Shipment) @admin.register(Shipment)
class ShipmentAdmin(admin.ModelAdmin): class ShipmentAdmin(admin.ModelAdmin):
list_display = ['id', 'merchant', 'customer', 'shipment_date', 'status', 'external_id', 'items_count', 'created_by', 'created_at'] list_display = [
'id',
'merchant',
'customer',
'shipment_date',
'status',
'delivery',
'external_id',
'items_count',
'created_by',
'created_at',
]
list_filter = ['merchant', 'status', 'shipment_date', 'created_at'] list_filter = ['merchant', 'status', 'shipment_date', 'created_at']
search_fields = ['customer__name', 'remark', 'external_id'] search_fields = [
'customer__name',
'remark',
'external_id',
'address',
'contact_name',
'contact_phone',
]
readonly_fields = ['created_at', 'updated_at', 'created_by'] readonly_fields = ['created_at', 'updated_at', 'created_by']
raw_id_fields = ['delivery']
date_hierarchy = 'shipment_date' date_hierarchy = 'shipment_date'
inlines = [SalesItemInline, ExternalFinishedProductInline] inlines = [SalesItemInline, ExternalFinishedProductInline]
@@ -75,3 +94,77 @@ class ExternalFinishedProductAdmin(admin.ModelAdmin):
if not change: if not change:
obj.created_by = request.user obj.created_by = request.user
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
@admin.register(ShipmentDelivery)
class ShipmentDeliveryAdmin(admin.ModelAdmin):
list_display = [
'id',
'merchant',
'driver_name',
'vehicle_trip',
'contact_phone',
'vehicle_capacity',
'status',
'shipments_count',
'operator',
'cancelled_by',
'created_by',
'started_at',
'delivered_at',
'cancelled_at',
'created_at',
]
list_filter = ['merchant', 'status', 'created_at', 'started_at', 'delivered_at', 'cancelled_at']
search_fields = [
'driver_name',
'vehicle_trip',
'contact_phone',
'vehicle_capacity',
'remark',
'internal_remark',
'operator__name',
'cancelled_by__username',
'created_by__username',
]
readonly_fields = ['created_at', 'updated_at', 'created_by', 'operator', 'cancelled_by', 'cancelled_at', 'shipments_summary']
fields = [
'merchant',
'driver_name',
'vehicle_trip',
'contact_phone',
'vehicle_capacity',
'remark',
'internal_remark',
'status',
'started_at',
'delivered_at',
'cancelled_at',
'operator',
'cancelled_by',
'created_by',
'shipments_summary',
'created_at',
'updated_at',
]
def shipments_count(self, obj):
return obj.shipments.count()
shipments_count.short_description = '出货单数量'
def shipments_summary(self, obj):
ids = list(obj.shipments.order_by('id').values_list('id', flat=True))
if not ids:
return ''
return ', '.join(str(item_id) for item_id in ids)
shipments_summary.short_description = '关联出货单'
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
employee = getattr(request.user, 'employee', None)
if employee:
obj.operator = employee
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,72 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("shipment", "0012_replace_cancelled_at_with_status_modified_at_and_add_approved_by"),
]
operations = [
migrations.CreateModel(
name="ShipmentDelivery",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
("status", models.IntegerField(choices=[(1, "待送货"), (2, "送货中"), (3, "已送达")], db_index=True, default=1, verbose_name="状态")),
("driver_name", models.CharField(db_index=True, max_length=100, verbose_name="司机名")),
("vehicle_trip", models.CharField(db_index=True, max_length=100, verbose_name="车次")),
("started_at", models.DateTimeField(blank=True, null=True, verbose_name="开始送货时间")),
("delivered_at", models.DateTimeField(blank=True, null=True, verbose_name="送达时间")),
("created_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_shipment_deliveries", to=settings.AUTH_USER_MODEL, verbose_name="创建人")),
("merchant", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="shipment_deliveries", to="basic_info.merchant", verbose_name="所属商户")),
],
options={
"verbose_name": "送货单",
"verbose_name_plural": "送货单",
"db_table": "shipment_delivery",
"ordering": ["-created_at", "-id"],
},
),
migrations.AddField(
model_name="shipment",
name="address",
field=models.CharField(blank=True, default="", max_length=255, verbose_name="地址"),
),
migrations.AddField(
model_name="shipment",
name="contact_name",
field=models.CharField(blank=True, default="", max_length=100, verbose_name="联系人"),
),
migrations.AddField(
model_name="shipment",
name="contact_phone",
field=models.CharField(blank=True, default="", max_length=50, verbose_name="联系电话"),
),
migrations.AddField(
model_name="shipment",
name="delivery",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shipments",
to="shipment.shipmentdelivery",
verbose_name="送货单",
),
),
migrations.AddField(
model_name="shipmentdelivery",
name="internal_remark",
field=models.CharField(blank=True, default="", max_length=200, verbose_name="内部备注"),
),
migrations.AddField(
model_name="shipmentdelivery",
name="remark",
field=models.CharField(blank=True, default="", max_length=200, verbose_name="备注"),
),
]

View File

@@ -0,0 +1,25 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("basic_info", "0025_customer_uniq_customer_merchant_name"),
("shipment", "0013_shipmentdelivery_shipment_delivery"),
]
operations = [
migrations.AddField(
model_name="shipmentdelivery",
name="operator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="operated_shipment_deliveries",
to="basic_info.employee",
verbose_name="操作人",
),
),
]

View File

@@ -0,0 +1,50 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("shipment", "0014_shipmentdelivery_operator"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="shipmentdelivery",
name="cancelled_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="取消时间"),
),
migrations.AddField(
model_name="shipmentdelivery",
name="cancelled_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="cancelled_shipment_deliveries",
to=settings.AUTH_USER_MODEL,
verbose_name="取消人",
),
),
migrations.AlterField(
model_name="shipmentdelivery",
name="status",
field=models.IntegerField(
choices=[(1, "待送货"), (2, "送货中"), (3, "已送达"), (4, "已取消")],
db_index=True,
default=1,
verbose_name="状态",
),
),
migrations.AlterModelOptions(
name="shipmentdelivery",
options={
"ordering": ["-created_at", "-id"],
"permissions": [("cancel_shipmentdelivery", "Can cancel shipment delivery")],
"verbose_name": "送货单",
"verbose_name_plural": "送货单",
},
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shipment", "0015_shipmentdelivery_cancel_fields_and_permission"),
]
operations = [
migrations.AddField(
model_name="shipmentdelivery",
name="contact_phone",
field=models.CharField(blank=True, default="", max_length=50, verbose_name="联系电话"),
),
migrations.AddField(
model_name="shipmentdelivery",
name="vehicle_capacity",
field=models.CharField(blank=True, default="", max_length=100, verbose_name="车辆容量"),
),
]

View File

@@ -29,6 +29,14 @@ class ShipmentStatus(models.IntegerChoices):
APPROVED = 5, '已审核' APPROVED = 5, '已审核'
class ShipmentDeliveryStatus(models.IntegerChoices):
"""送货单状态"""
PENDING = 1, '待送货'
IN_TRANSIT = 2, '送货中'
DELIVERED = 3, '已送达'
CANCELLED = 4, '已取消'
class ExternalFinishedProduct(ModelBase): class ExternalFinishedProduct(ModelBase):
""" """
外部成品表 外部成品表
@@ -111,6 +119,15 @@ class Shipment(ModelBase):
verbose_name='审核人', verbose_name='审核人',
) )
delivery = models.ForeignKey(
'ShipmentDelivery',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='shipments',
verbose_name='送货单',
)
external_id = models.CharField( external_id = models.CharField(
max_length=120, max_length=120,
null=True, null=True,
@@ -138,6 +155,27 @@ class Shipment(ModelBase):
help_text='实际出货日期' help_text='实际出货日期'
) )
address = models.CharField(
max_length=255,
blank=True,
default='',
verbose_name='地址',
)
contact_name = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='联系人',
)
contact_phone = models.CharField(
max_length=50,
blank=True,
default='',
verbose_name='联系电话',
)
area = models.CharField( area = models.CharField(
max_length=30, max_length=30,
blank=True, blank=True,
@@ -302,3 +340,121 @@ class SalesItem(ModelBase):
return basic_models.Customer.objects.get(id=self.customer_id) return basic_models.Customer.objects.get(id=self.customer_id)
except basic_models.Customer.DoesNotExist: except basic_models.Customer.DoesNotExist:
return None return None
class ShipmentDelivery(ModelBase):
"""
送货单
记录一次具体送货行为,可关联多个出货单。
"""
status = models.IntegerField(
choices=ShipmentDeliveryStatus.choices,
default=ShipmentDeliveryStatus.PENDING,
db_index=True,
verbose_name='状态',
)
merchant = models.ForeignKey(
basic_models.Merchant,
on_delete=models.PROTECT,
related_name='shipment_deliveries',
verbose_name='所属商户',
)
driver_name = models.CharField(
max_length=100,
db_index=True,
verbose_name='司机名',
)
vehicle_trip = models.CharField(
max_length=100,
db_index=True,
verbose_name='车次',
)
contact_phone = models.CharField(
max_length=50,
blank=True,
default='',
verbose_name='联系电话',
)
vehicle_capacity = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='车辆容量',
)
remark = models.CharField(
max_length=200,
blank=True,
default='',
verbose_name='备注',
)
internal_remark = models.CharField(
max_length=200,
blank=True,
default='',
verbose_name='内部备注',
)
started_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='开始送货时间',
)
delivered_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='送达时间',
)
cancelled_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='取消时间',
)
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='created_shipment_deliveries',
verbose_name='创建人',
)
operator = models.ForeignKey(
basic_models.Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='operated_shipment_deliveries',
verbose_name='操作人',
)
cancelled_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='cancelled_shipment_deliveries',
verbose_name='取消人',
)
class Meta:
db_table = 'shipment_delivery'
verbose_name = '送货单'
verbose_name_plural = '送货单'
ordering = ['-created_at', '-id']
permissions = [
('cancel_shipmentdelivery', 'Can cancel shipment delivery'),
]
def __str__(self) -> str:
return f'送货单 #{self.id} - {self.driver_name} / {self.vehicle_trip}'

View File

@@ -11,7 +11,63 @@ from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Su
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment, ShipmentStatus from shipment.models import (
ExternalFinishedProduct,
SalesItem,
Shipment,
ShipmentDelivery,
ShipmentDeliveryStatus,
ShipmentStatus,
)
def _resolve_user_merchant(user):
emp = getattr(user, "employee", None)
return getattr(emp, "merchant", None) if emp else None
def _resolve_user_employee(user):
return getattr(user, "employee", None)
def _resolve_delivery_shipments(
*,
shipment_ids: list[int],
merchant,
current_delivery: ShipmentDelivery | None = None,
) -> list[Shipment]:
normalized_ids = list(dict.fromkeys(shipment_ids or []))
if not normalized_ids:
return []
shipments = list(
Shipment.objects.filter(id__in=normalized_ids, merchant=merchant)
.select_related("customer", "delivery")
.order_by("id")
)
found_ids = {shipment.id for shipment in shipments}
missing_ids = sorted(set(normalized_ids) - found_ids)
if missing_ids:
raise ValueError(f"以下出货单不存在或不属于当前商户: {missing_ids}")
occupied_ids = sorted(
shipment.id
for shipment in shipments
if shipment.delivery_id is not None
and (current_delivery is None or shipment.delivery_id != current_delivery.id)
)
if occupied_ids:
raise ValueError(f"以下出货单已关联到其他送货单: {occupied_ids}")
not_approved_ids = sorted(
shipment.id
for shipment in shipments
if shipment.status != ShipmentStatus.APPROVED
)
if not_approved_ids:
raise ValueError(f"以下出货单未处于已审核状态,不能绑定到送货单: {not_approved_ids}")
return shipments
def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet: def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
@@ -137,6 +193,220 @@ def get_sales_items_by_customer(
return queryset.select_related("shipment").order_by("id") return queryset.select_related("shipment").order_by("id")
@transaction.atomic
def create_shipment_delivery(
*,
driver_name: str,
vehicle_trip: str,
contact_phone: str = "",
vehicle_capacity: str = "",
shipment_ids: list[int],
created_by,
remark: str = "",
internal_remark: str = "",
) -> ShipmentDelivery:
merchant = _resolve_user_merchant(created_by)
operator = _resolve_user_employee(created_by)
if not merchant:
raise ValueError("用户未关联商户,无法创建送货单")
driver_name = (driver_name or "").strip()
vehicle_trip = (vehicle_trip or "").strip()
if not driver_name:
raise ValueError("driver_name 不能为空")
if not vehicle_trip:
raise ValueError("vehicle_trip 不能为空")
shipments = _resolve_delivery_shipments(
shipment_ids=shipment_ids,
merchant=merchant,
)
delivery = ShipmentDelivery.objects.create(
merchant=merchant,
driver_name=driver_name,
vehicle_trip=vehicle_trip,
contact_phone=(contact_phone or "").strip(),
vehicle_capacity=(vehicle_capacity or "").strip(),
remark=(remark or "").strip(),
internal_remark=(internal_remark or "").strip(),
created_by=created_by,
operator=operator,
)
if shipments:
Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update(
delivery=delivery
)
return delivery
@transaction.atomic
def update_shipment_delivery(
delivery: ShipmentDelivery,
*,
driver_name: str | None = None,
vehicle_trip: str | None = None,
contact_phone: str | None = None,
vehicle_capacity: str | None = None,
shipment_ids: list[int] | None = None,
remark: str | None = None,
internal_remark: str | None = None,
operator=None,
) -> ShipmentDelivery:
if driver_name is not None:
normalized_driver_name = driver_name.strip()
if not normalized_driver_name:
raise ValueError("driver_name 不能为空")
delivery.driver_name = normalized_driver_name
if vehicle_trip is not None:
normalized_vehicle_trip = vehicle_trip.strip()
if not normalized_vehicle_trip:
raise ValueError("vehicle_trip 不能为空")
delivery.vehicle_trip = normalized_vehicle_trip
if contact_phone is not None:
delivery.contact_phone = (contact_phone or "").strip()
if vehicle_capacity is not None:
delivery.vehicle_capacity = (vehicle_capacity or "").strip()
if remark is not None:
delivery.remark = (remark or "").strip()
if internal_remark is not None:
delivery.internal_remark = (internal_remark or "").strip()
if shipment_ids is not None:
shipments = _resolve_delivery_shipments(
shipment_ids=shipment_ids,
merchant=delivery.merchant,
current_delivery=delivery,
)
Shipment.objects.filter(delivery=delivery).exclude(
id__in=[shipment.id for shipment in shipments]
).update(delivery=None)
if shipments:
Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update(
delivery=delivery
)
else:
Shipment.objects.filter(delivery=delivery).update(delivery=None)
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
delivery.save()
return delivery
@transaction.atomic
def bind_shipments_to_delivery(
delivery: ShipmentDelivery,
*,
shipment_ids: list[int],
operator=None,
) -> ShipmentDelivery:
shipments = _resolve_delivery_shipments(
shipment_ids=shipment_ids,
merchant=delivery.merchant,
current_delivery=delivery,
)
if not shipments:
return delivery
Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update(
delivery=delivery
)
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
delivery.save(update_fields=["operator", "updated_at"])
return delivery
@transaction.atomic
def modify_shipment_delivery_status(
delivery: ShipmentDelivery,
*,
target_status: int,
operator=None,
) -> ShipmentDelivery:
try:
target_status = int(target_status)
except (TypeError, ValueError):
raise ValueError("无效的送货单状态")
current_status = delivery.status
if current_status == target_status:
return delivery
allowed_transitions = {
ShipmentDeliveryStatus.PENDING: {ShipmentDeliveryStatus.IN_TRANSIT},
ShipmentDeliveryStatus.IN_TRANSIT: {ShipmentDeliveryStatus.DELIVERED},
ShipmentDeliveryStatus.DELIVERED: set(),
ShipmentDeliveryStatus.CANCELLED: set(),
}
if target_status not in ShipmentDeliveryStatus.values:
raise ValueError("无效的送货单状态")
if target_status == ShipmentDeliveryStatus.CANCELLED:
raise ValueError("取消送货单请使用独立的取消接口")
if target_status not in allowed_transitions.get(current_status, set()):
raise ValueError(
f"不允许将送货单状态从 {delivery.get_status_display()} 修改为 "
f"{ShipmentDeliveryStatus(target_status).label}"
)
now = timezone.now()
delivery.status = target_status
update_fields = ["status", "updated_at"]
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
update_fields.append("operator")
if target_status == ShipmentDeliveryStatus.IN_TRANSIT:
delivery.started_at = now
update_fields.append("started_at")
elif target_status == ShipmentDeliveryStatus.DELIVERED:
delivery.delivered_at = now
update_fields.append("delivered_at")
delivery.save(update_fields=update_fields)
return delivery
@transaction.atomic
def cancel_shipment_delivery(
delivery: ShipmentDelivery,
*,
cancelled_by=None,
operator=None,
) -> ShipmentDelivery:
if delivery.status == ShipmentDeliveryStatus.CANCELLED:
return delivery
delivery.status = ShipmentDeliveryStatus.CANCELLED
delivery.cancelled_at = timezone.now()
delivery.cancelled_by = cancelled_by
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
delivery.save(
update_fields=[
"status",
"cancelled_at",
"cancelled_by",
"operator",
"updated_at",
]
)
return delivery
@transaction.atomic
def delete_shipment_delivery(delivery: ShipmentDelivery) -> None:
Shipment.objects.filter(delivery=delivery).update(delivery=None)
delivery.delete()
@transaction.atomic @transaction.atomic
def modify_status( def modify_status(
shipment: Shipment, shipment: Shipment,
@@ -213,6 +483,9 @@ def update_shipment(
*, *,
customer_id: int | None = None, customer_id: int | None = None,
shipment_date=None, shipment_date=None,
address: str | None = None,
contact_name: str | None = None,
contact_phone: str | None = None,
area: str | None = None, area: str | None = None,
remark: str | None = None, remark: str | None = None,
external_id: str | None = None, external_id: str | None = None,
@@ -239,6 +512,12 @@ def update_shipment(
if shipment_date is not None: if shipment_date is not None:
shipment.shipment_date = shipment_date shipment.shipment_date = shipment_date
if address is not None:
shipment.address = address or ""
if contact_name is not None:
shipment.contact_name = contact_name or ""
if contact_phone is not None:
shipment.contact_phone = contact_phone or ""
if area is not None: if area is not None:
shipment.area = (area or "").strip() shipment.area = (area or "").strip()
if remark is not None: if remark is not None:
@@ -256,6 +535,9 @@ def create_shipment(
shipment_date, shipment_date,
sales_item_ids: List[int], sales_item_ids: List[int],
created_by, created_by,
address: str = "",
contact_name: str = "",
contact_phone: str = "",
remark: str = "", remark: str = "",
area: str = "", area: str = "",
) -> Shipment: ) -> Shipment:
@@ -349,6 +631,9 @@ def create_shipment(
merchant=merchant, merchant=merchant,
customer=customer, customer=customer,
shipment_date=shipment_date, shipment_date=shipment_date,
address=address or "",
contact_name=contact_name or "",
contact_phone=contact_phone or "",
area=(area or "").strip(), area=(area or "").strip(),
remark=remark, remark=remark,
created_by=created_by, created_by=created_by,
@@ -372,6 +657,9 @@ def create_external_shipment(
external_id: str, external_id: str,
external_finished_products: List[dict], external_finished_products: List[dict],
created_by, created_by,
address: str = "",
contact_name: str = "",
contact_phone: str = "",
remark: str = "", remark: str = "",
area: str = "", area: str = "",
) -> Shipment: ) -> Shipment:
@@ -409,6 +697,9 @@ def create_external_shipment(
merchant=merchant, merchant=merchant,
customer=customer, customer=customer,
shipment_date=shipment_date, shipment_date=shipment_date,
address=address or "",
contact_name=contact_name or "",
contact_phone=contact_phone or "",
area=(area or "").strip(), area=(area or "").strip(),
remark=remark, remark=remark,
created_by=created_by, created_by=created_by,