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)
|
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)
|
@receiver(post_save, sender=Order)
|
||||||
def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
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
|
return
|
||||||
|
|
||||||
# === ВАЛИДАЦИЯ: Запрет изменения статуса для возвращённых заказов без резервов ===
|
# === ВАЛИДАЦИЯ: Запрет изменения статуса для возвращённых заказов без резервов ===
|
||||||
# Если заказ был возвращён (is_returned=True) и резервов нет, можно использовать
|
# Если заказ был возвращён (is_returned=True) и резервов нет, проверяем доступность товаров
|
||||||
# только статусы отрицательного исхода (отменён и т.п.)
|
|
||||||
if instance.is_returned:
|
if instance.is_returned:
|
||||||
# Исключаем released резервы - они не могут быть использованы для создания Sale
|
# Исключаем released резервы - проверяем активные резервы
|
||||||
has_reservations = Reservation.objects.filter(
|
has_active_reservations = Reservation.objects.filter(
|
||||||
order_item__order=instance
|
order_item__order=instance
|
||||||
).exclude(status='released').exists()
|
).exclude(status='released').exists()
|
||||||
|
|
||||||
if not has_reservations:
|
if not has_active_reservations:
|
||||||
# Резервов нет — разрешены только отрицательные статусы
|
# Активных резервов нет — проверяем доступность товаров
|
||||||
if not instance.status.is_negative_end:
|
items_available = check_released_reservations_available(instance)
|
||||||
logger.error(
|
|
||||||
f"❌ Заказ {instance.order_number}: is_returned=True, резервов нет. "
|
if not items_available:
|
||||||
f"Попытка установить '{instance.status.name}' запрещена."
|
# Товары использованы — блокируем
|
||||||
)
|
if not instance.status.is_negative_end:
|
||||||
raise ValidationError(
|
logger.error(
|
||||||
f"Заказ {instance.order_number} был отменён, товары проданы в другом заказе. "
|
f"❌ Заказ {instance.order_number}: is_returned=True, товары использованы. "
|
||||||
f"Невозможно изменить статус. Для новой продажи создайте новый заказ."
|
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}'."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем: это положительный финальный статус?
|
# Проверяем: это положительный финальный статус?
|
||||||
|
|||||||
@@ -665,18 +665,21 @@ class OrderStatusTransitionCriticalTest(TestCase):
|
|||||||
|
|
||||||
# ==================== ТЕСТ 6: Валидация резервов (is_returned + только released) ====================
|
# ==================== ТЕСТ 6: Валидация резервов (is_returned + только released) ====================
|
||||||
|
|
||||||
def test_06_validation_released_reservations(self):
|
def test_06a_released_reservations_items_available_allow_transition(self):
|
||||||
"""
|
"""
|
||||||
ТЕСТ #6: Валидация резервов при is_returned=True и только released резервах
|
ТЕСТ #6a: Переход из CANCELLED в COMPLETED разрешён если товары доступны
|
||||||
|
|
||||||
Сценарий:
|
Сценарий:
|
||||||
1. completed (Sale созданы)
|
1. draft → completed (Sale созданы, резервы converted_to_sale)
|
||||||
2. → cancelled (резервы released, is_returned=True)
|
2. → cancelled (резервы released, is_returned=True, Sale удалены, товар восстановлен)
|
||||||
3. Попытка перейти в положительный статус должна быть запрещена
|
3. Товары НЕ использованы в других заказах (quantity_free достаточно)
|
||||||
|
4. → completed ДОЛЖНО РАБОТАТЬ ✅
|
||||||
|
|
||||||
Проверяем:
|
Проверяем:
|
||||||
- Заказ с is_returned=True и только released резервами не может перейти в положительный статус
|
- Заказ с is_returned=True и released резервами МОЖЕТ вернуться в COMPLETED
|
||||||
- Валидация корректно исключает released резервы
|
- Резервы корректно переходят: released → reserved → converted_to_sale
|
||||||
|
- Sales создаются заново
|
||||||
|
- is_returned обновляется на False
|
||||||
"""
|
"""
|
||||||
with schema_context('test_order_status'):
|
with schema_context('test_order_status'):
|
||||||
# ШАГ 1: Создаём заказ и переводим в completed
|
# ШАГ 1: Создаём заказ и переводим в completed
|
||||||
@@ -684,28 +687,128 @@ class OrderStatusTransitionCriticalTest(TestCase):
|
|||||||
order.status = self.status_completed
|
order.status = self.status_completed
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
# Проверяем, что Sale созданы
|
# Проверяем состояние после первого completed
|
||||||
self._assert_sale_exists(order, should_exist=True)
|
self._assert_sale_exists(order, should_exist=True)
|
||||||
self.assertFalse(order.is_returned, "[COMPLETED] is_returned должен быть False")
|
self._assert_reservation_status(order, 'converted_to_sale')
|
||||||
|
order.refresh_from_db()
|
||||||
|
self.assertFalse(order.is_returned, "[FIRST COMPLETED] is_returned должен быть False")
|
||||||
|
self._assert_stock_state(
|
||||||
|
available=Decimal('90.00'),
|
||||||
|
reserved=Decimal('0.00'),
|
||||||
|
free=Decimal('90.00'),
|
||||||
|
msg_prefix="[FIRST COMPLETED] "
|
||||||
|
)
|
||||||
|
|
||||||
# ШАГ 2: Переводим в cancelled (резервы станут released)
|
# ШАГ 2: Переводим в cancelled
|
||||||
order.status = self.status_cancelled
|
order.status = self.status_cancelled
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
# Проверяем, что Sale удалены и резервы released
|
# Проверяем состояние после cancelled
|
||||||
self._assert_sale_exists(order, should_exist=False)
|
self._assert_sale_exists(order, should_exist=False)
|
||||||
self._assert_reservation_status(order, 'released', "[CANCELLED] ")
|
self._assert_reservation_status(order, 'released', "[CANCELLED] ")
|
||||||
order.refresh_from_db()
|
order.refresh_from_db()
|
||||||
self.assertTrue(order.is_returned, "[CANCELLED] is_returned должен быть True")
|
self.assertTrue(order.is_returned, "[CANCELLED] is_returned должен быть True")
|
||||||
|
|
||||||
# ШАГ 3: Попытка перейти в положительный статус должна быть запрещена
|
# ВАЖНО: Проверяем что товары ДОСТУПНЫ (quantity_free = 100)
|
||||||
order.status = self.status_completed
|
self._assert_stock_state(
|
||||||
with self.assertRaises(ValidationError) as context:
|
available=Decimal('100.00'),
|
||||||
order.save()
|
reserved=Decimal('0.00'),
|
||||||
|
free=Decimal('100.00'),
|
||||||
|
msg_prefix="[CANCELLED - товары доступны] "
|
||||||
|
)
|
||||||
|
|
||||||
|
# ШАГ 3: Возвращаем в completed - ДОЛЖНО РАБОТАТЬ
|
||||||
|
order.status = self.status_completed
|
||||||
|
order.save() # НЕ должно быть ValidationError
|
||||||
|
|
||||||
|
# КРИТИЧЕСКИЕ ПРОВЕРКИ
|
||||||
|
self._assert_sale_exists(order, should_exist=True)
|
||||||
|
self._assert_reservation_status(order, 'converted_to_sale', "[RE-COMPLETED] ")
|
||||||
|
order.refresh_from_db()
|
||||||
|
self.assertFalse(
|
||||||
|
order.is_returned,
|
||||||
|
"[RE-COMPLETED] is_returned должен быть False после успешной продажи"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._assert_stock_state(
|
||||||
|
available=Decimal('90.00'),
|
||||||
|
reserved=Decimal('0.00'),
|
||||||
|
free=Decimal('90.00'),
|
||||||
|
msg_prefix="[RE-COMPLETED] "
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем количество Sales (должен быть ОДИН, не дублируется)
|
||||||
|
sales_count = Sale.objects.filter(order=order).count()
|
||||||
|
self.assertEqual(
|
||||||
|
sales_count, 1,
|
||||||
|
f"[RE-COMPLETED] Должен быть ОДИН Sale, найдено {sales_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_06b_released_reservations_items_used_block_transition(self):
|
||||||
|
"""
|
||||||
|
ТЕСТ #6b: Переход из CANCELLED в COMPLETED блокируется если товары использованы
|
||||||
|
|
||||||
|
Сценарий:
|
||||||
|
1. Order A: draft → completed → cancelled (резервы released)
|
||||||
|
2. Order B: создан и резервирует те же товары (quantity_free уменьшается)
|
||||||
|
3. Order A: попытка вернуться в completed → ДОЛЖЕН БЛОКИРОВАТЬСЯ ❌
|
||||||
|
|
||||||
|
Проверяем:
|
||||||
|
- Если товары из released резервов зарезервированы другим заказом
|
||||||
|
- ValidationError должен быть поднят
|
||||||
|
- Сообщение об ошибке корректное
|
||||||
|
"""
|
||||||
|
with schema_context('test_order_status'):
|
||||||
|
# ШАГ 1: Order A - completed → cancelled
|
||||||
|
order_a = self._create_order(self.status_draft, quantity=Decimal('50.00'))
|
||||||
|
order_a.status = self.status_completed
|
||||||
|
order_a.save()
|
||||||
|
order_a.status = self.status_cancelled
|
||||||
|
order_a.save()
|
||||||
|
|
||||||
|
# Проверяем состояние Order A
|
||||||
|
order_a.refresh_from_db()
|
||||||
|
self.assertTrue(order_a.is_returned, "[Order A CANCELLED] is_returned должен быть True")
|
||||||
|
self._assert_reservation_status(order_a, 'released', "[Order A CANCELLED] ")
|
||||||
|
self._assert_stock_state(
|
||||||
|
available=Decimal('100.00'),
|
||||||
|
reserved=Decimal('0.00'),
|
||||||
|
free=Decimal('100.00'),
|
||||||
|
msg_prefix="[После отмены Order A] "
|
||||||
|
)
|
||||||
|
|
||||||
|
# ШАГ 2: Order B резервирует большую часть товаров
|
||||||
|
order_b = self._create_order(self.status_draft, quantity=Decimal('60.00'))
|
||||||
|
|
||||||
|
# Проверяем что Order B зарезервировал товары
|
||||||
|
self._assert_stock_state(
|
||||||
|
available=Decimal('100.00'),
|
||||||
|
reserved=Decimal('60.00'),
|
||||||
|
free=Decimal('40.00'), # Осталось только 40, а Order A нужно 50
|
||||||
|
msg_prefix="[После создания Order B] "
|
||||||
|
)
|
||||||
|
|
||||||
|
# ШАГ 3: Попытка вернуть Order A в completed - ДОЛЖНА БЛОКИРОВАТЬСЯ
|
||||||
|
order_a.refresh_from_db()
|
||||||
|
original_status_id = order_a.status_id
|
||||||
|
order_a.status = self.status_completed
|
||||||
|
|
||||||
|
# Ожидаем ValidationError при попытке сохранения
|
||||||
|
# ВАЖНО: ValidationError в post_save сигнале откатывает транзакцию,
|
||||||
|
# но объект в памяти остается измененным. Поэтому сохраняем ID статуса ДО изменения
|
||||||
|
with self.assertRaises(ValidationError) as context:
|
||||||
|
order_a.save()
|
||||||
|
|
||||||
|
# Проверяем сообщение об ошибке
|
||||||
error_message = str(context.exception)
|
error_message = str(context.exception)
|
||||||
self.assertIn('был отменён', error_message)
|
self.assertIn('был отменён', error_message, "Сообщение должно содержать 'был отменён'")
|
||||||
self.assertIn('товары проданы', error_message)
|
self.assertIn('товары проданы', error_message, "Сообщение должно содержать 'товары проданы'")
|
||||||
|
self.assertIn(str(order_a.order_number), error_message, "Сообщение должно содержать номер заказа")
|
||||||
|
|
||||||
|
# Проверяем что Order A остался в cancelled (обновляем из БД!)
|
||||||
|
order_a.refresh_from_db()
|
||||||
|
self.assertEqual(order_a.status.code, 'cancelled', "Order A должен остаться в cancelled")
|
||||||
|
self.assertTrue(order_a.is_returned, "is_returned Order A должен остаться True")
|
||||||
|
|
||||||
# ==================== ТЕСТ 7: Ручное удаление Sale ====================
|
# ==================== ТЕСТ 7: Ручное удаление Sale ====================
|
||||||
|
|
||||||
@@ -736,11 +839,9 @@ class OrderStatusTransitionCriticalTest(TestCase):
|
|||||||
# ШАГ 2: Ручное удаление Sale
|
# ШАГ 2: Ручное удаление Sale
|
||||||
sale.delete()
|
sale.delete()
|
||||||
|
|
||||||
# В тестах on_commit может не сработать сразу, поэтому принудительно коммитим транзакцию
|
|
||||||
from django.db import transaction
|
|
||||||
transaction.get_connection().commit()
|
|
||||||
|
|
||||||
# Проверяем, что флаг обновился
|
# Проверяем, что флаг обновился
|
||||||
|
# Примечание: Order.save() теперь обернут в transaction.atomic(),
|
||||||
|
# поэтому принудительный commit() больше не нужен и вызовет ошибку
|
||||||
order.refresh_from_db()
|
order.refresh_from_db()
|
||||||
self.assertTrue(order.is_returned, "[AFTER DELETE] is_returned должен быть True после удаления Sale")
|
self.assertTrue(order.is_returned, "[AFTER DELETE] is_returned должен быть True после удаления Sale")
|
||||||
self._assert_sale_exists(order, should_exist=False)
|
self._assert_sale_exists(order, should_exist=False)
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ if DEBUG:
|
|||||||
'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG,
|
'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG,
|
||||||
# Или можно фильтровать по tenant (например, только для определенных поддоменов):
|
# Или можно фильтровать по tenant (например, только для определенных поддоменов):
|
||||||
# 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG and getattr(request, 'tenant', None) and request.tenant.schema_name != 'public',
|
# 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG and getattr(request, 'tenant', None) and request.tenant.schema_name != 'public',
|
||||||
|
# Отключить для тестов
|
||||||
|
'IS_RUNNING_TESTS': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class Order(models.Model):
|
|||||||
return reverse('orders:order-detail', kwargs={'order_number': self.order_number})
|
return reverse('orders:order-detail', kwargs={'order_number': self.order_number})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
from django.db import transaction
|
||||||
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
||||||
if not self.order_number:
|
if not self.order_number:
|
||||||
last_order = Order.objects.order_by('-order_number').first()
|
last_order = Order.objects.order_by('-order_number').first()
|
||||||
@@ -184,7 +185,10 @@ class Order(models.Model):
|
|||||||
self.order_number = max(last_order.order_number + 1, 100)
|
self.order_number = max(last_order.order_number + 1, 100)
|
||||||
else:
|
else:
|
||||||
self.order_number = 100
|
self.order_number = 100
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
# Оборачиваем в транзакцию чтобы ValidationError в сигналах откатывал save()
|
||||||
|
with transaction.atomic():
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def recalculate_amount_paid(self):
|
def recalculate_amount_paid(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -41,14 +41,9 @@ class DateCarousel {
|
|||||||
this.daysCount = 0; // Будет рассчитано динамически
|
this.daysCount = 0; // Будет рассчитано динамически
|
||||||
|
|
||||||
// Загружаем сохранённую дату из localStorage или устанавливаем сегодня
|
// Загружаем сохранённую дату из localStorage или устанавливаем сегодня
|
||||||
const savedData = this.loadSavedCenterDate();
|
// Но для нового поведения всегда устанавливаем сегодняшнюю дату как центральную
|
||||||
if (savedData) {
|
this.centerDate = new Date();
|
||||||
// Восстанавливаем центральную дату как есть
|
this.centerDate.setHours(0, 0, 0, 0); // Сбрасываем время!
|
||||||
this.centerDate = savedData.centerDate;
|
|
||||||
} else {
|
|
||||||
this.centerDate = new Date();
|
|
||||||
this.centerDate.setHours(0, 0, 0, 0); // Сбрасываем время!
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,10 +271,6 @@ class DateCarousel {
|
|||||||
|
|
||||||
console.log(`Selected date: ${formattedDate}, center stays at: ${this.formatDate(this.centerDate)}`);
|
console.log(`Selected date: ${formattedDate}, center stays at: ${this.formatDate(this.centerDate)}`);
|
||||||
|
|
||||||
// ВАЖНО: Сохраняем текущую позицию карусели перед отправкой формы
|
|
||||||
// чтобы после перезагрузки страницы карусель осталась на том же месте
|
|
||||||
this.saveCenterDate();
|
|
||||||
|
|
||||||
// Автоматическая отправка формы
|
// Автоматическая отправка формы
|
||||||
const form = this.minInput.closest('form');
|
const form = this.minInput.closest('form');
|
||||||
if (form) {
|
if (form) {
|
||||||
|
|||||||
Reference in New Issue
Block a user