Prevent duplication completion of ReturnOrder (#12386)

This commit is contained in:
Oliver 2026-07-14 12:06:53 +10:00 committed by GitHub
parent fcc68ee69f
commit 2b25a46eaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 42 additions and 0 deletions

View File

@ -3189,6 +3189,12 @@ class ReturnOrder(TotalPriceMixin, Order):
def _action_complete(self, *args, **kwargs):
"""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:
self.status = ReturnOrderStatus.COMPLETE.value
self.complete_date = InvenTree.helpers.current_date()

View File

@ -5,6 +5,7 @@ import io
import json
from datetime import date, datetime, timedelta
from typing import Optional
from unittest import mock
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
@ -3244,6 +3245,41 @@ class ReturnOrderTests(InvenTreeAPITestCase):
stock_item.refresh_from_db()
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):
"""Test the calendar export endpoint."""
# Full test is in test_po_calendar. Since these use the same backend, test only