Files
octopus/myproject/test_rollback_fix.py
Andrey Smakotin e0437cdb5a Исправлено двойное списание товаров при смене статуса заказа
Проблема:
- При изменении статуса заказа на 'Выполнен' товар списывался дважды
- Заказ на 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 - документация по исправлению
2025-12-01 00:56:26 +03:00

233 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Тест для проверки исправления двойного возврата товара и резервов.
Проблема: При смене статуса с '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()