Исправлена логика продажи витринных комплектов через POS

- Добавлена специальная обработка витринных комплектов в сигнале 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 для заказа с витринным комплектом
This commit is contained in:
2025-12-08 17:56:47 +03:00
parent 0fe888e405
commit 3ef2a19537
6 changed files with 157 additions and 26 deletions

View File

@@ -26,3 +26,7 @@ TENANT_ADMIN_NAME=Admin
# Django-tenants # Django-tenants
# Основной домен для public схемы # Основной домен для public схемы
PUBLIC_SCHEMA_DOMAIN=yourdomain.com PUBLIC_SCHEMA_DOMAIN=yourdomain.com
# Domain settings for multi-tenant URLs
TENANT_DOMAIN_BASE=yourdomain.com
USE_HTTPS=True

View File

@@ -29,3 +29,16 @@ DB_PORT=5432
TENANT_ADMIN_EMAIL=admin@localhost TENANT_ADMIN_EMAIL=admin@localhost
TENANT_ADMIN_PASSWORD=change-me-in-production TENANT_ADMIN_PASSWORD=change-me-in-production
TENANT_ADMIN_NAME=Admin 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

View File

@@ -121,9 +121,62 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
sale_creation_failed = False sale_creation_failed = False
for item in instance.items.all(): for item in instance.items.all():
# Определяем товар # Для комплектов (витринных и обычных) создаем Sale для каждого компонента
product = item.product if item.product else item.product_kit 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: if not product:
continue continue
@@ -632,6 +685,7 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
- Создание заказа с товарами → создаёт резервы - Создание заказа с товарами → создаёт резервы
- Редактирование + добавление товаров → создаёт резервы для новых - Редактирование + добавление товаров → создаёт резервы для новых
- Изменение количества → обновляет резервы (даже если уже converted_to_sale) - Изменение количества → обновляет резервы (даже если уже converted_to_sale)
- Витринные комплекты → привязывает существующие витринные резервы к заказу
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов! КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
Резерв ищется по order_item независимо от статуса. Резерв ищется по order_item независимо от статуса.
@@ -640,6 +694,38 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
import logging import logging
logger = logging.getLogger(__name__) 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') # Ищем резерв для этой позиции в ЛЮБОМ статусе (не только 'reserved')
# КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов # КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов
reservation = Reservation.objects.filter( reservation = Reservation.objects.filter(
@@ -658,14 +744,19 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})" f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})"
) )
else: 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() warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
if warehouse: if warehouse:
product = instance.product if instance.product else instance.product_kit product = instance.product
if product:
reservation = Reservation.objects.create( reservation = Reservation.objects.create(
order_item=instance, order_item=instance,
product=product, product=product,

View File

@@ -365,6 +365,18 @@ MAX_CATEGORY_DEPTH = 10
PHONENUMBER_DEFAULT_REGION = 'BY' 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 # EMAIL SETTINGS
# ============================================ # ============================================

View File

@@ -288,8 +288,12 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
) )
logger.info(f"Тенант создан: {client.id}") 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}") logger.info(f"Создание домена: {domain_name}")
domain = Domain.objects.create( domain = Domain.objects.create(
domain=domain_name, domain=domain_name,

View File

@@ -21,10 +21,17 @@ def send_password_setup_email(registration):
registration.password_setup_token = uuid.uuid4() registration.password_setup_token = uuid.uuid4()
registration.password_setup_token_created_at = timezone.now() registration.password_setup_token_created_at = timezone.now()
# Построить URL # Построить URL динамически в зависимости от окружения
# В продакшене это будет полный URL с доменом # Локально: http://localhost:8000
setup_url = f"http://localhost:8000/accounts/setup-password/{registration.password_setup_token}/" # Продакшен: https://mix.smaa.by
tenant_url = f"http://{registration.schema_name}.localhost:8000/" 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} активирован!" subject = f"Ваш магазин {registration.shop_name} активирован!"