From ebd687ccdd3b7b5f7be149303a438311b6dc1590 Mon Sep 17 00:00:00 2001 From: colaftc Date: Mon, 6 Apr 2026 15:04:22 +0800 Subject: [PATCH] feat: salesitem delete guard service --- api_v1/views/printing/serializers.py | 8 ++--- api_v1/views/printing/views.py | 4 +-- api_v1/views/shipment/test_api.py | 50 ++++++++++++++++++++++++++++ api_v1/views/shipment/views.py | 11 +++--- shipment/services.py | 37 +++++++++++++++----- 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index d136838..caacd38 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -413,9 +413,9 @@ class PrintingJobListSerializer(serializers.ModelSerializer): def get_saleitems(self, obj): items = getattr(obj, '_saleitems_cache', None) if items is None: - from shipment.models import SalesItem + from shipment.services import get_active_sales_items_queryset items = list( - SalesItem.objects.select_related('shipment', 'created_by') + get_active_sales_items_queryset().select_related('shipment', 'created_by') .filter(printing_job_id=obj.id) .order_by('id') ) @@ -475,9 +475,9 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): def get_saleitems(self, obj): items = getattr(obj, '_saleitems_cache', None) if items is None: - from shipment.models import SalesItem + from shipment.services import get_active_sales_items_queryset items = list( - SalesItem.objects.select_related('shipment', 'created_by') + get_active_sales_items_queryset().select_related('shipment', 'created_by') .filter(printing_job_id=obj.id) .order_by('id') ) diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index a714f60..3ff0f44 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -501,11 +501,11 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): if not jobs: return - from shipment.models import SalesItem + from shipment.services import get_active_sales_items_queryset job_ids = [job.id for job in jobs] saleitems = list( - SalesItem.objects.select_related("shipment", "created_by") + get_active_sales_items_queryset().select_related("shipment", "created_by") .filter(printing_job_id__in=job_ids) .order_by("id") ) diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 44a1f5e..513fd59 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -7,6 +7,7 @@ from decimal import Decimal from django.test import TestCase from django.conf import settings from django.contrib.auth.models import Permission +from django.utils import timezone from rest_framework.test import APIClient, APITestCase from rest_framework import status from django.contrib.auth import get_user_model @@ -321,6 +322,21 @@ class SalesItemByPrintingOrderAPITestCase(TestCase): self.assertEqual(data["count"], 0) self.assertEqual(data["results"], []) + def test_get_sales_items_by_printing_order_excludes_soft_deleted(self): + self.sales_item2.delete_at = timezone.now() + self.sales_item2.delete_by = self.user + self.sales_item2.save(update_fields=["delete_at", "delete_by", "updated_at"]) + + url = ( + f"/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/" + ) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + item_ids = [item["id"] for item in response.json()["results"]] + self.assertIn(self.sales_item1.id, item_ids) + self.assertNotIn(self.sales_item2.id, item_ids) + def test_get_sales_items_unauthenticated(self): """测试未认证用户""" self.client.logout() @@ -671,6 +687,20 @@ class SalesItemByCustomerAPITestCase(TestCase): self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + def test_get_sales_items_by_customer_excludes_soft_deleted(self): + self.sales_item2.delete_at = timezone.now() + self.sales_item2.delete_by = self.user + self.sales_item2.save(update_fields=["delete_at", "delete_by", "updated_at"]) + + resp = self.client.get( + f"/api/v1/shipment/sales-items/by-customer/{self.customer.id}/" + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + result_ids = [item["id"] for item in resp.json()["results"]] + self.assertIn(self.sales_item1.id, result_ids) + self.assertNotIn(self.sales_item2.id, result_ids) + def test_get_sales_items_by_customer_other_merchant_404(self): resp = self.client.get( f"/api/v1/shipment/sales-items/by-customer/{self.foreign_customer.id}/" @@ -893,6 +923,26 @@ class SalesItemDetailAPITestCase(APITestCase): self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + def test_delete_sales_item_rejects_when_already_bound_to_shipment(self): + permission = Permission.objects.get(codename="soft_delete_salesitem") + self.user.user_permissions.add(permission) + + shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date="2026-04-03", + created_by=self.user, + ) + self.sales_item.shipment = shipment + self.sales_item.save(update_fields=["shipment", "updated_at"]) + + resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/") + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(resp.json()["detail"], "已关联出货单的销售品不允许删除") + self.sales_item.refresh_from_db() + self.assertIsNone(self.sales_item.delete_at) + class ShipmentCreateAPITestCase(TestCase): """测试创建出货单 API""" diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index 253c184..41e4dce 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -984,11 +984,11 @@ class SalesItemDetailView(GenericAPIView): serializer_class = SalesItemDetailSerializer def get_queryset(self): - from shipment.models import SalesItem + from shipment.services import get_active_sales_items_queryset qs = ( - SalesItem.objects.select_related("shipment", "created_by", "delete_by") - .filter(delete_at__isnull=True) + get_active_sales_items_queryset() + .select_related("shipment", "created_by", "delete_by") .order_by("id") ) @@ -1074,5 +1074,8 @@ class SalesItemDetailView(GenericAPIView): from shipment.services import delete_sales_item - delete_sales_item(sales_item, deleted_by=request.user) + try: + delete_sales_item(sales_item, deleted_by=request.user) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK) diff --git a/shipment/services.py b/shipment/services.py index 412e4b5..9dd3bb8 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -32,6 +32,24 @@ def _resolve_user_employee(user): return getattr(user, "employee", None) +def get_active_sales_items_queryset() -> QuerySet[SalesItem]: + """ + 返回未软删除的销售品查询集。 + """ + return SalesItem.objects.filter(delete_at__isnull=True) + + +def ensure_sales_item_can_be_soft_deleted(sales_item: SalesItem) -> None: + """ + 校验销售品是否允许软删除。 + + 当前规则: + - 已关联出货单的销售品不允许删除 + """ + if sales_item.shipment_id is not None: + raise ValueError("已关联出货单的销售品不允许删除") + + def _resolve_delivery_shipments( *, shipment_ids: list[int], @@ -81,10 +99,9 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet: """ from basic_info.models import Customer - base_sales_items = SalesItem.objects.filter( + base_sales_items = get_active_sales_items_queryset().filter( merchant=merchant, shipment__isnull=True, - delete_at__isnull=True, customer_id=OuterRef("pk"), ) count_subquery = ( @@ -155,8 +172,9 @@ def get_sales_items_by_printing_order( ).values_list("id", flat=True) # 2. 查询 SalesItem,过滤 printing_job_id 在这些 job_ids 中 - queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids)) - queryset = queryset.filter(delete_at__isnull=True) + queryset = get_active_sales_items_queryset().filter( + printing_job_id__in=list(job_ids) + ) # 3. 根据参数决定是否过滤已出货的销售品 if not include_already_has_shipment: @@ -177,10 +195,9 @@ def get_sales_items_by_customer( 仅返回当前商户下、customer_id 匹配的销售品。 """ - queryset = SalesItem.objects.filter( + queryset = get_active_sales_items_queryset().filter( merchant=merchant, customer_id=customer_id, - delete_at__isnull=True, ) if external_order_id: @@ -277,6 +294,8 @@ def delete_sales_item( if sales_item.delete_at is not None: return sales_item + ensure_sales_item_can_be_soft_deleted(sales_item) + sales_item.delete_at = timezone.now() sales_item.delete_by = deleted_by sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"]) @@ -669,7 +688,7 @@ def create_shipment( from printing.models import PrintingJob # 查询销售品 - sales_items = SalesItem.objects.filter(id__in=sales_item_ids, delete_at__isnull=True) + sales_items = get_active_sales_items_queryset().filter(id__in=sales_item_ids) found_ids = set(sales_items.values_list("id", flat=True)) missing_ids = set(sales_item_ids) - found_ids @@ -731,8 +750,8 @@ def create_shipment( # 关联销售品 if sales_item_ids: - updated = SalesItem.objects.filter( - id__in=sales_item_ids, merchant=merchant, delete_at__isnull=True + updated = get_active_sales_items_queryset().filter( + id__in=sales_item_ids, merchant=merchant ).update(shipment=shipment) if updated != len(sales_item_ids): raise ValueError("存在不属于当前商户的销售品,无法关联到出货单")