Исправлено двойное списание товаров при смене статуса заказа
Проблема: - При изменении статуса заказа на 'Выполнен' товар списывался дважды - Заказ на 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 - документация по исправлению
This commit is contained in:
242
DOUBLE_SALE_FIX.md
Normal file
242
DOUBLE_SALE_FIX.md
Normal file
@@ -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
|
||||||
192
myproject/cleanup_duplicate_reservations.py
Normal file
192
myproject/cleanup_duplicate_reservations.py
Normal file
@@ -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()
|
||||||
@@ -85,6 +85,8 @@ class SaleProcessor:
|
|||||||
raise ValueError("Цена продажи не может быть отрицательной")
|
raise ValueError("Цена продажи не может быть отрицательной")
|
||||||
|
|
||||||
# Создаем запись Sale
|
# Создаем запись Sale
|
||||||
|
# ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
||||||
|
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
||||||
sale = Sale.objects.create(
|
sale = Sale.objects.create(
|
||||||
product=product,
|
product=product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
@@ -92,7 +94,7 @@ class SaleProcessor:
|
|||||||
sale_price=sale_price,
|
sale_price=sale_price,
|
||||||
order=order,
|
order=order,
|
||||||
document_number=document_number,
|
document_number=document_number,
|
||||||
processed=False
|
processed=True # Сразу отмечаем как обработанную
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -108,10 +110,7 @@ class SaleProcessor:
|
|||||||
cost_price=batch.cost_price
|
cost_price=batch.cost_price
|
||||||
)
|
)
|
||||||
|
|
||||||
# Отмечаем продажу как обработанную
|
# processed уже установлен в True при создании Sale
|
||||||
sale.processed = True
|
|
||||||
sale.save(update_fields=['processed'])
|
|
||||||
|
|
||||||
return sale
|
return sale
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.db.models.signals import post_save, pre_delete, post_delete
|
from django.db.models.signals import post_save, pre_delete, post_delete
|
||||||
|
from django.db import transaction
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -25,6 +26,7 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
|||||||
1. Проверяем, новый ли заказ (создан только что)
|
1. Проверяем, новый ли заказ (создан только что)
|
||||||
2. Для каждого товара в заказе создаем Reservation
|
2. Для каждого товара в заказе создаем Reservation
|
||||||
3. Статус резерва = 'reserved'
|
3. Статус резерва = 'reserved'
|
||||||
|
4. Проверяем на существующие резервы (защита от дубликатов)
|
||||||
"""
|
"""
|
||||||
if not created:
|
if not created:
|
||||||
return # Только для новых заказов
|
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
|
product = item.product if item.product else item.product_kit
|
||||||
|
|
||||||
if product:
|
if product:
|
||||||
# Создаем резерв
|
# ЗАЩИТА ОТ ДУБЛИКАТОВ: Проверяем, нет ли уже резерва для этой позиции
|
||||||
Reservation.objects.create(
|
existing_reservation = Reservation.objects.filter(
|
||||||
order_item=item,
|
order_item=item,
|
||||||
product=product,
|
product=product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse
|
||||||
quantity=Decimal(str(item.quantity)),
|
).first()
|
||||||
status='reserved'
|
|
||||||
)
|
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)
|
@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':
|
if not instance.status or instance.status.code != 'completed':
|
||||||
return # Только для статуса '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 для этого заказа
|
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
|
||||||
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(
|
||||||
|
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()
|
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)
|
@receiver(pre_delete, sender=Order)
|
||||||
def release_stock_on_order_delete(sender, instance, **kwargs):
|
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:
|
if created:
|
||||||
return # Новые позиции обрабатываются через Order signal
|
return # Новые позиции обрабатываются через Order signal
|
||||||
|
|
||||||
# Получаем резерв для этой позиции
|
# Получаем резерв для этой позиции в статусе 'reserved'
|
||||||
try:
|
reservation = Reservation.objects.filter(
|
||||||
reservation = Reservation.objects.get(
|
order_item=instance,
|
||||||
order_item=instance,
|
status='reserved'
|
||||||
status='reserved'
|
).first()
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем количество в резерве
|
if reservation:
|
||||||
|
# Резерв существует - обновляем его
|
||||||
reservation.quantity = Decimal(str(instance.quantity))
|
reservation.quantity = Decimal(str(instance.quantity))
|
||||||
reservation.save()
|
reservation.save()
|
||||||
|
else:
|
||||||
except Reservation.DoesNotExist:
|
# Резерва нет - создаем новый
|
||||||
# Если резерва нет - создаем новый
|
warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||||||
# (может быть, если заказ был создан до системы резервов)
|
|
||||||
warehouse = Warehouse.objects.filter(is_active=True).first()
|
|
||||||
|
|
||||||
if warehouse:
|
if warehouse:
|
||||||
product = instance.product if instance.product else instance.product_kit
|
product = instance.product if instance.product else instance.product_kit
|
||||||
|
|||||||
@@ -115,6 +115,32 @@ class OrderForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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 классы ко всем полям
|
# Добавляем Bootstrap классы ко всем полям
|
||||||
for field_name, field in self.fields.items():
|
for field_name, field in self.fields.items():
|
||||||
if isinstance(field.widget, forms.CheckboxInput):
|
if isinstance(field.widget, forms.CheckboxInput):
|
||||||
|
|||||||
125
myproject/test_order_status_default.py
Normal file
125
myproject/test_order_status_default.py
Normal file
@@ -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)
|
||||||
232
myproject/test_rollback_fix.py
Normal file
232
myproject/test_rollback_fix.py
Normal file
@@ -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()
|
||||||
@@ -1,21 +1,57 @@
|
|||||||
# Удаление Тенантов в Django-Tenants
|
# Удаление Тенантов в 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
|
```bash
|
||||||
# Базовое удаление (Client + БД, заявка остается в истории)
|
# Базовое удаление (Client + файлы, заявка остается в истории)
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput
|
python manage.py cleanup_tenant --schema=papa --noinput
|
||||||
|
|
||||||
# Полная очистка (Client + БД + заявка + файлы)
|
# Полная очистка (Client + заявка + файлы)
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
|
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):
|
### Альтернативный способ (встроенная команда django-tenants):
|
||||||
|
|
||||||
|
⚠️ **ВНИМАНИЕ:** В этом проекте `auto_drop_schema = False`, поэтому команда `delete_tenant` **НЕ удаляет схему из PostgreSQL**. Удаляется только запись `Client`, но все таблицы и данные в схеме остаются в базе. Для полного удаления используйте `cleanup_tenant` (см. выше) или удалите схему вручную.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Удалить конкретного тенанта
|
# Удалить конкретного тенанта (только запись Client, схема БД остаётся!)
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
python manage.py delete_tenant --schema=papa --noinput
|
||||||
|
|
||||||
# Удалить все файлы тенанта (после удаления из БД)
|
# Удалить все файлы тенанта (после удаления из БД)
|
||||||
@@ -28,16 +64,18 @@ Remove-Item -Path 'media/tenants' -Recurse -Force
|
|||||||
|
|
||||||
### ⭐ Способ 0: Новая улучшенная команда cleanup_tenant (РЕКОМЕНДУЕТСЯ)
|
### ⭐ Способ 0: Новая улучшенная команда cleanup_tenant (РЕКОМЕНДУЕТСЯ)
|
||||||
|
|
||||||
**Эта команда решает проблему с TenantRegistration и удаляет все в одной операции**
|
**Эта команда решает проблему с TenantRegistration и управляет связанными данными**
|
||||||
|
|
||||||
#### Что это за команда?
|
#### Что это за команда?
|
||||||
|
|
||||||
Это новая management команда, которая автоматически:
|
Это новая management команда, которая автоматически:
|
||||||
- Удаляет Client и схему БД (как delete_tenant)
|
- Удаляет запись Client из таблицы тенантов
|
||||||
- Обрабатывает TenantRegistration (может оставить в истории или удалить)
|
- Обрабатывает TenantRegistration (может оставить в истории или удалить)
|
||||||
- Опционально удаляет физические файлы
|
- Опционально удаляет физические файлы
|
||||||
- Показывает красивый прогресс с подтверждением
|
- Показывает красивый прогресс с подтверждением
|
||||||
|
|
||||||
|
⚠️ **ВАЖНО:** Команда **НЕ удаляет схему PostgreSQL** (т.к. `auto_drop_schema = False`). После выполнения команды нужно вручную удалить схему через SQL (см. раздел выше).
|
||||||
|
|
||||||
#### Параметры:
|
#### Параметры:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -52,20 +90,29 @@ Remove-Item -Path 'media/tenants' -Recurse -Force
|
|||||||
**1️⃣ Базовое удаление (рекомендуется):**
|
**1️⃣ Базовое удаление (рекомендуется):**
|
||||||
```bash
|
```bash
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput
|
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️⃣ Полная очистка:**
|
**2️⃣ Полная очистка:**
|
||||||
```bash
|
```bash
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
|
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
|
```bash
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration
|
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
|
• Владелец: admin@example.com
|
||||||
|
|
||||||
💾 База данных:
|
💾 База данных:
|
||||||
• Схема БД "papa" будет полностью удалена
|
• Запись Client "papa" будет удалена
|
||||||
• Все таблицы и данные будут удалены
|
• ⚠️ Схема PostgreSQL останется и требует ручного удаления!
|
||||||
|
|
||||||
📝 TenantRegistration:
|
📝 TenantRegistration:
|
||||||
• Заявка от Papa Owner (papa@example.com)
|
• Заявка от Papa Owner (papa@example.com)
|
||||||
@@ -96,9 +143,9 @@ python manage.py cleanup_tenant --schema=papa --noinput --purge-registration
|
|||||||
|
|
||||||
1️⃣ Обновляю TenantRegistration (сохраняю историю)...
|
1️⃣ Обновляю TenantRegistration (сохраняю историю)...
|
||||||
✓ TenantRegistration обновлена (tenant=NULL)
|
✓ TenantRegistration обновлена (tenant=NULL)
|
||||||
2️⃣ Удаляю Client и схему БД...
|
2️⃣ Удаляю Client...
|
||||||
✓ Client "Papa Shop" удален
|
✓ Client "Papa Shop" удален
|
||||||
✓ Схема БД "papa" удалена
|
⚠️ Схема БД "papa" НЕ удалена - требует ручного удаления!
|
||||||
|
|
||||||
======================================================================
|
======================================================================
|
||||||
✓ Тенант успешно удален!
|
✓ Тенант успешно удален!
|
||||||
@@ -125,11 +172,15 @@ Deleting 'papa'
|
|||||||
Deleted 'papa'
|
Deleted 'papa'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠️ **ВНИМАНИЕ:** В этом проекте `auto_drop_schema = False`, поэтому команда `delete_tenant` **НЕ удаляет схему из PostgreSQL**!
|
||||||
|
|
||||||
Эта команда удаляет:
|
Эта команда удаляет:
|
||||||
- ✅ Схему БД тенанта
|
- ✅ Запись Client из таблицы тенантов
|
||||||
- ✅ Все таблицы и данные в schema
|
- ❌ НЕ удаляет схему PostgreSQL (остаются все таблицы и данные!)
|
||||||
- ❌ НЕ удаляет файлы в `/media/tenants/{tenant_id}/`
|
- ❌ НЕ удаляет файлы в `/media/tenants/{tenant_id}/`
|
||||||
|
|
||||||
|
После выполнения нужно вручную удалить схему (см. раздел "КРИТИЧЕСКИ ВАЖНО" выше).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Способ 2: Интерактивное удаление (с выбором)
|
### Способ 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_product
|
||||||
├── products_productphoto
|
├── products_productphoto
|
||||||
├── products_productkit
|
├── products_productkit
|
||||||
@@ -191,6 +246,11 @@ PostgreSQL/MySQL schema: papa
|
|||||||
└── ... (все другие таблицы в schema)
|
└── ... (все другие таблицы в 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/
|
media/tenants/papa/
|
||||||
@@ -209,24 +269,17 @@ media/tenants/papa/
|
|||||||
|
|
||||||
## Полный цикл удаления тенанта
|
## Полный цикл удаления тенанта
|
||||||
|
|
||||||
### 1️⃣ Удалить из БД:
|
### 1️⃣ Удалить Client:
|
||||||
```bash
|
```bash
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2️⃣ Проверить оставшиеся файлы:
|
### 2️⃣ Удалить схему PostgreSQL:
|
||||||
```powershell
|
```bash
|
||||||
# На Windows
|
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
||||||
Get-ChildItem -Path 'c:\Users\team_\Desktop\test_qwen\myproject\media\tenants' -Recurse | Measure-Object | Select-Object -ExpandProperty Count
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3️⃣ Удалить файлы:
|
### 3️⃣ Проверить удаление схемы (опционально):
|
||||||
```powershell
|
|
||||||
# На Windows (PowerShell)
|
|
||||||
Remove-Item -Path 'media/tenants' -Recurse -Force
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4️⃣ Проверить удаление:
|
|
||||||
```powershell
|
```powershell
|
||||||
Test-Path 'c:\Users\team_\Desktop\test_qwen\myproject\media\tenants'
|
Test-Path 'c:\Users\team_\Desktop\test_qwen\myproject\media\tenants'
|
||||||
# Должно вернуть: False
|
# Должно вернуть: False
|
||||||
@@ -390,9 +443,10 @@ python manage.py cleanup_tenant --schema=papa --noinput --purge-registration
|
|||||||
```
|
```
|
||||||
|
|
||||||
Удаляет:
|
Удаляет:
|
||||||
- ✅ Client + схему БД
|
- ✅ Client
|
||||||
- ✅ TenantRegistration (полная очистка)
|
- ✅ TenantRegistration (полная очистка)
|
||||||
- ❌ Теряется история регистраций
|
- ❌ Теряется история регистраций
|
||||||
|
- ⚠️ Требует ручного удаления схемы PostgreSQL
|
||||||
|
|
||||||
**Если клиент захочет зарегистрироваться снова:**
|
**Если клиент захочет зарегистрироваться снова:**
|
||||||
1. Просто заполняет форму регистрации
|
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 <schema_name> 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
|
||||||
**Дата обновления:** 2025-11-23
|
**Дата обновления:** 2025-12-01
|
||||||
**Версия:** 2.0
|
**Версия:** 2.1
|
||||||
**Статус:** Production Ready ✅
|
**Статус:** Production Ready ✅
|
||||||
|
|
||||||
### Что нового в версии 2.0:
|
### Что нового в версии 2.1:
|
||||||
|
|
||||||
|
- 🔴 **КРИТИЧЕСКОЕ:** Добавлено предупреждение о необходимости ручного удаления схем PostgreSQL
|
||||||
|
- 📖 Добавлен раздел "Ручной контроль удаления схем" с объяснением стратегии `auto_drop_schema = False`
|
||||||
|
- 📖 Обновлены все примеры команд с указанием необходимости ручного удаления схемы
|
||||||
|
- 📖 Исправлены описания того, что именно удаляют команды `cleanup_tenant` и `delete_tenant`
|
||||||
|
- ✨ Добавлены три способа удаления схемы вручную (Django shell, консоль, psql)
|
||||||
|
|
||||||
|
### Что было в версии 2.0:
|
||||||
|
|
||||||
- ✨ Добавлена новая улучшенная команда `cleanup_tenant`
|
- ✨ Добавлена новая улучшенная команда `cleanup_tenant`
|
||||||
- ✨ Команда автоматически обрабатывает TenantRegistration
|
- ✨ Команда автоматически обрабатывает TenantRegistration
|
||||||
|
|||||||
Reference in New Issue
Block a user