Исправлена критическая проблема с резервами при смене статуса заказа

Проблема:
- При смене статуса заказа на 'Выполнен' товар списывался со склада
- Резервы обновлялись на статус '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:
2025-12-01 02:34:54 +03:00
parent 490e5d5401
commit e4cb175db2
5 changed files with 512 additions and 34 deletions

View File

@@ -0,0 +1,117 @@
"""
Команда для исправления Stock после обработки заказов.
Пересчитывает quantity_reserved для всех товаров.
"""
from django.core.management.base import BaseCommand
from inventory.models import Stock, Reservation
from django.db.models import Sum
from decimal import Decimal
class Command(BaseCommand):
help = 'Исправляет quantity_reserved в Stock после обработки заказов'
def add_arguments(self, parser):
parser.add_argument(
'--order',
type=int,
help='Номер заказа для исправления (опционально)'
)
def handle(self, *args, **options):
order_number = options.get('order')
if order_number:
self.stdout.write(f"Исправление Stock для заказа {order_number}...")
self.fix_for_order(order_number)
else:
self.stdout.write("Исправление всех Stock...")
self.fix_all_stock()
def fix_for_order(self, order_number):
"""Исправить Stock для конкретного заказа"""
from orders.models import Order
try:
order = Order.objects.get(order_number=order_number)
except Order.DoesNotExist:
self.stdout.write(
self.style.ERROR(f"Заказ {order_number} не найден!")
)
return
# Получаем все резервы для этого заказа
reservations = Reservation.objects.filter(
order_item__order=order
).values('product_id', 'warehouse_id').distinct()
fixed_count = 0
for res in reservations:
product_id = res['product_id']
warehouse_id = res['warehouse_id']
try:
stock = Stock.objects.get(
product_id=product_id,
warehouse_id=warehouse_id
)
old_reserved = stock.quantity_reserved
stock.refresh_from_batches()
new_reserved = stock.quantity_reserved
if old_reserved != new_reserved:
self.stdout.write(
self.style.SUCCESS(
f"✓ Обновлено: product_id={product_id}, "
f"warehouse_id={warehouse_id}, "
f"reserved: {old_reserved}{new_reserved}"
)
)
fixed_count += 1
else:
self.stdout.write(
f" Без изменений: product_id={product_id}"
)
except Stock.DoesNotExist:
self.stdout.write(
self.style.WARNING(
f"⚠ Stock не найден: product_id={product_id}, "
f"warehouse_id={warehouse_id}"
)
)
self.stdout.write(
self.style.SUCCESS(
f"\n✅ Готово! Обновлено {fixed_count} Stock для заказа {order_number}"
)
)
def fix_all_stock(self):
"""Пересчитать все Stock"""
stocks = Stock.objects.all()
total = stocks.count()
fixed_count = 0
self.stdout.write(f"Найдено {total} Stock записей...")
for stock in stocks:
old_reserved = stock.quantity_reserved
stock.refresh_from_batches()
new_reserved = stock.quantity_reserved
if old_reserved != new_reserved:
self.stdout.write(
self.style.SUCCESS(
f"{stock.product.name} на {stock.warehouse.name}: "
f"reserved {old_reserved}{new_reserved}"
)
)
fixed_count += 1
self.stdout.write(
self.style.SUCCESS(
f"\n✅ Готово! Обновлено {fixed_count} из {total} Stock"
)
)

View File

@@ -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()