Compare commits

..

24 Commits

Author SHA1 Message Date
5700314b10 feat(ui): replace alert notifications with toast messages
Add toast notification functionality using Bootstrap Toasts and update
checkout success/error handling to use toast messages instead of alert boxes.

**Changes:**
- Add `showToast` function to `terminal.js`
- Add toast container and templates to `terminal.html`
- Replace alert() calls in handleCheckoutSubmit with showToast()
2026-01-26 17:44:03 +03:00
b24a0d9f21 feat: Add UI for inventory transfer list and detail views. 2026-01-25 16:44:54 +03:00
034be20a5a feat: add showcase manager service 2026-01-25 15:28:41 +03:00
f75e861bb8 feat: Add new inventory and POS components, including a script to reproduce a POS checkout sale price bug. 2026-01-25 15:26:57 +03:00
5a66d492c8 feat: Add product kit views. 2026-01-25 00:52:03 +03:00
6cd0a945de feat: Add product kit creation view and its corresponding template. 2026-01-25 00:50:38 +03:00
41e6c33683 feat: Add Product Kit creation and editing functionality with new views and templates. 2026-01-25 00:09:45 +03:00
bf399996b8 fix(products): remove obsolete delete methods from ProductKit
Remove custom delete() and hard_delete() methods that referenced
non-existent is_deleted/deleted_at fields. ProductKit now uses
the correct implementation from BaseProductEntity which uses
status='discontinued' for soft delete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:52:32 +03:00
2bc70968c3 fix(pos): restrict quantity editing for showcase kits in cart edit modal
For showcase kits (showcase_kit type), the quantity field is now disabled
in the cart item edit modal since these are pre-assembled physical items
with reservations. Price editing remains available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:49:02 +03:00
38fbf36731 feat(pos): add write-off functionality for showcase kits
Add support for writing off showcase kits by creating a write-off document with components, converting reservations, and updating statuses.

- Add `write_off_from_showcase` static method to ShowcaseManager
- Add API endpoint `/pos/api/product-kits/<int:kit_id>/write-off/`
- Add write-off button to POS terminal UI
- Implement confirmation dialog with detailed information
- Add redirect to write-off document detail page after success

The write-off process includes:
1. Creating a write-off document in draft state
2. Converting existing reservations to write-off document items
3. Marking the showcase item as dismantled
4. Setting the product kit status to discontinued (if not already)

Breaking Changes: No
2026-01-24 03:21:56 +03:00
9c91a99189 refactor(pos): simplify showcase kit default name
Replace datetime-based naming with simple "Витринный букет XXX" format,
where XXX is a random 3-digit number (100-999). Date is now handled by
the separate showcase_created_at field.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:54:58 +03:00
1eec8b1cd5 chore(pos): remove debug logs from showcase date feature
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:44:56 +03:00
977ee91fee feat(pos): add editable showcase creation date for kits
- Add showcase_created_at field to ProductKit model
- Display days ago as badge in product card (0 дней, 1 день, etc.)
- Add date input field in edit modal
- Auto-set current date/time for new showcase kits

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:37:27 +03:00
fce8d9eb6e fix(products): correct kit-update URL to productkit-update in category list
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:00:46 +03:00
5070913346 fix(products): correct URL name for kit detail in category list template
Changed 'products:kit-detail' to 'products:productkit-detail' in category_list.html
to fix NoReverseMatch error when rendering category tree with kits.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 23:53:49 +03:00
87f6484258 fix(products): update kit price calculation to use actual_price instead of get_sale_price() 2026-01-23 23:51:09 +03:00
14c1a4f804 chore(config): enable debug mode in Django settings 2026-01-23 23:48:23 +03:00
adbbd7539b fix(orders): ensure modified_by field is set correctly for CustomUser instances
Add type check for request.user being CustomUser instance before setting order.modified_by field
in order_create and order_update views to prevent errors when user is not a CustomUser (e.g., admin user)
2026-01-23 23:28:49 +03:00
5ec5ee48d4 feat(integrations): add dynamic OpenRouter model loading
- Remove hardcoded OPENROUTER_MODEL_CHOICES from openrouter.py
- Add API endpoint /integrations/openrouter/models/ to fetch models dynamically
- Models loaded from OpenRouter API with free models (':free') at top
- Update OpenRouterIntegration model_name field (remove choices, blank=True)
- Add async buildForm() with dynamic_choices support
- Show asterisks (********) for saved API keys with helpful placeholder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:16:12 +03:00
3aac83474b refactor(ai): improve bouquet name balancing and normalization
- Filter names by word count (2, 3, 4 words) for balanced distribution
- Remove duplicates per word count category
- Merge names in 2:3:4 proportion to ensure equal representation
- Update normalization to lowercase all words except first letter of first word
- Replace simple deduplication with structured balancing logic
2026-01-23 17:44:02 +03:00
4a624d5fef feat(ai): улучшить требования к генерации названий букетов
- Изменить требование к количеству слов на равную пропорцию 2, 3 или 4 слов
- Добавить новые требования к качеству названий
- Добавить примеры хороших названий для лучшего понимания
- Улучшить структуру и читаемость запроса к AI-сервису
2026-01-23 15:25:25 +03:00
9ddf54f398 refactor(ai): улучшить архитектуру генератора названий букетов
- Добавить константы для параметров генерации
- Улучшить валидацию входных параметров
- Оптимизировать выбор AI-сервиса
- Реализовать нормализацию регистра названий
- Добавить обработку ошибок при сохранении в базу данных
- Улучшить логику фильтрации нежелательных префиксов
- Рефакторить метод generate_and_store для лучшей читаемости
2026-01-23 15:18:51 +03:00
84cfc5cd47 Улучшение генератора названий для букетов
- Добавлена функциональность для кнопок 'ВЗЯТЬ' и 'УДАЛИТЬ'
- Реализовано получение и удаление названий из базы данных
- Исправлена фильтрация названий
- Исправлена проблема с обработчиками событий
2026-01-23 14:10:00 +03:00
59f7a7c520 feat: add OpenRouter AI service integration 2026-01-22 22:11:39 +03:00
31 changed files with 2808 additions and 1584 deletions

