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