diff --git a/myproject/inventory/services/batch_manager.py b/myproject/inventory/services/batch_manager.py index f64222e..2edced5 100644 --- a/myproject/inventory/services/batch_manager.py +++ b/myproject/inventory/services/batch_manager.py @@ -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) diff --git a/myproject/inventory/services/sale_processor.py b/myproject/inventory/services/sale_processor.py index 8bf1364..197f4c7 100644 --- a/myproject/inventory/services/sale_processor.py +++ b/myproject/inventory/services/sale_processor.py @@ -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: diff --git a/myproject/orders/views.py b/myproject/orders/views.py index d09583a..26e57b1 100644 --- a/myproject/orders/views.py +++ b/myproject/orders/views.py @@ -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)