View File

@@ -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='Название модели'),
),
]

View File

@@ -10,14 +10,6 @@ def validate_temperature(value):
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 = [
(0.1, '0.1 - Очень консервативно'),
@@ -59,11 +51,11 @@ class OpenRouterIntegration(AIIntegration):
)
model_name = models.CharField(
max_length=100,
default="xiaomi/mimo-v2-flash:free",
choices=OPENROUTER_MODEL_CHOICES,
max_length=200,
default="",
blank=True,
verbose_name="Название модели",
help_text="Название используемой модели OpenRouter"
help_text="Название используемой модели OpenRouter (загружается автоматически)"
)
temperature = models.FloatField(

View File

@@ -3,28 +3,45 @@ from ..base import BaseIntegrationService
from .config import get_openrouter_config
import logging
import sys
import locale
import traceback
# Патч для исправления проблемы с кодировкой в httpx на Windows
# Устанавливаем кодировку по умолчанию для Python
if sys.platform == 'win32':
try:
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):
"""Патч для использования UTF-8 вместо ASCII для заголовков"""
try:
# Если значение уже bytes, возвращаем его как есть
if isinstance(value, bytes):
return value
# Если значение не строка и не байты, приводим к строке
if not isinstance(value, str):
value = str(value)
# Всегда используем UTF-8 вместо ASCII
encoding = encoding or 'utf-8'
if encoding.lower() == 'ascii':
encoding = 'utf-8'
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
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:
logging.getLogger(__name__).warning(f"Failed to apply httpx patch: {e}")
@@ -148,8 +165,10 @@ class OpenRouterIntegrationService(BaseIntegrationService):
}
except Exception as e:
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {str(e)}")
return False, f"Ошибка генерации: {str(e)}", None
error_msg = str(e)
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {error_msg}")
logger.error(traceback.format_exc())
return False, f"Ошибка генерации: {error_msg}", None
def generate_code(self,
prompt: str,
@@ -196,5 +215,7 @@ class OpenRouterIntegrationService(BaseIntegrationService):
}
except Exception as e:
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {str(e)}")
return False, f"Ошибка генерации кода: {str(e)}", None
error_msg = str(e)
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {error_msg}")
logger.error(traceback.format_exc())
return False, f"Ошибка генерации кода: {error_msg}", None

View File

@@ -6,6 +6,7 @@ from .views import (
get_integration_form_data,
test_integration_connection,
RecommerceBatchSyncView,
get_openrouter_models,
)
app_name = 'integrations'
@@ -22,4 +23,7 @@ urlpatterns = [
# Синхронизация
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
# OpenRouter модели
path("openrouter/models/", get_openrouter_models, name="openrouter_models"),
]

View File

