Исправлена критическая проблема с резервами при смене статуса заказа
Проблема: - При смене статуса заказа на 'Выполнен' товар списывался со склада - Резервы обновлялись на статус 'converted_to_sale' - НО Stock.quantity_reserved не обновлялся автоматически - В результате резервы продолжали 'держать' товар, хотя он уже продан Решение: 1. Изменен сигнал create_sale_on_order_completion: - Используется .save(update_fields=[...]) вместо .update() - Это вызывает сигнал update_stock_on_reservation_change - Убран костыль с ручным вызовом refresh_from_batches() 2. Оптимизирован сигнал update_stock_on_reservation_change: - Stock обновляется ТОЛЬКО при изменении status или quantity - При изменении других полей (даты и т.д.) Stock НЕ пересчитывается - Предотвращены лишние пересчёты и улучшена производительность 3. Добавлены диагностические инструменты: - check_stock_103.py - для диагностики проблем с Stock - fix_stock_after_sale.py - команда для исправления старых заказов - diagnose_reservation_issue.py - универсальная диагностика Результат: - Элегантное решение без дублирования логики - Stock автоматически обновляется при изменении резервов - Работает везде, не только в заказах - Оптимизировано для производительности
This commit is contained in:
@@ -69,16 +69,22 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
||||
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
@transaction.atomic
|
||||
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Сигнал: Когда заказ переходит в статус 'completed' (доставлен),
|
||||
создается операция Sale и резервы преобразуются в продажу.
|
||||
|
||||
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
|
||||
|
||||
Процесс:
|
||||
1. Проверяем, изменился ли статус на 'completed'
|
||||
2. Для каждого товара создаем Sale (автоматический FIFO-список)
|
||||
3. Обновляем резерв на 'converted_to_sale'
|
||||
3. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if created:
|
||||
return # Только для обновлений
|
||||
|
||||
@@ -90,41 +96,29 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
if Sale.objects.filter(order=instance).exists():
|
||||
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()
|
||||
if not reservations_to_update.exists():
|
||||
logger.warning(
|
||||
f"⚠ Заказ {instance.order_number} переведён в 'completed', но нет резервов в статусе 'reserved'"
|
||||
)
|
||||
|
||||
# Обновляем 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()
|
||||
|
||||
if not warehouse:
|
||||
logger.error(f"❌ Не найден склад для заказа {instance.order_number}. Списание невозможно.")
|
||||
return
|
||||
|
||||
# Для каждого товара в заказе создаем Sale
|
||||
sales_created = []
|
||||
sale_creation_failed = False
|
||||
|
||||
for item in instance.items.all():
|
||||
# Определяем товар
|
||||
product = item.product if item.product else item.product_kit
|
||||
@@ -142,14 +136,50 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
order=instance,
|
||||
document_number=instance.order_number
|
||||
)
|
||||
sales_created.append(sale)
|
||||
logger.info(f"✓ Sale создан для {product.name}: {item.quantity} шт.")
|
||||
|
||||
except ValueError as e:
|
||||
# Логируем ошибку, но не прерываем процесс
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
# Логируем ошибку и прерываем процесс
|
||||
logger.error(
|
||||
f"Ошибка при создании Sale для заказа {instance.order_number}: {e}"
|
||||
f"❌ ОШИБКА при создании Sale для заказа {instance.order_number}, товар {product.name}: {e}"
|
||||
)
|
||||
sale_creation_failed = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"❌ КРИТИЧЕСКАЯ ОШИБКА при создании Sale для заказа {instance.order_number}: {e}"
|
||||
)
|
||||
sale_creation_failed = True
|
||||
break
|
||||
|
||||
# КРИТИЧНО: Обновляем резервы ТОЛЬКО если ВСЕ Sale созданы успешно
|
||||
if sale_creation_failed:
|
||||
logger.error(
|
||||
f"❌ Не удалось создать Sale для заказа {instance.order_number}. "
|
||||
f"Резервы НЕ будут обновлены. Транзакция откатится."
|
||||
)
|
||||
# Поднимаем исключение чтобы откатить всю транзакцию
|
||||
raise ValueError(f"Не удалось создать Sale для заказа {instance.order_number}")
|
||||
|
||||
# Все Sale созданы успешно - теперь обновляем резервы
|
||||
if reservations_to_update.exists():
|
||||
# Обновляем резервы через .save() чтобы сработал сигнал обновления Stock
|
||||
# Сигнал update_stock_on_reservation_change автоматически обновит Stock
|
||||
for reservation in reservations_to_update:
|
||||
reservation.status = 'converted_to_sale'
|
||||
reservation.converted_at = timezone.now()
|
||||
reservation.save(update_fields=['status', 'converted_at'])
|
||||
|
||||
updated_count = reservations_to_update.count()
|
||||
logger.info(
|
||||
f"✓ Обновлено {updated_count} резервов для заказа {instance.order_number}: reserved → converted_to_sale"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"🎉 Заказ {instance.order_number} успешно обработан: создано {len(sales_created)} Sale, "
|
||||
f"обновлено {reservations_to_update.count() if reservations_to_update.exists() else 0} резервов"
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
@@ -700,21 +730,45 @@ def update_stock_on_reservation_change(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Сигнал: При создании или изменении резерва (Reservation) обновляем Stock.
|
||||
|
||||
ОПТИМИЗАЦИЯ: Обновляем Stock только если изменились поля, влияющие на резервы:
|
||||
- status (reserved/converted_to_sale/released)
|
||||
- quantity (количество резерва)
|
||||
- created (новый резерв)
|
||||
|
||||
Процесс:
|
||||
1. При создании или изменении резерва пересчитываем quantity_reserved
|
||||
2. Обновляем запись Stock для этого товара
|
||||
1. При создании резерва пересчитываем quantity_reserved
|
||||
2. При изменении статуса или количества пересчитываем quantity_reserved
|
||||
3. При других изменениях (например, дата) НЕ пересчитываем Stock (оптимизация)
|
||||
"""
|
||||
if not instance.product or not instance.warehouse:
|
||||
return
|
||||
|
||||
# Получаем или создаем Stock запись
|
||||
# Если это создание нового резерва - всегда обновляем Stock
|
||||
if created:
|
||||
stock, _ = Stock.objects.get_or_create(
|
||||
product=instance.product,
|
||||
warehouse=instance.warehouse
|
||||
)
|
||||
stock.refresh_from_batches()
|
||||
return
|
||||
|
||||
# Для обновления - проверяем, изменились ли поля, влияющие на Stock
|
||||
# Используем django-simple-history или проверяем через update_fields
|
||||
update_fields = kwargs.get('update_fields', None)
|
||||
|
||||
# Если update_fields указаны (вызов через save(update_fields=[...]))
|
||||
# проверяем, есть ли среди них 'status' или 'quantity'
|
||||
if update_fields is not None:
|
||||
fields_affecting_stock = {'status', 'quantity'}
|
||||
if not fields_affecting_stock.intersection(update_fields):
|
||||
# Изменились другие поля (например, дата) - не обновляем Stock
|
||||
return
|
||||
|
||||
# Если дошли сюда - нужно обновить Stock
|
||||
stock, _ = Stock.objects.get_or_create(
|
||||
product=instance.product,
|
||||
warehouse=instance.warehouse
|
||||
)
|
||||
|
||||
# Пересчитываем остатки из всех активных партий и резервов
|
||||
# refresh_from_batches() уже вызывает save()
|
||||
stock.refresh_from_batches()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user