Исправлено двойное списание товаров при смене статуса заказа
Проблема: - При изменении статуса заказа на 'Выполнен' товар списывался дважды - Заказ на 10 шт создавал Sale на 10 шт, но со склада уходило 20 шт Найдено ДВЕ причины: 1. Повторное обновление резервов через .save() (inventory/signals.py) - Резервы обновлялись через res.save() каждый раз при сохранении заказа - Это вызывало сигнал update_stock_on_reservation_change - При повторном сохранении заказа происходило двойное срабатывание Решение: - Проверка дубликатов ПЕРЕД обновлением резервов - Замена .save() на .update() для массового обновления без вызова сигналов - Ручное обновление Stock после .update() 2. Двойное FIFO-списание (inventory/services/sale_processor.py) - Sale создавалась с processed=False - Сигнал process_sale_fifo срабатывал и списывал товар (1-й раз) - Затем SaleProcessor.create_sale() тоже списывал товар (2-й раз) Решение: - Sale создаётся сразу с processed=True - Сигнал не срабатывает, списание только в сервисе Дополнительно: - Ограничен выбор статусов при создании заказа только промежуточными - Статус 'Черновик' установлен по умолчанию - Убран пустой выбор '-------' из поля статуса Изменённые файлы: - myproject/orders/forms.py - настройки статусов для формы заказа - myproject/inventory/signals.py - исправление сигнала create_sale_on_order_completion - myproject/inventory/services/sale_processor.py - исправление create_sale - myproject/test_order_status_default.py - обновлён тест - DOUBLE_SALE_FIX.md - документация по исправлению
This commit is contained in:
@@ -85,6 +85,8 @@ class SaleProcessor:
|
||||
raise ValueError("Цена продажи не может быть отрицательной")
|
||||
|
||||
# Создаем запись Sale
|
||||
# ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
||||
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
||||
sale = Sale.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
@@ -92,7 +94,7 @@ class SaleProcessor:
|
||||
sale_price=sale_price,
|
||||
order=order,
|
||||
document_number=document_number,
|
||||
processed=False
|
||||
processed=True # Сразу отмечаем как обработанную
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -108,10 +110,7 @@ class SaleProcessor:
|
||||
cost_price=batch.cost_price
|
||||
)
|
||||
|
||||
# Отмечаем продажу как обработанную
|
||||
sale.processed = True
|
||||
sale.save(update_fields=['processed'])
|
||||
|
||||
# processed уже установлен в True при создании Sale
|
||||
return sale
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
|
||||
from django.db.models.signals import post_save, pre_delete, post_delete
|
||||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
@@ -25,6 +26,7 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
||||
1. Проверяем, новый ли заказ (создан только что)
|
||||
2. Для каждого товара в заказе создаем Reservation
|
||||
3. Статус резерва = 'reserved'
|
||||
4. Проверяем на существующие резервы (защита от дубликатов)
|
||||
"""
|
||||
if not created:
|
||||
return # Только для новых заказов
|
||||
@@ -43,14 +45,27 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
||||
product = item.product if item.product else item.product_kit
|
||||
|
||||
if product:
|
||||
# Создаем резерв
|
||||
Reservation.objects.create(
|
||||
# ЗАЩИТА ОТ ДУБЛИКАТОВ: Проверяем, нет ли уже резерва для этой позиции
|
||||
existing_reservation = Reservation.objects.filter(
|
||||
order_item=item,
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal(str(item.quantity)),
|
||||
status='reserved'
|
||||
)
|
||||
warehouse=warehouse
|
||||
).first()
|
||||
|
||||
if existing_reservation:
|
||||
# Резерв уже существует - обновляем его вместо создания нового
|
||||
existing_reservation.quantity = Decimal(str(item.quantity))
|
||||
existing_reservation.status = 'reserved'
|
||||
existing_reservation.save()
|
||||
else:
|
||||
# Резерва нет - создаем новый
|
||||
Reservation.objects.create(
|
||||
order_item=item,
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal(str(item.quantity)),
|
||||
status='reserved'
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
@@ -71,21 +86,37 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
if not instance.status or instance.status.code != 'completed':
|
||||
return # Только для статуса 'completed'
|
||||
|
||||
# Сначала обновляем резервы (независимо от наличия Sale)
|
||||
# Это важно для случаев, когда Sale уже созданы, но резервы не обновлены
|
||||
for item in instance.items.all():
|
||||
reservations = Reservation.objects.filter(
|
||||
order_item=item,
|
||||
status='reserved'
|
||||
)
|
||||
for res in reservations:
|
||||
res.status = 'converted_to_sale'
|
||||
res.converted_at = timezone.now()
|
||||
res.save()
|
||||
|
||||
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
|
||||
if Sale.objects.filter(order=instance).exists():
|
||||
return # Продажи уже созданы, резервы обновлены, выходим
|
||||
return # Продажи уже созданы, выходим БЕЗ обновления резервов
|
||||
|
||||
# Обновляем резервы ТОЛЬКО если Sale ещё не созданы
|
||||
# Используем update() вместо save() чтобы избежать повторного вызова сигналов
|
||||
reservations_to_update = Reservation.objects.filter(
|
||||
order_item__order=instance,
|
||||
status='reserved'
|
||||
)
|
||||
|
||||
if reservations_to_update.exists():
|
||||
# Массовое обновление БЕЗ вызова сигналов
|
||||
reservations_to_update.update(
|
||||
status='converted_to_sale',
|
||||
converted_at=timezone.now()
|
||||
)
|
||||
|
||||
# Обновляем Stock вручную, т.к. update() не вызывает сигналы
|
||||
# Группируем по product + warehouse для эффективности
|
||||
reservation_groups = reservations_to_update.values_list('product_id', 'warehouse_id').distinct()
|
||||
|
||||
for product_id, warehouse_id in reservation_groups:
|
||||
try:
|
||||
stock = Stock.objects.get(
|
||||
product_id=product_id,
|
||||
warehouse_id=warehouse_id
|
||||
)
|
||||
stock.refresh_from_batches()
|
||||
except Stock.DoesNotExist:
|
||||
pass # Stock не найден, пропускаем
|
||||
|
||||
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
||||
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||||
@@ -121,6 +152,243 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
@transaction.atomic
|
||||
def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Сигнал: Откатить Sale при уходе от статуса 'completed'.
|
||||
|
||||
Триггер: completed → любой другой статус (draft, cancelled и т.д.)
|
||||
|
||||
Процесс:
|
||||
1. Отслеживаем переход ОТ статуса 'completed'
|
||||
2. Удаляем Sale и восстанавливаем StockBatch через SaleBatchAllocation
|
||||
3. Обновляем резервы (reserved или released в зависимости от сценария)
|
||||
4. Обновляем Stock
|
||||
5. Устанавливаем is_returned для отмены
|
||||
|
||||
Сценарии:
|
||||
- А (ошибка): completed → draft/in_delivery → резервы возвращаются в 'reserved'
|
||||
- Б (отмена): completed → cancelled → резервы освобождаются в 'released'
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Пропускаем новые заказы
|
||||
if created:
|
||||
return
|
||||
|
||||
# Проверяем наличие статуса
|
||||
if not instance.status:
|
||||
return
|
||||
|
||||
current_status = instance.status
|
||||
|
||||
# === Получаем предыдущий статус через django-simple-history ===
|
||||
try:
|
||||
# Получаем предыдущую запись из истории (индекс [1] = предпоследняя)
|
||||
history_count = instance.history.count()
|
||||
if history_count < 2:
|
||||
return # Нет истории для сравнения
|
||||
|
||||
previous_record = instance.history.all()[1]
|
||||
|
||||
if not previous_record.status_id:
|
||||
return
|
||||
|
||||
# Импортируем OrderStatus если еще не импортирован
|
||||
from orders.models import OrderStatus
|
||||
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
|
||||
|
||||
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
|
||||
# Нет истории или статус удалён
|
||||
return
|
||||
|
||||
# === Проверяем: был ли переход ОТ 'completed'? ===
|
||||
if previous_status.code != 'completed':
|
||||
return # Не было перехода от 'completed', выходим
|
||||
|
||||
# Проверяем: не остались ли мы в 'completed'?
|
||||
if current_status.code == 'completed':
|
||||
return # Всё ещё 'completed', откат не нужен
|
||||
|
||||
# === Если дошли сюда: был переход completed → другой_статус ===
|
||||
logger.info(
|
||||
f"🔄 Обнаружен уход от статуса 'completed' для заказа {instance.order_number}: "
|
||||
f"{previous_status.name} → {current_status.name}"
|
||||
)
|
||||
|
||||
# === Определяем сценарий отката ===
|
||||
is_cancellation = current_status.is_negative_end # True для 'cancelled'
|
||||
|
||||
if is_cancellation:
|
||||
# Сценарий Б: отмена заказа
|
||||
reservation_target_status = 'released'
|
||||
logger.info(f" Сценарий Б: Отмена заказа (статус: {current_status.name})")
|
||||
else:
|
||||
# Сценарий А: возврат к промежуточному статусу
|
||||
reservation_target_status = 'reserved'
|
||||
logger.info(f" Сценарий А: Возврат к промежуточному статусу (статус: {current_status.name})")
|
||||
|
||||
# === Откатываем Sale ===
|
||||
sales = Sale.objects.filter(order=instance).select_related('product', 'warehouse')
|
||||
|
||||
if not sales.exists():
|
||||
logger.warning(
|
||||
f"⚠ Для заказа {instance.order_number} нет Sale для отката. "
|
||||
f"Обновляем только резервы."
|
||||
)
|
||||
else:
|
||||
# Группируем Stock для обновления
|
||||
stocks_to_refresh = set()
|
||||
sales_count = sales.count()
|
||||
restored_count = 0
|
||||
|
||||
try:
|
||||
for sale in sales:
|
||||
# Находим все распределения партий
|
||||
allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch')
|
||||
|
||||
if not allocations.exists():
|
||||
logger.warning(
|
||||
f"⚠ Sale {sale.id} не имеет SaleBatchAllocation. "
|
||||
f"Удаляем Sale без восстановления товара."
|
||||
)
|
||||
sale.delete()
|
||||
continue
|
||||
|
||||
# Восстанавливаем товар в каждой партии
|
||||
for alloc in allocations:
|
||||
try:
|
||||
batch = alloc.batch
|
||||
|
||||
logger.debug(
|
||||
f" Восстанавливаем партию {batch.id}: "
|
||||
f"+{alloc.quantity} для {sale.product.name}"
|
||||
)
|
||||
|
||||
batch.quantity += alloc.quantity
|
||||
batch.is_active = True # Реактивируем если была деактивирована
|
||||
batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
|
||||
|
||||
restored_count += 1
|
||||
|
||||
# Запоминаем что нужно обновить Stock
|
||||
stocks_to_refresh.add((sale.product, sale.warehouse))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"❌ Ошибка при восстановлении партии {alloc.batch_id} "
|
||||
f"для Sale {sale.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
# Не прерываем процесс, продолжаем с другими партиями
|
||||
|
||||
# Удаляем Sale (каскадно удалит SaleBatchAllocation)
|
||||
sale.delete()
|
||||
|
||||
logger.info(
|
||||
f"✓ Удалено {sales_count} Sale, "
|
||||
f"восстановлено {restored_count} партий товара"
|
||||
)
|
||||
|
||||
# Обновляем все затронутые Stock записи
|
||||
for product, warehouse in stocks_to_refresh:
|
||||
try:
|
||||
stock, _ = Stock.objects.get_or_create(
|
||||
product=product,
|
||||
warehouse=warehouse
|
||||
)
|
||||
stock.refresh_from_batches()
|
||||
|
||||
logger.debug(
|
||||
f" Stock обновлен для {product.name} на {warehouse.name}: "
|
||||
f"доступно={stock.quantity_available}, "
|
||||
f"зарезервировано={stock.quantity_reserved}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"❌ Ошибка при обновлении Stock для {product.name}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"❌ Критическая ошибка при откате Sale для заказа {instance.order_number}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
# transaction.atomic откатит все изменения
|
||||
raise
|
||||
|
||||
# === Обновляем резервы ===
|
||||
reservations = Reservation.objects.filter(
|
||||
order_item__order=instance,
|
||||
status='converted_to_sale'
|
||||
)
|
||||
|
||||
reservations_count = reservations.count()
|
||||
|
||||
if reservations_count > 0:
|
||||
# Используем update() вместо save() для массового обновления
|
||||
# Это предотвращает повторный вызов сигнала update_stock_on_reservation_change
|
||||
# и двойное обновление Stock
|
||||
update_fields = {'status': reservation_target_status}
|
||||
|
||||
if reservation_target_status == 'released':
|
||||
update_fields['released_at'] = timezone.now()
|
||||
# converted_at оставляем (для истории)
|
||||
|
||||
reservations.update(**update_fields)
|
||||
|
||||
logger.info(
|
||||
f"✓ Обновлено {reservations_count} резервов: "
|
||||
f"converted_to_sale → {reservation_target_status}"
|
||||
)
|
||||
|
||||
# Обновляем Stock вручную, т.к. update() не вызывает сигналы
|
||||
# Группируем по product + warehouse для эффективности
|
||||
reservation_groups = reservations.values_list('product_id', 'warehouse_id').distinct()
|
||||
|
||||
for product_id, warehouse_id in reservation_groups:
|
||||
try:
|
||||
stock = Stock.objects.get(
|
||||
product_id=product_id,
|
||||
warehouse_id=warehouse_id
|
||||
)
|
||||
stock.refresh_from_batches()
|
||||
|
||||
logger.debug(
|
||||
f" Stock обновлен после изменения резервов: "
|
||||
f"product_id={product_id}, warehouse_id={warehouse_id}"
|
||||
)
|
||||
except Stock.DoesNotExist:
|
||||
logger.warning(
|
||||
f" Stock не найден для product_id={product_id}, warehouse_id={warehouse_id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'"
|
||||
)
|
||||
|
||||
# === Обновляем is_returned ===
|
||||
if is_cancellation:
|
||||
# Сценарий Б: устанавливаем is_returned = True
|
||||
Order.objects.filter(pk=instance.pk).update(is_returned=True)
|
||||
logger.info(f"✓ Установлен флаг is_returned = True")
|
||||
else:
|
||||
# Сценарий А: сбрасываем is_returned = False
|
||||
# (на случай если ранее был cancelled, а теперь вернули в промежуточный)
|
||||
if instance.is_returned:
|
||||
Order.objects.filter(pk=instance.pk).update(is_returned=False)
|
||||
logger.info(f"✓ Сброшен флаг is_returned = False")
|
||||
|
||||
logger.info(
|
||||
f"🎉 Откат для заказа {instance.order_number} завершён успешно: "
|
||||
f"{previous_status.name} → {current_status.name}"
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Order)
|
||||
def release_stock_on_order_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
@@ -157,21 +425,19 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
return # Новые позиции обрабатываются через Order signal
|
||||
|
||||
# Получаем резерв для этой позиции
|
||||
try:
|
||||
reservation = Reservation.objects.get(
|
||||
order_item=instance,
|
||||
status='reserved'
|
||||
)
|
||||
# Получаем резерв для этой позиции в статусе 'reserved'
|
||||
reservation = Reservation.objects.filter(
|
||||
order_item=instance,
|
||||
status='reserved'
|
||||
).first()
|
||||
|
||||
# Обновляем количество в резерве
|
||||
if reservation:
|
||||
# Резерв существует - обновляем его
|
||||
reservation.quantity = Decimal(str(instance.quantity))
|
||||
reservation.save()
|
||||
|
||||
except Reservation.DoesNotExist:
|
||||
# Если резерва нет - создаем новый
|
||||
# (может быть, если заказ был создан до системы резервов)
|
||||
warehouse = Warehouse.objects.filter(is_active=True).first()
|
||||
else:
|
||||
# Резерва нет - создаем новый
|
||||
warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||||
|
||||
if warehouse:
|
||||
product = instance.product if instance.product else instance.product_kit
|
||||
|
||||
Reference in New Issue
Block a user