diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 947557b..ce0ac01 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -57,7 +57,7 @@ services: # Монтируем код приложения для горячего обновления (опционально, если нужно обновлять без пересборки) # - /Volume1/DockerAppsData/mixapp/app:/app # Медиа и статика - # Медиа и статика (монтируем туда, где их ждет Django: внутри myproject) + # Медиа и статика (монтируем туда, где их ждет Django) - /Volume1/DockerAppsData/mixapp/media:/app/myproject/media - /Volume1/DockerAppsData/mixapp/static:/app/myproject/staticfiles ports: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 527478b..b93685c 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -103,6 +103,30 @@ except Exception as e: echo "Redis is up!" } +# Создание папок media и staticfiles с правильными правами +setup_directories() { + echo "Setting up media and static directories..." + + # Определяем пути (в Docker BASE_DIR = /app, поэтому MEDIA_ROOT = /app/myproject/media) + MEDIA_ROOT="/app/myproject/media" + STATIC_ROOT="/app/myproject/staticfiles" + + # Создаем папки если их нет (рекурсивно) + # Важно: создаем структуру папок для tenants + mkdir -p "$MEDIA_ROOT/tenants" "$STATIC_ROOT" 2>/dev/null || true + + # Пытаемся установить права доступа + # Используем 777 для папок media, чтобы контейнер мог писать независимо от прав на хосте + # Это безопасно, так как доступ контролируется на уровне Docker volume + # Устанавливаем права рекурсивно на все существующие файлы и папки + find "$MEDIA_ROOT" -type d -exec chmod 777 {} \; 2>/dev/null || true + find "$MEDIA_ROOT" -type f -exec chmod 666 {} \; 2>/dev/null || true + chmod -R 755 "$STATIC_ROOT" 2>/dev/null || true + + echo "Media directory created/checked: $MEDIA_ROOT (permissions set)" + echo "Static directory created/checked: $STATIC_ROOT" +} + # Применение миграций и создание суперпользователя run_migrations() { echo "Running migrations for shared apps..." @@ -167,6 +191,7 @@ case "$1" in web) wait_for_postgres wait_for_redis + setup_directories run_migrations create_superuser echo "Starting Gunicorn..." @@ -182,6 +207,7 @@ case "$1" in celery-worker) wait_for_postgres wait_for_redis + setup_directories echo "Starting Celery Worker..." exec celery -A myproject worker \ -l info \ diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 23f0d68..9e9c38d 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -35,15 +35,14 @@ def update_is_returned_flag(order): """ has_sale_now = Sale.objects.filter(order=order).exists() - # Проверяем историю: был ли когда-либо в положительном финальном статусе - was_completed_ever = order.history.filter( - status__is_positive_end=True - ).exists() - if has_sale_now: # Есть актуальные продажи → заказ не возвращён new_flag = False else: + # Проверяем историю только если нет Sale (оптимизация производительности) + was_completed_ever = order.history.filter( + status__is_positive_end=True + ).exists() # Продаж нет → возвращён только если был когда-то completed new_flag = was_completed_ever @@ -170,9 +169,10 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): # Если заказ был возвращён (is_returned=True) и резервов нет, можно использовать # только статусы отрицательного исхода (отменён и т.п.) if instance.is_returned: + # Исключаем released резервы - они не могут быть использованы для создания Sale has_reservations = Reservation.objects.filter( order_item__order=instance - ).exists() + ).exclude(status='released').exists() if not has_reservations: # Резервов нет — разрешены только отрицательные статусы @@ -206,10 +206,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): ).exclude(status='converted_to_sale') if not reservations_to_update.exists(): - logger.warning( - f"⚠ Заказ {instance.order_number} переведён в '{instance.status.name}', " - f"но нет резервов для обновления (все уже converted_to_sale или отсутствуют)" - ) + # Проверяем, есть ли вообще резервы (включая released) + has_any_reservations = Reservation.objects.filter( + order_item__order=instance + ).exists() + + if has_any_reservations: + logger.warning( + f"⚠ Заказ {instance.order_number}: все резервы в статусе 'converted_to_sale' " + f"или 'released'. Sale уже созданы или резервы освобождены." + ) + else: + logger.error( + f"❌ Заказ {instance.order_number}: нет резервов для создания Sale. " + f"Заказ в статусе '{instance.status.name}' без возможности списания." + ) + # Обновляем флаг is_returned и выходим update_is_returned_flag(instance) return @@ -1208,6 +1220,20 @@ def process_sale_fifo(sender, instance, created, **kwargs): logger.error(f"Ошибка при обработке Sale {instance.id}: {str(e)}") +@receiver(pre_delete, sender=Sale) +def update_order_on_sale_delete(sender, instance, **kwargs): + """ + Обновляет флаг is_returned заказа при удалении Sale. + Вызывается ДО удаления, чтобы можно было получить order. + """ + if instance.order: + # Используем on_commit чтобы обновить после завершения транзакции + from django.db import transaction + transaction.on_commit( + lambda: update_is_returned_flag(instance.order) + ) + + @receiver(post_save, sender=Inventory) def process_inventory_reconciliation(sender, instance, created, **kwargs): """ diff --git a/myproject/inventory/tests/test_order_status_transitions.py b/myproject/inventory/tests/test_order_status_transitions.py index bb26a56..5ed54f2 100644 --- a/myproject/inventory/tests/test_order_status_transitions.py +++ b/myproject/inventory/tests/test_order_status_transitions.py @@ -20,6 +20,7 @@ from django.test import TestCase from django.db import connection from django.contrib.auth import get_user_model from django_tenants.utils import schema_context +from django.core.exceptions import ValidationError from decimal import Decimal from tenants.models import Client, Domain @@ -661,3 +662,142 @@ class OrderStatusTransitionCriticalTest(TestCase): Decimal('100.00'), "[DRAFT ROLLBACK] StockBatch должен восстановиться до исходного значения" ) + + # ==================== ТЕСТ 6: Валидация резервов (is_returned + только released) ==================== + + def test_06_validation_released_reservations(self): + """ + ТЕСТ #6: Валидация резервов при is_returned=True и только released резервах + + Сценарий: + 1. completed (Sale созданы) + 2. → cancelled (резервы released, is_returned=True) + 3. Попытка перейти в положительный статус должна быть запрещена + + Проверяем: + - Заказ с is_returned=True и только released резервами не может перейти в положительный статус + - Валидация корректно исключает released резервы + """ + with schema_context('test_order_status'): + # ШАГ 1: Создаём заказ и переводим в completed + order = self._create_order(self.status_draft, quantity=Decimal('10.00')) + order.status = self.status_completed + order.save() + + # Проверяем, что Sale созданы + self._assert_sale_exists(order, should_exist=True) + self.assertFalse(order.is_returned, "[COMPLETED] is_returned должен быть False") + + # ШАГ 2: Переводим в cancelled (резервы станут released) + order.status = self.status_cancelled + order.save() + + # Проверяем, что Sale удалены и резервы released + self._assert_sale_exists(order, should_exist=False) + self._assert_reservation_status(order, 'released', "[CANCELLED] ") + order.refresh_from_db() + self.assertTrue(order.is_returned, "[CANCELLED] is_returned должен быть True") + + # ШАГ 3: Попытка перейти в положительный статус должна быть запрещена + order.status = self.status_completed + with self.assertRaises(ValidationError) as context: + order.save() + + error_message = str(context.exception) + self.assertIn('был отменён', error_message) + self.assertIn('товары проданы', error_message) + + # ==================== ТЕСТ 7: Ручное удаление Sale ==================== + + def test_07_manual_sale_deletion_updates_flag(self): + """ + ТЕСТ #7: Обновление is_returned при ручном удалении Sale + + Сценарий: + 1. completed (Sale созданы, is_returned=False) + 2. Ручное удаление Sale + 3. is_returned должен стать True + + Проверяем: + - При удалении Sale через delete() флаг is_returned обновляется + - Сигнал pre_delete корректно обрабатывает удаление + """ + with schema_context('test_order_status'): + # ШАГ 1: Создаём заказ и переводим в completed + order = self._create_order(self.status_draft, quantity=Decimal('10.00')) + order.status = self.status_completed + order.save() + + # Проверяем начальное состояние + sale = self._assert_sale_exists(order, should_exist=True) + order.refresh_from_db() + self.assertFalse(order.is_returned, "[INITIAL] is_returned должен быть False") + + # ШАГ 2: Ручное удаление Sale + sale.delete() + + # В тестах on_commit может не сработать сразу, поэтому принудительно коммитим транзакцию + from django.db import transaction + transaction.get_connection().commit() + + # Проверяем, что флаг обновился + order.refresh_from_db() + self.assertTrue(order.is_returned, "[AFTER DELETE] is_returned должен быть True после удаления Sale") + self._assert_sale_exists(order, should_exist=False) + + # ==================== ТЕСТ 8: Edge case - completed без резервов ==================== + + def test_08_completed_without_reservations(self): + """ + ТЕСТ #8: Поведение при переходе в completed без резервов + + Сценарий: + 1. Создаём заказ без резервов (или удаляем резервы) + 2. Переход в completed + 3. Проверяем корректную обработку edge case + + Проверяем: + - Заказ может перейти в completed без резервов (если они уже converted_to_sale) + - Корректное логирование ситуации + - is_returned обновляется корректно + """ + with schema_context('test_order_status'): + # ШАГ 1: Создаём заказ и переводим в completed (резервы создаются автоматически) + order = self._create_order(self.status_draft, quantity=Decimal('10.00')) + + # Проверяем, что резервы созданы + reservation = Reservation.objects.filter(order_item__order=order).first() + self.assertIsNotNone(reservation, "[DRAFT] Резерв должен быть создан") + self.assertEqual(reservation.status, 'reserved') + + # ШАГ 2: Переводим в completed (резервы станут converted_to_sale) + order.status = self.status_completed + order.save() + + # Проверяем, что резервы converted_to_sale + reservation.refresh_from_db() + self.assertEqual(reservation.status, 'converted_to_sale') + self._assert_sale_exists(order, should_exist=True) + + # ШАГ 3: Откатываем в draft (резервы вернутся в reserved) + order.status = self.status_draft + order.save() + + reservation.refresh_from_db() + self.assertEqual(reservation.status, 'reserved') + self._assert_sale_exists(order, should_exist=False) + + # ШАГ 4: Снова переводим в completed + # Теперь резервы уже есть в статусе reserved, но проверим edge case + # когда все резервы уже converted_to_sale (не должно быть, но проверим) + order.status = self.status_completed + order.save() + + # Проверяем, что Sale созданы снова + self._assert_sale_exists(order, should_exist=True) + reservation.refresh_from_db() + self.assertEqual(reservation.status, 'converted_to_sale') + + # Проверяем, что is_returned False (есть Sale) + order.refresh_from_db() + self.assertFalse(order.is_returned, "[COMPLETED AGAIN] is_returned должен быть False при наличии Sale") diff --git a/myproject/inventory/views/sale.py b/myproject/inventory/views/sale.py index 069f5c5..2d8031a 100644 --- a/myproject/inventory/views/sale.py +++ b/myproject/inventory/views/sale.py @@ -94,10 +94,20 @@ class SaleDeleteView(LoginRequiredMixin, DeleteView): template_name = 'inventory/sale/sale_confirm_delete.html' success_url = reverse_lazy('inventory:sale-list') - def form_valid(self, form): + def delete(self, request, *args, **kwargs): sale = self.get_object() + order = sale.order + + result = super().delete(request, *args, **kwargs) + + # Обновляем флаг заказа после удаления Sale + if order: + from inventory.signals import update_is_returned_flag + update_is_returned_flag(order) + messages.success( - self.request, + request, f'Продажа товара "{sale.product.name}" отменена.' ) - return super().form_valid(form) + + return result diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 12bd30e..595fb16 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -246,7 +246,15 @@ else: # ============================================ MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' +# MEDIA_ROOT должен соответствовать монтированию volume в docker-compose.yml +# На проде в Docker: /app/myproject/media (BASE_DIR = /app) +# Локально: BASE_DIR / 'media' (BASE_DIR = путь к проекту) +if os.environ.get('MEDIA_ROOT'): + MEDIA_ROOT = Path(os.environ.get('MEDIA_ROOT')) +elif str(BASE_DIR) == '/app': # В Docker контейнере + MEDIA_ROOT = BASE_DIR / 'myproject' / 'media' +else: # Локальная разработка + MEDIA_ROOT = BASE_DIR / 'media' # Custom file storage for tenant-aware file organization DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage' diff --git a/myproject/products/utils/storage.py b/myproject/products/utils/storage.py index 145e7d4..b4ab294 100644 --- a/myproject/products/utils/storage.py +++ b/myproject/products/utils/storage.py @@ -128,10 +128,16 @@ class TenantAwareFileSystemStorage(FileSystemStorage): # Добавляем tenant_id в путь для сохранения на диск tenant_aware_name = self._get_tenant_path(name) - logger.debug(f"[Storage] _save: {name} → {tenant_aware_name} (DB will store: {name})") + logger.info(f"[Storage] _save: {name} → {tenant_aware_name} (DB will store: {name})") - # Сохраняем файл на диск с tenant_id, но возвращаем исходный путь для БД - super()._save(tenant_aware_name, content) + try: + # Сохраняем файл на диск с tenant_id, но возвращаем исходный путь для БД + # Django FileSystemStorage автоматически создаст директории если их нет + saved_path = super()._save(tenant_aware_name, content) + logger.info(f"[Storage] File saved successfully: {saved_path}") + except Exception as e: + logger.error(f"[Storage] Error saving file {tenant_aware_name}: {str(e)}", exc_info=True) + raise # Возвращаем путь БЕЗ tenant_id для сохранения в БД # Это позволяет: @@ -181,6 +187,7 @@ class TenantAwareFileSystemStorage(FileSystemStorage): """ # Получаем tenant_id tenant_id = self._get_tenant_id() + logger.info(f"[Storage] exists called: name={name}, tenant_id={tenant_id}, schema={connection.schema_name}") # Если путь уже содержит tenants/, не добавляем еще раз if name.startswith("tenants/"): @@ -191,11 +198,15 @@ class TenantAwareFileSystemStorage(FileSystemStorage): f"Current tenant: {tenant_id}, file: {name}" ) return False - return super().exists(name) + result = super().exists(name) + logger.info(f"[Storage] exists (with tenant): {name} → {result}") + return result # Иначе добавляем tenant_id tenant_aware_name = self._get_tenant_path(name) - return super().exists(tenant_aware_name) + result = super().exists(tenant_aware_name) + logger.info(f"[Storage] exists: {name} → {tenant_aware_name} → {result}") + return result def url(self, name): """