Prevent duplication completion of ReturnOrder (#12386) (#12389)

(cherry picked from commit 2b25a46eaa)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
github-actions[bot]
2026-07-14 13:51:34 +10:00
committed by GitHub
co-authored by Oliver
parent 983916cb5a
commit 83774e126d
2 changed files with 42 additions and 0 deletions
+6
View File
@@ -3113,6 +3113,12 @@ class ReturnOrder(TotalPriceMixin, Order):
def _action_complete(self, *args, **kwargs): def _action_complete(self, *args, **kwargs):
"""Complete this ReturnOrder (if not already completed).""" """Complete this ReturnOrder (if not already completed)."""
# Lock this order against concurrent completion, and re-read the status
# from the database. Without this, two simultaneous completion requests
# can both observe status=IN_PROGRESS, and each would run the completion
# side effects (duplicate events and notifications).
self.status = ReturnOrder.objects.select_for_update().get(pk=self.pk).status
if self.status == ReturnOrderStatus.IN_PROGRESS.value: if self.status == ReturnOrderStatus.IN_PROGRESS.value:
self.status = ReturnOrderStatus.COMPLETE.value self.status = ReturnOrderStatus.COMPLETE.value
self.complete_date = InvenTree.helpers.current_date() self.complete_date = InvenTree.helpers.current_date()
+36
View File
@@ -5,6 +5,7 @@ import io
import json import json
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional from typing import Optional
from unittest import mock
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -3013,6 +3014,41 @@ class ReturnOrderTests(InvenTreeAPITestCase):
stock_item.refresh_from_db() stock_item.refresh_from_db()
self.assertEqual(stock_item.quantity, 6) self.assertEqual(stock_item.quantity, 6)
def test_complete_stale_instance_is_noop(self):
"""A second completion attempt with a stale instance must be a no-op.
Regression test: _action_complete() checked 'status' on the caller's
(potentially stale) instance, so two concurrent completion requests
could both run the completion side effects (duplicate COMPLETED events).
The status is now re-read (under lock) from the database before the check.
"""
company = Company.objects.get(pk=4)
rma = models.ReturnOrder.objects.create(
customer=company, description='A return order'
)
rma.issue_order()
# Two "concurrent" requests each hold their own instance of the order
order_a = models.ReturnOrder.objects.get(pk=rma.pk)
order_b = models.ReturnOrder.objects.get(pk=rma.pk)
order_a.complete_order()
rma.refresh_from_db()
self.assertEqual(rma.status, ReturnOrderStatus.COMPLETE.value)
# The second (stale) instance still believes the order is IN_PROGRESS -
# completion must be skipped based on the database state
self.assertEqual(order_b.status, ReturnOrderStatus.IN_PROGRESS.value)
with mock.patch('order.models.trigger_event') as trigger:
order_b.complete_order()
trigger.assert_not_called()
rma.refresh_from_db()
self.assertEqual(rma.status, ReturnOrderStatus.COMPLETE.value)
def test_ro_calendar(self): def test_ro_calendar(self):
"""Test the calendar export endpoint.""" """Test the calendar export endpoint."""
# Full test is in test_po_calendar. Since these use the same backend, test only # Full test is in test_po_calendar. Since these use the same backend, test only