Compare commits
23 Commits
22e300394b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b24a0d9f21 | |||
| 034be20a5a | |||
| f75e861bb8 | |||
| 5a66d492c8 | |||
| 6cd0a945de | |||
| 41e6c33683 | |||
| bf399996b8 | |||
| 2bc70968c3 | |||
| 38fbf36731 | |||
| 9c91a99189 | |||
| 1eec8b1cd5 | |||
| 977ee91fee | |||
| fce8d9eb6e | |||
| 5070913346 | |||
| 87f6484258 | |||
| 14c1a4f804 | |||
| adbbd7539b | |||
| 5ec5ee48d4 | |||
| 3aac83474b | |||
| 4a624d5fef | |||
| 9ddf54f398 | |||
| 84cfc5cd47 | |||
| 59f7a7c520 |
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2026-01-23 15:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('integrations', '0009_alter_glmintegration_model_name_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='openrouterintegration',
|
||||||
|
name='model_name',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Название используемой модели OpenRouter (загружается автоматически)', max_length=200, verbose_name='Название модели'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -10,14 +10,6 @@ def validate_temperature(value):
|
|||||||
raise ValidationError('Температура должна быть в диапазоне 0.0-2.0')
|
raise ValidationError('Температура должна быть в диапазоне 0.0-2.0')
|
||||||
|
|
||||||
|
|
||||||
# Список доступных моделей OpenRouter (бесплатные)
|
|
||||||
OPENROUTER_MODEL_CHOICES = [
|
|
||||||
('xiaomi/mimo-v2-flash:free', 'Xiaomi MIMO v2 Flash (Бесплатная)'),
|
|
||||||
('mistralai/devstral-2512:free', 'Mistral Devstral 2512 (Бесплатная)'),
|
|
||||||
('z-ai/glm-4.5-air:free', 'Z.AI GLM-4.5 Air (Бесплатная)'),
|
|
||||||
('qwen/qwen3-coder:free', 'Qwen 3 Coder (Бесплатная)'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Предустановленные значения температуры
|
# Предустановленные значения температуры
|
||||||
OPENROUTER_TEMPERATURE_CHOICES = [
|
OPENROUTER_TEMPERATURE_CHOICES = [
|
||||||
(0.1, '0.1 - Очень консервативно'),
|
(0.1, '0.1 - Очень консервативно'),
|
||||||
@@ -59,11 +51,11 @@ class OpenRouterIntegration(AIIntegration):
|
|||||||
)
|
)
|
||||||
|
|
||||||
model_name = models.CharField(
|
model_name = models.CharField(
|
||||||
max_length=100,
|
max_length=200,
|
||||||
default="xiaomi/mimo-v2-flash:free",
|
default="",
|
||||||
choices=OPENROUTER_MODEL_CHOICES,
|
blank=True,
|
||||||
verbose_name="Название модели",
|
verbose_name="Название модели",
|
||||||
help_text="Название используемой модели OpenRouter"
|
help_text="Название используемой модели OpenRouter (загружается автоматически)"
|
||||||
)
|
)
|
||||||
|
|
||||||
temperature = models.FloatField(
|
temperature = models.FloatField(
|
||||||
|
|||||||
@@ -3,28 +3,45 @@ from ..base import BaseIntegrationService
|
|||||||
from .config import get_openrouter_config
|
from .config import get_openrouter_config
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import locale
|
import traceback
|
||||||
|
|
||||||
# Патч для исправления проблемы с кодировкой в httpx на Windows
|
# Патч для исправления проблемы с кодировкой в httpx на Windows
|
||||||
# Устанавливаем кодировку по умолчанию для Python
|
# Устанавливаем кодировку по умолчанию для Python
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
try:
|
try:
|
||||||
import httpx._models
|
import httpx._models
|
||||||
original_normalize_header_value = httpx._models._normalize_header_value
|
|
||||||
|
# Сохраняем оригинальную функцию, если она есть
|
||||||
|
_original_normalize_header_value = getattr(httpx._models, '_normalize_header_value', None)
|
||||||
|
|
||||||
def patched_normalize_header_value(value, encoding):
|
def patched_normalize_header_value(value, encoding):
|
||||||
"""Патч для использования UTF-8 вместо ASCII для заголовков"""
|
"""Патч для использования UTF-8 вместо ASCII для заголовков"""
|
||||||
|
try:
|
||||||
# Если значение уже bytes, возвращаем его как есть
|
# Если значение уже bytes, возвращаем его как есть
|
||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
# Если значение не строка и не байты, приводим к строке
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = str(value)
|
||||||
|
|
||||||
# Всегда используем UTF-8 вместо ASCII
|
# Всегда используем UTF-8 вместо ASCII
|
||||||
encoding = encoding or 'utf-8'
|
encoding = encoding or 'utf-8'
|
||||||
if encoding.lower() == 'ascii':
|
if encoding.lower() == 'ascii':
|
||||||
encoding = 'utf-8'
|
encoding = 'utf-8'
|
||||||
|
|
||||||
return value.encode(encoding)
|
return value.encode(encoding)
|
||||||
|
except Exception as e:
|
||||||
|
# В случае ошибки логируем и пробуем максимально безопасный вариант
|
||||||
|
logging.getLogger(__name__).error(f"Error in patched_normalize_header_value: {e}. Value: {repr(value)}")
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.encode('utf-8', errors='ignore')
|
||||||
|
return b''
|
||||||
|
|
||||||
httpx._models._normalize_header_value = patched_normalize_header_value
|
httpx._models._normalize_header_value = patched_normalize_header_value
|
||||||
logging.getLogger(__name__).info("Applied patch for httpx header encoding on Windows")
|
logging.getLogger(__name__).info("Applied robust patch for httpx header encoding on Windows")
|
||||||
|
except ImportError:
|
||||||
|
logging.getLogger(__name__).warning("httpx module not found, patch skipped")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).warning(f"Failed to apply httpx patch: {e}")
|
logging.getLogger(__name__).warning(f"Failed to apply httpx patch: {e}")
|
||||||
|
|
||||||
@@ -148,8 +165,10 @@ class OpenRouterIntegrationService(BaseIntegrationService):
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {str(e)}")
|
error_msg = str(e)
|
||||||
return False, f"Ошибка генерации: {str(e)}", None
|
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {error_msg}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False, f"Ошибка генерации: {error_msg}", None
|
||||||
|
|
||||||
def generate_code(self,
|
def generate_code(self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -196,5 +215,7 @@ class OpenRouterIntegrationService(BaseIntegrationService):
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {str(e)}")
|
error_msg = str(e)
|
||||||
return False, f"Ошибка генерации кода: {str(e)}", None
|
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {error_msg}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False, f"Ошибка генерации кода: {error_msg}", None
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .views import (
|
|||||||
get_integration_form_data,
|
get_integration_form_data,
|
||||||
test_integration_connection,
|
test_integration_connection,
|
||||||
RecommerceBatchSyncView,
|
RecommerceBatchSyncView,
|
||||||
|
get_openrouter_models,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'integrations'
|
app_name = 'integrations'
|
||||||
@@ -22,4 +23,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Синхронизация
|
# Синхронизация
|
||||||
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
|
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
|
||||||
|
|
||||||
|
# OpenRouter модели
|
||||||
|
path("openrouter/models/", get_openrouter_models, name="openrouter_models"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST, require_GET
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from user_roles.mixins import OwnerRequiredMixin
|
from user_roles.mixins import OwnerRequiredMixin
|
||||||
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
|
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
|
||||||
@@ -181,6 +184,44 @@ def get_integration_service(integration_id: str, instance):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def get_openrouter_models(request):
|
||||||
|
"""
|
||||||
|
GET /settings/integrations/openrouter/models/
|
||||||
|
Возвращает список моделей OpenRouter (бесплатные сверху)
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get('https://openrouter.ai/api/v1/models', timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
models = data.get('data', [])
|
||||||
|
|
||||||
|
# Разделить на бесплатные и платные
|
||||||
|
free_models = []
|
||||||
|
paid_models = []
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
model_id = model.get('id', '')
|
||||||
|
model_name = model.get('name', model_id)
|
||||||
|
|
||||||
|
if ':free' in model_id:
|
||||||
|
free_models.append({'id': model_id, 'name': f"{model_name} (Бесплатная)"})
|
||||||
|
else:
|
||||||
|
paid_models.append({'id': model_id, 'name': model_name})
|
||||||
|
|
||||||
|
# Бесплатные сверху
|
||||||
|
all_models = free_models + paid_models
|
||||||
|
|
||||||
|
return JsonResponse({'models': all_models})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching OpenRouter models: {e}")
|
||||||
|
return JsonResponse({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class RecommerceBatchSyncView(TemplateView):
|
class RecommerceBatchSyncView(TemplateView):
|
||||||
"""
|
"""
|
||||||
API View для запуска массовой синхронизации с Recommerce.
|
API View для запуска массовой синхронизации с Recommerce.
|
||||||
@@ -363,29 +404,33 @@ def get_form_fields_meta(model):
|
|||||||
'label': getattr(field, 'verbose_name', field_name),
|
'label': getattr(field, 'verbose_name', field_name),
|
||||||
'help_text': getattr(field, 'help_text', ''),
|
'help_text': getattr(field, 'help_text', ''),
|
||||||
'required': not getattr(field, 'blank', True),
|
'required': not getattr(field, 'blank', True),
|
||||||
'type': 'text', # default
|
'type': 'password' if field_name == 'api_key' else 'text',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Определить тип поля
|
|
||||||
if 'BooleanField' in field.__class__.__name__:
|
|
||||||
field_info['type'] = 'checkbox'
|
|
||||||
elif 'URLField' in field.__class__.__name__:
|
|
||||||
field_info['type'] = 'url'
|
|
||||||
elif 'secret' in field_name.lower() or 'token' in field_name.lower() or 'key' in field_name.lower():
|
|
||||||
field_info['type'] = 'password'
|
|
||||||
|
|
||||||
fields.append(field_info)
|
fields.append(field_info)
|
||||||
elif field_name in ['model_name', 'temperature']:
|
|
||||||
|
elif field_name == 'temperature':
|
||||||
field = model._meta.get_field(field_name)
|
field = model._meta.get_field(field_name)
|
||||||
field_info = {
|
field_info = {
|
||||||
'name': field_name,
|
'name': field_name,
|
||||||
'label': getattr(field, 'verbose_name', field_name),
|
'label': getattr(field, 'verbose_name', field_name),
|
||||||
'help_text': getattr(field, 'help_text', ''),
|
'help_text': getattr(field, 'help_text', ''),
|
||||||
'required': not getattr(field, 'blank', True),
|
'required': not getattr(field, 'blank', True),
|
||||||
'type': 'select', # dropdown
|
'type': 'select',
|
||||||
'choices': getattr(field, 'choices', [])
|
'choices': getattr(field, 'choices', [])
|
||||||
}
|
}
|
||||||
|
fields.append(field_info)
|
||||||
|
|
||||||
|
elif field_name == 'model_name':
|
||||||
|
field = model._meta.get_field(field_name)
|
||||||
|
field_info = {
|
||||||
|
'name': field_name,
|
||||||
|
'label': getattr(field, 'verbose_name', field_name),
|
||||||
|
'help_text': getattr(field, 'help_text', ''),
|
||||||
|
'required': not getattr(field, 'blank', True),
|
||||||
|
'type': 'select',
|
||||||
|
'dynamic_choices': True,
|
||||||
|
'choices_url': '/settings/integrations/openrouter/models/'
|
||||||
|
}
|
||||||
fields.append(field_info)
|
fields.append(field_info)
|
||||||
# Для WooCommerce показываем только базовые поля для подключения
|
# Для WooCommerce показываем только базовые поля для подключения
|
||||||
elif model.__name__ == 'WooCommerceIntegration':
|
elif model.__name__ == 'WooCommerceIntegration':
|
||||||
|
|||||||
@@ -162,8 +162,6 @@ class ShowcaseManager:
|
|||||||
Raises:
|
Raises:
|
||||||
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
|
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
|
||||||
"""
|
"""
|
||||||
from inventory.services.sale_processor import SaleProcessor
|
|
||||||
|
|
||||||
sold_count = 0
|
sold_count = 0
|
||||||
order = order_item.order
|
order = order_item.order
|
||||||
|
|
||||||
@@ -207,17 +205,9 @@ class ShowcaseManager:
|
|||||||
|
|
||||||
# Сначала устанавливаем order_item для правильного определения цены
|
# Сначала устанавливаем order_item для правильного определения цены
|
||||||
reservation.order_item = order_item
|
reservation.order_item = order_item
|
||||||
reservation.save()
|
# ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
|
||||||
|
# Это сделает сигнал create_sale_on_order_completion автоматически.
|
||||||
# Теперь создаём продажу с правильной ценой из OrderItem
|
# Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
|
||||||
SaleProcessor.create_sale_from_reservation(
|
|
||||||
reservation=reservation,
|
|
||||||
order=order
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем статус резерва
|
|
||||||
reservation.status = 'converted_to_sale'
|
|
||||||
reservation.converted_at = timezone.now()
|
|
||||||
reservation.save()
|
reservation.save()
|
||||||
|
|
||||||
sold_count += 1
|
sold_count += 1
|
||||||
@@ -666,6 +656,113 @@ class ShowcaseManager:
|
|||||||
'message': f'Ошибка разбора: {str(e)}'
|
'message': f'Ошибка разбора: {str(e)}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_off_from_showcase(showcase_item, reason='spoilage', notes=None, created_by=None):
|
||||||
|
"""
|
||||||
|
Списывает экземпляр витринного комплекта:
|
||||||
|
1. Создаёт документ списания с компонентами комплекта
|
||||||
|
2. Преобразует резервы комплекта в позиции документа списания
|
||||||
|
3. Помечает экземпляр как разобранный
|
||||||
|
|
||||||
|
Args:
|
||||||
|
showcase_item: ShowcaseItem - экземпляр для списания
|
||||||
|
reason: str - причина списания (spoilage по умолчанию)
|
||||||
|
notes: str - примечания
|
||||||
|
created_by: User - пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'document_id': int,
|
||||||
|
'document_number': str,
|
||||||
|
'items_count': int,
|
||||||
|
'message': str,
|
||||||
|
'error': str (при ошибке)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from inventory.services.writeoff_document_service import WriteOffDocumentService
|
||||||
|
|
||||||
|
# Проверка статуса
|
||||||
|
if showcase_item.status == 'sold':
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': 'Нельзя списать проданный экземпляр'
|
||||||
|
}
|
||||||
|
|
||||||
|
if showcase_item.status == 'dismantled':
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': 'Экземпляр уже разобран'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
warehouse = showcase_item.showcase.warehouse
|
||||||
|
product_kit = showcase_item.product_kit
|
||||||
|
|
||||||
|
# Создаём документ списания (черновик)
|
||||||
|
document = WriteOffDocumentService.create_document(
|
||||||
|
warehouse=warehouse,
|
||||||
|
date=timezone.now().date(),
|
||||||
|
notes=f'Списание витринного комплекта: {product_kit.name}',
|
||||||
|
created_by=created_by
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем резервы этого экземпляра
|
||||||
|
reservations = Reservation.objects.filter(
|
||||||
|
showcase_item=showcase_item,
|
||||||
|
status='reserved'
|
||||||
|
).select_related('product')
|
||||||
|
|
||||||
|
items_count = 0
|
||||||
|
|
||||||
|
for reservation in reservations:
|
||||||
|
# Добавляем позицию в документ списания
|
||||||
|
# Используем add_item без создания резерва (меняем статус существующего)
|
||||||
|
from inventory.models import WriteOffDocumentItem
|
||||||
|
|
||||||
|
item = WriteOffDocumentItem.objects.create(
|
||||||
|
document=document,
|
||||||
|
product=reservation.product,
|
||||||
|
quantity=reservation.quantity,
|
||||||
|
reason=reason,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Привязываем существующий резерв к позиции документа
|
||||||
|
reservation.writeoff_document_item = item
|
||||||
|
reservation.status = 'converted_to_writeoff'
|
||||||
|
reservation.converted_at = timezone.now()
|
||||||
|
reservation.save(update_fields=['writeoff_document_item', 'status', 'converted_at'])
|
||||||
|
|
||||||
|
items_count += 1
|
||||||
|
|
||||||
|
# Помечаем экземпляр как разобранный
|
||||||
|
showcase_item.status = 'dismantled'
|
||||||
|
showcase_item.save(update_fields=['status'])
|
||||||
|
|
||||||
|
# Помечаем шаблон комплекта как снятый
|
||||||
|
if product_kit.status != 'discontinued':
|
||||||
|
product_kit.status = 'discontinued'
|
||||||
|
product_kit.save(update_fields=['status'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'document_id': document.id,
|
||||||
|
'document_number': document.document_number,
|
||||||
|
'items_count': items_count,
|
||||||
|
'message': f'Создан документ {document.document_number} с {items_count} позициями'
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': f'Ошибка списания: {str(e)}'
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_showcase_items_for_pos(showcase=None):
|
def get_showcase_items_for_pos(showcase=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
|
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
|
||||||
# Проверяем, есть ли уже Sale для этого заказа
|
# Проверяем, есть ли уже Sale для этого заказа
|
||||||
if Sale.objects.filter(order=instance).exists():
|
if Sale.objects.filter(order=instance).exists():
|
||||||
logger.info(f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем")
|
logger.info(f"Заказ {instance.order_number}: Sale уже существуют, пропускаем")
|
||||||
update_is_returned_flag(instance)
|
update_is_returned_flag(instance)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
previous_status = getattr(instance, '_previous_status', None)
|
previous_status = getattr(instance, '_previous_status', None)
|
||||||
if previous_status and previous_status.is_positive_end:
|
if previous_status and previous_status.is_positive_end:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
|
f"Заказ {instance.order_number}: повторный переход в положительный статус "
|
||||||
f"({previous_status.name} → {instance.status.name}). Проверяем Sale..."
|
f"({previous_status.name} → {instance.status.name}). Проверяем Sale..."
|
||||||
)
|
)
|
||||||
if Sale.objects.filter(order=instance).exists():
|
if Sale.objects.filter(order=instance).exists():
|
||||||
@@ -454,12 +454,65 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# === РАСЧЕТ ЦЕНЫ ===
|
||||||
|
# Рассчитываем фактическую стоимость продажи всего комплекта с учетом скидок
|
||||||
|
# 1. Базовая стоимость позиции
|
||||||
|
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
|
||||||
|
|
||||||
|
# 2. Скидки
|
||||||
|
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
|
||||||
|
|
||||||
|
# Скидка на заказ (распределенная)
|
||||||
|
instance.refresh_from_db()
|
||||||
|
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
|
||||||
|
delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0')
|
||||||
|
order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
|
||||||
|
|
||||||
|
if order_total > 0 and order_discount_amount > 0:
|
||||||
|
item_order_discount = order_discount_amount * (item_subtotal / order_total)
|
||||||
|
else:
|
||||||
|
item_order_discount = Decimal('0')
|
||||||
|
|
||||||
|
kit_net_total = item_subtotal - item_discount - item_order_discount
|
||||||
|
if kit_net_total < 0:
|
||||||
|
kit_net_total = Decimal('0')
|
||||||
|
|
||||||
|
# 3. Суммарная каталожная стоимость всех компонентов (для пропорции)
|
||||||
|
total_catalog_price = Decimal('0')
|
||||||
|
for reservation in kit_reservations:
|
||||||
|
qty = reservation.quantity_base or reservation.quantity
|
||||||
|
price = reservation.product.actual_price or Decimal('0')
|
||||||
|
total_catalog_price += price * qty
|
||||||
|
|
||||||
|
# 4. Коэффициент распределения
|
||||||
|
if total_catalog_price > 0:
|
||||||
|
ratio = kit_net_total / total_catalog_price
|
||||||
|
else:
|
||||||
|
# Если каталожная цена 0, распределяем просто по количеству или 0
|
||||||
|
ratio = Decimal('0')
|
||||||
|
|
||||||
# Создаем Sale для каждого компонента комплекта
|
# Создаем Sale для каждого компонента комплекта
|
||||||
for reservation in kit_reservations:
|
for reservation in kit_reservations:
|
||||||
try:
|
try:
|
||||||
# Рассчитываем цену продажи компонента пропорционально цене комплекта
|
# Рассчитываем цену продажи компонента пропорционально
|
||||||
# Используем actual_price компонента как цену продажи
|
catalog_price = reservation.product.actual_price or Decimal('0')
|
||||||
component_sale_price = reservation.product.actual_price
|
|
||||||
|
if ratio > 0:
|
||||||
|
# Распределяем реальную выручку
|
||||||
|
component_sale_price = catalog_price * ratio
|
||||||
|
else:
|
||||||
|
# Если выручка 0 или каталожные цены 0
|
||||||
|
if total_catalog_price == 0 and kit_net_total > 0:
|
||||||
|
# Крайний случай: товаров на 0 руб, а продали за деньги (услуга?)
|
||||||
|
# Распределяем равномерно
|
||||||
|
count = kit_reservations.count()
|
||||||
|
component_qty = reservation.quantity_base or reservation.quantity
|
||||||
|
if count > 0 and component_qty > 0:
|
||||||
|
component_sale_price = (kit_net_total / count) / component_qty
|
||||||
|
else:
|
||||||
|
component_sale_price = Decimal('0')
|
||||||
|
else:
|
||||||
|
component_sale_price = Decimal('0')
|
||||||
|
|
||||||
sale = SaleProcessor.create_sale(
|
sale = SaleProcessor.create_sale(
|
||||||
product=reservation.product,
|
product=reservation.product,
|
||||||
@@ -472,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
sales_created.append(sale)
|
sales_created.append(sale)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✓ Sale создан для компонента комплекта '{kit.name}': "
|
f"✓ Sale создан для компонента комплекта '{kit.name}': "
|
||||||
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. (базовых единиц)"
|
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. "
|
||||||
|
f"(цена: {component_sale_price})"
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -548,6 +602,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
else:
|
else:
|
||||||
base_price = price_with_discount
|
base_price = price_with_discount
|
||||||
|
|
||||||
|
# LOGGING DEBUG INFO
|
||||||
|
# print(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
|
||||||
|
# print(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
|
||||||
|
# print(f" Item Discount: {item_discount}, Order Discount Share: {item_order_discount}, Total Discount: {total_discount}")
|
||||||
|
# print(f" Price w/ Discount: {price_with_discount}")
|
||||||
|
# print(f" Sales Unit: {item.sales_unit}, Conversion: {item.conversion_factor_snapshot}")
|
||||||
|
# print(f" FINAL BASE PRICE: {base_price}")
|
||||||
|
# print(f" Sales Unit Object: {item.sales_unit}")
|
||||||
|
# if item.sales_unit:
|
||||||
|
# print(f" Sales Unit Conversion: {item.sales_unit.conversion_factor}")
|
||||||
|
|
||||||
|
logger.info(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
|
||||||
|
logger.info(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
|
||||||
|
logger.info(f" FINAL BASE PRICE: {base_price}")
|
||||||
|
|
||||||
# Создаем Sale (с автоматическим FIFO-списанием)
|
# Создаем Sale (с автоматическим FIFO-списанием)
|
||||||
sale = SaleProcessor.create_sale(
|
sale = SaleProcessor.create_sale(
|
||||||
product=product,
|
product=product,
|
||||||
|
|||||||
@@ -74,10 +74,12 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
<a href="{% url 'products:product-detail' item.product.id %}">{{
|
||||||
|
item.product.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
|
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
|
||||||
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
|
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.
|
||||||
|
</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -132,9 +134,11 @@
|
|||||||
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
||||||
</a>
|
</a>
|
||||||
|
<!--
|
||||||
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
|
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
|
||||||
<i class="bi bi-trash me-1"></i>Удалить
|
<i class="bi bi-trash me-1"></i>Удалить
|
||||||
</a>
|
</a>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,9 +39,11 @@
|
|||||||
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<!--
|
||||||
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</a>
|
</a>
|
||||||
|
-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# Initialize environment variables
|
# Initialize environment variables
|
||||||
env = environ.Env(
|
env = environ.Env(
|
||||||
# Set casting and default values
|
# Set casting and default values
|
||||||
DEBUG=(bool, False), # Security: default False
|
DEBUG=(bool, True), # Debug mode enabled
|
||||||
SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'),
|
SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY:
|
|||||||
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
|
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
|
||||||
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ def order_create(request):
|
|||||||
order.recipient = None
|
order.recipient = None
|
||||||
|
|
||||||
# Статус берём из формы (в том числе может быть "Черновик")
|
# Статус берём из формы (в том числе может быть "Черновик")
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
if isinstance(request.user, CustomUser):
|
||||||
order.modified_by = request.user
|
order.modified_by = request.user
|
||||||
|
else:
|
||||||
|
order.modified_by = None
|
||||||
|
|
||||||
# Сохраняем заказ в БД (теперь у него есть pk)
|
# Сохраняем заказ в БД (теперь у него есть pk)
|
||||||
order.save()
|
order.save()
|
||||||
@@ -367,11 +371,16 @@ def order_update(request, order_number):
|
|||||||
# Сохраняем получателя: если новый - создаем, если существующий - обновляем
|
# Сохраняем получателя: если новый - создаем, если существующий - обновляем
|
||||||
recipient.save() # Django автоматически определит create или update
|
recipient.save() # Django автоматически определит create или update
|
||||||
order.recipient = recipient
|
order.recipient = recipient
|
||||||
else:
|
|
||||||
# Если покупатель является получателем
|
# Если покупатель является получателем
|
||||||
order.recipient = None
|
order.recipient = None
|
||||||
|
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
if isinstance(request.user, CustomUser):
|
||||||
order.modified_by = request.user
|
order.modified_by = request.user
|
||||||
|
else:
|
||||||
|
# Если это админ платформы, не перезаписываем поле (оставляем как есть)
|
||||||
|
pass
|
||||||
|
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
editingCartKey = cartKey;
|
editingCartKey = cartKey;
|
||||||
basePrice = parseFloat(item.price) || 0;
|
basePrice = parseFloat(item.price) || 0;
|
||||||
|
|
||||||
|
// Проверяем, является ли товар витринным комплектом
|
||||||
|
const isShowcaseKit = item.type === 'showcase_kit';
|
||||||
|
|
||||||
// Заполнение полей
|
// Заполнение полей
|
||||||
document.getElementById('editModalProductName').textContent = item.name || '—';
|
document.getElementById('editModalProductName').textContent = item.name || '—';
|
||||||
|
|
||||||
@@ -48,6 +51,17 @@
|
|||||||
document.getElementById('editModalPrice').value = roundPrice(basePrice);
|
document.getElementById('editModalPrice').value = roundPrice(basePrice);
|
||||||
document.getElementById('editModalQuantity').value = item.qty || 1;
|
document.getElementById('editModalQuantity').value = item.qty || 1;
|
||||||
|
|
||||||
|
// Для витринных комплектов блокируем изменение количества
|
||||||
|
const qtyInput = document.getElementById('editModalQuantity');
|
||||||
|
const qtyHint = document.getElementById('editModalQtyHint');
|
||||||
|
if (isShowcaseKit) {
|
||||||
|
qtyInput.disabled = true;
|
||||||
|
qtyHint.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
qtyInput.disabled = false;
|
||||||
|
qtyHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Бейдж единицы измерения
|
// Бейдж единицы измерения
|
||||||
const unitBadge = document.getElementById('editModalUnitBadge');
|
const unitBadge = document.getElementById('editModalUnitBadge');
|
||||||
if (item.unit_name) {
|
if (item.unit_name) {
|
||||||
@@ -99,8 +113,13 @@
|
|||||||
// Используем roundQuantity из terminal.js
|
// Используем roundQuantity из terminal.js
|
||||||
const rndQty = typeof roundQuantity === 'function' ? roundQuantity : (v, d) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d);
|
const rndQty = typeof roundQuantity === 'function' ? roundQuantity : (v, d) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d);
|
||||||
|
|
||||||
|
const isShowcaseKit = item.type === 'showcase_kit';
|
||||||
|
|
||||||
item.price = newPrice;
|
item.price = newPrice;
|
||||||
|
// Для витринных комплектов не меняем количество
|
||||||
|
if (!isShowcaseKit) {
|
||||||
item.qty = rndQty(newQty, 3);
|
item.qty = rndQty(newQty, 3);
|
||||||
|
}
|
||||||
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
||||||
|
|
||||||
window.cart.set(editingCartKey, item);
|
window.cart.set(editingCartKey, item);
|
||||||
|
|||||||
@@ -98,6 +98,37 @@ function formatMoney(v) {
|
|||||||
return (Number(v)).toFixed(2);
|
return (Number(v)).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату как относительное время в русском языке
|
||||||
|
* @param {string|null} isoDate - ISO дата или null
|
||||||
|
* @returns {string} - "0 дней", "1 день", "2 дня", "5 дней", и т.д.
|
||||||
|
*/
|
||||||
|
function formatDaysAgo(isoDate) {
|
||||||
|
if (!isoDate) return '';
|
||||||
|
|
||||||
|
const created = new Date(isoDate);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - created;
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Русские формы множественного числа
|
||||||
|
const lastTwo = diffDays % 100;
|
||||||
|
const lastOne = diffDays % 10;
|
||||||
|
|
||||||
|
let suffix;
|
||||||
|
if (lastTwo >= 11 && lastTwo <= 19) {
|
||||||
|
suffix = 'дней';
|
||||||
|
} else if (lastOne === 1) {
|
||||||
|
suffix = 'день';
|
||||||
|
} else if (lastOne >= 2 && lastOne <= 4) {
|
||||||
|
suffix = 'дня';
|
||||||
|
} else {
|
||||||
|
suffix = 'дней';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${diffDays} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
|
// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -911,7 +942,7 @@ function renderProducts() {
|
|||||||
const stock = document.createElement('div');
|
const stock = document.createElement('div');
|
||||||
stock.className = 'product-stock';
|
stock.className = 'product-stock';
|
||||||
|
|
||||||
// Для витринных комплектов показываем название витрины И количество (доступно/всего)
|
// Для витринных комплектов показываем количество (доступно/всего) и дней на витрине
|
||||||
if (item.type === 'showcase_kit') {
|
if (item.type === 'showcase_kit') {
|
||||||
const availableCount = item.available_count || 0;
|
const availableCount = item.available_count || 0;
|
||||||
const totalCount = item.total_count || availableCount;
|
const totalCount = item.total_count || availableCount;
|
||||||
@@ -922,7 +953,14 @@ function renderProducts() {
|
|||||||
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
|
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
|
||||||
let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
|
let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
|
||||||
|
|
||||||
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge ${badgeClass} ms-1">${badgeText}</span>${cartInfo}`;
|
// Добавляем отображение дней с момента создания как бейдж справа
|
||||||
|
const daysAgo = formatDaysAgo(item.showcase_created_at);
|
||||||
|
const daysBadge = daysAgo ? ` <span class="badge bg-info ms-auto">${daysAgo}</span>` : '';
|
||||||
|
|
||||||
|
stock.innerHTML = `<span class="badge ${badgeClass}" style="font-size: 0.9rem;">${badgeText}</span>${daysBadge}${cartInfo}`;
|
||||||
|
stock.style.display = 'flex';
|
||||||
|
stock.style.justifyContent = 'space-between';
|
||||||
|
stock.style.alignItems = 'center';
|
||||||
stock.style.color = '#856404';
|
stock.style.color = '#856404';
|
||||||
stock.style.fontWeight = 'bold';
|
stock.style.fontWeight = 'bold';
|
||||||
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
|
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
|
||||||
@@ -1783,8 +1821,8 @@ async function openCreateTempKitModal() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Генерируем название по умолчанию
|
// Генерируем название по умолчанию
|
||||||
const now = new Date();
|
const randomSuffix = Math.floor(Math.random() * 900) + 100;
|
||||||
const defaultName = `Витрина — ${now.toLocaleDateString('ru-RU')} ${now.toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'})}`;
|
const defaultName = `Витринный букет ${randomSuffix}`;
|
||||||
document.getElementById('tempKitName').value = defaultName;
|
document.getElementById('tempKitName').value = defaultName;
|
||||||
|
|
||||||
// Загружаем список витрин
|
// Загружаем список витрин
|
||||||
@@ -1837,6 +1875,19 @@ async function openEditKitModal(kitId) {
|
|||||||
// Заполняем поля формы
|
// Заполняем поля формы
|
||||||
document.getElementById('tempKitName').value = kit.name;
|
document.getElementById('tempKitName').value = kit.name;
|
||||||
document.getElementById('tempKitDescription').value = kit.description;
|
document.getElementById('tempKitDescription').value = kit.description;
|
||||||
|
|
||||||
|
// Заполняем поле даты размещения на витрине
|
||||||
|
if (kit.showcase_created_at) {
|
||||||
|
// Конвертируем ISO в формат datetime-local (YYYY-MM-DDTHH:MM)
|
||||||
|
const date = new Date(kit.showcase_created_at);
|
||||||
|
// Компенсация смещения часового пояса
|
||||||
|
const offset = date.getTimezoneOffset() * 60000;
|
||||||
|
const localDate = new Date(date.getTime() - offset);
|
||||||
|
document.getElementById('showcaseCreatedAt').value = localDate.toISOString().slice(0, 16);
|
||||||
|
} else {
|
||||||
|
document.getElementById('showcaseCreatedAt').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type;
|
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type;
|
||||||
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value;
|
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value;
|
||||||
|
|
||||||
@@ -1872,6 +1923,7 @@ async function openEditKitModal(kitId) {
|
|||||||
|
|
||||||
// По<D09F><D0BE>азываем кнопку "Разобрать" и блок добавления товаров
|
// По<D09F><D0BE>азываем кнопку "Разобрать" и блок добавления товаров
|
||||||
document.getElementById('disassembleKitBtn').style.display = 'block';
|
document.getElementById('disassembleKitBtn').style.display = 'block';
|
||||||
|
document.getElementById('writeOffKitBtn').style.display = 'block';
|
||||||
document.getElementById('showcaseKitQuantityBlock').style.display = 'none';
|
document.getElementById('showcaseKitQuantityBlock').style.display = 'none';
|
||||||
document.getElementById('addProductBlock').style.display = 'block';
|
document.getElementById('addProductBlock').style.display = 'block';
|
||||||
|
|
||||||
@@ -2275,6 +2327,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
const kitName = document.getElementById('tempKitName').value.trim();
|
const kitName = document.getElementById('tempKitName').value.trim();
|
||||||
const showcaseId = document.getElementById('showcaseSelect').value;
|
const showcaseId = document.getElementById('showcaseSelect').value;
|
||||||
const description = document.getElementById('tempKitDescription').value.trim();
|
const description = document.getElementById('tempKitDescription').value.trim();
|
||||||
|
const showcaseCreatedAt = document.getElementById('showcaseCreatedAt').value;
|
||||||
const photoFile = document.getElementById('tempKitPhoto').files[0];
|
const photoFile = document.getElementById('tempKitPhoto').files[0];
|
||||||
|
|
||||||
// Валидация
|
// Валидация
|
||||||
@@ -2329,13 +2382,15 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
|
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
|
||||||
}
|
}
|
||||||
formData.append('description', description);
|
formData.append('description', description);
|
||||||
|
if (showcaseCreatedAt) {
|
||||||
|
formData.append('showcase_created_at', showcaseCreatedAt);
|
||||||
|
}
|
||||||
formData.append('items', JSON.stringify(items));
|
formData.append('items', JSON.stringify(items));
|
||||||
formData.append('price_adjustment_type', priceAdjustmentType);
|
formData.append('price_adjustment_type', priceAdjustmentType);
|
||||||
formData.append('price_adjustment_value', priceAdjustmentValue);
|
formData.append('price_adjustment_value', priceAdjustmentValue);
|
||||||
// Если пользователь не задал свою цену, используем вычисленную
|
// Если пользователь явно указал свою цену
|
||||||
const finalSalePrice = useSalePrice ? salePrice : calculatedPrice;
|
if (useSalePrice && salePrice > 0) {
|
||||||
if (finalSalePrice > 0) {
|
formData.append('sale_price', salePrice);
|
||||||
formData.append('sale_price', finalSalePrice);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фото: для редактирования проверяем, удалено ли оно
|
// Фото: для редактирования проверяем, удалено ли оно
|
||||||
@@ -2398,6 +2453,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
|
|
||||||
// Сбрасываем поля формы
|
// Сбрасываем поля формы
|
||||||
document.getElementById('tempKitDescription').value = '';
|
document.getElementById('tempKitDescription').value = '';
|
||||||
|
document.getElementById('showcaseCreatedAt').value = '';
|
||||||
document.getElementById('tempKitPhoto').value = '';
|
document.getElementById('tempKitPhoto').value = '';
|
||||||
document.getElementById('photoPreview').style.display = 'none';
|
document.getElementById('photoPreview').style.display = 'none';
|
||||||
document.getElementById('priceAdjustmentType').value = 'none';
|
document.getElementById('priceAdjustmentType').value = 'none';
|
||||||
@@ -2498,6 +2554,53 @@ document.getElementById('disassembleKitBtn').addEventListener('click', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Обработчик кнопки "Списать букет"
|
||||||
|
document.getElementById('writeOffKitBtn').addEventListener('click', async () => {
|
||||||
|
if (!isEditMode || !editingKitId) {
|
||||||
|
alert('Ошибка: режим редактирования не активен');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос подтверждения
|
||||||
|
const confirmed = confirm(
|
||||||
|
'Вы уверены?\n\n' +
|
||||||
|
'Букет будет списан:\n' +
|
||||||
|
'• Будет создан документ списания с компонентами букета\n' +
|
||||||
|
'• Комплект будет помечен как "Снят"\n' +
|
||||||
|
'• Будет открыта страница документа для редактирования\n\n' +
|
||||||
|
'Продолжить?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/pos/api/product-kits/${editingKitId}/write-off/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Закрываем модальное окно
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Перенаправляем на страницу документа
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
} else {
|
||||||
|
alert(`❌ Ошибка: ${data.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error writing off kit:', error);
|
||||||
|
alert('Произошла ошибка при списании букета');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Вспомогательная функция для определения мобильного устройства
|
// Вспомогательная функция для определения мобильного устройства
|
||||||
function isMobileDevice() {
|
function isMobileDevice() {
|
||||||
// Проверяем по юзер-агенту и размеру экрана
|
// Проверяем по юзер-агенту и размеру экрана
|
||||||
@@ -2560,8 +2663,9 @@ document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal'
|
|||||||
document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины';
|
document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины';
|
||||||
document.getElementById('confirmCreateTempKit').innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
|
document.getElementById('confirmCreateTempKit').innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
|
||||||
|
|
||||||
// Скрываем кнопку "Разобрать" и блок добавления товаров
|
// Скрываем кнопки "Разобрать" и "Списать" и блок добавления товаров
|
||||||
document.getElementById('disassembleKitBtn').style.display = 'none';
|
document.getElementById('disassembleKitBtn').style.display = 'none';
|
||||||
|
document.getElementById('writeOffKitBtn').style.display = 'none';
|
||||||
document.getElementById('showcaseKitQuantityBlock').style.display = 'block';
|
document.getElementById('showcaseKitQuantityBlock').style.display = 'block';
|
||||||
document.getElementById('addProductBlock').style.display = 'none';
|
document.getElementById('addProductBlock').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
<label for="editModalQuantity" class="form-label fw-semibold">Количество</label>
|
<label for="editModalQuantity" class="form-label fw-semibold">Количество</label>
|
||||||
<input type="number" class="form-control" id="editModalQuantity"
|
<input type="number" class="form-control" id="editModalQuantity"
|
||||||
min="0.001" step="0.001" value="1">
|
min="0.001" step="0.001" value="1">
|
||||||
|
<div id="editModalQtyHint" class="text-muted small mt-1" style="display: none;">
|
||||||
|
<i class="bi bi-info-circle"></i> Количество нельзя изменить для витринного комплекта (собранный товар с резервами)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Итого -->
|
<!-- Итого -->
|
||||||
|
|||||||
@@ -219,6 +219,14 @@
|
|||||||
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
|
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Дата размещения на витрине -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="showcaseCreatedAt" class="form-label">Дата размещения на витрине</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="showcaseCreatedAt"
|
||||||
|
placeholder="Выберите дату и время">
|
||||||
|
<small class="text-muted">Оставьте пустым для текущего времени</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Загрузка фото -->
|
<!-- Загрузка фото -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
|
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
|
||||||
@@ -322,6 +330,11 @@
|
|||||||
<i class="bi bi-scissors"></i> Разобрать букет
|
<i class="bi bi-scissors"></i> Разобрать букет
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Кнопка "Списать" (отображается только в режиме редактирования) -->
|
||||||
|
<button type="button" class="btn btn-warning me-auto" id="writeOffKitBtn" style="display: none;">
|
||||||
|
<i class="bi bi-file-earmark-x"></i> Списать букет
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Правая группа кнопок -->
|
<!-- Правая группа кнопок -->
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
|
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ urlpatterns = [
|
|||||||
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
|
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
|
||||||
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
|
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
|
||||||
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
||||||
|
# Списать витринный комплект (создать документ списания с компонентами) [POST]
|
||||||
|
path('api/product-kits/<int:kit_id>/write-off/', views.write_off_showcase_kit, name='write-off-showcase-kit'),
|
||||||
# Создать временный комплект и зарезервировать на витрину [POST]
|
# Создать временный комплект и зарезервировать на витрину [POST]
|
||||||
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
|
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
|
||||||
# Создать заказ и провести оплату в POS [POST]
|
# Создать заказ и провести оплату в POS [POST]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
@@ -13,7 +14,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from products.models import Product, ProductCategory, ProductKit, KitItem
|
from products.models import Product, ProductCategory, ProductKit, KitItem
|
||||||
from inventory.models import Showcase, Reservation, Warehouse, Stock
|
from inventory.models import Showcase, Reservation, Warehouse, Stock, ShowcaseItem
|
||||||
from inventory.services import ShowcaseManager
|
from inventory.services import ShowcaseManager
|
||||||
from inventory.signals import skip_sale_creation, reset_sale_creation
|
from inventory.signals import skip_sale_creation, reset_sale_creation
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ def get_showcase_kits_for_pos():
|
|||||||
'product_kit__price',
|
'product_kit__price',
|
||||||
'product_kit__sale_price',
|
'product_kit__sale_price',
|
||||||
'product_kit__base_price',
|
'product_kit__base_price',
|
||||||
|
'product_kit__showcase_created_at',
|
||||||
'showcase_id',
|
'showcase_id',
|
||||||
'showcase__name'
|
'showcase__name'
|
||||||
).annotate(
|
).annotate(
|
||||||
@@ -161,7 +163,9 @@ def get_showcase_kits_for_pos():
|
|||||||
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
|
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
|
||||||
'showcase_item_ids': available_item_ids, # IDs только доступных
|
'showcase_item_ids': available_item_ids, # IDs только доступных
|
||||||
# Флаг неактуальной цены
|
# Флаг неактуальной цены
|
||||||
'price_outdated': price_outdated
|
'price_outdated': price_outdated,
|
||||||
|
# Дата размещения на витрине
|
||||||
|
'showcase_created_at': item.get('product_kit__showcase_created_at')
|
||||||
})
|
})
|
||||||
|
|
||||||
return showcase_kits
|
return showcase_kits
|
||||||
@@ -1052,7 +1056,8 @@ def get_product_kit_details(request, kit_id):
|
|||||||
'final_price': str(kit.actual_price),
|
'final_price': str(kit.actual_price),
|
||||||
'showcase_id': showcase_id,
|
'showcase_id': showcase_id,
|
||||||
'items': items,
|
'items': items,
|
||||||
'photo_url': photo_url
|
'photo_url': photo_url,
|
||||||
|
'showcase_created_at': kit.showcase_created_at.isoformat() if kit.showcase_created_at else None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except ProductKit.DoesNotExist:
|
except ProductKit.DoesNotExist:
|
||||||
@@ -1087,6 +1092,7 @@ def create_temp_kit_to_showcase(request):
|
|||||||
sale_price_str = request.POST.get('sale_price', '')
|
sale_price_str = request.POST.get('sale_price', '')
|
||||||
photo_file = request.FILES.get('photo')
|
photo_file = request.FILES.get('photo')
|
||||||
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
|
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
|
||||||
|
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||||
|
|
||||||
# Парсим items из JSON
|
# Парсим items из JSON
|
||||||
items = json.loads(items_json)
|
items = json.loads(items_json)
|
||||||
@@ -1101,6 +1107,23 @@ def create_temp_kit_to_showcase(request):
|
|||||||
except (ValueError, InvalidOperation):
|
except (ValueError, InvalidOperation):
|
||||||
sale_price = None
|
sale_price = None
|
||||||
|
|
||||||
|
# Showcase created at (опционально)
|
||||||
|
showcase_created_at = None
|
||||||
|
if showcase_created_at_str:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
pass # Неверный формат, оставляем как None
|
||||||
|
|
||||||
|
# Если не указана - устанавливаем текущее время для новых комплектов
|
||||||
|
if not showcase_created_at:
|
||||||
|
showcase_created_at = timezone.now()
|
||||||
|
|
||||||
# Валидация
|
# Валидация
|
||||||
if not kit_name:
|
if not kit_name:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
@@ -1161,7 +1184,8 @@ def create_temp_kit_to_showcase(request):
|
|||||||
price_adjustment_type=price_adjustment_type,
|
price_adjustment_type=price_adjustment_type,
|
||||||
price_adjustment_value=price_adjustment_value,
|
price_adjustment_value=price_adjustment_value,
|
||||||
sale_price=sale_price,
|
sale_price=sale_price,
|
||||||
showcase=showcase
|
showcase=showcase,
|
||||||
|
showcase_created_at=showcase_created_at
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Создаём KitItem для каждого товара из корзины
|
# 2. Создаём KitItem для каждого товара из корзины
|
||||||
@@ -1296,6 +1320,7 @@ def update_product_kit(request, kit_id):
|
|||||||
sale_price_str = request.POST.get('sale_price', '')
|
sale_price_str = request.POST.get('sale_price', '')
|
||||||
photo_file = request.FILES.get('photo')
|
photo_file = request.FILES.get('photo')
|
||||||
remove_photo = request.POST.get('remove_photo', '') == '1'
|
remove_photo = request.POST.get('remove_photo', '') == '1'
|
||||||
|
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||||
|
|
||||||
items = json.loads(items_json)
|
items = json.loads(items_json)
|
||||||
|
|
||||||
@@ -1308,6 +1333,23 @@ def update_product_kit(request, kit_id):
|
|||||||
except (ValueError, InvalidOperation):
|
except (ValueError, InvalidOperation):
|
||||||
sale_price = None
|
sale_price = None
|
||||||
|
|
||||||
|
# Showcase created at (опционально)
|
||||||
|
showcase_created_at = None
|
||||||
|
if showcase_created_at_str:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
pass # Неверный формат, оставляем как есть
|
||||||
|
|
||||||
|
# Делаем datetime timezone-aware
|
||||||
|
if showcase_created_at and showcase_created_at.tzinfo is None:
|
||||||
|
from django.utils import timezone
|
||||||
|
showcase_created_at = timezone.make_aware(showcase_created_at)
|
||||||
|
|
||||||
# Валидация
|
# Валидация
|
||||||
if not kit_name:
|
if not kit_name:
|
||||||
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
|
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
|
||||||
@@ -1381,6 +1423,8 @@ def update_product_kit(request, kit_id):
|
|||||||
kit.price_adjustment_type = price_adjustment_type
|
kit.price_adjustment_type = price_adjustment_type
|
||||||
kit.price_adjustment_value = price_adjustment_value
|
kit.price_adjustment_value = price_adjustment_value
|
||||||
kit.sale_price = sale_price
|
kit.sale_price = sale_price
|
||||||
|
if showcase_created_at is not None: # Обновляем только если передана
|
||||||
|
kit.showcase_created_at = showcase_created_at
|
||||||
kit.save()
|
kit.save()
|
||||||
|
|
||||||
# Обновляем состав
|
# Обновляем состав
|
||||||
@@ -1493,6 +1537,88 @@ def disassemble_product_kit(request, kit_id):
|
|||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def write_off_showcase_kit(request, kit_id):
|
||||||
|
"""
|
||||||
|
Списывает витринный комплект с созданием документа списания.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP запрос
|
||||||
|
kit_id: ID комплекта для списания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
'success': bool,
|
||||||
|
'document_id': int,
|
||||||
|
'document_number': str,
|
||||||
|
'redirect_url': str,
|
||||||
|
'message': str,
|
||||||
|
'error': str (если failed)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Получаем комплект с витриной (только временные комплекты)
|
||||||
|
kit = ProductKit.objects.select_related('showcase').get(id=kit_id, is_temporary=True)
|
||||||
|
|
||||||
|
# Проверяем, что комплект ещё не разобран
|
||||||
|
if kit.status == 'discontinued':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект уже разобран (статус: Снят)'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Проверяем, что у комплекта есть привязанная витрина
|
||||||
|
if not kit.showcase:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект не привязан к витрине'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Находим экземпляр на витрине
|
||||||
|
showcase_item = ShowcaseItem.objects.filter(
|
||||||
|
showcase=kit.showcase,
|
||||||
|
product_kit=kit,
|
||||||
|
status='available'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not showcase_item:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Экземпляр комплекта не найден на витрине'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Создаём документ списания
|
||||||
|
result = ShowcaseManager.write_off_from_showcase(
|
||||||
|
showcase_item=showcase_item,
|
||||||
|
reason='spoilage',
|
||||||
|
notes=f'Витринный букет: {kit.name}',
|
||||||
|
created_by=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result['success']:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': result['message']
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Формируем URL для перенаправления
|
||||||
|
redirect_url = reverse('inventory:writeoff-document-detail', kwargs={'pk': result['document_id']})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'document_id': result['document_id'],
|
||||||
|
'document_number': result['document_number'],
|
||||||
|
'redirect_url': redirect_url,
|
||||||
|
'message': result['message']
|
||||||
|
})
|
||||||
|
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def pos_checkout(request):
|
def pos_checkout(request):
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2026-01-23 22:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0002_bouquetname'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='productkit',
|
||||||
|
name='showcase_created_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Дата создания букета для витрины (редактируемая)', null=True, verbose_name='Дата размещения на витрине'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -93,6 +93,14 @@ class ProductKit(BaseProductEntity):
|
|||||||
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
|
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Showcase creation date - editable date for when the bouquet was put on display
|
||||||
|
showcase_created_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Дата размещения на витрине",
|
||||||
|
help_text="Дата создания букета для витрины (редактируемая)"
|
||||||
|
)
|
||||||
|
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
'orders.Order',
|
'orders.Order',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -332,17 +340,6 @@ class ProductKit(BaseProductEntity):
|
|||||||
self.save(update_fields=['is_temporary', 'order'])
|
self.save(update_fields=['is_temporary', 'order'])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
"""Soft delete вместо hard delete - марк как удаленный"""
|
|
||||||
self.is_deleted = True
|
|
||||||
self.deleted_at = timezone.now()
|
|
||||||
self.save(update_fields=['is_deleted', 'deleted_at'])
|
|
||||||
return 1, {self.__class__._meta.label: 1}
|
|
||||||
|
|
||||||
def hard_delete(self):
|
|
||||||
"""Полное удаление из БД (необратимо!)"""
|
|
||||||
super().delete()
|
|
||||||
|
|
||||||
def create_snapshot(self):
|
def create_snapshot(self):
|
||||||
"""
|
"""
|
||||||
Создает снимок текущего состояния комплекта.
|
Создает снимок текущего состояния комплекта.
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ class BouquetNameGenerator(BaseAIProductService):
|
|||||||
"Избегайте общих терминов. Фокусируйтесь на эмоциях, эстетике"
|
"Избегайте общих терминов. Фокусируйтесь на эмоциях, эстетике"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Константы
|
||||||
|
MAX_TOKENS_GENERATION = 3000
|
||||||
|
DEFAULT_COUNT = 500
|
||||||
|
MAX_GENERATION_COUNT = 1000
|
||||||
|
SKIP_PREFIXES = {'here', 'names', "i'm", 'sorry', 'i hope', 'hope'}
|
||||||
|
|
||||||
def generate(
|
def generate(
|
||||||
self,
|
self,
|
||||||
count: int = 500,
|
count: int = 500,
|
||||||
@@ -38,17 +44,17 @@ class BouquetNameGenerator(BaseAIProductService):
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple: (success, message, data) где data содержит список названий
|
Tuple: (success, message, data) где data содержит список названий
|
||||||
"""
|
"""
|
||||||
|
# Валидация параметров
|
||||||
|
if count > self.MAX_GENERATION_COUNT:
|
||||||
|
count = self.MAX_GENERATION_COUNT
|
||||||
|
logger.warning(f"Count reduced to {self.MAX_GENERATION_COUNT}")
|
||||||
|
|
||||||
logger.info(f"Генерация {count} названий для букетов")
|
logger.info(f"Генерация {count} названий для букетов")
|
||||||
|
|
||||||
# Получаем доступный AI-сервис
|
# Получаем доступный AI-сервис
|
||||||
glm_service = self.get_glm_service()
|
service = self.get_glm_service() or self.get_openrouter_service()
|
||||||
if not glm_service:
|
if not service:
|
||||||
openrouter_service = self.get_openrouter_service()
|
|
||||||
if not openrouter_service:
|
|
||||||
return False, "Нет активных AI-интеграций", None
|
return False, "Нет активных AI-интеграций", None
|
||||||
service = openrouter_service
|
|
||||||
else:
|
|
||||||
service = glm_service
|
|
||||||
|
|
||||||
# Формируем промпт
|
# Формируем промпт
|
||||||
prompt = f"Сгенерируй {count} креативных и привлекательных названий для букетов цветов"
|
prompt = f"Сгенерируй {count} креативных и привлекательных названий для букетов цветов"
|
||||||
@@ -61,12 +67,18 @@ class BouquetNameGenerator(BaseAIProductService):
|
|||||||
|
|
||||||
prompt += (
|
prompt += (
|
||||||
"\n\nТребования к каждому названию:\n"
|
"\n\nТребования к каждому названию:\n"
|
||||||
"- Точно 2-4 слова\n"
|
"- 2, 3 или 4 слова в равных пропорциях\n"
|
||||||
"- Выразительные и эмоциональные\n"
|
"- Выразительные и эмоциональные\n"
|
||||||
"- Продаваемые и запоминающиеся\n"
|
"- Продаваемые и запоминающиеся\n"
|
||||||
"- Избегайте общих названий типа 'Букет #1'\n"
|
"- Избегайте общих названий типа 'Букет #1'\n"
|
||||||
"- Фокусируйтесь на красоте, романтике и подарках\n"
|
"- Фокусируйтесь на красоте, романтике и подарках\n"
|
||||||
"\nВерните названия в виде нумерованного списка, по одному на строку."
|
"- Используйте прилагательные и описательные слова\n"
|
||||||
|
"- Не используйте символы пунктуации в середине названий\n"
|
||||||
|
"\nВерните названия в виде нумерованного списка, по одному на строку.\n"
|
||||||
|
"Примеры хороших названий:\n"
|
||||||
|
"- 2 слова: 'Весенние Розы', 'Летнее Сияние', 'Нежность', 'Романтика'\n"
|
||||||
|
"- 3 слова: 'Весенний Вальс', 'Нежность Роз', 'Сияние Любви', 'Танец Цветов'\n"
|
||||||
|
"- 4 слова: 'Шепот Весенней Нежности', 'Сияние Розовой Любви', 'Танец Цветов Весны', 'Шёпот Сердечной Романтики'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Вызов AI-сервиса
|
# Вызов AI-сервиса
|
||||||
@@ -98,9 +110,7 @@ class BouquetNameGenerator(BaseAIProductService):
|
|||||||
for line in lines:
|
for line in lines:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
# Пропускаем пустые строки и заголовки
|
# Пропускаем пустые строки и заголовки
|
||||||
if not line or line.lower().startswith('here') or line.lower().startswith('names') or \
|
if not line or any(line.lower().startswith(prefix) for prefix in self.SKIP_PREFIXES):
|
||||||
line.lower().startswith('i\'m') or line.lower().startswith('sorry') or \
|
|
||||||
line.lower().startswith('i hope') or line.lower().startswith('hope'):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Удаляем номера списка
|
# Удаляем номера списка
|
||||||
@@ -119,17 +129,61 @@ class BouquetNameGenerator(BaseAIProductService):
|
|||||||
line = line.replace('**', '').replace('*', '').replace('"', '').replace("'", '').strip()
|
line = line.replace('**', '').replace('*', '').replace('"', '').replace("'", '').strip()
|
||||||
|
|
||||||
if line:
|
if line:
|
||||||
names.append(line)
|
# Приводим к нужному формату: первое слово с заглавной, остальные строчные
|
||||||
|
normalized_line = self._normalize_case(line)
|
||||||
|
names.append(normalized_line)
|
||||||
|
|
||||||
|
# Фильтруем и сортируем названия по длине для равномерного распределения
|
||||||
|
names_by_length = {2: [], 3: [], 4: []}
|
||||||
|
|
||||||
# Удаляем дубликаты
|
|
||||||
unique_names = []
|
|
||||||
seen = set()
|
|
||||||
for name in names:
|
for name in names:
|
||||||
|
word_count = len(name.split())
|
||||||
|
if word_count in names_by_length:
|
||||||
|
names_by_length[word_count].append(name)
|
||||||
|
|
||||||
|
# Удаляем дубликаты в каждой группе
|
||||||
|
for length in names_by_length:
|
||||||
|
unique_list = []
|
||||||
|
seen = set()
|
||||||
|
for name in names_by_length[length]:
|
||||||
if name not in seen:
|
if name not in seen:
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
unique_names.append(name)
|
unique_list.append(name)
|
||||||
|
names_by_length[length] = unique_list
|
||||||
|
|
||||||
return unique_names
|
# Объединяем названия в один список в пропорциях 2:3:4
|
||||||
|
balanced_names = []
|
||||||
|
|
||||||
|
# Определяем максимальное количество названий одного типа
|
||||||
|
max_per_length = max(len(names_list) for names_list in names_by_length.values()) if any(names_by_length.values()) else 0
|
||||||
|
|
||||||
|
# Добавляем названия по одному из каждой категории по очереди
|
||||||
|
for i in range(max_per_length):
|
||||||
|
for length in [2, 3, 4]: # Проходим по длине 2, 3, 4
|
||||||
|
if i < len(names_by_length[length]):
|
||||||
|
balanced_names.append(names_by_length[length][i])
|
||||||
|
|
||||||
|
return balanced_names
|
||||||
|
|
||||||
|
def _normalize_case(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Приводит текст к формату: первое слово с заглавной буквы, остальные строчные
|
||||||
|
Например: "романтический БУКЕТ роз" -> "Романтический букет роз"
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Разбиваем текст на слова
|
||||||
|
words = text.split()
|
||||||
|
|
||||||
|
if not words:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Первое слово с заглавной буквы, остальные строчные
|
||||||
|
normalized_words = [words[0].capitalize()] + [word.lower() for word in words[1:]]
|
||||||
|
|
||||||
|
# Собираем обратно в строку
|
||||||
|
return ' '.join(normalized_words)
|
||||||
|
|
||||||
def generate_and_store(
|
def generate_and_store(
|
||||||
self,
|
self,
|
||||||
@@ -148,7 +202,10 @@ class BouquetNameGenerator(BaseAIProductService):
|
|||||||
if success and data:
|
if success and data:
|
||||||
# Сохраняем названия в базу
|
# Сохраняем названия в базу
|
||||||
stored_count = 0
|
stored_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
for name in data['names']:
|
for name in data['names']:
|
||||||
|
try:
|
||||||
BouquetName.objects.get_or_create(
|
BouquetName.objects.get_or_create(
|
||||||
name=name,
|
name=name,
|
||||||
language=language,
|
language=language,
|
||||||
@@ -157,8 +214,15 @@ class BouquetNameGenerator(BaseAIProductService):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
stored_count += 1
|
stored_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка сохранения названия '{name}': {e}")
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
return True, f"Сгенерировано и сохранено {stored_count} названий для букетов", data
|
success_msg = f"Сгенерировано и сохранено {stored_count} названий для букетов"
|
||||||
|
if failed_count > 0:
|
||||||
|
success_msg += f", не удалось сохранить {failed_count} названий"
|
||||||
|
|
||||||
|
return True, success_msg, data
|
||||||
|
|
||||||
return success, msg, data
|
return success, msg, data
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
{% elif item.item_type == 'kit' %}
|
{% elif item.item_type == 'kit' %}
|
||||||
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
||||||
<a href="{% url 'products:kit-detail' item.pk %}"
|
<a href="{% url 'products:productkit-detail' item.pk %}"
|
||||||
style="color: #6c757d;">{{ item.name }}</a>
|
style="color: #6c757d;">{{ item.name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
{% elif item.item_type == 'product' %}
|
{% elif item.item_type == 'product' %}
|
||||||
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||||
{% elif item.item_type == 'kit' %}
|
{% elif item.item_type == 'kit' %}
|
||||||
<a href="{% url 'products:kit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
<a href="{% url 'products:productkit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -76,7 +76,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Загрузка с устройства -->
|
<!-- Загрузка с устройства -->
|
||||||
<input type="file" name="photos" accept="image/*" multiple class="form-control form-control-sm" id="id_photos">
|
<input type="file" name="photos" accept="image/*" multiple class="form-control form-control-sm"
|
||||||
|
id="id_photos">
|
||||||
<div id="photoPreviewContainer" class="mt-2" style="display: none;">
|
<div id="photoPreviewContainer" class="mt-2" style="display: none;">
|
||||||
<div id="photoPreview" class="row g-1"></div>
|
<div id="photoPreview" class="row g-1"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,13 +98,14 @@
|
|||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<p class="small text-muted mb-3">
|
<p class="small text-muted mb-3">
|
||||||
Сгенерируйте привлекательное название для вашего букета автоматически
|
Сгенерируйте привлекательное название для вашего букета автоматически
|
||||||
<br><span class="badge bg-secondary mt-1">В базе: <span id="bouquetNamesCount">{{ bouquet_names_count }}</span> названий</span>
|
<br><span class="badge bg-secondary mt-1">В базе: <span id="bouquetNamesCount">{{
|
||||||
|
bouquet_names_count }}</span> названий</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex gap-2 mb-4">
|
<div class="d-flex gap-2 mb-4">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
<button type="button" class="btn btn-outline-primary btn-sm" id="populateNamesBtn">
|
||||||
<i class="bi bi-magic"></i> Пополнить базу названиями
|
<i class="bi bi-magic"></i> Пополнить базу названиями
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm">
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="getThreeNamesBtn">
|
||||||
<i class="bi bi-refresh"></i> Дать три варианта
|
<i class="bi bi-refresh"></i> Дать три варианта
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,30 +113,36 @@
|
|||||||
<!-- Предложения названий -->
|
<!-- Предложения названий -->
|
||||||
<div class="name-suggestions">
|
<div class="name-suggestions">
|
||||||
<!-- Строка 1 -->
|
<!-- Строка 1 -->
|
||||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
|
||||||
<span class="text-muted small">Романтический букет роз</span>
|
data-name-id="">
|
||||||
<div class="d-flex gap-1">
|
<span class="text-muted small name-text">-</span>
|
||||||
<button type="button" class="btn btn-success btn-xs">Взять</button>
|
<div class="d-flex gap-1 name-buttons" style="display: none;">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
|
<button type="button"
|
||||||
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
|
class="btn btn-success btn-xs btn-take-name">Взять</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Строка 2 -->
|
<!-- Строка 2 -->
|
||||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
|
||||||
<span class="text-muted small">Солнечный букет подсолнухов</span>
|
data-name-id="">
|
||||||
<div class="d-flex gap-1">
|
<span class="text-muted small name-text">-</span>
|
||||||
<button type="button" class="btn btn-success btn-xs">Взять</button>
|
<div class="d-flex gap-1 name-buttons" style="display: none;">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
|
<button type="button"
|
||||||
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
|
class="btn btn-success btn-xs btn-take-name">Взять</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Строка 3 -->
|
<!-- Строка 3 -->
|
||||||
<div class="d-flex justify-content-between align-items-center py-2">
|
<div class="d-flex justify-content-between align-items-center py-2 name-row"
|
||||||
<span class="text-muted small">Элегантный букет лотосов</span>
|
data-name-id="">
|
||||||
<div class="d-flex gap-1">
|
<span class="text-muted small name-text">-</span>
|
||||||
<button type="button" class="btn btn-success btn-xs">Взять</button>
|
<div class="d-flex gap-1 name-buttons" style="display: none;">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
|
<button type="button"
|
||||||
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
|
class="btn btn-success btn-xs btn-take-name">Взять</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,8 +165,10 @@
|
|||||||
<!-- Базовая цена (отображение) -->
|
<!-- Базовая цена (отображение) -->
|
||||||
<div class="mb-3 p-2 rounded" style="background: #f8f9fa; border-left: 3px solid #6c757d;">
|
<div class="mb-3 p-2 rounded" style="background: #f8f9fa; border-left: 3px solid #6c757d;">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен компонентов:</span>
|
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен
|
||||||
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00 руб.</span>
|
компонентов:</span>
|
||||||
|
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00
|
||||||
|
руб.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,13 +182,15 @@
|
|||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="number" id="id_increase_percent" class="form-control" placeholder="%" step="0.01" min="0">
|
<input type="number" id="id_increase_percent" class="form-control"
|
||||||
|
placeholder="%" step="0.01" min="0">
|
||||||
<span class="input-group-text">%</span>
|
<span class="input-group-text">%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="number" id="id_increase_amount" class="form-control" placeholder="руб" step="0.01" min="0">
|
<input type="number" id="id_increase_amount" class="form-control"
|
||||||
|
placeholder="руб" step="0.01" min="0">
|
||||||
<span class="input-group-text">руб</span>
|
<span class="input-group-text">руб</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,13 +206,15 @@
|
|||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="number" id="id_decrease_percent" class="form-control" placeholder="%" step="0.01" min="0">
|
<input type="number" id="id_decrease_percent" class="form-control"
|
||||||
|
placeholder="%" step="0.01" min="0">
|
||||||
<span class="input-group-text">%</span>
|
<span class="input-group-text">%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="number" id="id_decrease_amount" class="form-control" placeholder="руб" step="0.01" min="0">
|
<input type="number" id="id_decrease_amount" class="form-control"
|
||||||
|
placeholder="руб" step="0.01" min="0">
|
||||||
<span class="input-group-text">руб</span>
|
<span class="input-group-text">руб</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,8 +227,10 @@
|
|||||||
<!-- Итоговая цена -->
|
<!-- Итоговая цена -->
|
||||||
<div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;">
|
<div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая цена:</strong></span>
|
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая
|
||||||
<span id="finalPriceDisplay" class="fw-bold" style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
|
цена:</strong></span>
|
||||||
|
<span id="finalPriceDisplay" class="fw-bold"
|
||||||
|
style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,7 +246,8 @@
|
|||||||
<h6 class="mb-2 text-muted"><i class="bi bi-tag-discount"></i> Цена со скидкой</h6>
|
<h6 class="mb-2 text-muted"><i class="bi bi-tag-discount"></i> Цена со скидкой</h6>
|
||||||
<label class="form-label small mb-1">{{ form.sale_price.label }}</label>
|
<label class="form-label small mb-1">{{ form.sale_price.label }}</label>
|
||||||
{{ form.sale_price }}
|
{{ form.sale_price }}
|
||||||
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной цены</small>
|
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной
|
||||||
|
цены</small>
|
||||||
{% if form.sale_price.errors %}
|
{% if form.sale_price.errors %}
|
||||||
<div class="text-danger small mt-1">{{ form.sale_price.errors }}</div>
|
<div class="text-danger small mt-1">{{ form.sale_price.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -299,7 +316,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sticky Footer -->
|
<!-- Sticky Footer -->
|
||||||
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
|
<div
|
||||||
|
class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
|
||||||
<a href="{% url 'products:products-list' %}" class="btn btn-outline-secondary">
|
<a href="{% url 'products:products-list' %}" class="btn btn-outline-secondary">
|
||||||
Отмена
|
Отмена
|
||||||
</a>
|
</a>
|
||||||
@@ -428,6 +446,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -531,7 +550,9 @@
|
|||||||
|
|
||||||
/* Адаптивность */
|
/* Адаптивность */
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.col-lg-8, .col-lg-4 {
|
|
||||||
|
.col-lg-8,
|
||||||
|
.col-lg-4 {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,6 +561,10 @@
|
|||||||
<!-- Select2 инициализация -->
|
<!-- Select2 инициализация -->
|
||||||
{% include 'products/includes/select2-product-init.html' %}
|
{% include 'products/includes/select2-product-init.html' %}
|
||||||
|
|
||||||
|
{{ selected_products|default:"{}"|json_script:"selected-products-data" }}
|
||||||
|
{{ selected_variants|default:"{}"|json_script:"selected-variants-data" }}
|
||||||
|
{{ selected_sales_units|default:"{}"|json_script:"selected-sales-units-data" }}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
// ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ==========
|
// ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ==========
|
||||||
@@ -553,6 +578,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const finalPriceDisplay = document.getElementById('finalPriceDisplay');
|
const finalPriceDisplay = document.getElementById('finalPriceDisplay');
|
||||||
|
|
||||||
let basePrice = 0;
|
let basePrice = 0;
|
||||||
|
let activeUpdates = 0; // Счетчик активных обновлений
|
||||||
|
|
||||||
// Кэш цен товаров для быстрого доступа
|
// Кэш цен товаров для быстрого доступа
|
||||||
const priceCache = {};
|
const priceCache = {};
|
||||||
@@ -746,6 +772,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Функция для обновления списка единиц продажи при выборе товара
|
// Функция для обновления списка единиц продажи при выборе товара
|
||||||
async function updateSalesUnitsOptions(salesUnitSelect, productValue) {
|
async function updateSalesUnitsOptions(salesUnitSelect, productValue) {
|
||||||
|
activeUpdates++; // Начинаем обновление
|
||||||
|
try {
|
||||||
|
// Сохраняем текущее значение перед очисткой (важно для редактирования и копирования)
|
||||||
|
let targetValue = salesUnitSelect.value;
|
||||||
|
|
||||||
|
// Если значения нет, проверяем preloaded данные (фаллбэк для инициализации)
|
||||||
|
if (!targetValue) {
|
||||||
|
const fieldName = salesUnitSelect.name;
|
||||||
|
if (selectedSalesUnits && selectedSalesUnits[fieldName]) {
|
||||||
|
targetValue = selectedSalesUnits[fieldName].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Очищаем текущие опции
|
// Очищаем текущие опции
|
||||||
salesUnitSelect.innerHTML = '<option value="">---------</option>';
|
salesUnitSelect.innerHTML = '<option value="">---------</option>';
|
||||||
salesUnitSelect.disabled = true;
|
salesUnitSelect.disabled = true;
|
||||||
@@ -764,7 +803,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(productId) || productId <= 0) {
|
if (isNaN(productId) || productId <= 0) {
|
||||||
console.warn('updateSalesUnitsOptions: invalid product id', productValue);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,13 +824,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
salesUnitSelect.appendChild(option);
|
salesUnitSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
salesUnitSelect.disabled = false;
|
salesUnitSelect.disabled = false;
|
||||||
// Обновляем Select2
|
|
||||||
|
// Восстанавливаем значение
|
||||||
|
if (targetValue) {
|
||||||
|
$(salesUnitSelect).val(targetValue).trigger('change');
|
||||||
|
} else {
|
||||||
|
// Обновляем Select2 без значения
|
||||||
$(salesUnitSelect).trigger('change');
|
$(salesUnitSelect).trigger('change');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching sales units:', error);
|
console.error('Error fetching sales units:', error);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
activeUpdates--; // Завершаем обновление
|
||||||
|
if (activeUpdates === 0) {
|
||||||
|
calculateFinalPrice();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем data-product-id и загружаем цену при выборе товара
|
// Обновляем data-product-id и загружаем цену при выборе товара
|
||||||
@@ -812,8 +862,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (salesUnitSelect) {
|
if (salesUnitSelect) {
|
||||||
await updateSalesUnitsOptions(salesUnitSelect, this.value);
|
await updateSalesUnitsOptions(salesUnitSelect, this.value);
|
||||||
}
|
}
|
||||||
calculateFinalPrice();
|
|
||||||
}
|
}
|
||||||
|
calculateFinalPrice();
|
||||||
}).on('select2:unselect', function () {
|
}).on('select2:unselect', function () {
|
||||||
const form = $(this).closest('.kititem-form');
|
const form = $(this).closest('.kititem-form');
|
||||||
// Очищаем список единиц продажи
|
// Очищаем список единиц продажи
|
||||||
@@ -888,6 +938,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Функция для расчета финальной цены
|
// Функция для расчета финальной цены
|
||||||
async function calculateFinalPrice() {
|
async function calculateFinalPrice() {
|
||||||
|
// Если идут обновления - не считаем, ждем их завершения
|
||||||
|
if (activeUpdates > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем базовую цену (сумма всех компонентов)
|
// Получаем базовую цену (сумма всех компонентов)
|
||||||
let newBasePrice = 0;
|
let newBasePrice = 0;
|
||||||
const formsContainer = document.getElementById('kititem-forms');
|
const formsContainer = document.getElementById('kititem-forms');
|
||||||
@@ -1063,8 +1118,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Инициальный расчет (асинхронно)
|
// Инициальный расчет не нужен, так как он выполняется по событиям изменения полей
|
||||||
calculateFinalPrice();
|
// и после завершения загрузки единиц продажи
|
||||||
|
|
||||||
// ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ==========
|
// ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ==========
|
||||||
function initSelect2(element, type, preloadedData) {
|
function initSelect2(element, type, preloadedData) {
|
||||||
@@ -1075,9 +1130,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedProducts = {{ selected_products|default:"{}"|safe }};
|
const selectedProducts = JSON.parse(document.getElementById('selected-products-data').textContent || '{}');
|
||||||
const selectedVariants = {{ selected_variants|default:"{}"|safe }};
|
const selectedVariants = JSON.parse(document.getElementById('selected-variants-data').textContent || '{}');
|
||||||
const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }};
|
const selectedSalesUnits = JSON.parse(document.getElementById('selected-sales-units-data').textContent || '{}');
|
||||||
|
|
||||||
$('[name$="-product"]').each(function () {
|
$('[name$="-product"]').each(function () {
|
||||||
const fieldName = $(this).attr('name');
|
const fieldName = $(this).attr('name');
|
||||||
@@ -1318,12 +1373,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
photoPreviewContainer.style.display = 'none';
|
photoPreviewContainer.style.display = 'none'; // Only hide if no source photos too (will check later)
|
||||||
photoPreview.innerHTML = '';
|
photoPreview.innerHTML = '';
|
||||||
|
|
||||||
|
// Re-render source photos if they exist and we just cleared new files
|
||||||
|
if (document.querySelectorAll('.source-photo-item').length > 0) {
|
||||||
|
photoPreviewContainer.style.display = 'block';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render source photos if present
|
||||||
|
{% if source_photos %}
|
||||||
|
photoPreviewContainer.style.display = 'block';
|
||||||
|
{% for photo in source_photos %}
|
||||||
|
(function () {
|
||||||
|
const col = document.createElement('div');
|
||||||
|
col.className = 'col-4 col-md-3 col-lg-2 source-photo-item';
|
||||||
|
col.innerHTML = `
|
||||||
|
<div class="card position-relative border-0 shadow-sm">
|
||||||
|
<img src="{{ photo.image.url }}" class="card-img-top" alt="Source Photo">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1" onclick="this.closest('.col-4').remove();">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="copied_photos" value="{{ photo.id }}">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
photoPreview.appendChild(col);
|
||||||
|
})();
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
window.removePhoto = function (index) {
|
window.removePhoto = function (index) {
|
||||||
selectedFiles.splice(index, 1);
|
selectedFiles.splice(index, 1);
|
||||||
const dataTransfer = new DataTransfer();
|
const dataTransfer = new DataTransfer();
|
||||||
@@ -1412,57 +1493,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== ГЕНЕРАТОР НАЗВАНИЙ ==========
|
// ========== ГЕНЕРАТОР НАЗВАНИЙ ==========
|
||||||
// Обработчик для кнопок "Взять"
|
|
||||||
document.querySelectorAll('.name-suggestions .btn-success').forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const suggestionText = this.closest('.d-flex').querySelector('.text-muted').textContent;
|
|
||||||
const nameInput = document.getElementById('id_name');
|
|
||||||
if (nameInput) {
|
|
||||||
nameInput.value = suggestionText;
|
|
||||||
// Улучшаем визуальный эффект
|
|
||||||
nameInput.style.borderColor = '#198754';
|
|
||||||
nameInput.style.boxShadow = '0 0 0 0.25rem rgba(25, 135, 84, 0.15)';
|
|
||||||
setTimeout(() => {
|
|
||||||
nameInput.style.borderColor = '';
|
|
||||||
nameInput.style.boxShadow = '';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
// Закрываем collapse
|
|
||||||
const collapse = document.getElementById('nameGeneratorCollapse');
|
|
||||||
const bsCollapse = new bootstrap.Collapse(collapse, { toggle: false });
|
|
||||||
bsCollapse.hide();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обработчик для кнопок "Убрать"
|
// Обработчик для кнопки "Пополнить базу названиям<D18F><D0BC>"
|
||||||
document.querySelectorAll('.name-suggestions .btn-outline-danger').forEach(button => {
|
const populateNamesBtn = document.getElementById('populateNamesBtn');
|
||||||
button.addEventListener('click', function() {
|
if (populateNamesBtn) {
|
||||||
this.closest('.d-flex').remove();
|
populateNamesBtn.addEventListener('click', async function () {
|
||||||
});
|
const originalHTML = populateNamesBtn.innerHTML;
|
||||||
});
|
populateNamesBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Пополнение...';
|
||||||
|
populateNamesBtn.disabled = true;
|
||||||
// Обработчик для кнопок "Потом"
|
|
||||||
document.querySelectorAll('.name-suggestions .btn-outline-secondary').forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const row = this.closest('.d-flex');
|
|
||||||
row.style.opacity = '0.5';
|
|
||||||
row.style.textDecoration = 'line-through';
|
|
||||||
setTimeout(() => {
|
|
||||||
row.style.opacity = '1';
|
|
||||||
row.style.textDecoration = 'none';
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== ГЕНЕРАТОР НАЗВАНИЙ - НОВЫЕ ОБРАБОТЧИКИ ==========
|
|
||||||
|
|
||||||
// Обработчик для кнопки "Сгенерировать" (LLM)
|
|
||||||
const generateBtn = document.querySelector('#nameGeneratorCollapse .btn-outline-primary');
|
|
||||||
if (generateBtn) {
|
|
||||||
generateBtn.addEventListener('click', async function() {
|
|
||||||
const originalHTML = generateBtn.innerHTML;
|
|
||||||
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Генерация...';
|
|
||||||
generateBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("{% url 'products:api-generate-bouquet-names' %}", {
|
const response = await fetch("{% url 'products:api-generate-bouquet-names' %}", {
|
||||||
@@ -1478,89 +1516,94 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Обновляем счётчик
|
// Обновляем счётчик
|
||||||
updateBouquetNamesCount();
|
updateBouquetNamesCount();
|
||||||
// Загружаем случайные 3
|
|
||||||
await loadRandomNames();
|
|
||||||
alert(data.message);
|
alert(data.message);
|
||||||
} else {
|
} else {
|
||||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Ошибка при генерации названий. Проверьте, что настроена AI-интеграция.');
|
alert('Ошибка при пополнении базы названий. Проверьте, что настроена AI-интеграция.');
|
||||||
} finally {
|
} finally {
|
||||||
generateBtn.innerHTML = originalHTML;
|
populateNamesBtn.innerHTML = originalHTML;
|
||||||
generateBtn.disabled = false;
|
populateNamesBtn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик для кнопки "Случайное"
|
// Обработчик для кнопки "Дать три варианта"
|
||||||
const randomBtn = document.querySelector('#nameGeneratorCollapse .btn-outline-secondary');
|
const getThreeNamesBtn = document.getElementById('getThreeNamesBtn');
|
||||||
if (randomBtn) {
|
if (getThreeNamesBtn) {
|
||||||
randomBtn.addEventListener('click', loadRandomNames);
|
getThreeNamesBtn.addEventListener('click', loadThreeRandomNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRandomNames() {
|
// Функция для загрузки трёх случайных названий
|
||||||
const originalHTML = randomBtn.innerHTML;
|
async function loadThreeRandomNames() {
|
||||||
randomBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
|
const originalHTML = getThreeNamesBtn.innerHTML;
|
||||||
randomBtn.disabled = true;
|
getThreeNamesBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
|
||||||
|
getThreeNamesBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=3");
|
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=3");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.names && data.names.length > 0) {
|
if (data.names && data.names.length > 0) {
|
||||||
updateNameSuggestions(data.names);
|
updateNameRows(data.names);
|
||||||
} else {
|
} else {
|
||||||
alert('В базе пока нет названий. Сначала запустите загрузку из JSON или нажмите "Сгенерировать"');
|
alert('В базе пока нет названий. Сначала запустите загрузку из JSON или нажмите "Пополнить базу названиями"');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Ошибка при загрузке названий');
|
alert('Ошибка при загрузке названий');
|
||||||
} finally {
|
} finally {
|
||||||
randomBtn.innerHTML = originalHTML;
|
getThreeNamesBtn.innerHTML = originalHTML;
|
||||||
randomBtn.disabled = false;
|
getThreeNamesBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateBouquetNamesCount() {
|
// Функция для обновления строк с названиями
|
||||||
try {
|
function updateNameRows(names) {
|
||||||
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=999");
|
const rows = document.querySelectorAll('.name-row');
|
||||||
const data = await response.json();
|
|
||||||
const countEl = document.getElementById('bouquetNamesCount');
|
|
||||||
if (countEl) countEl.textContent = data.names.length;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating count:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateNameSuggestions(names) {
|
rows.forEach((row, index) => {
|
||||||
const container = document.querySelector('.name-suggestions');
|
const nameTextElement = row.querySelector('.name-text');
|
||||||
container.innerHTML = '';
|
const buttonsElement = row.querySelector('.name-buttons');
|
||||||
|
|
||||||
names.forEach((name, index) => {
|
if (index < names.length) {
|
||||||
const row = document.createElement('div');
|
// Если есть название для этой строки
|
||||||
row.className = 'd-flex justify-content-between align-items-center py-2' +
|
const nameObj = names[index];
|
||||||
(index < names.length - 1 ? ' border-bottom' : '');
|
nameTextElement.textContent = nameObj.name;
|
||||||
row.innerHTML = `
|
row.setAttribute('data-name-id', nameObj.id);
|
||||||
<span class="text-muted small">${name}</span>
|
|
||||||
<div class="d-flex gap-1">
|
// Показываем кнопки
|
||||||
<button type="button" class="btn btn-success btn-xs btn-apply-name">Взять</button>
|
buttonsElement.style.display = 'flex';
|
||||||
<button type="button" class="btn btn-outline-danger btn-xs btn-remove-name">Убрать</button>
|
} else {
|
||||||
</div>
|
// Если нет названия для этой строки
|
||||||
`;
|
nameTextElement.textContent = '-';
|
||||||
container.appendChild(row);
|
row.setAttribute('data-name-id', '');
|
||||||
|
|
||||||
|
// Скрываем кнопки
|
||||||
|
buttonsElement.style.display = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
attachNameButtonHandlers();
|
|
||||||
|
// Устанавливаем обработчики событий для новых кнопок
|
||||||
|
attachNameRowHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachNameButtonHandlers() {
|
// Обработчики для кнопок "Взять" и "Удалить"
|
||||||
document.querySelectorAll('.btn-apply-name').forEach(button => {
|
function attachNameRowHandlers() {
|
||||||
button.addEventListener('click', function() {
|
// Обработчик для кнопки "Взять"
|
||||||
const name = this.closest('.d-flex').querySelector('.text-muted').textContent;
|
document.querySelectorAll('.btn-take-name').forEach(button => {
|
||||||
|
// Проверяем, был ли уже добавлен обработчик
|
||||||
|
if (!button.dataset.handlerAttached) {
|
||||||
|
button.addEventListener('click', async function () {
|
||||||
|
const row = this.closest('.name-row');
|
||||||
|
const nameText = row.querySelector('.name-text').textContent;
|
||||||
|
const nameId = row.getAttribute('data-name-id');
|
||||||
const nameInput = document.getElementById('id_name');
|
const nameInput = document.getElementById('id_name');
|
||||||
|
|
||||||
if (nameInput) {
|
if (nameInput) {
|
||||||
nameInput.value = name;
|
nameInput.value = nameText;
|
||||||
nameInput.style.borderColor = '#198754';
|
nameInput.style.borderColor = '#198754';
|
||||||
nameInput.style.boxShadow = '0 0 0 0.25rem rgba(25, 135, 84, 0.15)';
|
nameInput.style.boxShadow = '0 0 0 0.25rem rgba(25, 135, 84, 0.15)';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1568,16 +1611,117 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
nameInput.style.boxShadow = '';
|
nameInput.style.boxShadow = '';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Удаляем название из базы данных
|
||||||
|
if (nameId) {
|
||||||
|
await removeNameFromDatabase(nameId);
|
||||||
|
|
||||||
|
// Заменяем название новым из базы данных
|
||||||
|
await replaceNameInRow(row);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.btn-remove-name').forEach(button => {
|
// Отмечаем, что обработчик уже добавлен
|
||||||
button.addEventListener('click', function() {
|
button.dataset.handlerAttached = 'true';
|
||||||
this.closest('.d-flex').remove();
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Обработчик для кнопки "Удалить"
|
||||||
|
document.querySelectorAll('.btn-delete-name').forEach(button => {
|
||||||
|
// Проверяем, был ли уже добавлен обработчик
|
||||||
|
if (!button.dataset.handlerAttached) {
|
||||||
|
button.addEventListener('click', async function () {
|
||||||
|
const row = this.closest('.name-row');
|
||||||
|
const nameId = row.getAttribute('data-name-id');
|
||||||
|
|
||||||
|
// Удаляем название из базы данных
|
||||||
|
if (nameId) {
|
||||||
|
await removeNameFromDatabase(nameId);
|
||||||
|
|
||||||
|
// Заменяем название новым из базы данных
|
||||||
|
await replaceNameInRow(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отмечаем, что обработчик уже добавлен
|
||||||
|
button.dataset.handlerAttached = 'true';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для удаления названия из базы данных
|
||||||
|
async function removeNameFromDatabase(nameId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{% url 'products:api-delete-bouquet-name' 0 %}`.replace('/0/', `/${nameId}/`), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Обновляем счётчик
|
||||||
|
updateBouquetNamesCount();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to delete name from database');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для замены названия в строке новым из базы данных
|
||||||
|
async function replaceNameInRow(row) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=1");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.names && data.names.length > 0) {
|
||||||
|
const newName = data.names[0];
|
||||||
|
const nameTextElement = row.querySelector('.name-text');
|
||||||
|
const buttonsElement = row.querySelector('.name-buttons');
|
||||||
|
|
||||||
|
nameTextElement.textContent = newName.name;
|
||||||
|
row.setAttribute('data-name-id', newName.id);
|
||||||
|
|
||||||
|
// Показываем кнопки
|
||||||
|
buttonsElement.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
// Если в базе больше нет названий
|
||||||
|
const nameTextElement = row.querySelector('.name-text');
|
||||||
|
const buttonsElement = row.querySelector('.name-buttons');
|
||||||
|
|
||||||
|
nameTextElement.textContent = '-';
|
||||||
|
row.setAttribute('data-name-id', '');
|
||||||
|
|
||||||
|
// Скрываем кнопки
|
||||||
|
buttonsElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error replacing name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для обновления счётчика названий в базе
|
||||||
|
async function updateBouquetNamesCount() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("{% url 'products:api-get-bouquet-names-count' %}");
|
||||||
|
const data = await response.json();
|
||||||
|
const countEl = document.getElementById('bouquetNamesCount');
|
||||||
|
if (countEl) countEl.textContent = data.count;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating count:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация обработчиков кнопок
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target.classList.contains('btn-take-name') || e.target.classList.contains('btn-delete-name')) {
|
||||||
|
attachNameRowHandlers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ========== ВАЛИДАЦИЯ ПЕРЕД ОТПРАВКОЙ ==========
|
// ========== ВАЛИДАЦИЯ ПЕРЕД ОТПРАВКОЙ ==========
|
||||||
const kitForm = document.querySelector('form[method="post"]');
|
const kitForm = document.querySelector('form[method="post"]');
|
||||||
if (kitForm) {
|
if (kitForm) {
|
||||||
|
|||||||
@@ -506,6 +506,9 @@
|
|||||||
<a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary">
|
<a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary">
|
||||||
Отмена
|
Отмена
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'products:productkit-create' %}?copy_from={{ object.pk }}" class="btn btn-warning text-white mx-2">
|
||||||
|
<i class="bi bi-files me-1"></i>Копировать комплект
|
||||||
|
</a>
|
||||||
<button type="submit" class="btn btn-primary px-4">
|
<button type="submit" class="btn btn-primary px-4">
|
||||||
<i class="bi bi-check-circle me-1"></i>Сохранить изменения
|
<i class="bi bi-check-circle me-1"></i>Сохранить изменения
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ urlpatterns = [
|
|||||||
path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'),
|
path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'),
|
||||||
path('api/bouquet-names/random/', api_views.RandomBouquetNamesView.as_view(), name='api-random-bouquet-names'),
|
path('api/bouquet-names/random/', api_views.RandomBouquetNamesView.as_view(), name='api-random-bouquet-names'),
|
||||||
path('api/bouquet-names/generate/', api_views.GenerateBouquetNamesView.as_view(), name='api-generate-bouquet-names'),
|
path('api/bouquet-names/generate/', api_views.GenerateBouquetNamesView.as_view(), name='api-generate-bouquet-names'),
|
||||||
|
path('api/bouquet-names/<int:pk>/delete/', api_views.DeleteBouquetNameView.as_view(), name='api-delete-bouquet-name'),
|
||||||
|
path('api/bouquet-names/count/', api_views.GetBouquetNamesCountView.as_view(), name='api-get-bouquet-names-count'),
|
||||||
|
|
||||||
# Photo processing status API (for AJAX polling)
|
# Photo processing status API (for AJAX polling)
|
||||||
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||||
|
|||||||
@@ -158,49 +158,41 @@ class ImageProcessor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _resize_image(img, size):
|
def _resize_image(img, size):
|
||||||
"""
|
"""
|
||||||
Изменяет размер изображения с сохранением пропорций.
|
Изменяет размер изображения с center-crop до точного квадратного размера.
|
||||||
НЕ увеличивает маленькие изображения (сохраняет качество).
|
НЕ увеличивает маленькие изображения (сохраняет качество).
|
||||||
Создает адаптивный квадрат по размеру реального изображения.
|
Создает квадратное изображение без белых полей.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
img: PIL Image object
|
img: PIL Image object
|
||||||
size: Кортеж (width, height) - максимальный целевой размер
|
size: Кортеж (width, height) - целевой размер (обычно квадратный)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image object - квадратное изображение с минимальным белым фоном
|
PIL Image object - квадратное изображение без белых полей
|
||||||
"""
|
"""
|
||||||
# Копируем изображение, чтобы не модифицировать оригинал
|
|
||||||
img_copy = img.copy()
|
img_copy = img.copy()
|
||||||
|
target_width, target_height = size
|
||||||
|
|
||||||
# Вычисляем пропорции исходного изображения и целевого размера
|
# Шаг 1: Center crop для получения квадрата
|
||||||
img_aspect = img_copy.width / img_copy.height
|
# Определяем минимальную сторону (будет размер квадрата)
|
||||||
target_aspect = size[0] / size[1]
|
min_side = min(img_copy.width, img_copy.height)
|
||||||
|
|
||||||
# Определяем, какой размер будет ограничивающим при масштабировании
|
# Вычисляем координаты для обрезки из центра
|
||||||
if img_aspect > target_aspect:
|
left = (img_copy.width - min_side) // 2
|
||||||
# Изображение шире - ограничиваемый размер это ширина
|
top = (img_copy.height - min_side) // 2
|
||||||
new_width = min(img_copy.width, size[0])
|
right = left + min_side
|
||||||
new_height = int(new_width / img_aspect)
|
bottom = top + min_side
|
||||||
|
|
||||||
|
# Обрезаем до квадрата
|
||||||
|
img_cropped = img_copy.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
# Шаг 2: Масштабируем до целевого размера (если исходный квадрат больше цели)
|
||||||
|
# Не увеличиваем маленькие изображения
|
||||||
|
if min_side > target_width:
|
||||||
|
img_resized = img_cropped.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||||
else:
|
else:
|
||||||
# Изображение выше - ограничиваемый размер это высота
|
img_resized = img_cropped
|
||||||
new_height = min(img_copy.height, size[1])
|
|
||||||
new_width = int(new_height * img_aspect)
|
|
||||||
|
|
||||||
# Масштабируем только если необходимо (не увеличиваем маленькие изображения)
|
return img_resized
|
||||||
if img_copy.width > new_width or img_copy.height > new_height:
|
|
||||||
img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Создаем адаптивный квадрат по размеру реального изображения (а не по конфигурации)
|
|
||||||
# Это позволяет избежать огромных белых полей для маленьких фото
|
|
||||||
square_size = max(img_copy.width, img_copy.height)
|
|
||||||
new_img = Image.new('RGB', (square_size, square_size), (255, 255, 255))
|
|
||||||
|
|
||||||
# Центрируем исходное изображение на белом фоне
|
|
||||||
offset_x = (square_size - img_copy.width) // 2
|
|
||||||
offset_y = (square_size - img_copy.height) // 2
|
|
||||||
new_img.paste(img_copy, (offset_x, offset_y))
|
|
||||||
|
|
||||||
return new_img
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _make_square_image(img, max_size):
|
def _make_square_image(img, max_size):
|
||||||
|
|||||||
@@ -1816,8 +1816,12 @@ class RandomBouquetNamesView(View):
|
|||||||
count = int(request.GET.get('count', 3))
|
count = int(request.GET.get('count', 3))
|
||||||
# Ограничиваем максимум до 100
|
# Ограничиваем максимум до 100
|
||||||
count = min(count, 100)
|
count = min(count, 100)
|
||||||
names = list(BouquetName.objects.order_by('?')[:count].values_list('name', flat=True))
|
|
||||||
return JsonResponse({'names': names})
|
# Получаем случайные названия с ID (любые, не только одобренные)
|
||||||
|
queryset = BouquetName.objects.order_by('?')[:count]
|
||||||
|
names_data = [{'id': obj.id, 'name': obj.name} for obj in queryset]
|
||||||
|
|
||||||
|
return JsonResponse({'names': names_data})
|
||||||
|
|
||||||
|
|
||||||
class GenerateBouquetNamesView(View):
|
class GenerateBouquetNamesView(View):
|
||||||
@@ -1843,3 +1847,25 @@ class GenerateBouquetNamesView(View):
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return JsonResponse({'success': False, 'error': msg}, status=400)
|
return JsonResponse({'success': False, 'error': msg}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteBouquetNameView(View):
|
||||||
|
"""Удаляет конкретное название из базы"""
|
||||||
|
|
||||||
|
def delete(self, request, pk):
|
||||||
|
try:
|
||||||
|
name_obj = BouquetName.objects.get(pk=pk)
|
||||||
|
name_obj.delete()
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
except BouquetName.DoesNotExist:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Название не найдено'}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class GetBouquetNamesCountView(View):
|
||||||
|
"""Возвращает количество названий в базе"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
count = BouquetName.objects.count()
|
||||||
|
return JsonResponse({'count': count})
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class TreeItem:
|
|||||||
if item_type == 'product':
|
if item_type == 'product':
|
||||||
self.price = obj.sale_price
|
self.price = obj.sale_price
|
||||||
elif item_type == 'kit':
|
elif item_type == 'kit':
|
||||||
self.price = obj.get_sale_price()
|
self.price = obj.actual_price
|
||||||
else:
|
else:
|
||||||
self.price = None
|
self.price = None
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ from django.shortcuts import redirect
|
|||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
|
|
||||||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||||||
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName
|
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName, ProductSalesUnit
|
||||||
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
||||||
from .utils import handle_photos
|
from .utils import handle_photos
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||||||
@@ -97,6 +98,37 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
form_class = ProductKitForm
|
form_class = ProductKitForm
|
||||||
template_name = 'products/productkit_create.html'
|
template_name = 'products/productkit_create.html'
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
initial = super().get_initial()
|
||||||
|
copy_id = self.request.GET.get('copy_from')
|
||||||
|
if copy_id:
|
||||||
|
try:
|
||||||
|
kit = ProductKit.objects.get(pk=copy_id)
|
||||||
|
|
||||||
|
# Generate unique name
|
||||||
|
base_name = f"{kit.name} (Копия)"
|
||||||
|
new_name = base_name
|
||||||
|
counter = 1
|
||||||
|
while ProductKit.objects.filter(name=new_name).exists():
|
||||||
|
counter += 1
|
||||||
|
new_name = f"{base_name} {counter}"
|
||||||
|
|
||||||
|
initial.update({
|
||||||
|
'name': new_name,
|
||||||
|
'description': kit.description,
|
||||||
|
'short_description': kit.short_description,
|
||||||
|
'categories': list(kit.categories.values_list('pk', flat=True)),
|
||||||
|
'tags': list(kit.tags.values_list('pk', flat=True)),
|
||||||
|
'sale_price': kit.sale_price,
|
||||||
|
'price_adjustment_type': kit.price_adjustment_type,
|
||||||
|
'price_adjustment_value': kit.price_adjustment_value,
|
||||||
|
'external_category': kit.external_category,
|
||||||
|
'status': 'active', # Default to active for new kits
|
||||||
|
})
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
pass
|
||||||
|
return initial
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
||||||
@@ -132,7 +164,6 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
||||||
|
|
||||||
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
|
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
|
||||||
from ..models import Product, ProductVariantGroup, ProductSalesUnit
|
|
||||||
selected_products = {}
|
selected_products = {}
|
||||||
selected_variants = {}
|
selected_variants = {}
|
||||||
selected_sales_units = {}
|
selected_sales_units = {}
|
||||||
@@ -194,9 +225,99 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
context['selected_products'] = selected_products
|
context['selected_products'] = selected_products
|
||||||
context['selected_variants'] = selected_variants
|
context['selected_variants'] = selected_variants
|
||||||
context['selected_sales_units'] = selected_sales_units
|
context['selected_sales_units'] = selected_sales_units
|
||||||
|
else:
|
||||||
|
# COPY KIT LOGIC
|
||||||
|
copy_id = self.request.GET.get('copy_from')
|
||||||
|
initial_items = []
|
||||||
|
selected_products = {}
|
||||||
|
selected_variants = {}
|
||||||
|
selected_sales_units = {}
|
||||||
|
|
||||||
|
if copy_id:
|
||||||
|
try:
|
||||||
|
source_kit = ProductKit.objects.get(pk=copy_id)
|
||||||
|
for item in source_kit.kit_items.all():
|
||||||
|
item_data = {
|
||||||
|
'quantity': item.quantity,
|
||||||
|
# Delete flag is false by default
|
||||||
|
}
|
||||||
|
|
||||||
|
form_prefix = f"kititem-{len(initial_items)}"
|
||||||
|
|
||||||
|
if item.product:
|
||||||
|
item_data['product'] = item.product
|
||||||
|
# Select2 prefill
|
||||||
|
product = item.product
|
||||||
|
text = product.name
|
||||||
|
if product.sku:
|
||||||
|
text += f" ({product.sku})"
|
||||||
|
actual_price = product.sale_price if product.sale_price else product.price
|
||||||
|
selected_products[f"{form_prefix}-product"] = {
|
||||||
|
'id': product.id,
|
||||||
|
'text': text,
|
||||||
|
'price': str(product.price) if product.price else None,
|
||||||
|
'actual_price': str(actual_price) if actual_price else '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.sales_unit:
|
||||||
|
item_data['sales_unit'] = item.sales_unit
|
||||||
|
# Select2 prefill
|
||||||
|
sales_unit = item.sales_unit
|
||||||
|
text = f"{sales_unit.name} ({sales_unit.product.name})"
|
||||||
|
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
|
||||||
|
selected_sales_units[f"{form_prefix}-sales_unit"] = {
|
||||||
|
'id': sales_unit.id,
|
||||||
|
'text': text,
|
||||||
|
'price': str(sales_unit.price) if sales_unit.price else None,
|
||||||
|
'actual_price': str(actual_price) if actual_price else '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.variant_group:
|
||||||
|
item_data['variant_group'] = item.variant_group
|
||||||
|
# Select2 prefill
|
||||||
|
variant_group = ProductVariantGroup.objects.prefetch_related(
|
||||||
|
'items__product'
|
||||||
|
).get(id=item.variant_group.id)
|
||||||
|
variant_price = variant_group.price or 0
|
||||||
|
count = variant_group.items.count()
|
||||||
|
selected_variants[f"{form_prefix}-variant_group"] = {
|
||||||
|
'id': variant_group.id,
|
||||||
|
'text': f"{variant_group.name} ({count} вариантов)",
|
||||||
|
'price': str(variant_price),
|
||||||
|
'actual_price': str(variant_price),
|
||||||
|
'type': 'variant',
|
||||||
|
'count': count
|
||||||
|
}
|
||||||
|
|
||||||
|
initial_items.append(item_data)
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if initial_items:
|
||||||
|
context['kititem_formset'] = KitItemFormSetCreate(
|
||||||
|
prefix='kititem',
|
||||||
|
initial=initial_items
|
||||||
|
)
|
||||||
|
context['kititem_formset'].extra = len(initial_items)
|
||||||
else:
|
else:
|
||||||
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
|
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
|
||||||
|
|
||||||
|
# Pass Select2 data to context
|
||||||
|
context['selected_products'] = selected_products
|
||||||
|
context['selected_variants'] = selected_variants
|
||||||
|
context['selected_sales_units'] = selected_sales_units
|
||||||
|
|
||||||
|
# Pass source photos if copying
|
||||||
|
if copy_id:
|
||||||
|
try:
|
||||||
|
source_kit = ProductKit.objects.prefetch_related('photos').get(pk=copy_id)
|
||||||
|
photos = source_kit.photos.all().order_by('order')
|
||||||
|
print(f"DEBUG: Found {photos.count()} source photos for kit {copy_id}")
|
||||||
|
context['source_photos'] = photos
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
print(f"DEBUG: Source kit {copy_id} not found")
|
||||||
|
pass
|
||||||
|
|
||||||
# Количество названий букетов в базе
|
# Количество названий букетов в базе
|
||||||
context['bouquet_names_count'] = BouquetName.objects.count()
|
context['bouquet_names_count'] = BouquetName.objects.count()
|
||||||
|
|
||||||
@@ -235,6 +356,48 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
# Обработка фотографий
|
# Обработка фотографий
|
||||||
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||||||
|
|
||||||
|
# Handle copied photos
|
||||||
|
copied_photo_ids = self.request.POST.getlist('copied_photos')
|
||||||
|
print(f"DEBUG: copied_photo_ids in POST: {copied_photo_ids}")
|
||||||
|
|
||||||
|
if copied_photo_ids:
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
original_photos = ProductKitPhoto.objects.filter(id__in=copied_photo_ids)
|
||||||
|
print(f"DEBUG: Found {original_photos.count()} original photos to copy")
|
||||||
|
|
||||||
|
# Get max order from existing photos (uploaded via handle_photos)
|
||||||
|
from django.db.models import Max
|
||||||
|
max_order = self.object.photos.aggregate(Max('order'))['order__max']
|
||||||
|
next_order = 0 if max_order is None else max_order + 1
|
||||||
|
print(f"DEBUG: Starting order for copies: {next_order}")
|
||||||
|
|
||||||
|
for photo in original_photos:
|
||||||
|
try:
|
||||||
|
# Open the original image file
|
||||||
|
if photo.image:
|
||||||
|
print(f"DEBUG: Processing photo {photo.id}: {photo.image.name}")
|
||||||
|
with photo.image.open('rb') as f:
|
||||||
|
image_content = f.read()
|
||||||
|
|
||||||
|
# Create a new ContentFile
|
||||||
|
new_image_name = f"copy_{self.object.id}_{os.path.basename(photo.image.name)}"
|
||||||
|
print(f"DEBUG: New image name: {new_image_name}")
|
||||||
|
|
||||||
|
# Create new photo instance
|
||||||
|
new_photo = ProductKitPhoto(kit=self.object, order=next_order)
|
||||||
|
# Save the image file (this also saves the model instance)
|
||||||
|
new_photo.image.save(new_image_name, ContentFile(image_content))
|
||||||
|
print(f"DEBUG: Successfully saved copy for photo {photo.id}")
|
||||||
|
|
||||||
|
next_order += 1
|
||||||
|
else:
|
||||||
|
print(f"DEBUG: Photo {photo.id} has no image file")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error copying photo {photo.id}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
continue
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
f'Комплект "{self.object.name}" успешно создан!'
|
f'Комплект "{self.object.name}" успешно создан!'
|
||||||
|
|||||||
120
myproject/reproduce_issue.py
Normal file
120
myproject/reproduce_issue.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import django
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Setup Django
|
||||||
|
sys.path.append(os.getcwd())
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
from customers.models import Customer
|
||||||
|
from inventory.models import Warehouse, Sale
|
||||||
|
from products.models import Product, UnitOfMeasure
|
||||||
|
from pos.views import pos_checkout
|
||||||
|
from orders.models import OrderStatus
|
||||||
|
|
||||||
|
def run():
|
||||||
|
# Setup Data
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.first()
|
||||||
|
if not user:
|
||||||
|
print("No user found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create/Get Customer
|
||||||
|
customer, _ = Customer.objects.get_or_create(
|
||||||
|
name="Test Customer",
|
||||||
|
defaults={'phone': '+375291112233'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create/Get Warehouse
|
||||||
|
warehouse, _ = Warehouse.objects.get_or_create(
|
||||||
|
name="Test Warehouse",
|
||||||
|
defaults={'is_active': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create product
|
||||||
|
product, _ = Product.objects.get_or_create(
|
||||||
|
name="Test Product Debug",
|
||||||
|
defaults={
|
||||||
|
'sku': 'DEBUG001',
|
||||||
|
'buying_price': 10,
|
||||||
|
'actual_price': 50,
|
||||||
|
'warehouse': warehouse
|
||||||
|
}
|
||||||
|
)
|
||||||
|
product.actual_price = 50
|
||||||
|
product.save()
|
||||||
|
|
||||||
|
# Ensure OrderStatus exists
|
||||||
|
OrderStatus.objects.get_or_create(code='completed', is_system=True, defaults={'name': 'Completed', 'is_positive_end': True})
|
||||||
|
OrderStatus.objects.get_or_create(code='draft', is_system=True, defaults={'name': 'Draft'})
|
||||||
|
|
||||||
|
# Prepare Request
|
||||||
|
factory = RequestFactory()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"warehouse_id": warehouse.id,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "product",
|
||||||
|
"id": product.id,
|
||||||
|
"quantity": 1,
|
||||||
|
"price": 100.00, # Custom price
|
||||||
|
"quantity_base": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payments": [
|
||||||
|
{"payment_method": "cash", "amount": 100.00}
|
||||||
|
],
|
||||||
|
"notes": "Debug Sale"
|
||||||
|
}
|
||||||
|
|
||||||
|
request = factory.post(
|
||||||
|
'/pos/api/checkout/',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
print("Executing pos_checkout...")
|
||||||
|
response = pos_checkout(request)
|
||||||
|
print(f"Response: {response.content}")
|
||||||
|
|
||||||
|
# Verify Sale
|
||||||
|
sales = Sale.objects.filter(product=product).order_by('-id')[:1]
|
||||||
|
if sales:
|
||||||
|
sale = sales[0]
|
||||||
|
print(f"Sale created. ID: {sale.id}")
|
||||||
|
print(f"Sale Quantity: {sale.quantity}")
|
||||||
|
print(f"Sale Price: {sale.sale_price}")
|
||||||
|
if sale.sale_price == 0:
|
||||||
|
print("FAILURE: Sale price is 0!")
|
||||||
|
else:
|
||||||
|
print(f"SUCCESS: Sale price is {sale.sale_price}")
|
||||||
|
else:
|
||||||
|
print("FAILURE: No Sale created!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
# Replace with actual schema name if needed, assuming 'public' for now or the default tenant
|
||||||
|
# Since I don't know the tenant, I'll try to run in the current context.
|
||||||
|
# But usually need to set schema.
|
||||||
|
# Let's try to find a tenant.
|
||||||
|
from tenants.models import Client
|
||||||
|
tenant = Client.objects.first()
|
||||||
|
if tenant:
|
||||||
|
print(f"Running in tenant: {tenant.schema_name}")
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
run()
|
||||||
|
else:
|
||||||
|
print("No tenant found, running in public?")
|
||||||
|
run()
|
||||||
@@ -153,8 +153,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
statusBadge.style.display = 'inline';
|
statusBadge.style.display = 'inline';
|
||||||
|
|
||||||
// Построить форму
|
// Построить форму (теперь асинхронно)
|
||||||
buildForm(data.fields, data.data || {});
|
await buildForm(data.fields, data.data || {});
|
||||||
|
|
||||||
// Показать/скрыть кнопку тестирования
|
// Показать/скрыть кнопку тестирования
|
||||||
const testBtn = document.getElementById('test-connection-btn');
|
const testBtn = document.getElementById('test-connection-btn');
|
||||||
@@ -173,11 +173,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Построение формы из метаданных полей
|
// Построение формы из метаданных полей
|
||||||
function buildForm(fields, data) {
|
async function buildForm(fields, data) {
|
||||||
const container = document.getElementById('settings-fields');
|
const container = document.getElementById('settings-fields');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
fields.forEach(field => {
|
for (const field of fields) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'mb-3';
|
div.className = 'mb-3';
|
||||||
|
|
||||||
@@ -189,7 +189,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
|
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
|
||||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
} else if (field.type === 'select') {
|
} else if (field.type === 'select') {
|
||||||
|
let optionsHtml = '';
|
||||||
|
|
||||||
|
if (field.dynamic_choices) {
|
||||||
|
// Динамическая загрузка options
|
||||||
|
optionsHtml = '<option value="">Загрузка моделей...</option>';
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<label class="form-label" for="field-${field.name}">
|
<label class="form-label" for="field-${field.name}">
|
||||||
${field.label}
|
${field.label}
|
||||||
@@ -198,18 +205,66 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<select class="form-select" id="field-${field.name}"
|
<select class="form-select" id="field-${field.name}"
|
||||||
name="${field.name}"
|
name="${field.name}"
|
||||||
${field.required ? 'required' : ''}>
|
${field.required ? 'required' : ''}>
|
||||||
${field.choices.map(choice => `
|
${optionsHtml}
|
||||||
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
|
|
||||||
${choice[1]}
|
|
||||||
</option>
|
|
||||||
`).join('')}
|
|
||||||
</select>
|
</select>
|
||||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
|
||||||
|
// Асинхронная загрузка
|
||||||
|
const select = div.querySelector('select');
|
||||||
|
try {
|
||||||
|
const response = await fetch(field.choices_url);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
|
||||||
|
console.error(result.error);
|
||||||
} else {
|
} else {
|
||||||
|
select.innerHTML = result.models.map(m =>
|
||||||
|
`<option value="${m.id}">${m.name}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
if (data[field.name]) {
|
||||||
|
select.value = data[field.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
|
||||||
|
console.error('Error loading models:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Статический select (для temperature)
|
||||||
|
optionsHtml = field.choices.map(choice => `
|
||||||
|
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
|
||||||
|
${choice[1]}
|
||||||
|
</option>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<label class="form-label" for="field-${field.name}">
|
||||||
|
${field.label}
|
||||||
|
${field.required ? '<span class="text-danger">*</span>' : ''}
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="field-${field.name}"
|
||||||
|
name="${field.name}"
|
||||||
|
${field.required ? 'required' : ''}>
|
||||||
|
${optionsHtml}
|
||||||
|
</select>
|
||||||
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// text, password, url
|
||||||
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
|
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
|
||||||
const value = data[field.name] || '';
|
let value = data[field.name] || '';
|
||||||
const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : '';
|
const isMasked = value === '••••••••';
|
||||||
|
const placeholder = isMasked ? 'Ключ сохранён. Оставьте пустым, чтобы не менять' : '';
|
||||||
|
|
||||||
|
// Для password полей показываем звёздочки (8 штук как индикатор сохранённого ключа)
|
||||||
|
const inputValue = (field.type === 'password' && isMasked) ? '********' : value;
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<label class="form-label" for="field-${field.name}">
|
<label class="form-label" for="field-${field.name}">
|
||||||
@@ -217,15 +272,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
${field.required ? '<span class="text-danger">*</span>' : ''}
|
${field.required ? '<span class="text-danger">*</span>' : ''}
|
||||||
</label>
|
</label>
|
||||||
<input type="${inputType}" class="form-control" id="field-${field.name}"
|
<input type="${inputType}" class="form-control" id="field-${field.name}"
|
||||||
name="${field.name}" value="${value !== '........' ? value : ''}"
|
name="${field.name}" value="${inputValue}"
|
||||||
placeholder="${placeholder}"
|
placeholder="${placeholder}"
|
||||||
${field.required ? 'required' : ''}>
|
${field.required && !isMasked ? 'required' : ''}>
|
||||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type !== 'select' || !field.dynamic_choices) {
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик клика на интеграцию
|
// Обработчик клика на интеграцию
|
||||||
@@ -313,9 +370,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Собрать данные формы
|
// Собрать данные формы
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
// Пропустить пустые password поля (не менять если не введено)
|
// Пропустить пустые password поля или звёздочки (не менять если не введено новое значение)
|
||||||
const input = document.getElementById(`field-${key}`);
|
const input = document.getElementById(`field-${key}`);
|
||||||
if (input && input.type === 'password' && !value) continue;
|
if (input && input.type === 'password' && (!value || value === '********')) continue;
|
||||||
data[key] = value;
|
data[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
test_bouquet_api.py
Normal file
54
test_bouquet_api.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Простой тест для проверки API-эндпоинтов генератора названий букетов
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
# Настройка Django
|
||||||
|
sys.path.append(r'c:\Users\team_\Desktop\test_qwen')
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
def test_bouquet_api_endpoints():
|
||||||
|
client = Client()
|
||||||
|
|
||||||
|
print("Тестируем API-эндпоинты для названий букетов...")
|
||||||
|
|
||||||
|
# Тестируем получение случайных названий
|
||||||
|
print("\n1. Тестируем получение случайных названий...")
|
||||||
|
response = client.get('/products/api/bouquet-names/random/?count=3')
|
||||||
|
print(f"Статус: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Получено названий: {len(data.get('names', []))}")
|
||||||
|
print(f"Примеры: {data.get('names', [])[:2]}")
|
||||||
|
else:
|
||||||
|
print(f"Ошибка: {response.content.decode()}")
|
||||||
|
|
||||||
|
# Тестируем получение количества названий
|
||||||
|
print("\n2. Тестируем получение количества названий...")
|
||||||
|
response = client.get('/products/api/bouquet-names/count/')
|
||||||
|
print(f"Статус: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Количество названий в базе: {data.get('count', 0)}")
|
||||||
|
else:
|
||||||
|
print(f"Ошибка: {response.content.decode()}")
|
||||||
|
|
||||||
|
# Попробуем сгенерировать названия (только если есть настройки для AI)
|
||||||
|
print("\n3. Попробуем сгенерировать названия...")
|
||||||
|
try:
|
||||||
|
response = client.post('/products/api/bouquet-names/generate/', {'count': 5})
|
||||||
|
print(f"Статус: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Результат генерации: {data}")
|
||||||
|
else:
|
||||||
|
print(f"Ошибка генерации: {response.content.decode()}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Исключение при генерации: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_bouquet_api_endpoints()
|
||||||
Reference in New Issue
Block a user