forked from erp-dev/erp
feat: salesitem delete guard service
This commit is contained in:
@@ -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')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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("存在不属于当前商户的销售品,无法关联到出货单")
|
||||||
|
|||||||
Reference in New Issue
Block a user