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