From 3ef2a195375d656566911379141ccb30878ac272 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 8 Dec 2025 17:56:47 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6=D0=B8=20=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20POS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена специальная обработка витринных комплектов в сигнале update_reservation_on_item_change: * При создании OrderItem с витринным комплектом привязываются существующие витринные резервы компонентов * Не создаются новые резервы на уровне комплекта - Исправлена логика создания Sale для комплектов в сигнале create_sale_on_order_completion: * Для комплектов (витринных и обычных) создаются Sale для каждого компонента через резервы * Используется FIFO-списание для компонентов * Предотвращена ошибка передачи ProductKit в поле Reservation.product Fixes: Cannot assign ProductKit to Reservation.product field Fixes: Не удалось создать Sale для заказа с витринным комплектом --- docker/.env.docker.example | 4 + myproject/.env.example | 13 ++++ myproject/inventory/signals.py | 131 +++++++++++++++++++++++++++----- myproject/myproject/settings.py | 12 +++ myproject/tenants/admin.py | 8 +- myproject/tenants/services.py | 15 +++- 6 files changed, 157 insertions(+), 26 deletions(-) diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 04de10d..21afb52 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -26,3 +26,7 @@ TENANT_ADMIN_NAME=Admin # Django-tenants # Основной домен для public схемы PUBLIC_SCHEMA_DOMAIN=yourdomain.com + +# Domain settings for multi-tenant URLs +TENANT_DOMAIN_BASE=yourdomain.com +USE_HTTPS=True diff --git a/myproject/.env.example b/myproject/.env.example index a2dfe36..ede6508 100644 --- a/myproject/.env.example +++ b/myproject/.env.example @@ -29,3 +29,16 @@ DB_PORT=5432 TENANT_ADMIN_EMAIL=admin@localhost TENANT_ADMIN_PASSWORD=change-me-in-production TENANT_ADMIN_NAME=Admin + +# ============================================ +# DOMAIN SETTINGS +# ============================================ +# Базовый домен для мультитенантности (без схемы http/https) +# Локально: localhost:8000 +# Продакшен: your-domain.com +TENANT_DOMAIN_BASE=localhost:8000 + +# Использовать HTTPS для ссылок +# False - для локальной разработки (http://) +# True - для продакшена (https://) +USE_HTTPS=False diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index b4733a8..3d9095e 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -121,9 +121,62 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): sale_creation_failed = False for item in instance.items.all(): - # Определяем товар - product = item.product if item.product else item.product_kit - + # Для комплектов (витринных и обычных) создаем Sale для каждого компонента + if item.product_kit: + kit = item.product_kit + + # Находим все резервы компонентов этого комплекта для этого заказа + kit_reservations = Reservation.objects.filter( + order_item=item, + product_kit=kit + ).exclude(status='converted_to_sale') + + if not kit_reservations.exists(): + logger.warning( + f"⚠ Комплект '{kit.name}': не найдено резервов компонентов" + ) + continue + + # Создаем Sale для каждого компонента комплекта + for reservation in kit_reservations: + try: + # Рассчитываем цену продажи компонента пропорционально цене комплекта + # Используем actual_price компонента как цену продажи + component_sale_price = reservation.product.actual_price + + sale = SaleProcessor.create_sale( + product=reservation.product, + warehouse=warehouse, + quantity=reservation.quantity, + sale_price=component_sale_price, + order=instance, + document_number=instance.order_number + ) + sales_created.append(sale) + logger.info( + f"✓ Sale создан для компонента комплекта '{kit.name}': " + f"{reservation.product.name} - {reservation.quantity} шт." + ) + except ValueError as e: + logger.error( + f"❌ ОШИБКА при создании Sale для компонента комплекта '{kit.name}': {e}" + ) + sale_creation_failed = True + break + except Exception as e: + logger.error( + f"❌ КРИТИЧЕСКАЯ ОШИБКА при создании Sale для комплекта: {e}" + ) + sale_creation_failed = True + break + + if sale_creation_failed: + break + + continue + + # Обычный товар + product = item.product if not product: continue @@ -632,6 +685,7 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs): - Создание заказа с товарами → создаёт резервы - Редактирование + добавление товаров → создаёт резервы для новых - Изменение количества → обновляет резервы (даже если уже converted_to_sale) + - Витринные комплекты → привязывает существующие витринные резервы к заказу КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов! Резерв ищется по order_item независимо от статуса. @@ -640,6 +694,38 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs): import logging logger = logging.getLogger(__name__) + # Проверяем, это витринный комплект или обычный товар/комплект + is_showcase_kit = instance.product_kit and instance.product_kit.is_temporary and instance.product_kit.showcase + + if is_showcase_kit: + # Витринный комплект - привязываем существующие витринные резервы к заказу + kit = instance.product_kit + + # Находим все витринные резервы для компонентов этого комплекта + showcase_reservations = Reservation.objects.filter( + product_kit=kit, + showcase__isnull=False, + status='reserved' + ) + + if showcase_reservations.exists(): + # Привязываем витринные резервы к позиции заказа + updated_count = showcase_reservations.update( + order_item=instance, + # Оставляем showcase и product_kit для аудита + ) + + logger.info( + f"✓ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{instance.id}, " + f"заказ {instance.order.order_number}" + ) + else: + logger.warning( + f"⚠ Витринный комплект '{kit.name}': не найдено активных витринных резервов для OrderItem #{instance.id}" + ) + return # Для витринных комплектов не создаем новые резервы + + # Обычный товар или постоянный комплект # Ищем резерв для этой позиции в ЛЮБОМ статусе (не только 'reserved') # КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов reservation = Reservation.objects.filter( @@ -658,26 +744,31 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs): f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})" ) else: - # Резерва нет - создаем новый - # Это происходит при создании нового OrderItem (через форму или при редактировании) + # Резерва нет - создаем новый ТОЛЬКО для обычных товаров (не комплектов) + if not instance.product: + # Это обычный комплект (не витринный) - не создаем резерв на уровне комплекта + logger.info( + f"ℹ Обычный комплект '{instance.product_kit.name}': резервы управляются на уровне компонентов" + ) + return + + # Создаем резерв для обычного товара warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first() if warehouse: - product = instance.product if instance.product else instance.product_kit - - if product: - reservation = Reservation.objects.create( - order_item=instance, - product=product, - warehouse=warehouse, - quantity=Decimal(str(instance.quantity)), - status='reserved' - ) - - logger.info( - f"✓ Создан новый резерв #{reservation.id}: {product.name}, quantity={reservation.quantity} " - f"(OrderItem #{instance.id}, заказ {instance.order.order_number})" - ) + product = instance.product + reservation = Reservation.objects.create( + order_item=instance, + product=product, + warehouse=warehouse, + quantity=Decimal(str(instance.quantity)), + status='reserved' + ) + + logger.info( + f"✓ Создан новый резерв #{reservation.id}: {product.name}, quantity={reservation.quantity} " + f"(OrderItem #{instance.id}, заказ {instance.order.order_number})" + ) @receiver(post_save, sender=Incoming) diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index e23bcc9..a8604ea 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -365,6 +365,18 @@ MAX_CATEGORY_DEPTH = 10 PHONENUMBER_DEFAULT_REGION = 'BY' +# ============================================ +# DOMAIN SETTINGS (for multi-tenant URLs) +# ============================================ + +# Главный домен приложения (без схемы http/https) +# Локально: localhost:8000, в проде: mix.smaa.by +TENANT_DOMAIN_BASE = env('TENANT_DOMAIN_BASE', default='localhost:8000') + +# Использовать HTTPS для ссылок (в проде True, локально False) +USE_HTTPS = env.bool('USE_HTTPS', default=False) + + # ============================================ # EMAIL SETTINGS # ============================================ diff --git a/myproject/tenants/admin.py b/myproject/tenants/admin.py index 29bffb2..b97cb34 100644 --- a/myproject/tenants/admin.py +++ b/myproject/tenants/admin.py @@ -288,8 +288,12 @@ class TenantRegistrationAdmin(admin.ModelAdmin): ) logger.info(f"Тенант создан: {client.id}") - # Создаем домен (для локальной разработки используем localhost) - domain_name = f"{registration.schema_name}.localhost" + # Создаем домен (динамически определяется из настроек) + # Локально: schema_name.localhost:8000 + # Продакшен: schema_name.mix.smaa.by + from django.conf import settings + domain_base = settings.TENANT_DOMAIN_BASE + domain_name = f"{registration.schema_name}.{domain_base}" logger.info(f"Создание домена: {domain_name}") domain = Domain.objects.create( domain=domain_name, diff --git a/myproject/tenants/services.py b/myproject/tenants/services.py index 6371952..2a2bc68 100644 --- a/myproject/tenants/services.py +++ b/myproject/tenants/services.py @@ -21,10 +21,17 @@ def send_password_setup_email(registration): registration.password_setup_token = uuid.uuid4() registration.password_setup_token_created_at = timezone.now() - # Построить URL - # В продакшене это будет полный URL с доменом - setup_url = f"http://localhost:8000/accounts/setup-password/{registration.password_setup_token}/" - tenant_url = f"http://{registration.schema_name}.localhost:8000/" + # Построить URL динамически в зависимости от окружения + # Локально: http://localhost:8000 + # Продакшен: https://mix.smaa.by + protocol = 'https' if settings.USE_HTTPS else 'http' + domain_base = settings.TENANT_DOMAIN_BASE # localhost:8000 или mix.smaa.by + + # URL для установки пароля (главный домен) + setup_url = f"{protocol}://{domain_base}/accounts/setup-password/{registration.password_setup_token}/" + + # URL тенанта (поддомен) + tenant_url = f"{protocol}://{registration.schema_name}.{domain_base}/" # Составить письмо subject = f"Ваш магазин {registration.shop_name} активирован!"