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

Проблема: При переводе заказа в статус '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,39 +92,45 @@ def order_create(request):
formset = OrderItemFormSet(request.POST)
if form.is_valid() and formset.is_valid():
# Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items
order = form.save(commit=False)
try:
with transaction.atomic():
# Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items
order = form.save(commit=False)
# Обрабатываем адрес доставки
if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data)
if address:
# Если адрес не существует в БД, сохраняем его
if not address.pk:
address.save()
order.delivery_address = address
# Обрабатываем адрес доставки
if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data)
if address:
# Если адрес не существует в БД, сохраняем его
if not address.pk:
address.save()
order.delivery_address = address
# Статус берём из формы (в том числе может быть "Черновик")
order.modified_by = request.user
# Статус берём из формы (в том числе может быть "Черновик")
order.modified_by = request.user
# Сохраняем заказ в БД (теперь у него есть pk)
order.save()
# Сохраняем заказ в БД (теперь у него есть pk)
order.save()
# Сохраняем позиции заказа
formset.instance = order
formset.save()
# Сохраняем позиции заказа
formset.instance = order
formset.save()
# Пересчитываем стоимость доставки если она не установлена вручную
delivery_cost = form.cleaned_data.get('delivery_cost')
if not delivery_cost or delivery_cost <= 0:
order.reset_delivery_cost()
# Пересчитываем стоимость доставки если она не установлена вручную
delivery_cost = form.cleaned_data.get('delivery_cost')
if not delivery_cost or delivery_cost <= 0:
order.reset_delivery_cost()
# Пересчитываем итоговую стоимость
order.calculate_total()
order.update_payment_status()
# Пересчитываем итоговую стоимость
order.calculate_total()
order.update_payment_status()
messages.success(request, f'Заказ #{order.order_number} успешно создан!')
return redirect('orders:order-detail', order_number=order.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:
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
else:
@@ -201,43 +207,49 @@ def order_update(request, order_number):
formset = OrderItemFormSet(request.POST, instance=order)
if form.is_valid() and formset.is_valid():
order = form.save(commit=False)
try:
with transaction.atomic():
order = form.save(commit=False)
# Обрабатываем адрес доставки
if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data)
if address:
# Если адрес не существует в БД, сохраняем его
if not address.pk:
address.save()
order.delivery_address = address
else:
# Если режим "без адреса", удаляем существующий адрес
if order.delivery_address:
old_address = order.delivery_address
order.delivery_address = None
# Удаляем старый адрес, если он больше не используется
if old_address and not old_address.order:
old_address.delete()
else:
# Если не доставка, удаляем адрес если он был
if order.delivery_address:
old_address = order.delivery_address
order.delivery_address = None
# Удаляем старый адрес
if old_address and not old_address.order:
old_address.delete()
# Обрабатываем адрес доставки
if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data)
if address:
# Если адрес не существует в БД, сохраняем его
if not address.pk:
address.save()
order.delivery_address = address
else:
# Если режим "без адреса", удаляем существующий адрес
if order.delivery_address:
old_address = order.delivery_address
order.delivery_address = None
# Удаляем старый адрес, если он больше не используется
if old_address and not old_address.order:
old_address.delete()
else:
# Если не доставка, удаляем адрес если он был
if order.delivery_address:
old_address = order.delivery_address
order.delivery_address = None
# Удаляем старый адрес
if old_address and not old_address.order:
old_address.delete()
order.modified_by = request.user
order.save()
formset.save()
order.modified_by = request.user
order.save()
formset.save()
# Пересчитываем итоговую стоимость
order.calculate_total()
order.update_payment_status()
# Пересчитываем итоговую стоимость
order.calculate_total()
order.update_payment_status()
messages.success(request, f'Заказ #{order.order_number} успешно обновлен!')
return redirect('orders:order-detail', order_number=order.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,9 +650,10 @@ def set_order_status(request, order_number):
# Allow clearing status if empty
if status_id == '':
order.status = None
order.modified_by = request.user
order.save(update_fields=['status', 'modified_by', 'updated_at'])
with transaction.atomic():
order.status = None
order.modified_by = request.user
order.save(update_fields=['status', 'modified_by', 'updated_at'])
return JsonResponse({'success': True, 'status': None})
try:
@@ -645,9 +661,11 @@ def set_order_status(request, order_number):
except OrderStatus.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Status not found'}, status=404)
order.status = status
order.modified_by = request.user
order.save(update_fields=['status', 'modified_by', 'updated_at'])
# Оборачиваем в транзакцию, чтобы при ошибке в сигналах статус откатился
with transaction.atomic():
order.status = status
order.modified_by = request.user
order.save(update_fields=['status', 'modified_by', 'updated_at'])
return JsonResponse({
'success': True,
@@ -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)