diff --git a/myproject/check_stock_103.py b/myproject/check_stock_103.py new file mode 100644 index 0000000..c8da78a --- /dev/null +++ b/myproject/check_stock_103.py @@ -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("✅ Всё в порядке!") diff --git a/myproject/diagnose_reservation_issue.py b/myproject/diagnose_reservation_issue.py new file mode 100644 index 0000000..ba40dc5 --- /dev/null +++ b/myproject/diagnose_reservation_issue.py @@ -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() diff --git a/myproject/inventory/management/commands/fix_stock_after_sale.py b/myproject/inventory/management/commands/fix_stock_after_sale.py new file mode 100644 index 0000000..008bd5b --- /dev/null +++ b/myproject/inventory/management/commands/fix_stock_after_sale.py @@ -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" + ) + ) diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 674170c..1e1b94a 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -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() diff --git a/myproject/tenants/tests/test_tenant_creation.py b/myproject/tenants/tests/test_tenant_creation.py index a3e9f2c..ee8e35c 100644 --- a/myproject/tenants/tests/test_tenant_creation.py +++ b/myproject/tenants/tests/test_tenant_creation.py @@ -132,6 +132,8 @@ class TenantCreationIntegrationTest(TransactionTestCase): def test_new_tenant_gets_order_statuses(self): """ Тест: Новый тенант получает системные статусы заказов. + + КРИТИЧЕСКИ ВАЖНО: Должен быть минимум ОДИН позитивный и ОДИН негативный финальный статус. """ # Создаём и активируем тенант registration = TenantRegistration.objects.create( @@ -165,9 +167,48 @@ class TenantCreationIntegrationTest(TransactionTestCase): self.assertIsNotNone(draft_status, "Статус 'draft' не создан") 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() - self.assertIsNotNone(completed_status, "Статус 'completed' не создан") - self.assertTrue(completed_status.is_system) + self.assertIsNotNone( + 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): """