Исправлена ошибка списания товара при завершении заказа

Проблема: При переводе заказа в статус 'completed' возникала ошибка
"Не удалось создать Sale для заказа", т.к. резервы этого же заказа
блокировали списание товара.

Причина: write_off_by_fifo() считал все резервы со статусом 'reserved'
как занятые, включая резервы текущего заказа, которые ещё не были
переведены в 'converted_to_sale'.

Решение:
- Добавлен параметр exclude_order в write_off_by_fifo() для исключения
  резервов конкретного заказа из расчёта занятого товара
- SaleProcessor.create_sale() теперь передаёт order в write_off_by_fifo()
- Добавлены транзакции в views для атомарности операций с заказами:
  при ошибке в сигналах статус заказа откатывается вместе со всеми
  связанными изменениями

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-09 12:04:03 +03:00
parent 1d97da0d3e
commit 936d2275e4
3 changed files with 104 additions and 70 deletions

View File

@@ -70,7 +70,7 @@ class StockBatchManager:
return batch
@staticmethod
def write_off_by_fifo(product, warehouse, quantity_to_write_off):
def write_off_by_fifo(product, warehouse, quantity_to_write_off, exclude_order=None):
"""
Списать товар по FIFO (старые партии первыми).
ВАЖНО: Учитывает зарезервированное количество товара.
@@ -80,6 +80,9 @@ class StockBatchManager:
product: объект Product
warehouse: объект Warehouse
quantity_to_write_off: Decimal - сколько списать
exclude_order: (опционально) объект Order - исключить резервы этого заказа из расчёта.
Используется при переводе заказа в 'completed', когда резервы
заказа ещё не переведены в 'converted_to_sale'.
Returns:
list: [(batch, qty_written), ...] - какие партии и сколько списано
@@ -93,11 +96,16 @@ class StockBatchManager:
allocations = []
# Получаем общее количество зарезервированного товара (все резервы со статусом 'reserved')
total_reserved = Reservation.objects.filter(
# Исключаем резервы заказа, для которого делается списание (если передан)
reservation_filter = Reservation.objects.filter(
product=product,
warehouse=warehouse,
status='reserved'
).aggregate(total=Sum('quantity'))['total'] or Decimal('0')
)
if exclude_order:
reservation_filter = reservation_filter.exclude(order_item__order=exclude_order)
total_reserved = reservation_filter.aggregate(total=Sum('quantity'))['total'] or Decimal('0')
# Получаем партии по FIFO
batches = StockBatchManager.get_batches_for_fifo(product, warehouse)

View File

@@ -99,7 +99,11 @@ class SaleProcessor:
try:
# Списываем товар по FIFO
allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity)
# exclude_order позволяет не считать резервы этого заказа как занятые
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
allocations = StockBatchManager.write_off_by_fifo(
product, warehouse, quantity, exclude_order=order
)
# Фиксируем распределение для аудита
for batch, qty_allocated in allocations:

View File

@@ -6,7 +6,7 @@ from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.db import models
from django.db import models, transaction
from decimal import Decimal
from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod
from .forms import OrderForm, OrderItemFormSet, OrderItemForm, OrderStatusForm, TransactionForm
@@ -92,6 +92,8 @@ def order_create(request):
formset = OrderItemFormSet(request.POST)
if form.is_valid() and formset.is_valid():
try:
with transaction.atomic():
# Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items
order = form.save(commit=False)
@@ -125,6 +127,10 @@ def order_create(request):
messages.success(request, f'Заказ #{order.order_number} успешно создан!')
return redirect('orders:order-detail', order_number=order.order_number)
except ValueError as e:
# Ошибка в сигналах (например, не удалось создать Sale)
# Транзакция откатилась, заказ НЕ создан
messages.error(request, f'Ошибка при создании заказа: {e}')
else:
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
else:
@@ -201,6 +207,8 @@ def order_update(request, order_number):
formset = OrderItemFormSet(request.POST, instance=order)
if form.is_valid() and formset.is_valid():
try:
with transaction.atomic():
order = form.save(commit=False)
# Обрабатываем адрес доставки
@@ -238,6 +246,10 @@ def order_update(request, order_number):
messages.success(request, f'Заказ #{order.order_number} успешно обновлен!')
return redirect('orders:order-detail', order_number=order.order_number)
except ValueError as e:
# Ошибка в сигналах (например, не удалось создать Sale)
# Транзакция откатилась, статус НЕ изменился
messages.error(request, f'Ошибка при сохранении заказа: {e}')
else:
# Логируем ошибки для отладки
print("\n=== ОШИБКИ ВАЛИДАЦИИ ФОРМЫ ===")
@@ -628,6 +640,9 @@ def set_order_status(request, order_number):
Update order status via AJAX.
Accepts POST with 'status_id' (can be empty to clear).
Returns JSON with the resulting status info.
Использует транзакцию, чтобы при ошибке в сигналах (например, при создании Sale)
статус откатился вместе со всеми связанными изменениями.
"""
try:
order = get_object_or_404(Order, order_number=order_number)
@@ -635,6 +650,7 @@ def set_order_status(request, order_number):
# Allow clearing status if empty
if status_id == '':
with transaction.atomic():
order.status = None
order.modified_by = request.user
order.save(update_fields=['status', 'modified_by', 'updated_at'])
@@ -645,6 +661,8 @@ def set_order_status(request, order_number):
except OrderStatus.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Status not found'}, status=404)
# Оборачиваем в транзакцию, чтобы при ошибке в сигналах статус откатился
with transaction.atomic():
order.status = status
order.modified_by = request.user
order.save(update_fields=['status', 'modified_by', 'updated_at'])
@@ -657,5 +675,9 @@ def set_order_status(request, order_number):
'color': status.color
}
})
except ValueError as e:
# Ошибка в сигналах (например, не удалось создать Sale)
# Транзакция откатилась, статус НЕ изменился
return JsonResponse({'success': False, 'error': str(e)}, status=400)
except Exception as e:
return JsonResponse({'success': False, 'error': f'Server error: {str(e)}'}, status=500)