Исправлено двойное списание товаров при смене статуса заказа
Проблема: - При изменении статуса заказа на 'Выполнен' товар списывался дважды - Заказ на 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:
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()
|
||||
Reference in New Issue
Block a user