@@ -1,7 +1,10 @@
import json
import logging
from django.views.generic import TemplateView
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 .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
@@ -181,6 +184,44 @@ def get_integration_service(integration_id: str, instance):
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):
"""
API View для запуска массовой синхронизации с Recommerce.
@@ -363,29 +404,33 @@ def get_form_fields_meta(model):
'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''),
'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)
elif field_name in ['model_name', 'temperature']:
elif field_name == 'temperature':
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', # dropdown
'type': 'select',
'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)
# Для WooCommerce показываем только базовые поля для подключения
elif model.__name__ == 'WooCommerceIntegration':

View File

@@ -162,8 +162,6 @@ class ShowcaseManager:
Raises:
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
"""
from inventory.services.sale_processor import SaleProcessor
sold_count = 0
order = order_item.order
@@ -207,17 +205,9 @@ class ShowcaseManager:
# Сначала устанавливаем order_item для правильного определения цены
reservation.order_item = order_item
reservation.save()
# Теперь создаём продажу с правильной ценой из OrderItem
SaleProcessor.create_sale_from_reservation(
reservation=reservation,
order=order
)
# Обновляем статус резерва
reservation.status = 'converted_to_sale'
reservation.converted_at = timezone.now()
# ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
# Это сделает сигнал create_sale_on_order_completion автоматически.
# Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
reservation.save()
sold_count += 1
@@ -666,6 +656,113 @@ class ShowcaseManager:
'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
def get_showcase_items_for_pos(showcase=None):
"""

View File

@@ -366,7 +366,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
# Проверяем, есть ли уже Sale для этого заказа
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)
return
@@ -376,7 +376,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
previous_status = getattr(instance, '_previous_status', None)
if previous_status and previous_status.is_positive_end:
logger.info(
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
f"Заказ {instance.order_number}: повторный переход в положительный статус "
f"({previous_status.name}{instance.status.name}). Проверяем Sale..."
)
if Sale.objects.filter(order=instance).exists():
@@ -454,12 +454,65 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
)
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 для каждого компонента комплекта
for reservation in kit_reservations:
try:
# Рассчитываем цену продажи компонента пропорционально цене комплекта
# Используем actual_price компонента как цену продажи
component_sale_price = reservation.product.actual_price
# Рассчитываем цену продажи компонента пропорционально
catalog_price = reservation.product.actual_price or Decimal('0')
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(
product=reservation.product,
@@ -472,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
sales_created.append(sale)
logger.info(
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:
logger.error(
@@ -548,6 +602,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
else:
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 = SaleProcessor.create_sale(
product=product,

View File

@@ -74,10 +74,12 @@
{% for item in items %}
<tr>
<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 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">
<span class="badge bg-secondary">{{ item.batch.id }}</span>
</td>
@@ -132,9 +134,11 @@
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
</a>
<!--
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash me-1"></i>Удалить
</a>
-->
</div>
</div>
</div>

View File

@@ -39,9 +39,11 @@
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
<!--
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
-->
</td>
</tr>
{% endfor %}

View File

@@ -18,7 +18,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Initialize environment variables
env = environ.Env(
# 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'),
)
@@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY:
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
)

View File

@@ -91,7 +91,11 @@ def order_create(request):
order.recipient = None
# Статус берём из формы (в том числе может быть "Черновик")
from accounts.models import CustomUser
if isinstance(request.user, CustomUser):
order.modified_by = request.user
else:
order.modified_by = None
# Сохраняем заказ в БД (теперь у него есть pk)
order.save()
@@ -367,11 +371,16 @@ def order_update(request, order_number):
# Сохраняем получателя: если новый - создаем, если существующий - обновляем
recipient.save() # Django автоматически определит create или update
order.recipient = recipient
else:
# Если покупатель является получателем
order.recipient = None
from accounts.models import CustomUser
if isinstance(request.user, CustomUser):
order.modified_by = request.user
else:
# Если это админ платформы, не перезаписываем поле (оставляем как есть)
pass
order.save()

View File

@@ -38,6 +38,9 @@
editingCartKey = cartKey;
basePrice = parseFloat(item.price) || 0;
// Проверяем, является ли товар витринным комплектом
const isShowcaseKit = item.type === 'showcase_kit';
// Заполнение полей
document.getElementById('editModalProductName').textContent = item.name || '—';
@@ -48,6 +51,17 @@
document.getElementById('editModalPrice').value = roundPrice(basePrice);
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');
if (item.unit_name) {
@@ -99,8 +113,13 @@
// Используем roundQuantity из terminal.js
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;
// Для витринных комплектов не меняем количество
if (!isShowcaseKit) {
item.qty = rndQty(newQty, 3);
}
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
window.cart.set(editingCartKey, item);

View File

@@ -12,6 +12,38 @@ function roundQuantity(value, decimals = 3) {
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
/**
* Показывает toast уведомление в правом верхнем углу
* @param {string} type - 'success' или 'error'
* @param {string} message - Текст сообщения
*/
function showToast(type, message) {
const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast';
const messageId = type === 'success' ? 'toastMessage' : 'errorMessage';
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
const toastElement = document.getElementById(toastId);
const messageElement = document.getElementById(messageId);
// Устанавливаем сообщение
messageElement.textContent = message;
// Добавляем цвет фона
toastElement.classList.add(bgClass, 'text-white');
// Создаём и показываем toast (автоматически скроется через 3 секунды)
const toast = new bootstrap.Toast(toastElement, {
delay: 3000,
autohide: true
});
toast.show();
// Убираем класс цвета после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.classList.remove(bgClass, 'text-white');
}, { once: true });
}
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
let ITEMS = []; // Будем загружать через API
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent);
@@ -98,6 +130,37 @@ function formatMoney(v) {
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 +974,7 @@ function renderProducts() {
const stock = document.createElement('div');
stock.className = 'product-stock';
// Для витринных комплектов показываем название витрины И количество (доступно/всего)
// Для витринных комплектов показываем количество (доступно/всего) и дней на витрине
if (item.type === 'showcase_kit') {
const availableCount = item.available_count || 0;
const totalCount = item.total_count || availableCount;
@@ -922,7 +985,14 @@ function renderProducts() {
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
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.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
@@ -1783,8 +1853,8 @@ async function openCreateTempKitModal() {
});
// Генерируем название по умолчанию
const now = new Date();
const defaultName = `Витрина${now.toLocaleDateString('ru-RU')} ${now.toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'})}`;
const randomSuffix = Math.floor(Math.random() * 900) + 100;
const defaultName = `Витринный букет ${randomSuffix}`;
document.getElementById('tempKitName').value = defaultName;
// Загружаем список витрин
@@ -1837,6 +1907,19 @@ async function openEditKitModal(kitId) {
// Заполняем поля формы
document.getElementById('tempKitName').value = kit.name;
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('priceAdjustmentValue').value = kit.price_adjustment_value;
@@ -1872,6 +1955,7 @@ async function openEditKitModal(kitId) {
// По<D09F><D0BE>азываем кнопку "Разобрать" и блок добавления товаров
document.getElementById('disassembleKitBtn').style.display = 'block';
document.getElementById('writeOffKitBtn').style.display = 'block';
document.getElementById('showcaseKitQuantityBlock').style.display = 'none';
document.getElementById('addProductBlock').style.display = 'block';
@@ -2275,6 +2359,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
const kitName = document.getElementById('tempKitName').value.trim();
const showcaseId = document.getElementById('showcaseSelect').value;
const description = document.getElementById('tempKitDescription').value.trim();
const showcaseCreatedAt = document.getElementById('showcaseCreatedAt').value;
const photoFile = document.getElementById('tempKitPhoto').files[0];
// Валидация
@@ -2329,13 +2414,15 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
}
formData.append('description', description);
if (showcaseCreatedAt) {
formData.append('showcase_created_at', showcaseCreatedAt);
}
formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue);
// Если пользователь не задал свою цену, используем вычисленную
const finalSalePrice = useSalePrice ? salePrice : calculatedPrice;
if (finalSalePrice > 0) {
formData.append('sale_price', finalSalePrice);
// Если пользователь явно указал свою цену
if (useSalePrice && salePrice > 0) {
formData.append('sale_price', salePrice);
}
// Фото: для редактирования проверяем, удалено ли оно
@@ -2398,6 +2485,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
// Сбрасываем поля формы
document.getElementById('tempKitDescription').value = '';
document.getElementById('showcaseCreatedAt').value = '';
document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('priceAdjustmentType').value = 'none';
@@ -2498,6 +2586,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() {
// Проверяем по юзер-агенту и размеру экрана
@@ -2560,8 +2695,9 @@ document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal'
document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины';
document.getElementById('confirmCreateTempKit').innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
// Скрываем кнопку "Разобрать" и блок добавления товаров
// Скрываем кнопки "Разобрать" и "Списать" и блок добавления товаров
document.getElementById('disassembleKitBtn').style.display = 'none';
document.getElementById('writeOffKitBtn').style.display = 'none';
document.getElementById('showcaseKitQuantityBlock').style.display = 'block';
document.getElementById('addProductBlock').style.display = 'none';
}
@@ -3311,8 +3447,8 @@ async function handleCheckoutSubmit(paymentsData) {
if (result.success) {
console.log('✅ Заказ успешно создан:', result);
// Успех
alert(`Заказ #${result.order_number} успешно создан!\nСумма: ${result.total_amount.toFixed(2)} руб.`);
// Показываем toast уведомление
showToast('success', `Заказ #${result.order_number} успешно создан! Сумма: ${result.total_amount.toFixed(2)} руб.`);
// Очищаем корзину
cart.clear();
@@ -3333,12 +3469,12 @@ async function handleCheckoutSubmit(paymentsData) {
}, 500);
} else {
alert('Ошибка: ' + result.error);
showToast('error', 'Ошибка: ' + result.error);
}
} catch (error) {
console.error('Ошибка checkout:', error);
alert('Ошибка при проведении продажи: ' + error.message);
showToast('error', 'Ошибка при проведении продажи: ' + error.message);
} finally {
// Разблокируем кнопку
const btn = document.getElementById('confirmCheckoutBtn');

View File

@@ -42,6 +42,9 @@
<label for="editModalQuantity" class="form-label fw-semibold">Количество</label>
<input type="number" class="form-control" id="editModalQuantity"
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>
<!-- Итого -->

View File

@@ -219,6 +219,14 @@
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
</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">
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
@@ -322,6 +330,11 @@
<i class="bi bi-scissors"></i> Разобрать букет
</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-primary" id="confirmCreateTempKit">
@@ -716,6 +729,28 @@
<!-- Модалка редактирования товара в корзине -->
{% include 'pos/components/edit_cart_item_modal.html' %}
<!-- Toast Container для уведомлений -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1060;">
<div id="orderSuccessToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-check-circle-fill text-success me-2 fs-5"></i>
<span id="toastMessage"></span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" style="display: none;"></button>
</div>
</div>
<div id="orderErrorToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-exclamation-circle-fill text-danger me-2 fs-5"></i>
<span id="errorMessage"></span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}

View File

@@ -33,6 +33,8 @@ urlpatterns = [
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
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]
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
# Создать заказ и провести оплату в POS [POST]

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@@ -13,7 +14,7 @@ import json
import logging
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.signals import skip_sale_creation, reset_sale_creation
@@ -83,6 +84,7 @@ def get_showcase_kits_for_pos():
'product_kit__price',
'product_kit__sale_price',
'product_kit__base_price',
'product_kit__showcase_created_at',
'showcase_id',
'showcase__name'
).annotate(
@@ -161,7 +163,9 @@ def get_showcase_kits_for_pos():
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
'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
@@ -1052,7 +1056,8 @@ def get_product_kit_details(request, kit_id):
'final_price': str(kit.actual_price),
'showcase_id': showcase_id,
'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:
@@ -1087,6 +1092,7 @@ def create_temp_kit_to_showcase(request):
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
# Парсим items из JSON
items = json.loads(items_json)
@@ -1101,6 +1107,23 @@ def create_temp_kit_to_showcase(request):
except (ValueError, InvalidOperation):
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:
return JsonResponse({
@@ -1161,7 +1184,8 @@ def create_temp_kit_to_showcase(request):
price_adjustment_type=price_adjustment_type,
price_adjustment_value=price_adjustment_value,
sale_price=sale_price,
showcase=showcase
showcase=showcase,
showcase_created_at=showcase_created_at
)
# 2. Создаём KitItem для каждого товара из корзины
@@ -1296,6 +1320,7 @@ def update_product_kit(request, kit_id):
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
remove_photo = request.POST.get('remove_photo', '') == '1'
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
items = json.loads(items_json)
@@ -1308,6 +1333,23 @@ def update_product_kit(request, kit_id):
except (ValueError, InvalidOperation):
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:
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_value = price_adjustment_value
kit.sale_price = sale_price
if showcase_created_at is not None: # Обновляем только если передана
kit.showcase_created_at = showcase_created_at
kit.save()
# Обновляем состав
@@ -1493,6 +1537,88 @@ def disassemble_product_kit(request, kit_id):
}, 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
@require_http_methods(["POST"])
def pos_checkout(request):

View File

@@ -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='Дата размещения на витрине'),
),
]

View File

@@ -93,6 +93,14 @@ class ProductKit(BaseProductEntity):
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(
'orders.Order',
on_delete=models.SET_NULL,
@@ -332,17 +340,6 @@ class ProductKit(BaseProductEntity):
self.save(update_fields=['is_temporary', 'order'])
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):
"""
Создает снимок текущего состояния комплекта.

