Исправлено двойное списание товаров при смене статуса заказа

Проблема:
- При изменении статуса заказа на 'Выполнен' товар списывался дважды
- Заказ на 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:
2025-12-01 00:56:26 +03:00
parent 4e66f03957
commit e0437cdb5a
8 changed files with 1284 additions and 70 deletions

View 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()