From 29eb9632aca64e5b080aa2ba7b146a7d0086dfff Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 14 Oct 2026 10:00:51 +1100 Subject: [PATCH] Prevent deletion of line items which have received stock (#13004) * Prevent deletion of line items which have received stock * Revert docstring changes --- src/backend/InvenTree/order/api.py | 50 +++++++++++++++++++++++++ src/backend/InvenTree/order/test_api.py | 38 ++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 8d7d02184c..b8cf2594f8 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -36,6 +36,7 @@ from generic.states.api import FSMTransitionMixin, StatusView from InvenTree.api import ( BulkDeleteMixin, BulkDeleteViewsetMixin, + BulkRequestSerializer, BulkUpdateMixin, ListCreateDestroyAPIView, ParameterListMixin, @@ -686,6 +687,55 @@ class PurchaseOrderLineItemViewSet( serializer.data, status=status.HTTP_201_CREATED, headers=headers ) + def check_received(self, queryset) -> None: + """Prevent deletion of line items against which stock has been received. + + Deleting a received line item would erase the record of what was received + against the order. The line item rows are locked (in pk order, matching the + lock order used when receiving stock) so that a concurrent receipt cannot + succeed between this check and the deletion. + + Raises: + ValidationError: If any line item has a received quantity + """ + received = [ + pk + for pk, qty in queryset + .select_for_update() + .order_by('pk') + .values_list('pk', 'received') + if qty > 0 + ] + + if received: + raise rest_framework.serializers.ValidationError( + _('Cannot delete line items which have received stock') + ) + + def perform_destroy(self, instance): + """Prevent deletion of a single line item which has received stock.""" + with transaction.atomic(): + self.check_received( + models.PurchaseOrderLineItem.objects.filter(pk=instance.pk) + ) + instance.delete() + + def validate_delete(self, queryset, request) -> None: + """Prevent bulk deletion of line items which have received stock.""" + self.check_received(queryset) + + @extend_schema(request=BulkRequestSerializer) + def bulk_delete(self, request, *args, **kwargs): + """Perform a bulk delete operation. + + Provide either a list of ids (via `items`) or a filter (via `filters`) to select the items to be deleted. + + This action is performed attomically, so either all items will be deleted, or none will be deleted. + + """ + with transaction.atomic(): + return super().bulk_delete(request, *args, **kwargs) + filter_backends = SEARCH_ORDER_FILTER ordering_field_aliases = { diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 6ddc925109..ec2aa0d946 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -1123,16 +1123,50 @@ class PurchaseOrderLineItemTest(OrderTest): url = reverse('api-po-line-list') # Deletion should fail without the correct role - self.delete(url, {'items': [1, 2]}, expected_code=403) + self.delete(url, {'items': [1, 3]}, expected_code=403) self.assignRole('purchase_order.delete') # Try to delete a set of line items via their IDs - self.delete(url, {'items': [1, 2]}, expected_code=200) + self.delete(url, {'items': [1, 3]}, expected_code=200) # We should have 2 less PurchaseOrderLineItems after deleting them self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) + def test_po_line_delete_received(self): + """Line items against which stock has been received cannot be deleted.""" + self.assignRole('purchase_order.delete') + + n = models.PurchaseOrderLineItem.objects.count() + + # Line item 2 is partially received (fixture data) + self.assertGreater(models.PurchaseOrderLineItem.objects.get(pk=2).received, 0) + + # Single delete + response = self.delete( + reverse('api-po-line-detail', kwargs={'pk': 2}), expected_code=400 + ) + self.assertIn( + 'Cannot delete line items which have received stock', str(response.data) + ) + + # Bulk delete - received line item is mixed in with unreceived line items + url = reverse('api-po-line-list') + self.delete(url, {'items': [1, 2, 3]}, expected_code=400) + + # Bulk delete by filter + self.delete(url, {'filters': {'order': 1}}, expected_code=400) + + # Nothing has been deleted + self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n) + + # Line items without received stock can still be deleted + self.delete(url, {'items': [1, 3]}, expected_code=200) + self.delete(reverse('api-po-line-detail', kwargs={'pk': 23}), expected_code=204) + + self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 3) + self.assertTrue(models.PurchaseOrderLineItem.objects.filter(pk=2).exists()) + def test_po_extra_line_bulk_delete(self): """Test that we can bulk delete multiple PurchaseOrderExtraLine items via the API.""" po = models.PurchaseOrder.objects.get(pk=1)