Исправлена логика продажи витринных комплектов через 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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,26 +744,31 @@ 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
|
||||||
|
reservation = Reservation.objects.create(
|
||||||
if product:
|
order_item=instance,
|
||||||
reservation = Reservation.objects.create(
|
product=product,
|
||||||
order_item=instance,
|
warehouse=warehouse,
|
||||||
product=product,
|
quantity=Decimal(str(instance.quantity)),
|
||||||
warehouse=warehouse,
|
status='reserved'
|
||||||
quantity=Decimal(str(instance.quantity)),
|
)
|
||||||
status='reserved'
|
|
||||||
)
|
logger.info(
|
||||||
|
f"✓ Создан новый резерв #{reservation.id}: {product.name}, quantity={reservation.quantity} "
|
||||||
logger.info(
|
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||||||
f"✓ Создан новый резерв #{reservation.id}: {product.name}, quantity={reservation.quantity} "
|
)
|
||||||
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Incoming)
|
@receiver(post_save, sender=Incoming)
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} активирован!"
|
||||||
|
|||||||
Reference in New Issue
Block a user