Исправлена ошибка списания товара при завершении заказа
Проблема: При переводе заказа в статус '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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user