Исправлена ошибка списания товара при завершении заказа
Проблема: При переводе заказа в статус '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,6 +92,8 @@ 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():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
# Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items
|
# Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items
|
||||||
order = form.save(commit=False)
|
order = form.save(commit=False)
|
||||||
|
|
||||||
@@ -125,6 +127,10 @@ def order_create(request):
|
|||||||
|
|
||||||
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,6 +207,8 @@ 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():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
order = form.save(commit=False)
|
order = form.save(commit=False)
|
||||||
|
|
||||||
# Обрабатываем адрес доставки
|
# Обрабатываем адрес доставки
|
||||||
@@ -238,6 +246,10 @@ def order_update(request, order_number):
|
|||||||
|
|
||||||
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,6 +650,7 @@ def set_order_status(request, order_number):
|
|||||||
|
|
||||||
# Allow clearing status if empty
|
# Allow clearing status if empty
|
||||||
if status_id == '':
|
if status_id == '':
|
||||||
|
with transaction.atomic():
|
||||||
order.status = None
|
order.status = None
|
||||||
order.modified_by = request.user
|
order.modified_by = request.user
|
||||||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||||||
@@ -645,6 +661,8 @@ 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)
|
||||||
|
|
||||||
|
# Оборачиваем в транзакцию, чтобы при ошибке в сигналах статус откатился
|
||||||
|
with transaction.atomic():
|
||||||
order.status = status
|
order.status = status
|
||||||
order.modified_by = request.user
|
order.modified_by = request.user
|
||||||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||||||
@@ -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