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

Проблема:
- При изменении статуса заказа на 'Выполнен' товар списывался дважды
- Заказ на 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,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()