View File

@@ -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(
self,
count: int = 500,
@@ -38,17 +44,17 @@ class BouquetNameGenerator(BaseAIProductService):
Returns:
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} названий для букетов")
# Получаем доступный AI-сервис
glm_service = self.get_glm_service()
if not glm_service:
openrouter_service = self.get_openrouter_service()
if not openrouter_service:
service = self.get_glm_service() or self.get_openrouter_service()
if not service:
return False, "Нет активных AI-интеграций", None
service = openrouter_service
else:
service = glm_service
# Формируем промпт
prompt = f"Сгенерируй {count} креативных и привлекательных названий для букетов цветов"
@@ -61,12 +67,18 @@ class BouquetNameGenerator(BaseAIProductService):
prompt += (
"\n\nТребования к каждому названию:\n"
"- Точно 2-4 слова\n"
"- 2, 3 или 4 слова в равных пропорциях\n"
"- Выразительные и эмоциональные\n"
"- Продаваемые и запоминающиеся\n"
"- Избегайте общих названий типа 'Букет #1'\n"
"- Фокусируйтесь на красоте, романтике и подарках\n"
"\nВерните названия в виде нумерованного списка, по одному на строку."
"- Используйте прилагательные и описательные слова\n"
"- Не используйте символы пунктуации в середине названий\n"
"\nВерните названия в виде нумерованного списка, по одному на строку.\n"
"Примеры хороших названий:\n"
"- 2 слова: 'Весенние Розы', 'Летнее Сияние', 'Нежность', 'Романтика'\n"
"- 3 слова: 'Весенний Вальс', 'Нежность Роз', 'Сияние Любви', 'Танец Цветов'\n"
"- 4 слова: 'Шепот Весенней Нежности', 'Сияние Розовой Любви', 'Танец Цветов Весны', 'Шёпот Сердечной Романтики'"
)
# Вызов AI-сервиса
@@ -98,9 +110,7 @@ class BouquetNameGenerator(BaseAIProductService):
for line in lines:
line = line.strip()
# Пропускаем пустые строки и заголовки
if not line or line.lower().startswith('here') or line.lower().startswith('names') or \
line.lower().startswith('i\'m') or line.lower().startswith('sorry') or \
line.lower().startswith('i hope') or line.lower().startswith('hope'):
if not line or any(line.lower().startswith(prefix) for prefix in self.SKIP_PREFIXES):
continue
# Удаляем номера списка
@@ -119,17 +129,61 @@ class BouquetNameGenerator(BaseAIProductService):
line = line.replace('**', '').replace('*', '').replace('"', '').replace("'", '').strip()
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:
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:
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(
self,
@@ -148,7 +202,10 @@ class BouquetNameGenerator(BaseAIProductService):
if success and data:
# Сохраняем названия в базу
stored_count = 0
failed_count = 0
for name in data['names']:
try:
BouquetName.objects.get_or_create(
name=name,
language=language,
@@ -157,8 +214,15 @@ class BouquetNameGenerator(BaseAIProductService):
}
)
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

