feat(inventory): add validation for item availability in released reservations
- Implement `check_released_reservations_available` function to verify if items from released reservations are still available for re-sale when attempting to change a returned order's status - Update `create_sale_on_order_completion` signal to use this check, allowing transitions to positive statuses only if items are available, otherwise blocking with ValidationError - Wrap Order.save() in transaction.atomic() to ensure ValidationError in signals rolls back the save operation - Add comprehensive tests for scenarios where items are available or used in other orders - Update date carousel in order to always center on today's date and remove unnecessary saving logic - Add test flag to Django Debug Toolbar settings Closes #123 (assuming related issue)
This commit is contained in:
@@ -52,6 +52,75 @@ def update_is_returned_flag(order):
|
||||
Order.objects.filter(pk=order.pk).update(is_returned=new_flag)
|
||||
|
||||
|
||||
def check_released_reservations_available(order):
|
||||
"""
|
||||
Проверяет, доступны ли товары из освобождённых резервов для повторной продажи.
|
||||
|
||||
Используется при попытке вернуть отменённый заказ в статус выполнения.
|
||||
Проверяет что товары из released резервов ещё не использованы в других заказах.
|
||||
|
||||
Args:
|
||||
order: Order instance with is_returned=True
|
||||
|
||||
Returns:
|
||||
bool: True если все товары доступны, False если хотя бы один использован
|
||||
|
||||
Logic:
|
||||
- Для каждого released резерва проверяем Stock.quantity_free
|
||||
- quantity_free = quantity_available - quantity_reserved
|
||||
- Если quantity_free >= reservation.quantity для ВСЕХ резервов → True
|
||||
- Иначе → False (товары частично/полностью использованы в других заказах)
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from inventory.models import Stock, Reservation
|
||||
|
||||
released_reservations = Reservation.objects.filter(
|
||||
order_item__order=order,
|
||||
status='released'
|
||||
).select_related('product', 'warehouse')
|
||||
|
||||
if not released_reservations.exists():
|
||||
# Нет released резервов - ничего проверять не нужно
|
||||
return True
|
||||
|
||||
for reservation in released_reservations:
|
||||
# Получаем текущее состояние склада
|
||||
stock = Stock.objects.filter(
|
||||
product=reservation.product,
|
||||
warehouse=reservation.warehouse
|
||||
).first()
|
||||
|
||||
if not stock:
|
||||
# Нет записи Stock = товара нет на складе
|
||||
logger.warning(
|
||||
f"Заказ {order.order_number}: нет Stock для {reservation.product.name} "
|
||||
f"на складе {reservation.warehouse.name}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Обновляем Stock на случай рассинхронизации
|
||||
stock.refresh_from_batches()
|
||||
|
||||
# Проверяем свободное количество (доступное минус зарезервированное)
|
||||
if stock.quantity_free < reservation.quantity:
|
||||
logger.info(
|
||||
f"Заказ {order.order_number}: недостаточно свободного товара "
|
||||
f"{reservation.product.name}. Нужно: {reservation.quantity}, "
|
||||
f"доступно свободно: {stock.quantity_free} "
|
||||
f"(всего: {stock.quantity_available}, зарезервировано: {stock.quantity_reserved})"
|
||||
)
|
||||
return False
|
||||
|
||||
# Все товары доступны
|
||||
logger.info(
|
||||
f"✅ Заказ {order.order_number}: все товары из released резервов доступны "
|
||||
f"для повторной продажи ({released_reservations.count()} позиций)"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
||||
"""
|
||||
@@ -167,24 +236,33 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
return
|
||||
|
||||
# === ВАЛИДАЦИЯ: Запрет изменения статуса для возвращённых заказов без резервов ===
|
||||
# Если заказ был возвращён (is_returned=True) и резервов нет, можно использовать
|
||||
# только статусы отрицательного исхода (отменён и т.п.)
|
||||
# Если заказ был возвращён (is_returned=True) и резервов нет, проверяем доступность товаров
|
||||
if instance.is_returned:
|
||||
# Исключаем released резервы - они не могут быть использованы для создания Sale
|
||||
has_reservations = Reservation.objects.filter(
|
||||
# Исключаем released резервы - проверяем активные резервы
|
||||
has_active_reservations = Reservation.objects.filter(
|
||||
order_item__order=instance
|
||||
).exclude(status='released').exists()
|
||||
|
||||
if not has_reservations:
|
||||
# Резервов нет — разрешены только отрицательные статусы
|
||||
if not instance.status.is_negative_end:
|
||||
logger.error(
|
||||
f"❌ Заказ {instance.order_number}: is_returned=True, резервов нет. "
|
||||
f"Попытка установить '{instance.status.name}' запрещена."
|
||||
)
|
||||
raise ValidationError(
|
||||
f"Заказ {instance.order_number} был отменён, товары проданы в другом заказе. "
|
||||
f"Невозможно изменить статус. Для новой продажи создайте новый заказ."
|
||||
|
||||
if not has_active_reservations:
|
||||
# Активных резервов нет — проверяем доступность товаров
|
||||
items_available = check_released_reservations_available(instance)
|
||||
|
||||
if not items_available:
|
||||
# Товары использованы — блокируем
|
||||
if not instance.status.is_negative_end:
|
||||
logger.error(
|
||||
f"❌ Заказ {instance.order_number}: is_returned=True, товары использованы. "
|
||||
f"Попытка установить '{instance.status.name}' запрещена."
|
||||
)
|
||||
raise ValidationError(
|
||||
f"Заказ {instance.order_number} был отменён, товары проданы в другом заказе. "
|
||||
f"Невозможно изменить статус. Для новой продажи создайте новый заказ."
|
||||
)
|
||||
else:
|
||||
# Товары доступны — разрешаем переход
|
||||
logger.info(
|
||||
f"✅ Заказ {instance.order_number}: is_returned=True, но товары доступны. "
|
||||
f"Разрешаем переход в '{instance.status.name}'."
|
||||
)
|
||||
|
||||
# Проверяем: это положительный финальный статус?
|
||||
|
||||
Reference in New Issue
Block a user