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

Проблема:
- При смене статуса заказа на 'Выполнен' товар списывался со склада
- Резервы обновлялись на статус '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,95 @@
#!/usr/bin/env python
"""
Диагностика Stock для заказа 103 в схеме buba
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()
from django_tenants.utils import schema_context
from orders.models import Order
from inventory.models import Reservation, Stock, Sale
from django.db.models import Sum
# Работаем в схеме buba
with schema_context('buba'):
# Получаем заказ 103
order = Order.objects.get(order_number=103)
print("="*80)
print(f"ДИАГНОСТИКА ЗАКАЗА #{order.order_number} (схема: buba)")
print("="*80)
# Проверяем резервы
print("\n📝 РЕЗЕРВЫ:")
reservations = Reservation.objects.filter(order_item__order=order)
for res in reservations:
print(f" - {res.product.name}:")
print(f" Количество: {res.quantity}")
print(f" Статус: {res.status}")
print(f" Склад: {res.warehouse.name}")
print(f" Product ID: {res.product_id}")
print(f" Warehouse ID: {res.warehouse_id}")
# Проверяем Sale
print("\n💰 ПРОДАЖИ (Sale):")
sales = Sale.objects.filter(order=order)
for sale in sales:
print(f" - {sale.product.name}: {sale.quantity} шт.")
# Проверяем Stock
print("\n📊 STOCK:")
for res in reservations:
stock = Stock.objects.get(
product_id=res.product_id,
warehouse_id=res.warehouse_id
)
print(f" - {stock.product.name} на {stock.warehouse.name}:")
print(f" quantity_available: {stock.quantity_available}")
print(f" quantity_reserved: {stock.quantity_reserved}")
print(f" quantity_free: {stock.quantity_free}")
# Проверяем: сколько РЕАЛЬНО резервов со статусом 'reserved'
print("\n🔍 ПЕРЕСЧЁТ РЕЗЕРВОВ ВРУЧНУЮ:")
for res in reservations.values('product_id', 'warehouse_id').distinct():
product_id = res['product_id']
warehouse_id = res['warehouse_id']
# Считаем резервы со статусом 'reserved'
reserved_count = Reservation.objects.filter(
product_id=product_id,
warehouse_id=warehouse_id,
status='reserved'
).aggregate(Sum('quantity'))['quantity__sum'] or 0
# Считаем резервы со статусом 'converted_to_sale'
converted_count = Reservation.objects.filter(
product_id=product_id,
warehouse_id=warehouse_id,
status='converted_to_sale'
).aggregate(Sum('quantity'))['quantity__sum'] or 0
print(f" Product ID {product_id}, Warehouse ID {warehouse_id}:")
print(f" Резервов 'reserved': {reserved_count}")
print(f" Резервов 'converted_to_sale': {converted_count}")
# Что должно быть в Stock
stock = Stock.objects.get(product_id=product_id, warehouse_id=warehouse_id)
print(f" Stock.quantity_reserved: {stock.quantity_reserved}")
print(f" ❌ ПРОБЛЕМА!" if stock.quantity_reserved != reserved_count else " ✅ OK")
print("\n" + "="*80)
print("ВЫВОД:")
print("="*80)
if stock.quantity_reserved > 0 and converted_count > 0:
print("❌ Stock НЕ обновился после конвертации резервов!")
print(" quantity_reserved показывает старое значение")
print("\n🔧 Попробуем обновить вручную...")
stock.refresh_from_batches()
print(f" После refresh_from_batches():")
print(f" quantity_reserved: {stock.quantity_reserved}")
print(f" ✅ ИСПРАВЛЕНО!" if stock.quantity_reserved == 0 else "НЕ ПОМОГЛО!")
else:
print("✅ Всё в порядке!")

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python
"""
Скрипт для диагностики проблемы с резервами при смене статуса на 'completed'.
Проверяет:
1. Изменяется ли статус резервов на 'converted_to_sale'
2. Создаются ли Sale
3. Списывается ли товар со склада
4. Освобождаются ли резервы
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()
from django.db import connection
from orders.models import Order, OrderStatus
from inventory.models import Reservation, Sale, Stock
def diagnose_order(order_number):
"""Диагностика конкретного заказа"""
print(f"\n{'='*80}")
print(f"ДИАГНОСТИКА ЗАКАЗА #{order_number}")
print(f"{'='*80}\n")
# Проверяем тенант
print(f"📌 Текущая схема БД: {connection.schema_name}")
try:
order = Order.objects.get(order_number=order_number)
except Order.DoesNotExist:
print(f"❌ ОШИБКА: Заказ #{order_number} не найден!")
return
print(f"✓ Заказ найден")
print(f" - Клиент: {order.customer.name}")
print(f" - Статус: {order.status.name if order.status else 'Нет статуса'} (code: {order.status.code if order.status else 'None'})")
print(f" - Дата создания: {order.created_at}")
print(f" - Дата обновления: {order.updated_at}")
# Проверяем позиции заказа
print(f"\n📦 ПОЗИЦИИ ЗАКАЗА:")
items = order.items.all()
if not items.exists():
print(" ⚠ Нет позиций в заказе")
else:
for item in items:
product = item.product or item.product_kit
print(f" - {product.name}: {item.quantity} шт. по {item.price} руб.")
# Проверяем резервы
print(f"\n📝 РЕЗЕРВЫ:")
reservations = Reservation.objects.filter(order_item__order=order)
if not reservations.exists():
print("НЕТ РЕЗЕРВОВ для этого заказа!")
else:
for res in reservations:
print(f" - {res.product.name}:")
print(f" • Количество: {res.quantity}")
print(f" • Статус: {res.get_status_display()} ({res.status})")
print(f" • Склад: {res.warehouse.name}")
print(f" • Зарезервировано: {res.reserved_at}")
if res.converted_at:
print(f" • Конвертировано: {res.converted_at}")
if res.released_at:
print(f" • Освобождено: {res.released_at}")
# Проверяем Sale
print(f"\n💰 ПРОДАЖИ (Sale):")
sales = Sale.objects.filter(order=order)
if not sales.exists():
print("НЕТ ПРОДАЖ для этого заказа!")
if order.status and order.status.code == 'completed':
print(" ❌ ПРОБЛЕМА: Заказ в статусе 'completed', но Sale не созданы!")
else:
for sale in sales:
print(f" - {sale.product.name}:")
print(f" • Количество: {sale.quantity}")
print(f" • Цена продажи: {sale.sale_price}")
print(f" • Склад: {sale.warehouse.name}")
print(f" • Дата: {sale.created_at}")
# Проверяем распределение по партиям
from inventory.models import SaleBatchAllocation
allocations = SaleBatchAllocation.objects.filter(sale=sale)
if allocations.exists():
print(f" • Распределение по партиям:")
for alloc in allocations:
print(f" - Партия #{alloc.batch.id}: {alloc.quantity} шт.")
# Проверяем Stock
print(f"\n📊 ОСТАТКИ НА СКЛАДЕ (Stock):")
for item in items:
product = item.product or item.product_kit
warehouse = order.pickup_warehouse or order.items.first().order.pickup_warehouse
if warehouse:
try:
stock = Stock.objects.get(product=product, warehouse=warehouse)
print(f" - {product.name} на складе {warehouse.name}:")
print(f"Всего: {stock.quantity}")
print(f" • Зарезервировано: {stock.reserved_quantity}")
print(f" • Доступно: {stock.available_quantity}")
except Stock.DoesNotExist:
print(f" - {product.name}: ❌ Stock не найден")
else:
print(f" - {product.name}: ⚠ Склад не определён")
# Проверяем историю изменений
print(f"\n📜 ИСТОРИЯ ИЗМЕНЕНИЙ СТАТУСА:")
history = order.history.all()[:5] # Последние 5 записей
for idx, record in enumerate(history, 1):
status_name = record.status.name if record.status else "Нет статуса"
print(f" {idx}. {status_name} - {record.history_date}")
# Выводим выводы
print(f"\n{'='*80}")
print("ДИАГНОСТИКА:")
print(f"{'='*80}")
issues = []
# Проверка 1: Резервы существуют?
if not reservations.exists():
issues.append("❌ КРИТИЧНО: Нет резервов для заказа")
# Проверка 2: Статус 'completed' + нет Sale
if order.status and order.status.code == 'completed':
if not sales.exists():
issues.append("❌ КРИТИЧНО: Заказ 'completed', но Sale не созданы")
# Проверка 3: Статус резервов
reserved_count = reservations.filter(status='reserved').count()
converted_count = reservations.filter(status='converted_to_sale').count()
if reserved_count > 0:
issues.append(f"❌ ПРОБЛЕМА: {reserved_count} резервов ещё в статусе 'reserved' при completed заказе")
if converted_count == 0 and reservations.exists():
issues.append("❌ ПРОБЛЕМА: Ни один резерв не конвертирован в продажу")
if issues:
print("\n🔴 НАЙДЕНЫ ПРОБЛЕМЫ:")
for issue in issues:
print(f" {issue}")
else:
print("\n✅ Проблем не обнаружено")
print(f"\n{'='*80}\n")
def main():
print("\n" + "="*80)
print("ДИАГНОСТИКА ПРОБЛЕМЫ С РЕЗЕРВАМИ")
print("="*80)
# Спрашиваем номер заказа
order_number_input = input("\nВведите номер заказа для диагностики: ").strip()
try:
order_number = int(order_number_input)
except ValueError:
print("❌ Ошибка: Введите корректный номер заказа (число)")
return
diagnose_order(order_number)
if __name__ == "__main__":
main()

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) @receiver(post_save, sender=Order)
@transaction.atomic
def create_sale_on_order_completion(sender, instance, created, **kwargs): def create_sale_on_order_completion(sender, instance, created, **kwargs):
""" """
Сигнал: Когда заказ переходит в статус 'completed' (доставлен), Сигнал: Когда заказ переходит в статус 'completed' (доставлен),
создается операция Sale и резервы преобразуются в продажу. создается операция Sale и резервы преобразуются в продажу.
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
Процесс: Процесс:
1. Проверяем, изменился ли статус на 'completed' 1. Проверяем, изменился ли статус на 'completed'
2. Для каждого товара создаем Sale (автоматический FIFO-список) 2. Для каждого товара создаем Sale (автоматический FIFO-список)
3. Обновляем резерв на 'converted_to_sale' 3. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
""" """
import logging
logger = logging.getLogger(__name__)
if created: if created:
return # Только для обновлений return # Только для обновлений
@@ -90,41 +96,29 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
if Sale.objects.filter(order=instance).exists(): if Sale.objects.filter(order=instance).exists():
return # Продажи уже созданы, выходим БЕЗ обновления резервов return # Продажи уже созданы, выходим БЕЗ обновления резервов
# Обновляем резервы ТОЛЬКО если Sale ещё не созданы # Проверяем наличие резервов ДО начала операции
# Используем update() вместо save() чтобы избежать повторного вызова сигналов
reservations_to_update = Reservation.objects.filter( reservations_to_update = Reservation.objects.filter(
order_item__order=instance, order_item__order=instance,
status='reserved' status='reserved'
) )
if reservations_to_update.exists(): if not reservations_to_update.exists():
# Массовое обновление БЕЗ вызова сигналов logger.warning(
reservations_to_update.update( f"⚠ Заказ {instance.order_number} переведён в 'completed', но нет резервов в статусе 'reserved'"
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() warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
if not warehouse: if not warehouse:
logger.error(f"Не найден склад для заказа {instance.order_number}. Списание невозможно.")
return return
# Для каждого товара в заказе создаем Sale # Для каждого товара в заказе создаем Sale
sales_created = []
sale_creation_failed = False
for item in instance.items.all(): for item in instance.items.all():
# Определяем товар # Определяем товар
product = item.product if item.product else item.product_kit 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, order=instance,
document_number=instance.order_number document_number=instance.order_number
) )
sales_created.append(sale)
logger.info(f"✓ Sale создан для {product.name}: {item.quantity} шт.")
except ValueError as e: except ValueError as e:
# Логируем ошибку, но не прерываем процесс # Логируем ошибку и прерываем процесс
import logging
logger = logging.getLogger(__name__)
logger.error( 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) @receiver(post_save, sender=Order)
@@ -700,21 +730,45 @@ def update_stock_on_reservation_change(sender, instance, created, **kwargs):
""" """
Сигнал: При создании или изменении резерва (Reservation) обновляем Stock. Сигнал: При создании или изменении резерва (Reservation) обновляем Stock.
ОПТИМИЗАЦИЯ: Обновляем Stock только если изменились поля, влияющие на резервы:
- status (reserved/converted_to_sale/released)
- quantity (количество резерва)
- created (новый резерв)
Процесс: Процесс:
1. При создании или изменении резерва пересчитываем quantity_reserved 1. При создании резерва пересчитываем quantity_reserved
2. Обновляем запись Stock для этого товара 2. При изменении статуса или количества пересчитываем quantity_reserved
3. При других изменениях (например, дата) НЕ пересчитываем Stock (оптимизация)
""" """
if not instance.product or not instance.warehouse: if not instance.product or not instance.warehouse:
return 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( stock, _ = Stock.objects.get_or_create(
product=instance.product, product=instance.product,
warehouse=instance.warehouse warehouse=instance.warehouse
) )
# Пересчитываем остатки из всех активных партий и резервов
# refresh_from_batches() уже вызывает save()
stock.refresh_from_batches() stock.refresh_from_batches()

