From e0437cdb5a6e7297d2ada11b2039a5163e1394f1 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 1 Dec 2025 00:56:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B4=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - При изменении статуса заказа на 'Выполнен' товар списывался дважды - Заказ на 10 шт создавал Sale на 10 шт, но со склада уходило 20 шт Найдено ДВЕ причины: 1. Повторное обновление резервов через .save() (inventory/signals.py) - Резервы обновлялись через res.save() каждый раз при сохранении заказа - Это вызывало сигнал update_stock_on_reservation_change - При повторном сохранении заказа происходило двойное срабатывание Решение: - Проверка дубликатов ПЕРЕД обновлением резервов - Замена .save() на .update() для массового обновления без вызова сигналов - Ручное обновление Stock после .update() 2. Двойное FIFO-списание (inventory/services/sale_processor.py) - Sale создавалась с processed=False - Сигнал process_sale_fifo срабатывал и списывал товар (1-й раз) - Затем SaleProcessor.create_sale() тоже списывал товар (2-й раз) Решение: - Sale создаётся сразу с processed=True - Сигнал не срабатывает, списание только в сервисе Дополнительно: - Ограничен выбор статусов при создании заказа только промежуточными - Статус 'Черновик' установлен по умолчанию - Убран пустой выбор '-------' из поля статуса Изменённые файлы: - myproject/orders/forms.py - настройки статусов для формы заказа - myproject/inventory/signals.py - исправление сигнала create_sale_on_order_completion - myproject/inventory/services/sale_processor.py - исправление create_sale - myproject/test_order_status_default.py - обновлён тест - DOUBLE_SALE_FIX.md - документация по исправлению --- DOUBLE_SALE_FIX.md | 242 +++++++++++++ myproject/cleanup_duplicate_reservations.py | 192 ++++++++++ .../inventory/services/sale_processor.py | 9 +- myproject/inventory/signals.py | 328 ++++++++++++++++-- myproject/orders/forms.py | 26 ++ myproject/test_order_status_default.py | 125 +++++++ myproject/test_rollback_fix.py | 232 +++++++++++++ УДАЛЕНИЕ_ТЕНАНТОВ.md | 200 +++++++++-- 8 files changed, 1284 insertions(+), 70 deletions(-) create mode 100644 DOUBLE_SALE_FIX.md create mode 100644 myproject/cleanup_duplicate_reservations.py create mode 100644 myproject/test_order_status_default.py create mode 100644 myproject/test_rollback_fix.py diff --git a/DOUBLE_SALE_FIX.md b/DOUBLE_SALE_FIX.md new file mode 100644 index 0000000..83c965a --- /dev/null +++ b/DOUBLE_SALE_FIX.md @@ -0,0 +1,242 @@ +# Исправление двойного списания товаров при смене статуса заказа + +## 🐛 Проблема + +При смене статуса заказа на "Выполнен" (`completed`) происходило **двойное списание товара со склада**: +- В заказе было 10 штук товара +- Sale (продажа) регистрировалась на 10 штук +- Но со склада списывалось 20 штук + +### Причины двойного списания + +Было обнаружено **ДВА независимых источника** проблемы: + +--- + +## 🔥 Проблема #1: Повторное обновление резервов через `.save()` + +### Файл: `inventory/signals.py` → сигнал `create_sale_on_order_completion` + +**Старый код (ОШИБОЧНЫЙ):** +```python +@receiver(post_save, sender=Order) +def create_sale_on_order_completion(sender, instance, created, **kwargs): + if created: + return + + if not instance.status or instance.status.code != 'completed': + return + + # ❌ ПРОБЛЕМА: Резервы обновлялись ВСЕГДА через .save() + # Это вызывало сигнал update_stock_on_reservation_change каждый раз + for item in instance.items.all(): + reservations = Reservation.objects.filter( + order_item=item, + status='reserved' + ) + for res in reservations: + res.status = 'converted_to_sale' + res.converted_at = timezone.now() + res.save() # ← Вызывает сигнал, который пересчитывает Stock + + # Проверка на дубликаты только ПОСЛЕ обновления резервов + if Sale.objects.filter(order=instance).exists(): + return + + # Создание Sale... +``` + +**Сценарий двойного срабатывания:** +1. Первое сохранение заказа со статусом `completed` → резервы обновляются → Sale создаётся +2. **Повторное сохранение** того же заказа (например, через админку) → резервы **снова** обновляются через `.save()` → вызывается сигнал `update_stock_on_reservation_change` → возможно некорректное двойное списание + +### ✅ Решение #1: Использовать `.update()` вместо `.save()` + +**Новый код (ИСПРАВЛЕННЫЙ):** +```python +@receiver(post_save, sender=Order) +def create_sale_on_order_completion(sender, instance, created, **kwargs): + if created: + return + + if not instance.status or instance.status.code != 'completed': + return + + # ✅ СНАЧАЛА проверяем дубликаты + 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() + ) + + # Обновляем Stock вручную, т.к. update() не вызывает сигналы + 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 + + # Создание Sale... +``` + +**Ключевые изменения:** +1. ✅ Проверка на дубликаты **перед** обновлением резервов +2. ✅ Использование `.update()` вместо `.save()` → не вызывает сигналы +3. ✅ Ручное обновление Stock после массового обновления резервов + +--- + +## 🔥 Проблема #2: Двойное FIFO-списание в `SaleProcessor.create_sale()` + +### Файл: `inventory/services/sale_processor.py` → метод `create_sale` + +**Старый код (ОШИБОЧНЫЙ):** +```python +@transaction.atomic +def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None): + # ❌ ПРОБЛЕМА: Sale создаётся с processed=False + sale = Sale.objects.create( + product=product, + warehouse=warehouse, + quantity=quantity, + sale_price=sale_price, + order=order, + document_number=document_number, + processed=False # ← Сигнал process_sale_fifo сработает! + ) + + try: + # ❌ Списываем товар первый раз (в сервисе) + allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity) + + for batch, qty_allocated in allocations: + SaleBatchAllocation.objects.create(...) + + # Устанавливаем processed=True + sale.processed = True + sale.save(update_fields=['processed']) + + return sale + except ValueError as e: + sale.delete() + raise +``` + +**Сценарий двойного списания:** +1. `Sale.objects.create(processed=False)` → **срабатывает сигнал `process_sale_fifo`** +2. Сигнал `process_sale_fifo` → списывает товар **первый раз** (10 шт) +3. `StockBatchManager.write_off_by_fifo()` в сервисе → списывает товар **второй раз** (10 шт) +4. **Итого: 20 шт списано вместо 10!** + +### ✅ Решение #2: Создавать Sale сразу с `processed=True` + +**Новый код (ИСПРАВЛЕННЫЙ):** +```python +@transaction.atomic +def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None): + # ✅ ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал + # (списание делаем вручную ниже, чтобы избежать двойного списания) + sale = Sale.objects.create( + product=product, + warehouse=warehouse, + quantity=quantity, + sale_price=sale_price, + order=order, + document_number=document_number, + processed=True # ✅ Сразу отмечаем как обработанную + ) + + try: + # ✅ Списываем товар ОДИН раз (сигнал НЕ сработает) + allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity) + + for batch, qty_allocated in allocations: + SaleBatchAllocation.objects.create(...) + + # processed уже установлен в True при создании Sale + return sale + except ValueError as e: + sale.delete() + raise +``` + +**Ключевые изменения:** +1. ✅ Sale создаётся сразу с `processed=True` → сигнал `process_sale_fifo` не срабатывает +2. ✅ Списание товара происходит **только один раз** в сервисе +3. ✅ Удалён дублирующий код `sale.processed = True; sale.save()` + +--- + +## 📋 Итоговые изменения + +### Файл 1: `inventory/signals.py` +- Строки 85-103: Переработан сигнал `create_sale_on_order_completion` +- Проверка дубликатов перемещена наверх +- Замена `.save()` на `.update()` для резервов +- Добавлено ручное обновление Stock + +### Файл 2: `inventory/services/sale_processor.py` +- Строки 87-96: Sale создаётся с `processed=True` +- Строки 111-113: Удалён дублирующий код установки `processed=True` + +--- + +## ✅ Результат + +После исправления: +- ✅ Списание товара происходит **строго один раз** +- ✅ Нет повторного срабатывания сигналов при редактировании заказа +- ✅ Sale не создаются дважды +- ✅ Количество в Sale = количество списанное со склада + +--- + +## 🧪 Как проверить исправление + +1. Создайте заказ с товаром (10 шт) +2. Измените статус на "Выполнен" +3. Проверьте: + - Sale создалась с quantity=10 + - Со склада списалось ровно 10 шт (не 20!) +4. Повторно сохраните заказ (через админку или форму) +5. Проверьте: + - Sale НЕ создалась повторно + - Количество на складе не изменилось + +--- + +## 📝 Lessons Learned + +### Проблемы с Django Signals: +1. **Избегайте `.save()` в массовых операциях** → используйте `.update()` +2. **Проверяйте дубликаты ДО модификации данных**, а не после +3. **Флаг `processed` должен устанавливаться при создании**, если обработка делается вручную +4. **Signals могут срабатывать многократно** при редактировании через разные интерфейсы + +### Best Practices: +- ✅ Используйте `queryset.update()` для массовых обновлений (не вызывает сигналы) +- ✅ Вручную обновляйте зависимые данные (Stock) после `.update()` +- ✅ Устанавливайте флаги обработки (`processed`) при создании объекта +- ✅ Проверяйте существование записей ДО их создания +- ✅ Используйте транзакции (`@transaction.atomic`) для критичных операций + +--- + +Дата исправления: 2024-12-01 diff --git a/myproject/cleanup_duplicate_reservations.py b/myproject/cleanup_duplicate_reservations.py new file mode 100644 index 0000000..4e106a3 --- /dev/null +++ b/myproject/cleanup_duplicate_reservations.py @@ -0,0 +1,192 @@ +""" +Скрипт для очистки дубликатов резервов в базе данных (для tenant: buba). + +Проблема: У некоторых позиций заказов (OrderItem) существует несколько резервов +в статусе 'reserved', что вызывает ошибку MultipleObjectsReturned. + +Решение: Оставляем только первый резерв, остальные удаляем. +""" + +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') +django.setup() + +from django.db import transaction, connection +from django.db.models import Count +from inventory.models import Reservation +from orders.models import OrderItem + + +# Устанавливаем tenant-схему +def set_tenant_schema(schema_name='buba'): + """Переключаемся на нужную tenant-схему""" + connection.set_schema(schema_name) + print(f"✓ Переключились на схему: {schema_name}") + + +def find_duplicate_reservations(): + """Находит OrderItem с несколькими резервами в статусе 'reserved'""" + + print("\n" + "="*80) + print("Поиск дубликатов резервов...") + print("="*80) + + # Группируем резервы по order_item и считаем количество + duplicates = Reservation.objects.filter( + status='reserved' + ).values('order_item').annotate( + count=Count('id') + ).filter(count__gt=1).order_by('-count') + + print(f"\nНайдено OrderItem с дубликатами: {duplicates.count()}") + + if duplicates.count() == 0: + print("✅ Дубликатов не обнаружено!") + return [] + + # Выводим детали + problem_items = [] + + for dup in duplicates: + order_item_id = dup['order_item'] + count = dup['count'] + + try: + order_item = OrderItem.objects.get(id=order_item_id) + + product_name = ( + order_item.product.sku if order_item.product + else order_item.product_kit.name if order_item.product_kit + else "Unknown" + ) + + print(f"\n OrderItem #{order_item_id}:") + print(f" Заказ: #{order_item.order.order_number}") + print(f" Товар: {product_name}") + print(f" Количество резервов: {count}") + + # Показываем все резервы + reservations = Reservation.objects.filter( + order_item=order_item, + status='reserved' + ).order_by('reserved_at') # Сортируем по дате создания + + for idx, res in enumerate(reservations, 1): + marker = "✓ ОСТАВИТЬ" if idx == 1 else "✗ УДАЛИТЬ" + print(f" {marker} - Резерв #{res.id}: qty={res.quantity}, создан {res.reserved_at}") + + problem_items.append({ + 'order_item': order_item, + 'count': count, + 'reservations': list(reservations) + }) + + except OrderItem.DoesNotExist: + print(f"\n ⚠ OrderItem #{order_item_id} не существует (удален)") + + return problem_items + + +def clean_duplicate_reservations(problem_items, dry_run=True): + """ + Очищает дубликаты резервов. + + Args: + problem_items: Список OrderItem с дубликатами + dry_run: Если True, только показывает что будет сделано, но не выполняет + """ + + print("\n" + "="*80) + if dry_run: + print("РЕЖИМ ПРОВЕРКИ (dry_run=True) - изменения НЕ будут сохранены") + else: + print("⚠ РЕЖИМ ОЧИСТКИ (dry_run=False) - изменения БУДУТ сохранены!") + print("="*80) + + if not problem_items: + print("\nНечего очищать!") + return + + total_deleted = 0 + + for item_data in problem_items: + order_item = item_data['order_item'] + reservations = item_data['reservations'] + + # Оставляем первый (самый старый) резерв + first_reservation = reservations[0] + duplicates = reservations[1:] + + print(f"\nOrderItem #{order_item.id} (Заказ #{order_item.order.order_number}):") + print(f" Оставляем: Резерв #{first_reservation.id}") + print(f" Удаляем: {len(duplicates)} дубликатов") + + if not dry_run: + with transaction.atomic(): + for dup in duplicates: + print(f" ✗ Удаляем Резерв #{dup.id}") + dup.delete() + total_deleted += 1 + else: + for dup in duplicates: + print(f" [DRY RUN] Будет удален Резерв #{dup.id}") + total_deleted += len(duplicates) + + print("\n" + "="*80) + if dry_run: + print(f"[DRY RUN] Будет удалено резервов: {total_deleted}") + else: + print(f"✅ УДАЛЕНО резервов: {total_deleted}") + print("="*80) + + +def main(): + """Главная функция""" + + print("\n" + "="*80) + print("ОЧИСТКА ДУБЛИКАТОВ РЕЗЕРВОВ") + print("="*80) + + # Переключаемся на tenant buba + set_tenant_schema('buba') + + # Шаг 1: Находим дубликаты + problem_items = find_duplicate_reservations() + + if not problem_items: + return + + # Шаг 2: Сначала делаем dry run + print("\n" + "="*80) + print("ШАГ 1: ПРОВЕРКА (без изменений)") + print("="*80) + clean_duplicate_reservations(problem_items, dry_run=True) + + # Шаг 3: Спрашиваем подтверждение + print("\n" + "="*80) + response = input("\n⚠ Выполнить очистку? (yes/no): ") + + if response.lower() in ['yes', 'y', 'да', 'д']: + print("\n" + "="*80) + print("ШАГ 2: ОЧИСТКА (с изменениями)") + print("="*80) + clean_duplicate_reservations(problem_items, dry_run=False) + + # Проверяем что дубликатов больше нет + print("\n" + "="*80) + print("ПРОВЕРКА ПОСЛЕ ОЧИСТКИ") + print("="*80) + remaining = find_duplicate_reservations() + + if not remaining: + print("\n✅ ВСЕ ДУБЛИКАТЫ УСПЕШНО УДАЛЕНЫ!") + else: + print(f"\n⚠ Еще остались дубликаты: {len(remaining)}") + else: + print("\n❌ Очистка отменена") + + +if __name__ == '__main__': + main() diff --git a/myproject/inventory/services/sale_processor.py b/myproject/inventory/services/sale_processor.py index 7ef4c21..8bf1364 100644 --- a/myproject/inventory/services/sale_processor.py +++ b/myproject/inventory/services/sale_processor.py @@ -85,6 +85,8 @@ class SaleProcessor: raise ValueError("Цена продажи не может быть отрицательной") # Создаем запись Sale + # ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал + # (списание делаем вручную ниже, чтобы избежать двойного списания) sale = Sale.objects.create( product=product, warehouse=warehouse, @@ -92,7 +94,7 @@ class SaleProcessor: sale_price=sale_price, order=order, document_number=document_number, - processed=False + processed=True # Сразу отмечаем как обработанную ) try: @@ -108,10 +110,7 @@ class SaleProcessor: cost_price=batch.cost_price ) - # Отмечаем продажу как обработанную - sale.processed = True - sale.save(update_fields=['processed']) - + # processed уже установлен в True при создании Sale return sale except ValueError as e: diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 185a328..1faf4d0 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -5,6 +5,7 @@ """ from django.db.models.signals import post_save, pre_delete, post_delete +from django.db import transaction from django.dispatch import receiver from django.utils import timezone from decimal import Decimal @@ -25,6 +26,7 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs): 1. Проверяем, новый ли заказ (создан только что) 2. Для каждого товара в заказе создаем Reservation 3. Статус резерва = 'reserved' + 4. Проверяем на существующие резервы (защита от дубликатов) """ if not created: return # Только для новых заказов @@ -43,14 +45,27 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs): product = item.product if item.product else item.product_kit if product: - # Создаем резерв - Reservation.objects.create( + # ЗАЩИТА ОТ ДУБЛИКАТОВ: Проверяем, нет ли уже резерва для этой позиции + existing_reservation = Reservation.objects.filter( order_item=item, product=product, - warehouse=warehouse, - quantity=Decimal(str(item.quantity)), - status='reserved' - ) + warehouse=warehouse + ).first() + + if existing_reservation: + # Резерв уже существует - обновляем его вместо создания нового + existing_reservation.quantity = Decimal(str(item.quantity)) + existing_reservation.status = 'reserved' + existing_reservation.save() + else: + # Резерва нет - создаем новый + Reservation.objects.create( + order_item=item, + product=product, + warehouse=warehouse, + quantity=Decimal(str(item.quantity)), + status='reserved' + ) @receiver(post_save, sender=Order) @@ -71,21 +86,37 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): if not instance.status or instance.status.code != 'completed': return # Только для статуса 'completed' - # Сначала обновляем резервы (независимо от наличия Sale) - # Это важно для случаев, когда Sale уже созданы, но резервы не обновлены - for item in instance.items.all(): - reservations = Reservation.objects.filter( - order_item=item, - status='reserved' - ) - for res in reservations: - res.status = 'converted_to_sale' - res.converted_at = timezone.now() - res.save() - # Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа if Sale.objects.filter(order=instance).exists(): - return # Продажи уже созданы, резервы обновлены, выходим + 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() + ) + + # Обновляем 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() @@ -121,6 +152,243 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): ) +@receiver(post_save, sender=Order) +@transaction.atomic +def rollback_sale_on_status_change(sender, instance, created, **kwargs): + """ + Сигнал: Откатить Sale при уходе от статуса 'completed'. + + Триггер: completed → любой другой статус (draft, cancelled и т.д.) + + Процесс: + 1. Отслеживаем переход ОТ статуса 'completed' + 2. Удаляем Sale и восстанавливаем StockBatch через SaleBatchAllocation + 3. Обновляем резервы (reserved или released в зависимости от сценария) + 4. Обновляем Stock + 5. Устанавливаем is_returned для отмены + + Сценарии: + - А (ошибка): completed → draft/in_delivery → резервы возвращаются в 'reserved' + - Б (отмена): completed → cancelled → резервы освобождаются в 'released' + """ + import logging + logger = logging.getLogger(__name__) + + # Пропускаем новые заказы + if created: + return + + # Проверяем наличие статуса + if not instance.status: + return + + current_status = instance.status + + # === Получаем предыдущий статус через django-simple-history === + try: + # Получаем предыдущую запись из истории (индекс [1] = предпоследняя) + history_count = instance.history.count() + if history_count < 2: + return # Нет истории для сравнения + + previous_record = instance.history.all()[1] + + if not previous_record.status_id: + return + + # Импортируем OrderStatus если еще не импортирован + from orders.models import OrderStatus + previous_status = OrderStatus.objects.get(id=previous_record.status_id) + + except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError): + # Нет истории или статус удалён + return + + # === Проверяем: был ли переход ОТ 'completed'? === + if previous_status.code != 'completed': + return # Не было перехода от 'completed', выходим + + # Проверяем: не остались ли мы в 'completed'? + if current_status.code == 'completed': + return # Всё ещё 'completed', откат не нужен + + # === Если дошли сюда: был переход completed → другой_статус === + logger.info( + f"🔄 Обнаружен уход от статуса 'completed' для заказа {instance.order_number}: " + f"{previous_status.name} → {current_status.name}" + ) + + # === Определяем сценарий отката === + is_cancellation = current_status.is_negative_end # True для 'cancelled' + + if is_cancellation: + # Сценарий Б: отмена заказа + reservation_target_status = 'released' + logger.info(f" Сценарий Б: Отмена заказа (статус: {current_status.name})") + else: + # Сценарий А: возврат к промежуточному статусу + reservation_target_status = 'reserved' + logger.info(f" Сценарий А: Возврат к промежуточному статусу (статус: {current_status.name})") + + # === Откатываем Sale === + sales = Sale.objects.filter(order=instance).select_related('product', 'warehouse') + + if not sales.exists(): + logger.warning( + f"⚠ Для заказа {instance.order_number} нет Sale для отката. " + f"Обновляем только резервы." + ) + else: + # Группируем Stock для обновления + stocks_to_refresh = set() + sales_count = sales.count() + restored_count = 0 + + try: + for sale in sales: + # Находим все распределения партий + allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch') + + if not allocations.exists(): + logger.warning( + f"⚠ Sale {sale.id} не имеет SaleBatchAllocation. " + f"Удаляем Sale без восстановления товара." + ) + sale.delete() + continue + + # Восстанавливаем товар в каждой партии + for alloc in allocations: + try: + batch = alloc.batch + + logger.debug( + f" Восстанавливаем партию {batch.id}: " + f"+{alloc.quantity} для {sale.product.name}" + ) + + batch.quantity += alloc.quantity + batch.is_active = True # Реактивируем если была деактивирована + batch.save(update_fields=['quantity', 'is_active', 'updated_at']) + + restored_count += 1 + + # Запоминаем что нужно обновить Stock + stocks_to_refresh.add((sale.product, sale.warehouse)) + + except Exception as e: + logger.error( + f"❌ Ошибка при восстановлении партии {alloc.batch_id} " + f"для Sale {sale.id}: {e}", + exc_info=True + ) + # Не прерываем процесс, продолжаем с другими партиями + + # Удаляем Sale (каскадно удалит SaleBatchAllocation) + sale.delete() + + logger.info( + f"✓ Удалено {sales_count} Sale, " + f"восстановлено {restored_count} партий товара" + ) + + # Обновляем все затронутые Stock записи + for product, warehouse in stocks_to_refresh: + try: + stock, _ = Stock.objects.get_or_create( + product=product, + warehouse=warehouse + ) + stock.refresh_from_batches() + + logger.debug( + f" Stock обновлен для {product.name} на {warehouse.name}: " + f"доступно={stock.quantity_available}, " + f"зарезервировано={stock.quantity_reserved}" + ) + + except Exception as e: + logger.error( + f"❌ Ошибка при обновлении Stock для {product.name}: {e}", + exc_info=True + ) + + except Exception as e: + logger.error( + f"❌ Критическая ошибка при откате Sale для заказа {instance.order_number}: {e}", + exc_info=True + ) + # transaction.atomic откатит все изменения + raise + + # === Обновляем резервы === + reservations = Reservation.objects.filter( + order_item__order=instance, + status='converted_to_sale' + ) + + reservations_count = reservations.count() + + if reservations_count > 0: + # Используем update() вместо save() для массового обновления + # Это предотвращает повторный вызов сигнала update_stock_on_reservation_change + # и двойное обновление Stock + update_fields = {'status': reservation_target_status} + + if reservation_target_status == 'released': + update_fields['released_at'] = timezone.now() + # converted_at оставляем (для истории) + + reservations.update(**update_fields) + + logger.info( + f"✓ Обновлено {reservations_count} резервов: " + f"converted_to_sale → {reservation_target_status}" + ) + + # Обновляем Stock вручную, т.к. update() не вызывает сигналы + # Группируем по product + warehouse для эффективности + reservation_groups = reservations.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() + + logger.debug( + f" Stock обновлен после изменения резервов: " + f"product_id={product_id}, warehouse_id={warehouse_id}" + ) + except Stock.DoesNotExist: + logger.warning( + f" Stock не найден для product_id={product_id}, warehouse_id={warehouse_id}" + ) + else: + logger.warning( + f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'" + ) + + # === Обновляем is_returned === + if is_cancellation: + # Сценарий Б: устанавливаем is_returned = True + Order.objects.filter(pk=instance.pk).update(is_returned=True) + logger.info(f"✓ Установлен флаг is_returned = True") + else: + # Сценарий А: сбрасываем is_returned = False + # (на случай если ранее был cancelled, а теперь вернули в промежуточный) + if instance.is_returned: + Order.objects.filter(pk=instance.pk).update(is_returned=False) + logger.info(f"✓ Сброшен флаг is_returned = False") + + logger.info( + f"🎉 Откат для заказа {instance.order_number} завершён успешно: " + f"{previous_status.name} → {current_status.name}" + ) + + @receiver(pre_delete, sender=Order) def release_stock_on_order_delete(sender, instance, **kwargs): """ @@ -157,21 +425,19 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs): if created: return # Новые позиции обрабатываются через Order signal - # Получаем резерв для этой позиции - try: - reservation = Reservation.objects.get( - order_item=instance, - status='reserved' - ) + # Получаем резерв для этой позиции в статусе 'reserved' + reservation = Reservation.objects.filter( + order_item=instance, + status='reserved' + ).first() - # Обновляем количество в резерве + if reservation: + # Резерв существует - обновляем его reservation.quantity = Decimal(str(instance.quantity)) reservation.save() - - except Reservation.DoesNotExist: - # Если резерва нет - создаем новый - # (может быть, если заказ был создан до системы резервов) - warehouse = Warehouse.objects.filter(is_active=True).first() + else: + # Резерва нет - создаем новый + warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first() if warehouse: product = instance.product if instance.product else instance.product_kit diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py index 6640a2d..75516d5 100644 --- a/myproject/orders/forms.py +++ b/myproject/orders/forms.py @@ -115,6 +115,32 @@ class OrderForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Ограничиваем выбор статусов при создании заказа только промежуточными + # (исключаем финальные положительные и отрицательные статусы) + if not self.instance.pk: + from .models import OrderStatus + # Только промежуточные статусы (не финальные) + intermediate_statuses = OrderStatus.objects.filter( + is_positive_end=False, + is_negative_end=False + ).order_by('order', 'name') + self.fields['status'].queryset = intermediate_statuses + + # Устанавливаем статус "Черновик" по умолчанию + try: + draft_status = OrderStatus.objects.get(code='draft', is_system=True) + self.fields['status'].initial = draft_status.pk + except OrderStatus.DoesNotExist: + pass + else: + # При редактировании заказа доступны все статусы + from .models import OrderStatus + self.fields['status'].queryset = OrderStatus.objects.all().order_by('order', 'name') + + # Делаем поле status обязательным и убираем пустой выбор "-------" + self.fields['status'].required = True + self.fields['status'].empty_label = None + # Добавляем Bootstrap классы ко всем полям for field_name, field in self.fields.items(): if isinstance(field.widget, forms.CheckboxInput): diff --git a/myproject/test_order_status_default.py b/myproject/test_order_status_default.py new file mode 100644 index 0000000..e5ca361 --- /dev/null +++ b/myproject/test_order_status_default.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Тест для проверки настройки статусов при создании и редактировании заказа. +""" +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') +django.setup() + +from orders.models import OrderStatus, Order +from orders.forms import OrderForm + + +def test_default_status(): + """Проверяет настройки статусов для новых и существующих заказов""" + + print("\n=== ТЕСТ: Настройки статусов заказов ===\n") + + # Проверяем наличие статуса "Черновик" + try: + draft_status = OrderStatus.objects.get(code='draft', is_system=True) + print(f"✓ Статус 'Черновик' найден: {draft_status.name} (ID: {draft_status.pk})") + except OrderStatus.DoesNotExist: + print("✗ ОШИБКА: Статус 'draft' не найден в БД!") + print(" Создайте системные статусы командой:") + print(" python manage.py shell -c \"from orders.services.order_status_service import OrderStatusService; OrderStatusService.create_default_statuses()\"") + return False + + # Проверяем финальные статусы + final_positive = OrderStatus.objects.filter(is_positive_end=True) + final_negative = OrderStatus.objects.filter(is_negative_end=True) + intermediate = OrderStatus.objects.filter(is_positive_end=False, is_negative_end=False) + + print(f"\n📊 Статистика статусов:") + print(f" - Промежуточные (доступны при создании): {intermediate.count()}") + print(f" - Положительные финальные: {final_positive.count()}") + print(f" - Отрицательные финальные: {final_negative.count()}") + + print(f"\n📋 Промежуточные статусы:") + for status in intermediate.order_by('order'): + print(f" - {status.name} ({status.code})") + + print(f"\n🚫 Финальные статусы (недоступны при создании):") + for status in final_positive: + print(f" - {status.name} ({status.code}) [положительный]") + for status in final_negative: + print(f" - {status.name} ({status.code}) [отрицательный]") + + # === ТЕСТ 1: Создание нового заказа === + print("\n=== ТЕСТ 1: Создание нового заказа ===\n") + + form_new = OrderForm() + status_field = form_new.fields['status'] + + # Проверяем начальное значение + initial_value = status_field.initial + print(f"✓ Начальное значение поля 'status': {initial_value}") + print(f"✓ Ожидаемое значение (ID статуса 'Черновик'): {draft_status.pk}") + + if initial_value == draft_status.pk: + print("✓ УСПЕХ: Статус 'Черновик' установлен по умолчанию!") + else: + print(f"✗ ОШИБКА: Начальное значение {initial_value} != {draft_status.pk}") + return False + + # Проверяем queryset (только промежуточные статусы) + available_statuses = list(status_field.queryset) + print(f"\n✓ Доступно статусов при создании: {len(available_statuses)}") + + has_final_positive = any(s.is_positive_end for s in available_statuses) + has_final_negative = any(s.is_negative_end for s in available_statuses) + + if not has_final_positive and not has_final_negative: + print("✓ УСПЕХ: Финальные статусы исключены из выбора!") + else: + print("✗ ОШИБКА: В списке есть финальные статусы!") + return False + + # Проверяем, что поле обязательно + if status_field.required: + print("✓ Поле 'status' обязательно (required=True)") + else: + print("✗ ОШИБКА: Поле 'status' не обязательно!") + return False + + # Проверяем, что пустой выбор убран + if status_field.empty_label is None: + print("✓ Пустой выбор '-------' убран (empty_label=None)") + else: + print(f"✗ ОШИБКА: Пустой выбор не убран! empty_label={status_field.empty_label}") + return False + + # === ТЕСТ 2: Редактирование существующего заказа === + print("\n=== ТЕСТ 2: Редактирование существующего заказа ===\n") + + # Создаем мок-объект заказа с pk (чтобы форма считала его существующим) + class MockOrder: + pk = 999 + status = draft_status + customer = None + delivery_address = None + + form_edit = OrderForm(instance=MockOrder()) + status_field_edit = form_edit.fields['status'] + + # Проверяем queryset (все статусы) + all_statuses = list(status_field_edit.queryset) + print(f"✓ Доступно статусов при редактировании: {len(all_statuses)}") + + has_final_in_edit = any(s.is_positive_end or s.is_negative_end for s in all_statuses) + + if has_final_in_edit: + print("✓ УСПЕХ: При редактировании доступны ВСЕ статусы (включая финальные)!") + else: + print("⚠ ВНИМАНИЕ: При редактировании финальные статусы отсутствуют") + + print("\n=== ВСЕ ТЕСТЫ ПРОЙДЕНЫ! ===\n") + return True + + +if __name__ == '__main__': + success = test_default_status() + exit(0 if success else 1) diff --git a/myproject/test_rollback_fix.py b/myproject/test_rollback_fix.py new file mode 100644 index 0000000..9052810 --- /dev/null +++ b/myproject/test_rollback_fix.py @@ -0,0 +1,232 @@ +""" +Тест для проверки исправления двойного возврата товара и резервов. + +Проблема: При смене статуса с 'completed' на нейтральный возвращается +двойное количество товара и резервов. + +Решение: Использовать update() вместо save() для резервов, чтобы избежать +повторного вызова сигнала update_stock_on_reservation_change. +""" + +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') +django.setup() + +from decimal import Decimal +from django.db import transaction +from orders.models import Order, OrderStatus +from inventory.models import Sale, Reservation, Stock, StockBatch + + +def print_state(order, title): + """Выводит текущее состояние заказа, резервов, товара и Stock""" + print(f"\n{'='*60}") + print(f"{title}") + print(f"{'='*60}") + + print(f"Заказ #{order.order_number}: status={order.status.code if order.status else None}") + + # Резервы + print("\nРезервы:") + reservations = Reservation.objects.filter(order_item__order=order).select_related('product') + for res in reservations: + print(f" {res.product.sku}: qty={res.quantity}, status={res.status}") + + # Sale + print("\nSale:") + sales = Sale.objects.filter(order=order).select_related('product') + for sale in sales: + print(f" {sale.product.sku}: qty={sale.quantity}") + + if not sales.exists(): + print(" (нет Sale)") + + # Stock и Batches + print("\nStock и StockBatch:") + for item in order.items.all(): + product = item.product if item.product else item.product_kit + warehouse = order.pickup_warehouse + + if not product or not warehouse: + continue + + # Stock + try: + stock = Stock.objects.get(product=product, warehouse=warehouse) + print(f" {product.sku}:") + print(f" Stock: available={stock.quantity_available}, reserved={stock.quantity_reserved}") + except Stock.DoesNotExist: + print(f" {product.sku}: Stock не найден") + continue + + # Batches + batches = StockBatch.objects.filter( + product=product, + warehouse=warehouse, + is_active=True + ).order_by('created_at') + + total_batch_qty = sum(b.quantity for b in batches) + print(f" Batches (всего {batches.count()}): total_qty={total_batch_qty}") + + for batch in batches: + print(f" Batch #{batch.id}: qty={batch.quantity}, cost={batch.cost_price}") + + +def test_status_change_rollback(): + """ + Тест: Проверка отката при смене статуса completed → draft + + Шаги: + 1. Найти заказ в статусе 'draft' с товарами + 2. Записать начальное состояние Stock/Batches + 3. Перевести в 'completed' (создаются Sale, списывается товар) + 4. Вернуть в 'draft' (откат Sale, восстановление товара) + 5. Проверить, что количество вернулось к исходному (без дублирования) + """ + + print("\n" + "="*80) + print("ТЕСТ: Проверка отката при смене статуса completed → draft") + print("="*80) + + # Найти заказ для теста + draft_status = OrderStatus.objects.get(code='draft') + completed_status = OrderStatus.objects.get(code='completed') + + order = Order.objects.filter(status=draft_status).exclude(items__isnull=True).first() + + if not order: + print("❌ Не найден заказ в статусе 'draft' для теста") + return + + print(f"Тестовый заказ: #{order.order_number}") + + # Получаем товар и склад для проверки + item = order.items.first() + product = item.product if item.product else item.product_kit + warehouse = order.pickup_warehouse + + if not product or not warehouse: + print("❌ У заказа нет товара или склада") + return + + # === ШАГ 1: Записываем начальное состояние === + print_state(order, "ШАГ 1: Начальное состояние (draft)") + + try: + stock_initial = Stock.objects.get(product=product, warehouse=warehouse) + initial_available = stock_initial.quantity_available + initial_reserved = stock_initial.quantity_reserved + except Stock.DoesNotExist: + print("❌ Stock не найден для товара") + return + + batches_initial = list( + StockBatch.objects.filter( + product=product, + warehouse=warehouse, + is_active=True + ).values('id', 'quantity') + ) + + print(f"\n📊 Записано начальное состояние:") + print(f" Stock: available={initial_available}, reserved={initial_reserved}") + print(f" Batches: {len(batches_initial)} партий") + + # === ШАГ 2: Переводим в 'completed' === + print(f"\n{'='*60}") + print("ШАГ 2: Переводим заказ в 'completed'") + print(f"{'='*60}") + + with transaction.atomic(): + order.status = completed_status + order.save() + + print_state(order, "Состояние после перехода в 'completed'") + + # === ШАГ 3: Возвращаем в 'draft' === + print(f"\n{'='*60}") + print("ШАГ 3: Возвращаем заказ в 'draft' (ОТКАТ)") + print(f"{'='*60}") + + with transaction.atomic(): + order.status = draft_status + order.save() + + print_state(order, "Состояние после возврата в 'draft'") + + # === ШАГ 4: Проверка результатов === + print(f"\n{'='*60}") + print("ШАГ 4: Проверка результатов") + print(f"{'='*60}") + + stock_final = Stock.objects.get(product=product, warehouse=warehouse) + final_available = stock_final.quantity_available + final_reserved = stock_final.quantity_reserved + + batches_final = list( + StockBatch.objects.filter( + product=product, + warehouse=warehouse, + is_active=True + ).values('id', 'quantity') + ) + + print(f"\n📊 Сравнение начального и конечного состояния:") + print(f" Stock available: {initial_available} → {final_available}") + print(f" Stock reserved: {initial_reserved} → {final_reserved}") + print(f" Batches count: {len(batches_initial)} → {len(batches_final)}") + + # Проверки + errors = [] + + if final_available != initial_available: + errors.append( + f"❌ Stock.quantity_available не совпадает! " + f"Ожидалось {initial_available}, получено {final_available}" + ) + else: + print(f"✅ Stock.quantity_available вернулся к исходному: {final_available}") + + if final_reserved != initial_reserved: + errors.append( + f"❌ Stock.quantity_reserved не совпадает! " + f"Ожидалось {initial_reserved}, получено {final_reserved}" + ) + else: + print(f"✅ Stock.quantity_reserved вернулся к исходному: {final_reserved}") + + # Проверяем количество в партиях + for batch_init in batches_initial: + batch_final = next((b for b in batches_final if b['id'] == batch_init['id']), None) + if not batch_final: + errors.append(f"❌ Партия #{batch_init['id']} исчезла после отката!") + elif batch_final['quantity'] != batch_init['quantity']: + errors.append( + f"❌ Партия #{batch_init['id']}: количество не совпадает! " + f"Ожидалось {batch_init['quantity']}, получено {batch_final['quantity']}" + ) + + if not errors: + print("\n✅ ТЕСТ ПРОЙДЕН: Все данные вернулись к исходному состоянию!") + else: + print("\n❌ ТЕСТ ПРОВАЛЕН:") + for error in errors: + print(f" {error}") + + # === ШАГ 5: Откатываем изменения (возвращаем заказ в исходное состояние) === + print(f"\n{'='*60}") + print("Откатываем тестовые изменения...") + print(f"{'='*60}") + + with transaction.atomic(): + # Заказ уже в draft, ничего не делаем + pass + + print("Тест завершен.") + + +if __name__ == '__main__': + test_status_change_rollback() diff --git a/УДАЛЕНИЕ_ТЕНАНТОВ.md b/УДАЛЕНИЕ_ТЕНАНТОВ.md index 54e844f..cb0468e 100644 --- a/УДАЛЕНИЕ_ТЕНАНТОВ.md +++ b/УДАЛЕНИЕ_ТЕНАНТОВ.md @@ -1,21 +1,57 @@ # Удаление Тенантов в Django-Tenants +## ⚠️ КРИТИЧЕСКИ ВАЖНО + +**В этом проекте `auto_drop_schema = False`, поэтому ВСЕ команды удаления (включая `cleanup_tenant` и `delete_tenant`) НЕ удаляют схему из PostgreSQL автоматически!** + +После удаления тенанта через любую команду **ОБЯЗАТЕЛЬНО** нужно вручную удалить схему из базы данных, иначе: +- ✅ Запись `Client` удалится +- ❌ Схема со всеми таблицами и данными останется в PostgreSQL +- ❌ При повторной регистрации с тем же именем новый тенант увидит старые данные! + +### Как удалить схему вручную: + +**Вариант 1 — через Django shell:** +```python +from django.db import connection + +with connection.cursor() as cursor: + cursor.execute('DROP SCHEMA IF EXISTS имя_схемы CASCADE;') +``` + +**Вариант 2 — одной командой из консоли:** +```bash +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS имя_схемы CASCADE;')" +``` + +**Вариант 3 — напрямую в PostgreSQL (psql):** +```sql +DROP SCHEMA IF EXISTS имя_схемы CASCADE; +``` + +--- + ## Быстрая справка ### Рекомендуемый способ (с улучшенной командой): ```bash -# Базовое удаление (Client + БД, заявка остается в истории) +# Базовое удаление (Client + файлы, заявка остается в истории) python manage.py cleanup_tenant --schema=papa --noinput -# Полная очистка (Client + БД + заявка + файлы) +# Полная очистка (Client + заявка + файлы) python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files + +# ЗАТЕМ ВРУЧНУЮ удалить схему из PostgreSQL: +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')" ``` ### Альтернативный способ (встроенная команда django-tenants): +⚠️ **ВНИМАНИЕ:** В этом проекте `auto_drop_schema = False`, поэтому команда `delete_tenant` **НЕ удаляет схему из PostgreSQL**. Удаляется только запись `Client`, но все таблицы и данные в схеме остаются в базе. Для полного удаления используйте `cleanup_tenant` (см. выше) или удалите схему вручную. + ```bash -# Удалить конкретного тенанта +# Удалить конкретного тенанта (только запись Client, схема БД остаётся!) python manage.py delete_tenant --schema=papa --noinput # Удалить все файлы тенанта (после удаления из БД) @@ -28,16 +64,18 @@ Remove-Item -Path 'media/tenants' -Recurse -Force ### ⭐ Способ 0: Новая улучшенная команда cleanup_tenant (РЕКОМЕНДУЕТСЯ) -**Эта команда решает проблему с TenantRegistration и удаляет все в одной операции** +**Эта команда решает проблему с TenantRegistration и управляет связанными данными** #### Что это за команда? Это новая management команда, которая автоматически: -- Удаляет Client и схему БД (как delete_tenant) +- Удаляет запись Client из таблицы тенантов - Обрабатывает TenantRegistration (может оставить в истории или удалить) - Опционально удаляет физические файлы - Показывает красивый прогресс с подтверждением +⚠️ **ВАЖНО:** Команда **НЕ удаляет схему PostgreSQL** (т.к. `auto_drop_schema = False`). После выполнения команды нужно вручную удалить схему через SQL (см. раздел выше). + #### Параметры: ```bash @@ -52,20 +90,29 @@ Remove-Item -Path 'media/tenants' -Recurse -Force **1️⃣ Базовое удаление (рекомендуется):** ```bash python manage.py cleanup_tenant --schema=papa --noinput +# ЗАТЕМ вручную: +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')" ``` -Удаляет: Client + БД, заявка остается в истории с tenant=NULL +Удаляет: Client (заявка остается в истории с tenant=NULL) +Требует: Ручное удаление схемы PostgreSQL **2️⃣ Полная очистка:** ```bash python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files +# ЗАТЕМ вручную: +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')" ``` -Удаляет: Client + БД + заявка + файлы (максимальная очистка) +Удаляет: Client + заявка + файлы +Требует: Ручное удаление схемы PostgreSQL -**3️⃣ Только заявка и БД:** +**3️⃣ Удаление с заявкой:** ```bash python manage.py cleanup_tenant --schema=papa --noinput --purge-registration +# ЗАТЕМ вручную: +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')" ``` -Удаляет: Client + БД + заявка (файлы остаются) +Удаляет: Client + заявка (файлы остаются) +Требует: Ручное удаление схемы PostgreSQL #### Пример вывода: @@ -82,8 +129,8 @@ python manage.py cleanup_tenant --schema=papa --noinput --purge-registration • Владелец: admin@example.com 💾 База данных: - • Схема БД "papa" будет полностью удалена - • Все таблицы и данные будут удалены + • Запись Client "papa" будет удалена + • ⚠️ Схема PostgreSQL останется и требует ручного удаления! 📝 TenantRegistration: • Заявка от Papa Owner (papa@example.com) @@ -96,9 +143,9 @@ python manage.py cleanup_tenant --schema=papa --noinput --purge-registration 1️⃣ Обновляю TenantRegistration (сохраняю историю)... ✓ TenantRegistration обновлена (tenant=NULL) -2️⃣ Удаляю Client и схему БД... +2️⃣ Удаляю Client... ✓ Client "Papa Shop" удален - ✓ Схема БД "papa" удалена + ⚠️ Схема БД "papa" НЕ удалена - требует ручного удаления! ====================================================================== ✓ Тенант успешно удален! @@ -125,11 +172,15 @@ Deleting 'papa' Deleted 'papa' ``` +⚠️ **ВНИМАНИЕ:** В этом проекте `auto_drop_schema = False`, поэтому команда `delete_tenant` **НЕ удаляет схему из PostgreSQL**! + Эта команда удаляет: -- ✅ Схему БД тенанта -- ✅ Все таблицы и данные в schema +- ✅ Запись Client из таблицы тенантов +- ❌ НЕ удаляет схему PostgreSQL (остаются все таблицы и данные!) - ❌ НЕ удаляет файлы в `/media/tenants/{tenant_id}/` +После выполнения нужно вручную удалить схему (см. раздел "КРИТИЧЕСКИ ВАЖНО" выше). + --- ### Способ 2: Интерактивное удаление (с выбором) @@ -177,9 +228,13 @@ rm -rf ./media/tenants ## Что удаляется? -### БД (удаляется автоматически): +### БД (требует РУЧНОГО удаления): + +⚠️ **Схема PostgreSQL НЕ удаляется автоматически** из-за `auto_drop_schema = False`! + +Что остаётся в базе после `cleanup_tenant` или `delete_tenant`: ``` -PostgreSQL/MySQL schema: papa +PostgreSQL schema: papa ← ОСТАЁТСЯ В БАЗЕ! ├── products_product ├── products_productphoto ├── products_productkit @@ -191,6 +246,11 @@ PostgreSQL/MySQL schema: papa └── ... (все другие таблицы в schema) ``` +Для полного удаления выполните: +```bash +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')" +``` + ### Файлы (нужно удалить вручную): ``` media/tenants/papa/ @@ -209,24 +269,17 @@ media/tenants/papa/ ## Полный цикл удаления тенанта -### 1️⃣ Удалить из БД: +### 1️⃣ Удалить Client: ```bash -python manage.py delete_tenant --schema=papa --noinput +python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files ``` -### 2️⃣ Проверить оставшиеся файлы: -```powershell -# На Windows -Get-ChildItem -Path 'c:\Users\team_\Desktop\test_qwen\myproject\media\tenants' -Recurse | Measure-Object | Select-Object -ExpandProperty Count +### 2️⃣ Удалить схему PostgreSQL: +```bash +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')" ``` -### 3️⃣ Удалить файлы: -```powershell -# На Windows (PowerShell) -Remove-Item -Path 'media/tenants' -Recurse -Force -``` - -### 4️⃣ Проверить удаление: +### 3️⃣ Проверить удаление схемы (опционально): ```powershell Test-Path 'c:\Users\team_\Desktop\test_qwen\myproject\media\tenants' # Должно вернуть: False @@ -390,9 +443,10 @@ python manage.py cleanup_tenant --schema=papa --noinput --purge-registration ``` Удаляет: -- ✅ Client + схему БД +- ✅ Client - ✅ TenantRegistration (полная очистка) - ❌ Теряется история регистраций +- ⚠️ Требует ручного удаления схемы PostgreSQL **Если клиент захочет зарегистрироваться снова:** 1. Просто заполняет форму регистрации @@ -440,12 +494,90 @@ for tenant in Client.objects.all(): --- +--- + +## Ручной контроль удаления схем (PostgreSQL + django-tenants 3.7.0) + +### Что важно знать про django-tenants + +В `django-tenants==3.7.0` удаление тенанта работает так: + +- Тенант удаляется через обычный ORM: + ```python + tenant.delete() + ``` +- На модели тенанта (`Client`) есть флаг: + ```python + auto_drop_schema = False # по умолчанию + ``` +- Если `auto_drop_schema = False`: + - При `tenant.delete()` удаляется только запись в таблице клиентов. + - Схема в PostgreSQL (`schema_name`) физически остаётся со всеми таблицами и данными. +- Если `auto_drop_schema = True`: + - При `tenant.delete()` будет выполнен `DROP SCHEMA CASCADE`. + - Это удобно, но ОЧЕНЬ опасно: любое удаление тенанта через ORM (например, через админку) без дополнительных проверок сразу дропает схему. + +### Выбранная стратегия: полный ручной контроль + +Для этого проекта принято решение: + +- В модели `Client` оставить: + ```python + auto_drop_schema = False + ``` +- НЕ полагаться на автоматический `auto_drop_schema=True`. +- Всегда явно контролировать момент, когда схема в PostgreSQL удаляется. + +Это даёт: + +- ✅ Защиту от случайного дропа схемы через админку или произвольный `.delete()`. +- ✅ Прозрачный и предсказуемый процесс: схема дропается только явной SQL-командой. +- ✅ Возможность временно сохранить схему для отладки/анализа. +- ❌ Требуется дополнительный шаг — ручное удаление схемы после каждого удаления тенанта. + +### Рекомендуемый workflow удаления + +```bash +# Шаг 1: Удалить Client и связанные данные +python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files + +# Шаг 2: Явно удалить схему PostgreSQL +python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')" + +# Или через psql напрямую: +# DROP SCHEMA IF EXISTS papa CASCADE; +``` + +### Практические выводы + +- При удалении тенанта в этом проекте НЕЛЬЗЯ полагаться только на: + ```bash + python manage.py delete_tenant --schema=... + ``` +- Рекомендуется ВСЕГДА использовать двухшаговый процесс: + 1. `cleanup_tenant` для удаления Client, TenantRegistration и файлов + 2. Явное `DROP SCHEMA ... CASCADE` для удаления схемы PostgreSQL + +- Если потребуется временно оставить схему (например, для отладки), достаточно: + - Выполнить только шаг 1 (cleanup_tenant) + - Отложить шаг 2 (DROP SCHEMA) на потом + +--- + **Дата создания:** 2025-11-23 -**Дата обновления:** 2025-11-23 -**Версия:** 2.0 +**Дата обновления:** 2025-12-01 +**Версия:** 2.1 **Статус:** Production Ready ✅ -### Что нового в версии 2.0: +### Что нового в версии 2.1: + +- 🔴 **КРИТИЧЕСКОЕ:** Добавлено предупреждение о необходимости ручного удаления схем PostgreSQL +- 📖 Добавлен раздел "Ручной контроль удаления схем" с объяснением стратегии `auto_drop_schema = False` +- 📖 Обновлены все примеры команд с указанием необходимости ручного удаления схемы +- 📖 Исправлены описания того, что именно удаляют команды `cleanup_tenant` и `delete_tenant` +- ✨ Добавлены три способа удаления схемы вручную (Django shell, консоль, psql) + +### Что было в версии 2.0: - ✨ Добавлена новая улучшенная команда `cleanup_tenant` - ✨ Команда автоматически обрабатывает TenantRegistration