Prevent deletion of line items which have received stock (#13004)

* Prevent deletion of line items which have received stock

* Revert docstring changes
This commit is contained in:
Oliver authored and GitHub committed 2026-10-14 10:00:51 +11:00
1 parent 99893f3453
commit 29eb9632ac
2 files changed
+86 -2

No files matched your search

+50
View File
@@ -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 = {
+36 -2
View File
@@ -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)