[bug] prevent double-complete of shipments (#12366)

This commit is contained in:
Oliver
2026-07-13 14:34:08 +10:00
committed by GitHub
parent c5e81a7820
commit 32ca78f65c
2 changed files with 56 additions and 5 deletions
+18 -4
View File
@@ -255,14 +255,28 @@ def complete_sales_order_shipment(
At this stage, the shipment is assumed to be complete, At this stage, the shipment is assumed to be complete,
and we need to perform the required "processing" tasks. and we need to perform the required "processing" tasks.
""" """
# Do not handle any lookup errors here
# If the shipment cannot be found, then we want the task to fail (and retry later)
shipment = order.models.SalesOrderShipment.objects.get(pk=shipment_id)
user = User.objects.filter(pk=user_id).first() if user_id else None user = User.objects.filter(pk=user_id).first() if user_id else None
logger.info('Completing SalesOrderShipment <%s>', shipment) logger.info('Completing SalesOrderShipment <%s>', shipment_id)
with transaction.atomic(): with transaction.atomic():
# Lock the shipment row for the duration of the update,
# and re-check that it has not already been completed.
# This guards against duplicate task execution - e.g. if the completion
# request was submitted multiple times before the first task ran.
# Do not handle any lookup errors here:
# If the shipment cannot be found, then we want the task to fail (and retry later)
shipment = order.models.SalesOrderShipment.objects.select_for_update().get(
pk=shipment_id
)
if shipment.is_complete():
logger.warning(
'SalesOrderShipment <%s> has already been completed - skipping',
shipment_id,
)
return
for allocation in shipment.allocations.all(): for allocation in shipment.allocations.all():
allocation.complete_allocation(user=user) allocation.complete_allocation(user=user)
@@ -21,7 +21,7 @@ from order.models import (
SalesOrderShipment, SalesOrderShipment,
) )
from part.models import Part from part.models import Part
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockItemTracking, StockLocation
from users.models import Owner from users.models import Owner
@@ -337,6 +337,43 @@ class SalesOrderTest(InvenTreeTestCase):
self.assertEqual(self.line.fulfilled_quantity(), 50) self.assertEqual(self.line.fulfilled_quantity(), 50)
self.assertEqual(self.line.allocated_quantity(), 50) self.assertEqual(self.line.allocated_quantity(), 50)
def test_complete_shipment_task_is_idempotent(self):
"""Duplicate execution of the shipment completion task must not double-ship.
Regression test: two rapid completion requests could enqueue the background
completion task twice - each execution then completed every allocation again,
double-counting the 'shipped' quantity and re-assigning stock to the customer.
"""
self.allocate_stock(True)
# Complete the shipment (the task runs inline during testing)
self.shipment.complete_shipment(None)
self.shipment.refresh_from_db()
self.line.refresh_from_db()
self.assertTrue(self.shipment.is_complete())
self.assertEqual(self.line.shipped, 50)
# A second completion request is rejected outright
self.assertFalse(self.shipment.check_can_complete(raise_error=False))
n_items = StockItem.objects.count()
n_tracking = StockItemTracking.objects.count()
# Simulate a duplicated task execution
# (e.g. a second completion request submitted before the first task ran)
order.tasks.complete_sales_order_shipment(self.shipment.pk, None, None)
self.line.refresh_from_db()
# The 'shipped' quantity must not be double-counted
self.assertEqual(self.line.shipped, 50)
# No additional stock items or tracking entries have been created
self.assertEqual(StockItem.objects.count(), n_items)
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
def test_shipment_many_items(self): def test_shipment_many_items(self):
"""Test completion of a shipment with many items. """Test completion of a shipment with many items.