View File

@@ -132,6 +132,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
def test_new_tenant_gets_order_statuses(self): def test_new_tenant_gets_order_statuses(self):
""" """
Тест: Новый тенант получает системные статусы заказов. Тест: Новый тенант получает системные статусы заказов.
КРИТИЧЕСКИ ВАЖНО: Должен быть минимум ОДИН позитивный и ОДИН негативный финальный статус.
""" """
# Создаём и активируем тенант # Создаём и активируем тенант
registration = TenantRegistration.objects.create( registration = TenantRegistration.objects.create(
@@ -165,9 +167,48 @@ class TenantCreationIntegrationTest(TransactionTestCase):
self.assertIsNotNone(draft_status, "Статус 'draft' не создан") self.assertIsNotNone(draft_status, "Статус 'draft' не создан")
self.assertTrue(draft_status.is_system) self.assertTrue(draft_status.is_system)
# КРИТИЧЕСКАЯ ПРОВЕРКА #1: Позитивный финальный статус
positive_statuses = OrderStatus.objects.filter(is_positive_end=True)
self.assertGreaterEqual(
positive_statuses.count(),
1,
"Должен быть минимум ОДИН позитивный финальный статус (is_positive_end=True)"
)
# Проверяем что есть 'completed' (основной позитивный статус)
completed_status = OrderStatus.objects.filter(code='completed').first() completed_status = OrderStatus.objects.filter(code='completed').first()
self.assertIsNotNone(completed_status, "Статус 'completed' не создан") self.assertIsNotNone(
self.assertTrue(completed_status.is_system) completed_status,
"Статус 'completed' не создан (основной позитивный статус)"
)
self.assertTrue(
completed_status.is_system,
"'completed' должен быть системным"
)
self.assertTrue(
completed_status.is_positive_end,
"'completed' должен быть позитивным финальным статусом"
)
# КРИТИЧЕСКАЯ ПРОВЕРКА #2: Негативный финальный статус
negative_statuses = OrderStatus.objects.filter(is_negative_end=True)
self.assertGreaterEqual(
negative_statuses.count(),
1,
"Должен быть минимум ОДИН негативный финальный статус (is_negative_end=True)"
)
# Проверяем что есть 'cancelled' или другой негативный статус
# Проверяем первый найденный негативный статус
first_negative_status = negative_statuses.first()
self.assertTrue(
first_negative_status.is_system,
f"Негативный статус '{first_negative_status.code}' должен быть системным"
)
self.assertTrue(
first_negative_status.is_negative_end,
f"Статус '{first_negative_status.code}' должен быть негативным финальным"
)
def test_new_tenant_gets_system_customer(self): def test_new_tenant_gets_system_customer(self):
""" """