diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 7a31da2..8871c3a 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -85,6 +85,7 @@ class ShipmentSerializer(serializers.ModelSerializer): external_finished_products_count = serializers.SerializerMethodField() merchant_id = serializers.IntegerField(source="merchant.id", read_only=True) merchant_name = serializers.CharField(source="merchant.name", read_only=True) + delivery_id = serializers.IntegerField(read_only=True, allow_null=True) sales_items = serializers.SerializerMethodField() external_finished_products = serializers.SerializerMethodField() @@ -106,6 +107,7 @@ class ShipmentSerializer(serializers.ModelSerializer): "status_display", "external_id", "geo_coordinates", + "delivery_id", "status_modified_at", "cancelled_by_id", "cancelled_by_name", @@ -492,6 +494,7 @@ class ShipmentSalesItemCustomerSerializer(serializers.Serializer): 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) + delivery_id = serializers.IntegerField(read_only=True, allow_null=True) class Meta: model = Shipment @@ -503,6 +506,7 @@ class ShipmentDeliveryShipmentSummarySerializer(serializers.ModelSerializer): "status", "status_display", "external_id", + "delivery_id", ] read_only_fields = fields diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 9a14e1c..c546944 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -759,6 +759,11 @@ class SalesItemDetailAPITestCase(APITestCase): mobile="13800138033", status=basic_models.EmployeeStatusEnum.ACTIVE, ) + self.no_employee_user = User.objects.create_user( + username="sales_item_detail_no_employee_user", + password="testpass123", + email="sales_item_detail_no_employee@example.com", + ) self.customer = basic_models.Customer.objects.create( merchant=self.merchant, @@ -1110,6 +1115,19 @@ class SalesItemDetailAPITestCase(APITestCase): self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(resp.json()["detail"], "已关联出货单的销售品不允许删除") + def test_rebuild_sales_item_user_without_employee_returns_404(self): + self.client.force_authenticate(user=self.no_employee_user) + + resp = self.client.post( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/", + { + "new_printing_job_id": self.new_printing_job.id, + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + class ShipmentCreateAPITestCase(TestCase): """测试创建出货单 API""" @@ -1672,6 +1690,8 @@ class ShipmentQueryAPITestCase(TestCase): self.assertIn("contact_name", item) self.assertIn("contact_phone", item) self.assertIn("area", item) + self.assertIn("delivery_id", item) + self.assertIsNone(item["delivery_id"]) self.assertEqual(item["area"], "A1") self.assertEqual(item["status"], shipment_models.ShipmentStatus.DRAFT) self.assertEqual(item["status_display"], "草稿(未发布)") @@ -1707,6 +1727,107 @@ class ShipmentQueryAPITestCase(TestCase): self.assertIn(shipment_with_address.id, false_ids) self.assertNotIn(self.shipment1.id, false_ids) + def test_list_shipments_supports_delivery_id_filter(self): + delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant1, + driver_name="过滤司机", + vehicle_trip="FILTER-001", + created_by=self.user1, + ) + self.shipment1.delivery = delivery + self.shipment1.save(update_fields=["delivery", "updated_at"]) + + other_same_merchant = shipment_models.Shipment.objects.create( + merchant=self.merchant1, + customer=self.customer1, + shipment_date="2026-01-17", + created_by=self.user1, + remark="other", + area="A2", + ) + + resp = self.client.get(f"/api/v1/shipment/shipments/?delivery_id={delivery.id}") + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["id"], self.shipment1.id) + self.assertEqual(data["results"][0]["delivery_id"], delivery.id) + self.assertNotEqual(other_same_merchant.id, self.shipment1.id) + + def test_list_shipments_ignores_null_like_delivery_id_filter(self): + delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant1, + driver_name="空值司机", + vehicle_trip="FILTER-NULL", + created_by=self.user1, + ) + self.shipment1.delivery = delivery + self.shipment1.save(update_fields=["delivery", "updated_at"]) + + resp = self.client.get("/api/v1/shipment/shipments/?delivery_id=null") + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + ids = [it["id"] for it in data["results"]] + self.assertIn(self.shipment1.id, ids) + + shipment_item = next(it for it in data["results"] if it["id"] == self.shipment1.id) + self.assertEqual(shipment_item["delivery_id"], delivery.id) + + def test_list_shipments_supports_delivery_isnull_true_filter(self): + delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant1, + driver_name="未绑定筛选司机", + vehicle_trip="FILTER-ISNULL-TRUE", + created_by=self.user1, + ) + self.shipment1.delivery = delivery + self.shipment1.save(update_fields=["delivery", "updated_at"]) + + shipment_without_delivery = shipment_models.Shipment.objects.create( + merchant=self.merchant1, + customer=self.customer1, + shipment_date="2026-01-18", + created_by=self.user1, + remark="no-delivery", + area="A3", + ) + + resp = self.client.get("/api/v1/shipment/shipments/?delivery_isnull=true") + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + ids = [it["id"] for it in resp.json()["results"]] + self.assertIn(shipment_without_delivery.id, ids) + self.assertNotIn(self.shipment1.id, ids) + + def test_list_shipments_supports_delivery_isnull_false_filter(self): + delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant1, + driver_name="已绑定筛选司机", + vehicle_trip="FILTER-ISNULL-FALSE", + created_by=self.user1, + ) + self.shipment1.delivery = delivery + self.shipment1.save(update_fields=["delivery", "updated_at"]) + + shipment_models.Shipment.objects.create( + merchant=self.merchant1, + customer=self.customer1, + shipment_date="2026-01-19", + created_by=self.user1, + remark="still-null", + area="A4", + ) + + resp = self.client.get("/api/v1/shipment/shipments/?delivery_isnull=false") + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["id"], self.shipment1.id) + self.assertEqual(data["results"][0]["delivery_id"], delivery.id) + def test_retrieve_shipment_success(self): resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/") self.assertEqual(resp.status_code, status.HTTP_200_OK) @@ -1714,6 +1835,8 @@ class ShipmentQueryAPITestCase(TestCase): self.assertIn("address", result) self.assertIn("contact_name", result) self.assertIn("contact_phone", result) + self.assertIn("delivery_id", result) + self.assertIsNone(result["delivery_id"]) self.assertEqual(result["id"], self.shipment1.id) self.assertIn("area", result) self.assertEqual(result["area"], "A1") @@ -2664,6 +2787,9 @@ class ShipmentDeliveryAPITestCase(APITestCase): 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]) + for item in data["shipments"]: + self.assertIn("delivery_id", item) + self.assertEqual(item["delivery_id"], delivery.id) def test_patch_delivery_updates_fields_and_replaces_shipments(self): delivery = shipment_models.ShipmentDelivery.objects.create( diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index 0b85e9a..c828766 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -121,6 +121,8 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): 支持过滤参数(可选): - customer: 客户ID - status: 状态(1=草稿, 2=已发布, 3=已取消, 4=已驳回, 5=已审核) + - delivery_id: 送货单ID(仅当传入具体ID时过滤;null/空值不触发过滤) + - delivery_isnull: 是否仅查询未绑定/已绑定送货单的出货单(true/false) - external_id: 外部订单号(精确匹配) - shipment_date_from: 出货日期起始(YYYY-MM-DD) - shipment_date_to: 出货日期结束(YYYY-MM-DD,包含整天) @@ -151,6 +153,20 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): if status_val: qs = qs.filter(status=status_val) + delivery_id = self.request.query_params.get("delivery_id") + if delivery_id is not None: + normalized_delivery_id = delivery_id.strip() + if normalized_delivery_id and normalized_delivery_id.lower() not in {"null", "none"}: + qs = qs.filter(delivery_id=normalized_delivery_id) + + delivery_isnull = self.request.query_params.get("delivery_isnull") + if delivery_isnull is not None: + normalized_delivery_isnull = delivery_isnull.strip().lower() + if normalized_delivery_isnull in {"1", "true", "yes"}: + qs = qs.filter(delivery_id__isnull=True) + elif normalized_delivery_isnull in {"0", "false", "no"}: + qs = qs.filter(delivery_id__isnull=False) + external_id = self.request.query_params.get("external_id") if external_id: qs = qs.filter(external_id=external_id) @@ -1009,7 +1025,7 @@ class SalesItemDetailView(GenericAPIView): emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: - return SalesItem.objects.none() + return qs.none() return qs.filter(merchant=merchant) def get(self, request, pk: int): @@ -1116,7 +1132,7 @@ class SalesItemRebuildView(GenericAPIView): emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: - return SalesItem.objects.none() + return qs.none() return qs.filter(merchant=merchant) def post(self, request, pk: int): diff --git a/api_v2/views/ai.py b/api_v2/views/ai.py index 5e08ae8..0d0f69a 100644 --- a/api_v2/views/ai.py +++ b/api_v2/views/ai.py @@ -101,6 +101,7 @@ class AgentSalesItemSerializer(serializers.ModelSerializer): class AgentShipmentListSerializer(serializers.ModelSerializer): customer_name = serializers.CharField(source="customer.name", read_only=True) status_display = serializers.CharField(source="get_status_display", read_only=True) + delivery_id = serializers.IntegerField(read_only=True, allow_null=True) sales_items = serializers.SerializerMethodField() class Meta: @@ -120,6 +121,7 @@ class AgentShipmentListSerializer(serializers.ModelSerializer): "status_display", "external_id", "geo_coordinates", + "delivery_id", "delivery", "sales_items", "created_at", diff --git a/docs/api_v2_agent_api.md b/docs/api_v2_agent_api.md index 8df6db0..02d459c 100644 --- a/docs/api_v2_agent_api.md +++ b/docs/api_v2_agent_api.md @@ -55,6 +55,7 @@ Authorization: "status_display": "待出货", "external_id": null, "geo_coordinates": {"lat": 23.1291, "lng": 113.2644}, + "delivery_id": null, "delivery": null, "sales_items": [ { @@ -152,6 +153,7 @@ Authorization: 说明: - "未出货"定义:`delivery` 为空,即尚未关联送货单 +- `delivery_id` 始终返回送货单ID;未关联时为 `null` - 结果按 `created_at` 降序排列 - 只返回 `merchant_id` 对应商户的数据 - `sales_items` 包含该出货单的所有销售品(软删除的条目自动排除) diff --git a/docs/shipment_api.md b/docs/shipment_api.md index 3112190..0641eb4 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -31,6 +31,8 @@ | offset | int | 偏移量 | | customer | int | 客户ID | | status | int | 状态(1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核) | +| delivery_id | int | 送货单ID。仅当传入具体ID时过滤;传 `null`、`None`、空字符串时不触发过滤 | +| delivery_isnull | bool | 是否按送货单是否为空过滤。传 `true/1/yes` 仅返回 `delivery_id = null`;传 `false/0/no` 仅返回 `delivery_id != null` | | external_id | string | 外部订单号(精确匹配) | | shipment_date_from | string | 出货日期起始(YYYY-MM-DD) | | shipment_date_to | string | 出货日期结束(YYYY-MM-DD) | @@ -54,6 +56,7 @@ "shipment_date": "2026-01-14", "status": 1, "status_display": "草稿(未发布)", + "delivery_id": null, "status_modified_at": null, "cancelled_by_id": null, "cancelled_by_name": null, @@ -75,6 +78,8 @@ 说明: - `sales_items` 现在返回的是带图片字段的销售品详情结构 +- `delivery_id` 始终返回送货单ID;未绑定送货单时为 `null` +- `delivery_id=null` 不会触发过滤;如需按是否绑定送货单筛选,请使用 `delivery_isnull` - 若销售品关联的 `PrintingJob.product` 存在主图,则会返回 `product_image_url` - 若无关联图片,则 `product_image_url` 为 `null` - 若出货单已经被驳回,则其销售品会在驳回时被自动解绑,因此此处的 `sales_items` 会变为空数组 @@ -93,6 +98,7 @@ 说明: +- 详情响应同样始终包含 `delivery_id`;未绑定送货单时返回 `null` - 详情中的 `sales_items` 同样返回带 `product_image_url` 的销售品详情结构 --- @@ -195,6 +201,7 @@ "remark": "备注信息", "status": 1, "status_display": "草稿(未发布)", + "delivery_id": null, "status_modified_at": null, "cancelled_by_id": null, "cancelled_by_name": null, @@ -220,6 +227,7 @@ | customer | int | 客户ID | | customer_name | string | 客户名称 | | shipment_date | string | 出货日期 | +| delivery_id | int/null | 关联送货单ID;未绑定时为 null | | address | string | 地址 | | contact_name | string | 联系人 | | contact_phone | string | 联系电话 |