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

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

View File

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