View File

@@ -65,7 +65,7 @@
{% elif item.item_type == 'kit' %}
<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>
{% endif %}
</td>
@@ -100,7 +100,7 @@
{% elif item.item_type == 'product' %}
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% 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 %}
</td>
</tr>

View File

@@ -76,7 +76,8 @@
</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="photoPreview" class="row g-1"></div>
</div>
@@ -97,13 +98,14 @@
<div class="card-body p-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>
<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> Пополнить базу названиями
</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> Дать три варианта
</button>
</div>
@@ -111,30 +113,36 @@
<!-- Предложения названий -->
<div class="name-suggestions">
<!-- Строка 1 -->
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
<span class="text-muted small">Романтический букет роз</span>
<div class="d-flex gap-1">
<button type="button" class="btn btn-success btn-xs">Взять</button>
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
data-name-id="">
<span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="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>
<!-- Строка 2 -->
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
<span class="text-muted small">Солнечный букет подсолнухов</span>
<div class="d-flex gap-1">
<button type="button" class="btn btn-success btn-xs">Взять</button>
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
data-name-id="">
<span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="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>
<!-- Строка 3 -->
<div class="d-flex justify-content-between align-items-center py-2">
<span class="text-muted small">Элегантный букет лотосов</span>
<div class="d-flex gap-1">
<button type="button" class="btn btn-success btn-xs">Взять</button>
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
<div class="d-flex justify-content-between align-items-center py-2 name-row"
data-name-id="">
<span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="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>
@@ -157,8 +165,10 @@
<!-- Базовая цена (отображение) -->
<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">
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен компонентов:</span>
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00 руб.</span>
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен
компонентов:</span>
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00
руб.</span>
</div>
</div>
@@ -172,13 +182,15 @@
<div class="row g-2">
<div class="col-6">
<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>
</div>
</div>
<div class="col-6">
<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>
</div>
</div>
@@ -194,13 +206,15 @@
<div class="row g-2">
<div class="col-6">
<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>
</div>
</div>
<div class="col-6">
<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>
</div>
</div>
@@ -213,8 +227,10 @@
<!-- Итоговая цена -->
<div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;">
<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 id="finalPriceDisplay" class="fw-bold" style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая
цена:</strong></span>
<span id="finalPriceDisplay" class="fw-bold"
style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
</div>
</div>
@@ -230,7 +246,8 @@
<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>
{{ form.sale_price }}
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной цены</small>
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной
цены</small>
{% if form.sale_price.errors %}
<div class="text-danger small mt-1">{{ form.sale_price.errors }}</div>
{% endif %}
@@ -299,7 +316,8 @@
</div>
<!-- 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>
@@ -428,6 +446,7 @@
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -531,7 +550,9 @@
/* Адаптивность */
@media (max-width: 991px) {
.col-lg-8, .col-lg-4 {
.col-lg-8,
.col-lg-4 {
max-width: 100%;
}
}
@@ -540,6 +561,10 @@
<!-- Select2 инициализация -->
{% 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>
document.addEventListener('DOMContentLoaded', function () {
// ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ==========
@@ -553,6 +578,7 @@ document.addEventListener('DOMContentLoaded', function() {
const finalPriceDisplay = document.getElementById('finalPriceDisplay');
let basePrice = 0;
let activeUpdates = 0; // Счетчик активных обновлений
// Кэш цен товаров для быстрого доступа
const priceCache = {};
@@ -746,6 +772,19 @@ document.addEventListener('DOMContentLoaded', function() {
// Функция для обновления списка единиц продажи при выборе товара
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.disabled = true;
@@ -764,7 +803,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
if (isNaN(productId) || productId <= 0) {
console.warn('updateSalesUnitsOptions: invalid product id', productValue);
return;
}
@@ -786,13 +824,25 @@ document.addEventListener('DOMContentLoaded', function() {
salesUnitSelect.appendChild(option);
});
salesUnitSelect.disabled = false;
// Обновляем Select2
// Восстанавливаем значение
if (targetValue) {
$(salesUnitSelect).val(targetValue).trigger('change');
} else {
// Обновляем Select2 без значения
$(salesUnitSelect).trigger('change');
}
}
}
} catch (error) {
console.error('Error fetching sales units:', error);
}
} finally {
activeUpdates--; // Завершаем обновление
if (activeUpdates === 0) {
calculateFinalPrice();
}
}
}
// Обновляем data-product-id и загружаем цену при выборе товара
@@ -812,8 +862,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (salesUnitSelect) {
await updateSalesUnitsOptions(salesUnitSelect, this.value);
}
calculateFinalPrice();
}
calculateFinalPrice();
}).on('select2:unselect', function () {
const form = $(this).closest('.kititem-form');
// Очищаем список единиц продажи
@@ -888,6 +938,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Функция для расчета финальной цены
async function calculateFinalPrice() {
// Если идут обновления - не считаем, ждем их завершения
if (activeUpdates > 0) {
return;
}
// Получаем базовую цену (сумма всех компонентов)
let newBasePrice = 0;
const formsContainer = document.getElementById('kititem-forms');
@@ -1063,8 +1118,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Инициальный расчет (асинхронно)
calculateFinalPrice();
// Инициальный расчет не нужен, так как он выполняется по событиям изменения полей
// и после завершения загрузки единиц продажи
// ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ==========
function initSelect2(element, type, preloadedData) {
@@ -1075,9 +1130,9 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
const selectedProducts = {{ selected_products|default:"{}"|safe }};
const selectedVariants = {{ selected_variants|default:"{}"|safe }};
const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }};
const selectedProducts = JSON.parse(document.getElementById('selected-products-data').textContent || '{}');
const selectedVariants = JSON.parse(document.getElementById('selected-variants-data').textContent || '{}');
const selectedSalesUnits = JSON.parse(document.getElementById('selected-sales-units-data').textContent || '{}');
$('[name$="-product"]').each(function () {
const fieldName = $(this).attr('name');
@@ -1318,12 +1373,38 @@ document.addEventListener('DOMContentLoaded', function() {
reader.readAsDataURL(file);
});
} else {
photoPreviewContainer.style.display = 'none';
photoPreviewContainer.style.display = 'none'; // Only hide if no source photos too (will check later)
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) {
selectedFiles.splice(index, 1);
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();
});
});
// Обработчик для кнопок "Убрать"
document.querySelectorAll('.name-suggestions .btn-outline-danger').forEach(button => {
button.addEventListener('click', function() {
this.closest('.d-flex').remove();
});
});
// Обработчик для кнопок "Потом"
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;
// Обработчик для кнопки "Пополнить базу названиям<D18F><D0BC>"
const populateNamesBtn = document.getElementById('populateNamesBtn');
if (populateNamesBtn) {
populateNamesBtn.addEventListener('click', async function () {
const originalHTML = populateNamesBtn.innerHTML;
populateNamesBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Пополнение...';
populateNamesBtn.disabled = true;
try {
const response = await fetch("{% url 'products:api-generate-bouquet-names' %}", {
@@ -1478,89 +1516,94 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.success) {
// Обновляем счётчик
updateBouquetNamesCount();
// Загружаем случайные 3
await loadRandomNames();
alert(data.message);
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
} catch (error) {
console.error('Error:', error);
alert('Ошибка при генерации названий. Проверьте, что настроена AI-интеграция.');
alert('Ошибка при пополнении базы названий. Проверьте, что настроена AI-интеграция.');
} finally {
generateBtn.innerHTML = originalHTML;
generateBtn.disabled = false;
populateNamesBtn.innerHTML = originalHTML;
populateNamesBtn.disabled = false;
}
});
}
// Обработчик для кнопки "Случайное"
const randomBtn = document.querySelector('#nameGeneratorCollapse .btn-outline-secondary');
if (randomBtn) {
randomBtn.addEventListener('click', loadRandomNames);
// Обработчик для кнопки "Дать три варианта"
const getThreeNamesBtn = document.getElementById('getThreeNamesBtn');
if (getThreeNamesBtn) {
getThreeNamesBtn.addEventListener('click', loadThreeRandomNames);
}
async function loadRandomNames() {
const originalHTML = randomBtn.innerHTML;
randomBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
randomBtn.disabled = true;
// Функция для загрузки трёх случайных названий
async function loadThreeRandomNames() {
const originalHTML = getThreeNamesBtn.innerHTML;
getThreeNamesBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
getThreeNamesBtn.disabled = true;
try {
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=3");
const data = await response.json();
if (data.names && data.names.length > 0) {
updateNameSuggestions(data.names);
updateNameRows(data.names);
} else {
alert('В базе пока нет названий. Сначала запустите загрузку из JSON или нажмите "Сгенерировать"');
alert('В базе пока нет названий. Сначала запустите загрузку из JSON или нажмите "Пополнить базу названиями"');
}
} catch (error) {
console.error('Error:', error);
alert('Ошибка при загрузке названий');
} finally {
randomBtn.innerHTML = originalHTML;
randomBtn.disabled = false;
getThreeNamesBtn.innerHTML = originalHTML;
getThreeNamesBtn.disabled = false;
}
}
async function updateBouquetNamesCount() {
try {
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=999");
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 updateNameRows(names) {
const rows = document.querySelectorAll('.name-row');
function updateNameSuggestions(names) {
const container = document.querySelector('.name-suggestions');
container.innerHTML = '';
rows.forEach((row, index) => {
const nameTextElement = row.querySelector('.name-text');
const buttonsElement = row.querySelector('.name-buttons');
names.forEach((name, index) => {
const row = document.createElement('div');
row.className = 'd-flex justify-content-between align-items-center py-2' +
(index < names.length - 1 ? ' border-bottom' : '');
row.innerHTML = `
<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>
<button type="button" class="btn btn-outline-danger btn-xs btn-remove-name">Убрать</button>
</div>
`;
container.appendChild(row);
if (index < names.length) {
// Если есть название для этой строки
const nameObj = names[index];
nameTextElement.textContent = nameObj.name;
row.setAttribute('data-name-id', nameObj.id);
// Показываем кнопки
buttonsElement.style.display = 'flex';
} else {
// Если нет названия для этой строки
nameTextElement.textContent = '-';
row.setAttribute('data-name-id', '');
// Скрываем кнопки
buttonsElement.style.display = 'none';
}
});
attachNameButtonHandlers();
// Устанавливаем обработчики событий для новых кнопок
attachNameRowHandlers();
}
function attachNameButtonHandlers() {
document.querySelectorAll('.btn-apply-name').forEach(button => {
button.addEventListener('click', function() {
const name = this.closest('.d-flex').querySelector('.text-muted').textContent;
// Обработчики для кнопок "Взять" и "Удалить"
function attachNameRowHandlers() {
// Обработчик для кнопки "Взять"
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');
if (nameInput) {
nameInput.value = name;
nameInput.value = nameText;
nameInput.style.borderColor = '#198754';
nameInput.style.boxShadow = '0 0 0 0.25rem rgba(25, 135, 84, 0.15)';
setTimeout(() => {
@@ -1568,16 +1611,117 @@ document.addEventListener('DOMContentLoaded', function() {
nameInput.style.boxShadow = '';
}, 2000);
}
});
// Удаляем название из базы данных
if (nameId) {
await removeNameFromDatabase(nameId);
// Заменяем название новым из базы данных
await replaceNameInRow(row);
}
});
document.querySelectorAll('.btn-remove-name').forEach(button => {
button.addEventListener('click', function() {
this.closest('.d-flex').remove();
// Отмечаем, что обработчик уже добавлен
button.dataset.handlerAttached = 'true';
}
});
// Обработчик для кнопки "Удалить"
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"]');
if (kitForm) {

View File

@@ -506,6 +506,9 @@
<a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary">
Отмена
</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">
<i class="bi bi-check-circle me-1"></i>Сохранить изменения
</button>

View File

@@ -58,6 +58,8 @@ urlpatterns = [
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/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)
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),

View File

@@ -158,49 +158,41 @@ class ImageProcessor:
@staticmethod
def _resize_image(img, size):
"""
Изменяет размер изображения с сохранением пропорций.
Изменяет размер изображения с center-crop до точного квадратного размера.
НЕ увеличивает маленькие изображения (сохраняет качество).
Создает адаптивный квадрат по размеру реального изображения.
Создает квадратное изображение без белых полей.
Args:
img: PIL Image object
size: Кортеж (width, height) - максимальный целевой размер
size: Кортеж (width, height) - целевой размер (обычно квадратный)
Returns:
PIL Image object - квадратное изображение с минимальным белым фоном
PIL Image object - квадратное изображение без белых полей
"""
# Копируем изображение, чтобы не модифицировать оригинал
img_copy = img.copy()
target_width, target_height = size
# Вычисляем пропорции исходного изображения и целевого размера
img_aspect = img_copy.width / img_copy.height
target_aspect = size[0] / size[1]
# Шаг 1: Center crop для получения квадрата
# Определяем минимальную сторону (будет размер квадрата)
min_side = min(img_copy.width, img_copy.height)
# Определяем, какой размер будет ограничивающим при масштабировании
if img_aspect > target_aspect:
# Изображение шире - ограничиваемый размер это ширина
new_width = min(img_copy.width, size[0])
new_height = int(new_width / img_aspect)
# Вычисляем координаты для обрезки из центра
left = (img_copy.width - min_side) // 2
top = (img_copy.height - min_side) // 2
right = left + min_side
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:
# Изображение выше - ограничиваемый размер это высота
new_height = min(img_copy.height, size[1])
new_width = int(new_height * img_aspect)
img_resized = img_cropped
# Масштабируем только если необходимо (не увеличиваем маленькие изображения)
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
return img_resized
@staticmethod
def _make_square_image(img, max_size):

View File

@@ -1816,8 +1816,12 @@ class RandomBouquetNamesView(View):
count = int(request.GET.get('count', 3))
# Ограничиваем максимум до 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):
@@ -1843,3 +1847,25 @@ class GenerateBouquetNamesView(View):
})
else:
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})

View File

@@ -41,7 +41,7 @@ class TreeItem:
if item_type == 'product':
self.price = obj.sale_price
elif item_type == 'kit':
self.price = obj.get_sale_price()
self.price = obj.actual_price
else:
self.price = None

View File

@@ -9,9 +9,10 @@ from django.shortcuts import redirect
from django.db import transaction, IntegrityError
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 .utils import handle_photos
import os
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
@@ -97,6 +98,37 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
form_class = ProductKitForm
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):
"""
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
@@ -132,7 +164,6 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {}
selected_variants = {}
selected_sales_units = {}
@@ -194,9 +225,99 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
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:
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()
@@ -235,6 +356,48 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
# Обработка фотографий
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(
self.request,
f'Комплект "{self.object.name}" успешно создан!'

View 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()

View File

@@ -153,8 +153,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
statusBadge.style.display = 'inline';
// Построить форму
buildForm(data.fields, data.data || {});
// Построить форму (теперь асинхронно)
await buildForm(data.fields, data.data || {});
// Показать/скрыть кнопку тестирования
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');
container.innerHTML = '';
fields.forEach(field => {
for (const field of fields) {
const div = document.createElement('div');
div.className = 'mb-3';
@@ -189,7 +189,14 @@ document.addEventListener('DOMContentLoaded', function() {
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
} else if (field.type === 'select') {
let optionsHtml = '';
if (field.dynamic_choices) {
// Динамическая загрузка options
optionsHtml = '<option value="">Загрузка моделей...</option>';
div.innerHTML = `
<label class="form-label" for="field-${field.name}">
${field.label}
@@ -198,18 +205,66 @@ document.addEventListener('DOMContentLoaded', function() {
<select class="form-select" id="field-${field.name}"
name="${field.name}"
${field.required ? 'required' : ''}>
${field.choices.map(choice => `
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
${choice[1]}
</option>
`).join('')}
${optionsHtml}
</select>
${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 {
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 value = data[field.name] || '';
const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : '';
let value = data[field.name] || '';
const isMasked = value === '••••••••';
const placeholder = isMasked ? 'Ключ сохранён. Оставьте пустым, чтобы не менять' : '';
// Для password полей показываем звёздочки (8 штук как индикатор сохранённого ключа)
const inputValue = (field.type === 'password' && isMasked) ? '********' : value;
div.innerHTML = `
<label class="form-label" for="field-${field.name}">
@@ -217,15 +272,17 @@ document.addEventListener('DOMContentLoaded', function() {
${field.required ? '<span class="text-danger">*</span>' : ''}
</label>
<input type="${inputType}" class="form-control" id="field-${field.name}"
name="${field.name}" value="${value !== '........' ? value : ''}"
name="${field.name}" value="${inputValue}"
placeholder="${placeholder}"
${field.required ? 'required' : ''}>
${field.required && !isMasked ? 'required' : ''}>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
}
if (field.type !== 'select' || !field.dynamic_choices) {
container.appendChild(div);
});
}
}
}
// Обработчик клика на интеграцию
@@ -313,9 +370,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Собрать данные формы
for (const [key, value] of formData.entries()) {
// Пропустить пустые password поля (не менять если не введено)
// Пропустить пустые password поля или звёздочки (не менять если не введено новое значение)
const input = document.getElementById(`field-${key}`);
if (input && input.type === 'password' && !value) continue;
if (input && input.type === 'password' && (!value || value === '********')) continue;
data[key] = value;
}

54
test_bouquet_api.py Normal file
View 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()