From 76b30cf4f48a0fbb45ce6459ff088dc0460dde2b Mon Sep 17 00:00:00 2001 From: colaftc Date: Fri, 3 Apr 2026 16:55:22 +0800 Subject: [PATCH] feat: shipment_delivery --- ..._printingexternalsyncfailure_created_at.py | 18 + api_v1/urls.py | 30 + api_v1/views/shipment/__init__.py | 10 + api_v1/views/shipment/serializers.py | 306 ++++++++- api_v1/views/shipment/test_api.py | 603 +++++++++++++++++- api_v1/views/shipment/views.py | 323 +++++++++- ...-rds-pgbouncer-server-side-cursor-issue.md | 161 +++++ docs/shipment_api.md | 36 +- docs/shipment_delivery.md | 319 +++++++++ shipment/admin.py | 99 ++- ...0013_shipmentdelivery_shipment_delivery.py | 72 +++ .../0014_shipmentdelivery_operator.py | 25 + ...ntdelivery_cancel_fields_and_permission.py | 50 ++ ...very_contact_phone_and_vehicle_capacity.py | 21 + shipment/models.py | 156 +++++ shipment/services.py | 293 ++++++++- 16 files changed, 2513 insertions(+), 9 deletions(-) create mode 100644 api_v1/migrations/0014_alter_printingexternalsyncfailure_created_at.py create mode 100644 docs/2026-04-02-rds-pgbouncer-server-side-cursor-issue.md create mode 100644 docs/shipment_delivery.md create mode 100644 shipment/migrations/0013_shipmentdelivery_shipment_delivery.py create mode 100644 shipment/migrations/0014_shipmentdelivery_operator.py create mode 100644 shipment/migrations/0015_shipmentdelivery_cancel_fields_and_permission.py create mode 100644 shipment/migrations/0016_shipmentdelivery_contact_phone_and_vehicle_capacity.py diff --git a/api_v1/migrations/0014_alter_printingexternalsyncfailure_created_at.py b/api_v1/migrations/0014_alter_printingexternalsyncfailure_created_at.py new file mode 100644 index 0000000..c77eeab --- /dev/null +++ b/api_v1/migrations/0014_alter_printingexternalsyncfailure_created_at.py @@ -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='创建时间'), + ), + ] diff --git a/api_v1/urls.py b/api_v1/urls.py index 8d6d372..000e1dc 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -37,6 +37,11 @@ from .views.shipment import ( SalesItemByCustomerView, SalesItemByPrintingOrderView, SalesItemCreateView, + ShipmentDeliveryBindShipmentsView, + ShipmentDeliveryCancelView, + ShipmentDeliveryDetailView, + ShipmentDeliveryListCreateView, + ShipmentDeliveryStatusUpdateView, ShipmentSalesItemCustomerListView, ShipmentListCreateView, ShipmentDetailView, @@ -308,6 +313,31 @@ urlpatterns = [ ShipmentExternalCreateView.as_view(), name="shipment_create_external", ), + path( + "shipment/deliveries/", + ShipmentDeliveryListCreateView.as_view(), + name="shipment_delivery_list_create", + ), + path( + "shipment/deliveries//", + ShipmentDeliveryDetailView.as_view(), + name="shipment_delivery_detail", + ), + path( + "shipment/deliveries//status/", + ShipmentDeliveryStatusUpdateView.as_view(), + name="shipment_delivery_status_update", + ), + path( + "shipment/deliveries//cancel/", + ShipmentDeliveryCancelView.as_view(), + name="shipment_delivery_cancel", + ), + path( + "shipment/deliveries//bind-shipments/", + ShipmentDeliveryBindShipmentsView.as_view(), + name="shipment_delivery_bind_shipments", + ), path( "shipment/sales-items/customers/", ShipmentSalesItemCustomerListView.as_view(), diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py index 2062886..dc02b06 100644 --- a/api_v1/views/shipment/__init__.py +++ b/api_v1/views/shipment/__init__.py @@ -8,6 +8,11 @@ from .views import ( SalesItemByCustomerView, SalesItemByPrintingOrderView, SalesItemCreateView, + ShipmentDeliveryBindShipmentsView, + ShipmentDeliveryDetailView, + ShipmentDeliveryListCreateView, + ShipmentDeliveryCancelView, + ShipmentDeliveryStatusUpdateView, ShipmentSalesItemCustomerListView, ShipmentListCreateView, ShipmentDetailView, @@ -19,6 +24,11 @@ __all__ = [ 'SalesItemByCustomerView', 'SalesItemByPrintingOrderView', 'SalesItemCreateView', + 'ShipmentDeliveryBindShipmentsView', + 'ShipmentDeliveryDetailView', + 'ShipmentDeliveryListCreateView', + 'ShipmentDeliveryCancelView', + 'ShipmentDeliveryStatusUpdateView', 'ShipmentSalesItemCustomerListView', 'ShipmentListCreateView', 'ShipmentDetailView', diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 1778919..6c8e784 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -4,7 +4,61 @@ Shipment API 序列化器 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): @@ -42,6 +96,9 @@ class ShipmentSerializer(serializers.ModelSerializer): "customer", "customer_name", "shipment_date", + "address", + "contact_name", + "contact_phone", "area", "remark", "status", @@ -100,7 +157,13 @@ class ShipmentSerializer(serializers.ModelSerializer): # 优先使用 prefetch 的 related manager;兜底为 none() rel = getattr(obj, "items", None) 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): """ @@ -150,6 +213,27 @@ class ShipmentCreateNormalSerializer(serializers.Serializer): customer = serializers.IntegerField(help_text="客户ID") 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( max_length=30, required=False, @@ -194,6 +278,27 @@ class ShipmentCreateExternalSerializer(serializers.Serializer): customer = serializers.IntegerField(help_text="客户ID") 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( max_length=30, required=False, @@ -232,6 +337,15 @@ class ShipmentUpdateSerializer(serializers.Serializer): customer = serializers.IntegerField(required=False, help_text="客户ID(可选)") 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( 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) +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): """ 销售品创建序列化器 diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 8aaa4a1..af51ab7 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -6,6 +6,7 @@ from decimal import Decimal from django.test import TestCase from django.conf import settings +from django.contrib.auth.models import Permission from rest_framework.test import APIClient, APITestCase from rest_framework import status from django.contrib.auth import get_user_model @@ -935,6 +936,9 @@ class ShipmentCreateAPITestCase(TestCase): data = { "customer": self.customer.id, "shipment_date": "2026-01-14", + "address": "杭州市测试路 1 号", + "contact_name": "张三", + "contact_phone": "13800138000", "area": "华东", "remark": "测试备注", "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_name"], self.customer.name) 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["remark"], "测试备注") self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT) @@ -1106,6 +1113,9 @@ class ShipmentExternalCreateAPITestCase(TestCase): data = { "customer": self.customer.id, "shipment_date": "2026-01-14", + "address": "绍兴市仓库 2 号", + "contact_name": "李四", + "contact_phone": "13900139000", "area": "华南", "remark": "external 备注", "external_id": "EXT-ORDER-001", @@ -1123,6 +1133,9 @@ class ShipmentExternalCreateAPITestCase(TestCase): self.assertEqual(result["customer"], self.customer.id) 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["status"], shipment_models.ShipmentStatus.DRAFT) self.assertEqual(result["status_display"], "草稿(未发布)") @@ -1234,6 +1247,38 @@ class ShipmentQueryAPITestCase(TestCase): 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( merchant=self.merchant1, customer=self.customer1, @@ -1250,6 +1295,16 @@ class ShipmentQueryAPITestCase(TestCase): remark="s2", 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) @@ -1267,12 +1322,16 @@ class ShipmentQueryAPITestCase(TestCase): # 关键:列表序列化器必须稳定输出明细数组字段(即使为空) 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.assertEqual(item["area"], "A1") self.assertEqual(item["status"], shipment_models.ShipmentStatus.DRAFT) self.assertEqual(item["status_display"], "草稿(未发布)") self.assertIn("sales_items", item) 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.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}/") self.assertEqual(resp.status_code, status.HTTP_200_OK) 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.assertIn("area", result) self.assertEqual(result["area"], "A1") @@ -1287,6 +1349,7 @@ class ShipmentQueryAPITestCase(TestCase): self.assertEqual(result["status_display"], "草稿(未发布)") self.assertIn("sales_items", result) 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.assertIsInstance(result["external_finished_products"], list) @@ -1296,16 +1359,27 @@ class ShipmentQueryAPITestCase(TestCase): """ resp = self.client.patch( f"/api/v1/shipment/shipments/{self.shipment1.id}/", - data={"area": "更新地区"}, + data={ + "area": "更新地区", + "address": "宁波市滨海路 9 号", + "contact_name": "王五", + "contact_phone": "13700137000", + }, format="json", ) self.assertEqual(resp.status_code, status.HTTP_200_OK) result = resp.json() self.assertEqual(result["id"], self.shipment1.id) 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.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): """ @@ -1773,3 +1847,530 @@ class SalesItemCreateAPITestCase(APITestCase): resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") 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) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index e434ea2..daeec38 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -10,11 +10,16 @@ from rest_framework.response import Response from rest_framework.views import APIView from flower.viewsets import LimitedLimitOffsetPagination -from shipment.models import Shipment +from shipment.models import Shipment, ShipmentDelivery from .serializers import ( SalesItemDetailSerializer, SalesItemSerializer, + ShipmentDeliveryBindShipmentsSerializer, + ShipmentDeliveryCreateSerializer, + ShipmentDeliverySerializer, + ShipmentDeliveryStatusUpdateSerializer, + ShipmentDeliveryUpdateSerializer, ShipmentSerializer, ShipmentCreateNormalSerializer, ShipmentCreateExternalSerializer, @@ -178,6 +183,9 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): shipment_date=data["shipment_date"], sales_item_ids=data["sales_items"], created_by=request.user, + address=data.get("address", ""), + contact_name=data.get("contact_name", ""), + contact_phone=data.get("contact_phone", ""), remark=data.get("remark", ""), area=data.get("area", ""), ) @@ -246,6 +254,9 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): shipment, customer_id=data.get("customer"), 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"), remark=data.get("remark"), external_id=data.get("external_id"), @@ -263,6 +274,313 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): 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// + - PATCH /api/v1/shipment/deliveries// + - PUT /api/v1/shipment/deliveries// + - DELETE /api/v1/shipment/deliveries// + """ + + 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//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//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//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): """ 创建出货单(external 版) @@ -293,6 +611,9 @@ class ShipmentExternalCreateView(APIView): external_id=data["external_id"], external_finished_products=data["external_finished_products"], created_by=request.user, + address=data.get("address", ""), + contact_name=data.get("contact_name", ""), + contact_phone=data.get("contact_phone", ""), remark=data.get("remark", ""), area=data.get("area", ""), ) diff --git a/docs/2026-04-02-rds-pgbouncer-server-side-cursor-issue.md b/docs/2026-04-02-rds-pgbouncer-server-side-cursor-issue.md new file mode 100644 index 0000000..4a391e2 --- /dev/null +++ b/docs/2026-04-02-rds-pgbouncer-server-side-cursor-issue.md @@ -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//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`** + +该决策用于降低生产环境中命名游标相关错误的出现概率,并保持系统整体连接池架构不变。 diff --git a/docs/shipment_api.md b/docs/shipment_api.md index 959bd17..74c5cfd 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -70,6 +70,12 @@ } ``` +说明: + +- `sales_items` 现在返回的是带图片字段的销售品详情结构 +- 若销售品关联的 `PrintingJob.product` 存在主图,则会返回 `product_image_url` +- 若无关联图片,则 `product_image_url` 为 `null` + ### 出货单详情 - **URL**: `/api/v1/shipment/shipments//` @@ -82,6 +88,10 @@ |------|------|------| | 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` - 仅 `草稿(未发布)` 状态的出货单允许修改 - 非草稿状态会返回 `400 Bad Request` @@ -101,6 +111,9 @@ ```json { + "address": "宁波市滨海路 9 号", + "contact_name": "王五", + "contact_phone": "13700137000", "area": "更新地区", "remark": "更新备注" } @@ -139,6 +152,9 @@ |------|------|------|------| | customer | int | 是 | 客户ID | | shipment_date | string | 是 | 出货日期(YYYY-MM-DD) | +| address | string | 否 | 地址(可空字符串,长度<=255) | +| contact_name | string | 否 | 联系人(可空字符串,长度<=100) | +| contact_phone | string | 否 | 联系电话(可空字符串,长度<=50) | | area | string | 否 | 出货地区(可空字符串,长度<=30) | | remark | string | 否 | 备注 | | sales_items | array[int] | 否 | 要关联的销售品ID列表 | @@ -149,6 +165,9 @@ { "customer": 1, "shipment_date": "2026-01-14", + "address": "杭州市测试路 1 号", + "contact_name": "张三", + "contact_phone": "13800138000", "area": "华东", "remark": "备注信息", "sales_items": [1, 2, 3] @@ -165,6 +184,9 @@ "customer": 1, "customer_name": "客户A", "shipment_date": "2026-01-14", + "address": "杭州市测试路 1 号", + "contact_name": "张三", + "contact_phone": "13800138000", "area": "华东", "remark": "备注信息", "status": 1, @@ -194,6 +216,9 @@ | customer | int | 客户ID | | customer_name | string | 客户名称 | | shipment_date | string | 出货日期 | +| address | string | 地址 | +| contact_name | string | 联系人 | +| contact_phone | string | 联系电话 | | area | string | 出货地区 | | remark | string | 备注 | | status | int | 状态枚举(1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核) | @@ -287,6 +312,9 @@ |------|------|------|------| | customer | int | 是 | 客户ID | | shipment_date | string | 是 | 出货日期(YYYY-MM-DD) | +| address | string | 否 | 地址(可空字符串,长度<=255) | +| contact_name | string | 否 | 联系人(可空字符串,长度<=100) | +| contact_phone | string | 否 | 联系电话(可空字符串,长度<=50) | | area | string | 否 | 出货地区(可空字符串,长度<=30) | | remark | string | 否 | 备注 | | external_id | string | 是 | 外部订单号(长度<=120) | @@ -306,6 +334,9 @@ { "customer": 1, "shipment_date": "2026-01-14", + "address": "绍兴市仓库 2 号", + "contact_name": "李四", + "contact_phone": "13900139000", "area": "华南", "remark": "external 备注(可选)", "external_id": "EXT-ORDER-001", @@ -326,6 +357,9 @@ "customer": 1, "customer_name": "客户A", "shipment_date": "2026-01-14", + "address": "绍兴市仓库 2 号", + "contact_name": "李四", + "contact_phone": "13900139000", "area": "华南", "remark": "external 备注(可选)", "status": 1, diff --git a/docs/shipment_delivery.md b/docs/shipment_delivery.md new file mode 100644 index 0000000..55d1f2b --- /dev/null +++ b/docs/shipment_delivery.md @@ -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` +- 非法状态流转报错 +- 删除送货单后解除出货单绑定 diff --git a/shipment/admin.py b/shipment/admin.py index 614dcfa..cd33ddf 100644 --- a/shipment/admin.py +++ b/shipment/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import ExternalFinishedProduct, Shipment, SalesItem +from .models import ExternalFinishedProduct, Shipment, SalesItem, ShipmentDelivery class SalesItemInline(admin.TabularInline): @@ -18,10 +18,29 @@ class ExternalFinishedProductInline(admin.TabularInline): @admin.register(Shipment) 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'] - 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'] + raw_id_fields = ['delivery'] date_hierarchy = 'shipment_date' inlines = [SalesItemInline, ExternalFinishedProductInline] @@ -75,3 +94,77 @@ class ExternalFinishedProductAdmin(admin.ModelAdmin): if not change: obj.created_by = request.user 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) diff --git a/shipment/migrations/0013_shipmentdelivery_shipment_delivery.py b/shipment/migrations/0013_shipmentdelivery_shipment_delivery.py new file mode 100644 index 0000000..b4fc6a1 --- /dev/null +++ b/shipment/migrations/0013_shipmentdelivery_shipment_delivery.py @@ -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="备注"), + ), + ] diff --git a/shipment/migrations/0014_shipmentdelivery_operator.py b/shipment/migrations/0014_shipmentdelivery_operator.py new file mode 100644 index 0000000..031cae7 --- /dev/null +++ b/shipment/migrations/0014_shipmentdelivery_operator.py @@ -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="操作人", + ), + ), + ] diff --git a/shipment/migrations/0015_shipmentdelivery_cancel_fields_and_permission.py b/shipment/migrations/0015_shipmentdelivery_cancel_fields_and_permission.py new file mode 100644 index 0000000..56ff06e --- /dev/null +++ b/shipment/migrations/0015_shipmentdelivery_cancel_fields_and_permission.py @@ -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": "送货单", + }, + ), + ] diff --git a/shipment/migrations/0016_shipmentdelivery_contact_phone_and_vehicle_capacity.py b/shipment/migrations/0016_shipmentdelivery_contact_phone_and_vehicle_capacity.py new file mode 100644 index 0000000..5c1ddf5 --- /dev/null +++ b/shipment/migrations/0016_shipmentdelivery_contact_phone_and_vehicle_capacity.py @@ -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="车辆容量"), + ), + ] diff --git a/shipment/models.py b/shipment/models.py index 1b9c520..dc78056 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -29,6 +29,14 @@ class ShipmentStatus(models.IntegerChoices): APPROVED = 5, '已审核' +class ShipmentDeliveryStatus(models.IntegerChoices): + """送货单状态""" + PENDING = 1, '待送货' + IN_TRANSIT = 2, '送货中' + DELIVERED = 3, '已送达' + CANCELLED = 4, '已取消' + + class ExternalFinishedProduct(ModelBase): """ 外部成品表 @@ -111,6 +119,15 @@ class Shipment(ModelBase): verbose_name='审核人', ) + delivery = models.ForeignKey( + 'ShipmentDelivery', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='shipments', + verbose_name='送货单', + ) + external_id = models.CharField( max_length=120, null=True, @@ -138,6 +155,27 @@ class Shipment(ModelBase): 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( max_length=30, blank=True, @@ -302,3 +340,121 @@ class SalesItem(ModelBase): return basic_models.Customer.objects.get(id=self.customer_id) except basic_models.Customer.DoesNotExist: 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}' diff --git a/shipment/services.py b/shipment/services.py index 41d59a6..d73f3da 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -11,7 +11,63 @@ from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Su from django.db.models.functions import Coalesce 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: @@ -137,6 +193,220 @@ def get_sales_items_by_customer( 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 def modify_status( shipment: Shipment, @@ -213,6 +483,9 @@ def update_shipment( *, customer_id: int | None = None, shipment_date=None, + address: str | None = None, + contact_name: str | None = None, + contact_phone: str | None = None, area: str | None = None, remark: str | None = None, external_id: str | None = None, @@ -239,6 +512,12 @@ def update_shipment( if shipment_date is not None: 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: shipment.area = (area or "").strip() if remark is not None: @@ -256,6 +535,9 @@ def create_shipment( shipment_date, sales_item_ids: List[int], created_by, + address: str = "", + contact_name: str = "", + contact_phone: str = "", remark: str = "", area: str = "", ) -> Shipment: @@ -349,6 +631,9 @@ def create_shipment( merchant=merchant, customer=customer, shipment_date=shipment_date, + address=address or "", + contact_name=contact_name or "", + contact_phone=contact_phone or "", area=(area or "").strip(), remark=remark, created_by=created_by, @@ -372,6 +657,9 @@ def create_external_shipment( external_id: str, external_finished_products: List[dict], created_by, + address: str = "", + contact_name: str = "", + contact_phone: str = "", remark: str = "", area: str = "", ) -> Shipment: @@ -409,6 +697,9 @@ def create_external_shipment( merchant=merchant, customer=customer, shipment_date=shipment_date, + address=address or "", + contact_name=contact_name or "", + contact_phone=contact_phone or "", area=(area or "").strip(), remark=remark, created_by=created_by,