Исправлена критическая проблема с резервами при смене статуса заказа
Проблема: - При смене статуса заказа на 'Выполнен' товар списывался со склада - Резервы обновлялись на статус '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:
117
myproject/inventory/management/commands/fix_stock_after_sale.py
Normal file
117
myproject/inventory/management/commands/fix_stock_after_sale.py
Normal 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"
|
||||
)
|
||||
)
|
||||
@@ -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