Restock product inventory when an order is cancelled

checkout() decrements stock_quantity/increments sold_count per order
item, but cancellation never reversed it, so a cancelled order's stock
stayed permanently reduced. Add _restock_order_items(), called from
_transition() on the CANCELLED transition, using F() expressions to
reverse both counters atomically; wrap _transition() itself in
@transaction.atomic so the status change and restock can't partially
apply.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ali Asadi 2026-09-05 15:08:02 +03:30
parent dcf63952b5
commit 6431c28e0f
2 changed files with 38 additions and 0 deletions

View file

@ -1,11 +1,14 @@
from itertools import groupby from itertools import groupby
from django.db import transaction from django.db import transaction
from django.db.models import F
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from utils.exceptions import Conflict from utils.exceptions import Conflict
from apps.catalog.models import Product
from .models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog from .models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
@ -136,6 +139,16 @@ def checkout(user, address, delivery_type, scheduled_at, payment_method, notes):
return order_group return order_group
def _restock_order_items(order):
"""Reverses the stock_quantity/sold_count decrement made at checkout (see checkout())."""
for item in order.items.select_related('product').filter(product__isnull=False):
Product.objects.filter(pk=item.product_id).update(
stock_quantity=F('stock_quantity') + item.quantity,
sold_count=F('sold_count') - item.quantity,
)
@transaction.atomic
def _transition(order, to_status, *, changed_by=None, note='', **extra_fields): def _transition(order, to_status, *, changed_by=None, note='', **extra_fields):
from_status = order.status from_status = order.status
order.status = to_status order.status = to_status
@ -147,6 +160,7 @@ def _transition(order, to_status, *, changed_by=None, note='', **extra_fields):
OrderStatusLog.objects.create(order=order, from_status=from_status, to_status=to_status, changed_by=changed_by, note=note) OrderStatusLog.objects.create(order=order, from_status=from_status, to_status=to_status, changed_by=changed_by, note=note)
if to_status == Order.Status.CANCELLED: if to_status == Order.Status.CANCELLED:
_restock_order_items(order)
notify( notify(
recipient=order.store.owner, recipient=order.store.owner,
type=Notification.Type.ORDER_CANCELLED, type=Notification.Type.ORDER_CANCELLED,

View file

@ -50,6 +50,30 @@ class StatusTransitionTests(OrdersTestCase):
self.order.refresh_from_db() self.order.refresh_from_db()
self.assertEqual(self.order.status, Order.Status.CANCELLED) self.assertEqual(self.order.status, Order.Status.CANCELLED)
def test_cancelling_an_order_restocks_its_products(self):
self.product1.refresh_from_db()
stock_after_checkout = self.product1.stock_quantity
sold_after_checkout = self.product1.sold_count
self.assertEqual(stock_after_checkout, 49) # base.py seeds 50, checkout bought 1
self.assertEqual(sold_after_checkout, 1)
self.client.force_authenticate(user=self.customer)
response = self.client.post(f'/api/v1/orders/{self.order.uuid}/cancel/')
self.assertEqual(response.status_code, 200)
self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, stock_after_checkout + 1)
self.assertEqual(self.product1.sold_count, sold_after_checkout - 1)
def test_cancelling_after_confirm_also_restocks(self):
self._as_seller()
self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/confirm/')
self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/cancel/')
self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, 50)
self.assertEqual(self.product1.sold_count, 0)
def test_cannot_cancel_after_handed_to_courier(self): def test_cannot_cancel_after_handed_to_courier(self):
self._as_seller() self._as_seller()
for action in ('confirm', 'mark-ready', 'mark-shipped'): for action in ('confirm', 'mark-ready', 'mark-shipped'):