1
0
forked from erp-dev/erp

feat: salesitem delete guard service

This commit is contained in:
2026-04-06 15:04:22 +08:00
parent 9afbb73799
commit ebd687ccdd
5 changed files with 91 additions and 19 deletions

View File

@@ -413,9 +413,9 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
def get_saleitems(self, obj): def get_saleitems(self, obj):
items = getattr(obj, '_saleitems_cache', None) items = getattr(obj, '_saleitems_cache', None)
if items is None: if items is None:
from shipment.models import SalesItem from shipment.services import get_active_sales_items_queryset
items = list( 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) .filter(printing_job_id=obj.id)
.order_by('id') .order_by('id')
) )
@@ -475,9 +475,9 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
def get_saleitems(self, obj): def get_saleitems(self, obj):
items = getattr(obj, '_saleitems_cache', None) items = getattr(obj, '_saleitems_cache', None)
if items is None: if items is None:
from shipment.models import SalesItem from shipment.services import get_active_sales_items_queryset
items = list( 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) .filter(printing_job_id=obj.id)
.order_by('id') .order_by('id')
) )

View File

@@ -501,11 +501,11 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
if not jobs: if not jobs:
return return
from shipment.models import SalesItem from shipment.services import get_active_sales_items_queryset
job_ids = [job.id for job in jobs] job_ids = [job.id for job in jobs]
saleitems = list( 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) .filter(printing_job_id__in=job_ids)
.order_by("id") .order_by("id")
) )

View File

@@ -7,6 +7,7 @@ from decimal import Decimal
from django.test import TestCase from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase from rest_framework.test import APIClient, APITestCase
from rest_framework import status from rest_framework import status
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -321,6 +322,21 @@ class SalesItemByPrintingOrderAPITestCase(TestCase):
self.assertEqual(data["count"], 0) self.assertEqual(data["count"], 0)
self.assertEqual(data["results"], []) 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): def test_get_sales_items_unauthenticated(self):
"""测试未认证用户""" """测试未认证用户"""
self.client.logout() self.client.logout()
@@ -671,6 +687,20 @@ class SalesItemByCustomerAPITestCase(TestCase):
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) 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): def test_get_sales_items_by_customer_other_merchant_404(self):
resp = self.client.get( resp = self.client.get(
f"/api/v1/shipment/sales-items/by-customer/{self.foreign_customer.id}/" 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) 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): class ShipmentCreateAPITestCase(TestCase):
"""测试创建出货单 API""" """测试创建出货单 API"""

View File

@@ -984,11 +984,11 @@ class SalesItemDetailView(GenericAPIView):
serializer_class = SalesItemDetailSerializer serializer_class = SalesItemDetailSerializer
def get_queryset(self): def get_queryset(self):
from shipment.models import SalesItem from shipment.services import get_active_sales_items_queryset
qs = ( qs = (
SalesItem.objects.select_related("shipment", "created_by", "delete_by") get_active_sales_items_queryset()
.filter(delete_at__isnull=True) .select_related("shipment", "created_by", "delete_by")
.order_by("id") .order_by("id")
) )
@@ -1074,5 +1074,8 @@ class SalesItemDetailView(GenericAPIView):
from shipment.services import delete_sales_item 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) return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK)

View File

@@ -32,6 +32,24 @@ def _resolve_user_employee(user):
return getattr(user, "employee", None) 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( def _resolve_delivery_shipments(
*, *,
shipment_ids: list[int], shipment_ids: list[int],
@@ -81,10 +99,9 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
""" """
from basic_info.models import Customer from basic_info.models import Customer
base_sales_items = SalesItem.objects.filter( base_sales_items = get_active_sales_items_queryset().filter(
merchant=merchant, merchant=merchant,
shipment__isnull=True, shipment__isnull=True,
delete_at__isnull=True,
customer_id=OuterRef("pk"), customer_id=OuterRef("pk"),
) )
count_subquery = ( count_subquery = (
@@ -155,8 +172,9 @@ def get_sales_items_by_printing_order(
).values_list("id", flat=True) ).values_list("id", flat=True)
# 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中 # 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中
queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids)) queryset = get_active_sales_items_queryset().filter(
queryset = queryset.filter(delete_at__isnull=True) printing_job_id__in=list(job_ids)
)
# 3. 根据参数决定是否过滤已出货的销售品 # 3. 根据参数决定是否过滤已出货的销售品
if not include_already_has_shipment: if not include_already_has_shipment:
@@ -177,10 +195,9 @@ def get_sales_items_by_customer(
仅返回当前商户下、customer_id 匹配的销售品。 仅返回当前商户下、customer_id 匹配的销售品。
""" """
queryset = SalesItem.objects.filter( queryset = get_active_sales_items_queryset().filter(
merchant=merchant, merchant=merchant,
customer_id=customer_id, customer_id=customer_id,
delete_at__isnull=True,
) )
if external_order_id: if external_order_id:
@@ -277,6 +294,8 @@ def delete_sales_item(
if sales_item.delete_at is not None: if sales_item.delete_at is not None:
return sales_item return sales_item
ensure_sales_item_can_be_soft_deleted(sales_item)
sales_item.delete_at = timezone.now() sales_item.delete_at = timezone.now()
sales_item.delete_by = deleted_by sales_item.delete_by = deleted_by
sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"]) sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"])
@@ -669,7 +688,7 @@ def create_shipment(
from printing.models import PrintingJob 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)) found_ids = set(sales_items.values_list("id", flat=True))
missing_ids = set(sales_item_ids) - found_ids missing_ids = set(sales_item_ids) - found_ids
@@ -731,8 +750,8 @@ def create_shipment(
# 关联销售品 # 关联销售品
if sales_item_ids: if sales_item_ids:
updated = SalesItem.objects.filter( updated = get_active_sales_items_queryset().filter(
id__in=sales_item_ids, merchant=merchant, delete_at__isnull=True id__in=sales_item_ids, merchant=merchant
).update(shipment=shipment) ).update(shipment=shipment)
if updated != len(sales_item_ids): if updated != len(sales_item_ids):
raise ValueError("存在不属于当前商户的销售品,无法关联到出货单") raise ValueError("存在不属于当前商户的销售品,无法关联到出货单")