Compare commits
42 Commits
0b35b80ee7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b24a0d9f21 | |||
| 034be20a5a | |||
| f75e861bb8 | |||
| 5a66d492c8 | |||
| 6cd0a945de | |||
| 41e6c33683 | |||
| bf399996b8 | |||
| 2bc70968c3 | |||
| 38fbf36731 | |||
| 9c91a99189 | |||
| 1eec8b1cd5 | |||
| 977ee91fee | |||
| fce8d9eb6e | |||
| 5070913346 | |||
| 87f6484258 | |||
| 14c1a4f804 | |||
| adbbd7539b | |||
| 5ec5ee48d4 | |||
| 3aac83474b | |||
| 4a624d5fef | |||
| 9ddf54f398 | |||
| 84cfc5cd47 | |||
| 59f7a7c520 | |||
| 22e300394b | |||
| 01873be15d | |||
| 036b9d1634 | |||
| 391d48640b | |||
| 07a9de040f | |||
| 622c544182 | |||
| ffc5f4cfc1 | |||
| e138a28475 | |||
| 2dc36b3d01 | |||
| 1e4b7598ae | |||
| 2620eea779 | |||
| 1071f3cacc | |||
| 6b327fa7e0 | |||
| 0938878e67 | |||
| 9cd3796527 | |||
| 271ac66098 | |||
| 0b5db0c2e6 | |||
| 4b384ef359 | |||
| d76fd2e7b2 |
@@ -1,4 +1,5 @@
|
|||||||
{% extends "system_settings/base_settings.html" %}
|
{% extends "system_settings/base_settings.html" %}
|
||||||
|
{% load inventory_filters %}
|
||||||
|
|
||||||
{% block title %}{% if is_edit %}Редактирование скидки{% else %}Создание скидки{% endif %}{% endblock %}
|
{% block title %}{% if is_edit %}Редактирование скидки{% else %}Создание скидки{% endif %}{% endblock %}
|
||||||
|
|
||||||
@@ -33,13 +34,13 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="id_name" class="form-label">Название * <span class="text-muted small">(макс. 200 символов)</span></label>
|
<label for="id_name" class="form-label">Название * <span class="text-muted small">(макс. 200 символов)</span></label>
|
||||||
<input type="text" class="form-control" id="id_name" name="name"
|
<input type="text" class="form-control" id="id_name" name="name"
|
||||||
value="{% if form.name.value %}{{ form.name.value }}{% endif %}"
|
value="{{ form.name.value|default_if_none:'' }}"
|
||||||
maxlength="200" required>
|
maxlength="200" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="id_priority" class="form-label">Приоритет</label>
|
<label for="id_priority" class="form-label">Приоритет</label>
|
||||||
<input type="number" class="form-control" id="id_priority" name="priority"
|
<input type="number" class="form-control" id="id_priority" name="priority"
|
||||||
value="{% if form.priority.value %}{{ form.priority.value }}{% else %}0{% endif %}"
|
value="{{ form.priority.value|default_if_none:0 }}"
|
||||||
min="0">
|
min="0">
|
||||||
<div class="form-text">Выше = применяется раньше</div>
|
<div class="form-text">Выше = применяется раньше</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="id_description" class="form-label">Описание</label>
|
<label for="id_description" class="form-label">Описание</label>
|
||||||
<textarea class="form-control" id="id_description" name="description" rows="2">{{ form.description.value }}</textarea>
|
<textarea class="form-control" id="id_description" name="description" rows="2">{{ form.description.value|default_if_none:'' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Параметры скидки -->
|
<!-- Параметры скидки -->
|
||||||
@@ -57,23 +58,28 @@
|
|||||||
<label for="id_discount_type" class="form-label">Тип скидки *</label>
|
<label for="id_discount_type" class="form-label">Тип скидки *</label>
|
||||||
<select class="form-select" id="id_discount_type" name="discount_type" required>
|
<select class="form-select" id="id_discount_type" name="discount_type" required>
|
||||||
<option value="">Выберите...</option>
|
<option value="">Выберите...</option>
|
||||||
<option value="percentage" {% if form.discount_type.value == 'percentage' %}selected{% endif %}>Процент</option>
|
<option value="percentage"
|
||||||
<option value="fixed_amount" {% if form.discount_type.value == 'fixed_amount' %}selected{% endif %}>Фиксированная сумма (руб.)</option>
|
{% if form.discount_type.value == 'percentage' %}selected{% endif %}>Процент</option>
|
||||||
|
<option value="fixed_amount"
|
||||||
|
{% if form.discount_type.value == 'fixed_amount' %}selected{% endif %}>Фиксированная сумма (руб.)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="id_value" class="form-label">Значение *</label>
|
<label for="id_value" class="form-label">Значение *</label>
|
||||||
<input type="number" class="form-control" id="id_value" name="value"
|
<input type="number" class="form-control" id="id_value" name="value"
|
||||||
value="{% if form.value.value %}{{ form.value.value }}{% endif %}"
|
value="{{ form.value.value|format_decimal:2|default_if_none:'' }}"
|
||||||
step="0.01" min="0" required>
|
step="0.01" min="0" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="id_scope" class="form-label">Область действия *</label>
|
<label for="id_scope" class="form-label">Область действия *</label>
|
||||||
<select class="form-select" id="id_scope" name="scope" required>
|
<select class="form-select" id="id_scope" name="scope" required>
|
||||||
<option value="">Выберите...</option>
|
<option value="">Выберите...</option>
|
||||||
<option value="order" {% if form.scope.value == 'order' %}selected{% endif %}>На весь заказ</option>
|
<option value="order"
|
||||||
<option value="product" {% if form.scope.value == 'product' %}selected{% endif %}>На конкретные товары</option>
|
{% if form.scope.value == 'order' %}selected{% endif %}>На весь заказ</option>
|
||||||
<option value="category" {% if form.scope.value == 'category' %}selected{% endif %}>На категории товаров</option>
|
<option value="product"
|
||||||
|
{% if form.scope.value == 'product' %}selected{% endif %}>На конкретные товары</option>
|
||||||
|
<option value="category"
|
||||||
|
{% if form.scope.value == 'category' %}selected{% endif %}>На категории товаров</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-check form-switch mt-4">
|
<div class="form-check form-switch mt-4">
|
||||||
<input class="form-check-input" type="checkbox" id="id_is_active" name="is_active"
|
<input class="form-check-input" type="checkbox" id="id_is_active" name="is_active"
|
||||||
{% if form.is_active.value is None or form.is_active.value %}checked{% endif %}>
|
{% if form.is_active.value %}checked{% endif %}>
|
||||||
<label class="form-check-label" for="id_is_active">
|
<label class="form-check-label" for="id_is_active">
|
||||||
Активна
|
Активна
|
||||||
</label>
|
</label>
|
||||||
@@ -104,13 +110,16 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="id_combine_mode" class="form-label">Режим объединения с другими скидками</label>
|
<label for="id_combine_mode" class="form-label">Режим объединения с другими скидками</label>
|
||||||
<select class="form-select" id="id_combine_mode" name="combine_mode">
|
<select class="form-select" id="id_combine_mode" name="combine_mode">
|
||||||
<option value="max_only" {% if form.combine_mode.value == 'max_only' or not form.combine_mode.value %}selected{% endif %}>
|
<option value="max_only"
|
||||||
|
{% if form.combine_mode.value == 'max_only' or not form.combine_mode.value %}selected{% endif %}>
|
||||||
🏆 Только максимум (применяется лучшая скидка)
|
🏆 Только максимум (применяется лучшая скидка)
|
||||||
</option>
|
</option>
|
||||||
<option value="stack" {% if form.combine_mode.value == 'stack' %}selected{% endif %}>
|
<option value="stack"
|
||||||
|
{% if form.combine_mode.value == 'stack' %}selected{% endif %}>
|
||||||
📚 Складывать (суммировать с другими)
|
📚 Складывать (суммировать с другими)
|
||||||
</option>
|
</option>
|
||||||
<option value="exclusive" {% if form.combine_mode.value == 'exclusive' %}selected{% endif %}>
|
<option value="exclusive"
|
||||||
|
{% if form.combine_mode.value == 'exclusive' %}selected{% endif %}>
|
||||||
🚫 Исключающая (отменяет остальные скидки)
|
🚫 Исключающая (отменяет остальные скидки)
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -123,14 +132,14 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="id_min_order_amount" class="form-label">Мин. сумма заказа</label>
|
<label for="id_min_order_amount" class="form-label">Мин. сумма заказа</label>
|
||||||
<input type="number" class="form-control" id="id_min_order_amount" name="min_order_amount"
|
<input type="number" class="form-control" id="id_min_order_amount" name="min_order_amount"
|
||||||
value="{% if form.min_order_amount.value %}{{ form.min_order_amount.value }}{% endif %}"
|
value="{{ form.min_order_amount.value|format_decimal:2|default_if_none:'' }}"
|
||||||
step="0.01" min="0">
|
step="0.01" min="0">
|
||||||
<div class="form-text">Для скидок на заказ</div>
|
<div class="form-text">Для скидок на заказ</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="id_max_usage_count" class="form-label">Макс. использований</label>
|
<label for="id_max_usage_count" class="form-label">Макс. использований</label>
|
||||||
<input type="number" class="form-control" id="id_max_usage_count" name="max_usage_count"
|
<input type="number" class="form-control" id="id_max_usage_count" name="max_usage_count"
|
||||||
value="{% if form.max_usage_count.value %}{{ form.max_usage_count.value }}{% endif %}"
|
value="{{ form.max_usage_count.value|default_if_none:'' }}"
|
||||||
min="1">
|
min="1">
|
||||||
<div class="form-text">Оставьте пустым для безлимитного использования</div>
|
<div class="form-text">Оставьте пустым для безлимитного использования</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,12 +149,12 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="id_start_date" class="form-label">Дата начала</label>
|
<label for="id_start_date" class="form-label">Дата начала</label>
|
||||||
<input type="datetime-local" class="form-control" id="id_start_date" name="start_date"
|
<input type="datetime-local" class="form-control" id="id_start_date" name="start_date"
|
||||||
value="{% if form.start_date.value %}{{ form.start_date.value|date:'Y-m-d\TH:i' }}{% endif %}">
|
value="{{ form.start_date.value|date:'Y-m-d\TH:i'|default_if_none:'' }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="id_end_date" class="form-label">Дата окончания</label>
|
<label for="id_end_date" class="form-label">Дата окончания</label>
|
||||||
<input type="datetime-local" class="form-control" id="id_end_date" name="end_date"
|
<input type="datetime-local" class="form-control" id="id_end_date" name="end_date"
|
||||||
value="{% if form.end_date.value %}{{ form.end_date.value|date:'Y-m-d\TH:i' }}{% endif %}">
|
value="{{ form.end_date.value|date:'Y-m-d\TH:i'|default_if_none:'' }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,7 +183,6 @@
|
|||||||
{% if not all_categories %}
|
{% if not all_categories %}
|
||||||
<option value="" disabled>Нет доступных категорий</option>
|
<option value="" disabled>Нет доступных категорий</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
%}
|
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Удерживайте Ctrl для выбора нескольких категорий</div>
|
<div class="form-text">Удерживайте Ctrl для выбора нескольких категорий</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,4 +212,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Заменяем запятые на точки в числовых полях при загрузке страницы
|
||||||
|
const numberInputs = document.querySelectorAll('input[type="number"]');
|
||||||
|
numberInputs.forEach(function(input) {
|
||||||
|
if (input.value && input.value.includes(',')) {
|
||||||
|
// Сохраняем оригинальное значение с запятой для отображения
|
||||||
|
const displayValue = input.value;
|
||||||
|
const actualValue = displayValue.replace(',', '.');
|
||||||
|
|
||||||
|
// Устанавливаем значение с точкой для корректной работы HTML5 поля
|
||||||
|
input.value = actualValue;
|
||||||
|
|
||||||
|
// При фокусе возвращаем запятую для удобства пользователя
|
||||||
|
input.addEventListener('focus', function() {
|
||||||
|
if (input.value && input.value.includes('.')) {
|
||||||
|
input.value = input.value.replace('.', ',');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// При потере фокуса возвращаем точку для корректной отправки
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
if (input.value && input.value.includes(',')) {
|
||||||
|
input.value = input.value.replace(',', '.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Также обрабатываем отправку формы для замены запятых на точки
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
const numberInputs = document.querySelectorAll('input[type="number"]');
|
||||||
|
numberInputs.forEach(function(input) {
|
||||||
|
if (input.value && input.value.includes(',')) {
|
||||||
|
input.value = input.value.replace(',', '.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2026-01-23 15:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('integrations', '0009_alter_glmintegration_model_name_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='openrouterintegration',
|
||||||
|
name='model_name',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Название используемой модели OpenRouter (загружается автоматически)', max_length=200, verbose_name='Название модели'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -10,14 +10,6 @@ def validate_temperature(value):
|
|||||||
raise ValidationError('Температура должна быть в диапазоне 0.0-2.0')
|
raise ValidationError('Температура должна быть в диапазоне 0.0-2.0')
|
||||||
|
|
||||||
|
|
||||||
# Список доступных моделей OpenRouter (бесплатные)
|
|
||||||
OPENROUTER_MODEL_CHOICES = [
|
|
||||||
('xiaomi/mimo-v2-flash:free', 'Xiaomi MIMO v2 Flash (Бесплатная)'),
|
|
||||||
('mistralai/devstral-2512:free', 'Mistral Devstral 2512 (Бесплатная)'),
|
|
||||||
('z-ai/glm-4.5-air:free', 'Z.AI GLM-4.5 Air (Бесплатная)'),
|
|
||||||
('qwen/qwen3-coder:free', 'Qwen 3 Coder (Бесплатная)'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Предустановленные значения температуры
|
# Предустановленные значения температуры
|
||||||
OPENROUTER_TEMPERATURE_CHOICES = [
|
OPENROUTER_TEMPERATURE_CHOICES = [
|
||||||
(0.1, '0.1 - Очень консервативно'),
|
(0.1, '0.1 - Очень консервативно'),
|
||||||
@@ -59,11 +51,11 @@ class OpenRouterIntegration(AIIntegration):
|
|||||||
)
|
)
|
||||||
|
|
||||||
model_name = models.CharField(
|
model_name = models.CharField(
|
||||||
max_length=100,
|
max_length=200,
|
||||||
default="xiaomi/mimo-v2-flash:free",
|
default="",
|
||||||
choices=OPENROUTER_MODEL_CHOICES,
|
blank=True,
|
||||||
verbose_name="Название модели",
|
verbose_name="Название модели",
|
||||||
help_text="Название используемой модели OpenRouter"
|
help_text="Название используемой модели OpenRouter (загружается автоматически)"
|
||||||
)
|
)
|
||||||
|
|
||||||
temperature = models.FloatField(
|
temperature = models.FloatField(
|
||||||
|
|||||||
@@ -3,28 +3,45 @@ from ..base import BaseIntegrationService
|
|||||||
from .config import get_openrouter_config
|
from .config import get_openrouter_config
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import locale
|
import traceback
|
||||||
|
|
||||||
# Патч для исправления проблемы с кодировкой в httpx на Windows
|
# Патч для исправления проблемы с кодировкой в httpx на Windows
|
||||||
# Устанавливаем кодировку по умолчанию для Python
|
# Устанавливаем кодировку по умолчанию для Python
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
try:
|
try:
|
||||||
import httpx._models
|
import httpx._models
|
||||||
original_normalize_header_value = httpx._models._normalize_header_value
|
|
||||||
|
# Сохраняем оригинальную функцию, если она есть
|
||||||
|
_original_normalize_header_value = getattr(httpx._models, '_normalize_header_value', None)
|
||||||
|
|
||||||
def patched_normalize_header_value(value, encoding):
|
def patched_normalize_header_value(value, encoding):
|
||||||
"""Патч для использования UTF-8 вместо ASCII для заголовков"""
|
"""Патч для использования UTF-8 вместо ASCII для заголовков"""
|
||||||
# Если значение уже bytes, возвращаем его как есть
|
try:
|
||||||
if isinstance(value, bytes):
|
# Если значение уже bytes, возвращаем его как есть
|
||||||
return value
|
if isinstance(value, bytes):
|
||||||
# Всегда используем UTF-8 вместо ASCII
|
return value
|
||||||
encoding = encoding or 'utf-8'
|
|
||||||
if encoding.lower() == 'ascii':
|
# Если значение не строка и не байты, приводим к строке
|
||||||
encoding = 'utf-8'
|
if not isinstance(value, str):
|
||||||
return value.encode(encoding)
|
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
|
httpx._models._normalize_header_value = patched_normalize_header_value
|
||||||
logging.getLogger(__name__).info("Applied patch for httpx header encoding on Windows")
|
logging.getLogger(__name__).info("Applied robust patch for httpx header encoding on Windows")
|
||||||
|
except ImportError:
|
||||||
|
logging.getLogger(__name__).warning("httpx module not found, patch skipped")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).warning(f"Failed to apply httpx patch: {e}")
|
logging.getLogger(__name__).warning(f"Failed to apply httpx patch: {e}")
|
||||||
|
|
||||||
@@ -148,8 +165,10 @@ class OpenRouterIntegrationService(BaseIntegrationService):
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {str(e)}")
|
error_msg = str(e)
|
||||||
return False, f"Ошибка генерации: {str(e)}", None
|
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {error_msg}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False, f"Ошибка генерации: {error_msg}", None
|
||||||
|
|
||||||
def generate_code(self,
|
def generate_code(self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -196,5 +215,7 @@ class OpenRouterIntegrationService(BaseIntegrationService):
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {str(e)}")
|
error_msg = str(e)
|
||||||
return False, f"Ошибка генерации кода: {str(e)}", None
|
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {error_msg}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False, f"Ошибка генерации кода: {error_msg}", None
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import requests
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from .base import MarketplaceService
|
from .base import MarketplaceService
|
||||||
|
|
||||||
@@ -5,16 +6,58 @@ from .base import MarketplaceService
|
|||||||
class WooCommerceService(MarketplaceService):
|
class WooCommerceService(MarketplaceService):
|
||||||
"""Сервис для работы с WooCommerce API"""
|
"""Сервис для работы с WooCommerce API"""
|
||||||
|
|
||||||
|
def _get_api_url(self) -> str:
|
||||||
|
"""Получить базовый URL для WooCommerce REST API"""
|
||||||
|
base = self.config.store_url.rstrip('/')
|
||||||
|
# WooCommerce REST API v3 endpoint
|
||||||
|
return f"{base}/wp-json/wc/v3/"
|
||||||
|
|
||||||
|
def _get_auth(self) -> tuple:
|
||||||
|
"""Получить кортеж для Basic Auth (consumer_key, consumer_secret)"""
|
||||||
|
return (self.config.consumer_key or '', self.config.consumer_secret or '')
|
||||||
|
|
||||||
def test_connection(self) -> Tuple[bool, str]:
|
def test_connection(self) -> Tuple[bool, str]:
|
||||||
"""Проверить соединение с WooCommerce API"""
|
"""
|
||||||
|
Проверить соединение с WooCommerce API.
|
||||||
|
|
||||||
|
Использует endpoint /wp-json/wc/v3/ для проверки.
|
||||||
|
Аутентификация через HTTP Basic Auth.
|
||||||
|
"""
|
||||||
if not self.config.store_url:
|
if not self.config.store_url:
|
||||||
return False, 'Не указан URL магазина'
|
return False, 'Не указан URL магазина'
|
||||||
|
|
||||||
if not self.config.consumer_key or not self.config.consumer_secret:
|
if not self.config.consumer_key or not self.config.consumer_secret:
|
||||||
return False, 'Не указаны ключи API'
|
return False, 'Не указаны ключи API'
|
||||||
|
|
||||||
# TODO: реализовать проверку соединения с WooCommerce API
|
url = self._get_api_url()
|
||||||
return True, 'Соединение успешно (заглушка)'
|
|
||||||
|
try:
|
||||||
|
# Пытаемся получить список товаров (limit=1) для проверки авторизации
|
||||||
|
# Это более надёжный способ проверки, чем просто обращение к корню API
|
||||||
|
response = requests.get(
|
||||||
|
f"{url}products",
|
||||||
|
params={'per_page': 1},
|
||||||
|
auth=self._get_auth(),
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True, 'Соединение установлено успешно'
|
||||||
|
elif response.status_code == 401:
|
||||||
|
return False, 'Неверные ключи API (Consumer Key/Secret)'
|
||||||
|
elif response.status_code == 403:
|
||||||
|
return False, 'Доступ запрещён. Проверьте права API ключа'
|
||||||
|
elif response.status_code == 404:
|
||||||
|
return False, 'WooCommerce REST API не найден. Проверьте, что WooCommerce установлен и активирован'
|
||||||
|
else:
|
||||||
|
return False, f'Ошибка соединения: HTTP {response.status_code}'
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return False, 'Таймаут соединения (15 сек)'
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False, 'Не удалось подключиться к серверу. Проверьте URL магазина'
|
||||||
|
except Exception as e:
|
||||||
|
return False, f'Ошибка: {str(e)}'
|
||||||
|
|
||||||
def sync(self) -> Tuple[bool, str]:
|
def sync(self) -> Tuple[bool, str]:
|
||||||
"""Выполнить синхронизацию с WooCommerce"""
|
"""Выполнить синхронизацию с WooCommerce"""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .views import (
|
|||||||
get_integration_form_data,
|
get_integration_form_data,
|
||||||
test_integration_connection,
|
test_integration_connection,
|
||||||
RecommerceBatchSyncView,
|
RecommerceBatchSyncView,
|
||||||
|
get_openrouter_models,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'integrations'
|
app_name = 'integrations'
|
||||||
@@ -22,4 +23,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Синхронизация
|
# Синхронизация
|
||||||
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
|
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
|
||||||
|
|
||||||
|
# OpenRouter модели
|
||||||
|
path("openrouter/models/", get_openrouter_models, name="openrouter_models"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST, require_GET
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from user_roles.mixins import OwnerRequiredMixin
|
from user_roles.mixins import OwnerRequiredMixin
|
||||||
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
|
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
|
||||||
@@ -170,8 +173,8 @@ def get_integration_service(integration_id: str, instance):
|
|||||||
from .services.marketplaces.recommerce import RecommerceService
|
from .services.marketplaces.recommerce import RecommerceService
|
||||||
return RecommerceService(instance)
|
return RecommerceService(instance)
|
||||||
elif integration_id == 'woocommerce':
|
elif integration_id == 'woocommerce':
|
||||||
# TODO: WooCommerceService
|
from .services.marketplaces.woocommerce import WooCommerceService
|
||||||
return None
|
return WooCommerceService(instance)
|
||||||
elif integration_id == 'glm':
|
elif integration_id == 'glm':
|
||||||
from .services.ai_services.glm_service import GLMIntegrationService
|
from .services.ai_services.glm_service import GLMIntegrationService
|
||||||
return GLMIntegrationService(instance)
|
return GLMIntegrationService(instance)
|
||||||
@@ -181,6 +184,44 @@ def get_integration_service(integration_id: str, instance):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def get_openrouter_models(request):
|
||||||
|
"""
|
||||||
|
GET /settings/integrations/openrouter/models/
|
||||||
|
Возвращает список моделей OpenRouter (бесплатные сверху)
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get('https://openrouter.ai/api/v1/models', timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
models = data.get('data', [])
|
||||||
|
|
||||||
|
# Разделить на бесплатные и платные
|
||||||
|
free_models = []
|
||||||
|
paid_models = []
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
model_id = model.get('id', '')
|
||||||
|
model_name = model.get('name', model_id)
|
||||||
|
|
||||||
|
if ':free' in model_id:
|
||||||
|
free_models.append({'id': model_id, 'name': f"{model_name} (Бесплатная)"})
|
||||||
|
else:
|
||||||
|
paid_models.append({'id': model_id, 'name': model_name})
|
||||||
|
|
||||||
|
# Бесплатные сверху
|
||||||
|
all_models = free_models + paid_models
|
||||||
|
|
||||||
|
return JsonResponse({'models': all_models})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching OpenRouter models: {e}")
|
||||||
|
return JsonResponse({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class RecommerceBatchSyncView(TemplateView):
|
class RecommerceBatchSyncView(TemplateView):
|
||||||
"""
|
"""
|
||||||
API View для запуска массовой синхронизации с Recommerce.
|
API View для запуска массовой синхронизации с Recommerce.
|
||||||
@@ -363,7 +404,46 @@ def get_form_fields_meta(model):
|
|||||||
'label': getattr(field, 'verbose_name', field_name),
|
'label': getattr(field, 'verbose_name', field_name),
|
||||||
'help_text': getattr(field, 'help_text', ''),
|
'help_text': getattr(field, 'help_text', ''),
|
||||||
'required': not getattr(field, 'blank', True),
|
'required': not getattr(field, 'blank', True),
|
||||||
'type': 'text', # default
|
'type': 'password' if field_name == 'api_key' else 'text',
|
||||||
|
}
|
||||||
|
fields.append(field_info)
|
||||||
|
|
||||||
|
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',
|
||||||
|
'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':
|
||||||
|
basic_fields = ['store_url', 'consumer_key', 'consumer_secret']
|
||||||
|
for field_name in editable_fields:
|
||||||
|
if field_name in basic_fields:
|
||||||
|
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': 'text',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Определить тип поля
|
# Определить тип поля
|
||||||
@@ -371,21 +451,9 @@ def get_form_fields_meta(model):
|
|||||||
field_info['type'] = 'checkbox'
|
field_info['type'] = 'checkbox'
|
||||||
elif 'URLField' in field.__class__.__name__:
|
elif 'URLField' in field.__class__.__name__:
|
||||||
field_info['type'] = 'url'
|
field_info['type'] = 'url'
|
||||||
elif 'secret' in field_name.lower() or 'token' in field_name.lower() or 'key' in field_name.lower():
|
elif 'secret' in field_name.lower() or 'key' in field_name.lower():
|
||||||
field_info['type'] = 'password'
|
field_info['type'] = 'password'
|
||||||
|
|
||||||
fields.append(field_info)
|
|
||||||
elif field_name in ['model_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
|
|
||||||
'choices': getattr(field, 'choices', [])
|
|
||||||
}
|
|
||||||
|
|
||||||
fields.append(field_info)
|
fields.append(field_info)
|
||||||
else:
|
else:
|
||||||
# Для других интеграций - все редактируемые поля
|
# Для других интеграций - все редактируемые поля
|
||||||
|
|||||||
@@ -667,7 +667,16 @@ class ShowcaseItem(models.Model):
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
self.status = 'in_cart'
|
self.status = 'in_cart'
|
||||||
self.locked_by_user = user
|
|
||||||
|
# Проверяем тип пользователя - locked_by_user только для CustomUser
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
if isinstance(user, CustomUser):
|
||||||
|
self.locked_by_user = user
|
||||||
|
else:
|
||||||
|
# Для PlatformAdmin и других типов пользователей поле оставляем пустым
|
||||||
|
# Блокировка будет работать через cart_session_id
|
||||||
|
self.locked_by_user = None
|
||||||
|
|
||||||
self.cart_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
|
self.cart_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
|
||||||
self.cart_session_id = session_id
|
self.cart_session_id = session_id
|
||||||
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
|
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
|
||||||
|
|||||||
@@ -35,8 +35,34 @@ class SaleProcessor:
|
|||||||
"""
|
"""
|
||||||
# Определяем цену продажи из заказа или из товара
|
# Определяем цену продажи из заказа или из товара
|
||||||
if order and reservation.order_item:
|
if order and reservation.order_item:
|
||||||
# Цена из OrderItem
|
item = reservation.order_item
|
||||||
sale_price = reservation.order_item.price
|
# Цена за единицу с учётом всех скидок (позиция + заказ)
|
||||||
|
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
|
||||||
|
|
||||||
|
# Скидка на позицию
|
||||||
|
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount else Decimal('0')
|
||||||
|
|
||||||
|
# Скидка на заказ (распределяется пропорционально доле позиции в заказе)
|
||||||
|
# Вычисляем как разницу между subtotal и total_amount (так как discount_amount может быть 0)
|
||||||
|
order_total = order.subtotal if hasattr(order, 'subtotal') else Decimal('0')
|
||||||
|
# Скидка = subtotal - (total_amount - delivery) (вычитаем доставку, если есть)
|
||||||
|
delivery_cost = Decimal(str(order.delivery.cost)) if hasattr(order, 'delivery') and order.delivery else Decimal('0')
|
||||||
|
order_discount = (order_total - (Decimal(str(order.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
|
||||||
|
|
||||||
|
total_discount = item_discount + order_discount
|
||||||
|
if total_discount and item.quantity > 0:
|
||||||
|
# Распределяем общую скидку пропорционально доле позиции
|
||||||
|
item_order_discount = order_discount * (item_subtotal / order_total) if order_total > 0 else Decimal('0')
|
||||||
|
total_discount = item_discount + item_order_discount
|
||||||
|
price_with_discount = (item_subtotal - total_discount) / Decimal(str(item.quantity))
|
||||||
|
else:
|
||||||
|
price_with_discount = Decimal(str(item.price))
|
||||||
|
|
||||||
|
# Пересчитываем цену в базовые единицы
|
||||||
|
if item.sales_unit and item.conversion_factor_snapshot:
|
||||||
|
sale_price = price_with_discount * item.conversion_factor_snapshot
|
||||||
|
else:
|
||||||
|
sale_price = price_with_discount
|
||||||
else:
|
else:
|
||||||
# Цена из товара
|
# Цена из товара
|
||||||
sale_price = reservation.product.actual_price or Decimal('0')
|
sale_price = reservation.product.actual_price or Decimal('0')
|
||||||
|
|||||||
@@ -162,8 +162,6 @@ class ShowcaseManager:
|
|||||||
Raises:
|
Raises:
|
||||||
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
|
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
|
||||||
"""
|
"""
|
||||||
from inventory.services.sale_processor import SaleProcessor
|
|
||||||
|
|
||||||
sold_count = 0
|
sold_count = 0
|
||||||
order = order_item.order
|
order = order_item.order
|
||||||
|
|
||||||
@@ -207,17 +205,9 @@ class ShowcaseManager:
|
|||||||
|
|
||||||
# Сначала устанавливаем order_item для правильного определения цены
|
# Сначала устанавливаем order_item для правильного определения цены
|
||||||
reservation.order_item = order_item
|
reservation.order_item = order_item
|
||||||
reservation.save()
|
# ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
|
||||||
|
# Это сделает сигнал create_sale_on_order_completion автоматически.
|
||||||
# Теперь создаём продажу с правильной ценой из OrderItem
|
# Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
|
||||||
SaleProcessor.create_sale_from_reservation(
|
|
||||||
reservation=reservation,
|
|
||||||
order=order
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем статус резерва
|
|
||||||
reservation.status = 'converted_to_sale'
|
|
||||||
reservation.converted_at = timezone.now()
|
|
||||||
reservation.save()
|
reservation.save()
|
||||||
|
|
||||||
sold_count += 1
|
sold_count += 1
|
||||||
@@ -666,6 +656,113 @@ class ShowcaseManager:
|
|||||||
'message': f'Ошибка разбора: {str(e)}'
|
'message': f'Ошибка разбора: {str(e)}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_off_from_showcase(showcase_item, reason='spoilage', notes=None, created_by=None):
|
||||||
|
"""
|
||||||
|
Списывает экземпляр витринного комплекта:
|
||||||
|
1. Создаёт документ списания с компонентами комплекта
|
||||||
|
2. Преобразует резервы комплекта в позиции документа списания
|
||||||
|
3. Помечает экземпляр как разобранный
|
||||||
|
|
||||||
|
Args:
|
||||||
|
showcase_item: ShowcaseItem - экземпляр для списания
|
||||||
|
reason: str - причина списания (spoilage по умолчанию)
|
||||||
|
notes: str - примечания
|
||||||
|
created_by: User - пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'document_id': int,
|
||||||
|
'document_number': str,
|
||||||
|
'items_count': int,
|
||||||
|
'message': str,
|
||||||
|
'error': str (при ошибке)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from inventory.services.writeoff_document_service import WriteOffDocumentService
|
||||||
|
|
||||||
|
# Проверка статуса
|
||||||
|
if showcase_item.status == 'sold':
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': 'Нельзя списать проданный экземпляр'
|
||||||
|
}
|
||||||
|
|
||||||
|
if showcase_item.status == 'dismantled':
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': 'Экземпляр уже разобран'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
warehouse = showcase_item.showcase.warehouse
|
||||||
|
product_kit = showcase_item.product_kit
|
||||||
|
|
||||||
|
# Создаём документ списания (черновик)
|
||||||
|
document = WriteOffDocumentService.create_document(
|
||||||
|
warehouse=warehouse,
|
||||||
|
date=timezone.now().date(),
|
||||||
|
notes=f'Списание витринного комплекта: {product_kit.name}',
|
||||||
|
created_by=created_by
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем резервы этого экземпляра
|
||||||
|
reservations = Reservation.objects.filter(
|
||||||
|
showcase_item=showcase_item,
|
||||||
|
status='reserved'
|
||||||
|
).select_related('product')
|
||||||
|
|
||||||
|
items_count = 0
|
||||||
|
|
||||||
|
for reservation in reservations:
|
||||||
|
# Добавляем позицию в документ списания
|
||||||
|
# Используем add_item без создания резерва (меняем статус существующего)
|
||||||
|
from inventory.models import WriteOffDocumentItem
|
||||||
|
|
||||||
|
item = WriteOffDocumentItem.objects.create(
|
||||||
|
document=document,
|
||||||
|
product=reservation.product,
|
||||||
|
quantity=reservation.quantity,
|
||||||
|
reason=reason,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Привязываем существующий резерв к позиции документа
|
||||||
|
reservation.writeoff_document_item = item
|
||||||
|
reservation.status = 'converted_to_writeoff'
|
||||||
|
reservation.converted_at = timezone.now()
|
||||||
|
reservation.save(update_fields=['writeoff_document_item', 'status', 'converted_at'])
|
||||||
|
|
||||||
|
items_count += 1
|
||||||
|
|
||||||
|
# Помечаем экземпляр как разобранный
|
||||||
|
showcase_item.status = 'dismantled'
|
||||||
|
showcase_item.save(update_fields=['status'])
|
||||||
|
|
||||||
|
# Помечаем шаблон комплекта как снятый
|
||||||
|
if product_kit.status != 'discontinued':
|
||||||
|
product_kit.status = 'discontinued'
|
||||||
|
product_kit.save(update_fields=['status'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'document_id': document.id,
|
||||||
|
'document_number': document.document_number,
|
||||||
|
'items_count': items_count,
|
||||||
|
'message': f'Создан документ {document.document_number} с {items_count} позициями'
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': f'Ошибка списания: {str(e)}'
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_showcase_items_for_pos(showcase=None):
|
def get_showcase_items_for_pos(showcase=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
Подключаются при создании, изменении и удалении заказов.
|
Подключаются при создании, изменении и удалении заказов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
from django.db.models.signals import post_save, pre_delete, post_delete, pre_save
|
from django.db.models.signals import post_save, pre_delete, post_delete, pre_save
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -19,6 +20,26 @@ from inventory.services import SaleProcessor
|
|||||||
from inventory.services.batch_manager import StockBatchManager
|
from inventory.services.batch_manager import StockBatchManager
|
||||||
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Thread-local storage для временных флагов управления сигналами
|
||||||
|
# ============================================================================
|
||||||
|
_skip_sale_creation = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def skip_sale_creation():
|
||||||
|
"""Установить флаг для пропуска создания Sale в сигнале."""
|
||||||
|
_skip_sale_creation.value = True
|
||||||
|
|
||||||
|
|
||||||
|
def reset_sale_creation():
|
||||||
|
"""Сбросить флаг пропуска создания Sale."""
|
||||||
|
_skip_sale_creation.value = False
|
||||||
|
|
||||||
|
|
||||||
|
def is_skip_sale_creation():
|
||||||
|
"""Проверить, установлен ли флаг пропуска создания Sale."""
|
||||||
|
return getattr(_skip_sale_creation, 'value', False)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# pre_save сигнал для сохранения предыдущего статуса Order
|
# pre_save сигнал для сохранения предыдущего статуса Order
|
||||||
@@ -201,9 +222,14 @@ def reserve_stock_on_item_create(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
for kit_item in instance.kit_snapshot.items.select_related('original_product'):
|
for kit_item in instance.kit_snapshot.items.select_related('original_product'):
|
||||||
if kit_item.original_product:
|
if kit_item.original_product:
|
||||||
# Суммируем количество: qty компонента * qty комплектов в заказе
|
# Рассчитываем количество одного компонента в базовых единицах
|
||||||
|
component_qty_base = kit_item.quantity
|
||||||
|
if kit_item.conversion_factor and kit_item.conversion_factor > 0:
|
||||||
|
component_qty_base = kit_item.quantity / kit_item.conversion_factor
|
||||||
|
|
||||||
|
# Суммируем количество: qty компонента (base) * qty комплектов в заказе
|
||||||
product_quantities[kit_item.original_product_id] += (
|
product_quantities[kit_item.original_product_id] += (
|
||||||
kit_item.quantity * Decimal(str(instance.quantity))
|
component_qty_base * Decimal(str(instance.quantity))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаём по одному резерву на каждый уникальный товар
|
# Создаём по одному резерву на каждый уникальный товар
|
||||||
@@ -267,7 +293,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
создается операция Sale и резервы преобразуются в продажу.
|
создается операция Sale и резервы преобразуются в продажу.
|
||||||
|
|
||||||
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
|
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
|
||||||
|
|
||||||
ВАЛИДАЦИЯ:
|
ВАЛИДАЦИЯ:
|
||||||
- Запрещаем переход в положительный финальный статус для заказов с is_returned=True,
|
- Запрещаем переход в положительный финальный статус для заказов с is_returned=True,
|
||||||
у которых нет резервов (товар уже продан в другом заказе).
|
у которых нет резервов (товар уже продан в другом заказе).
|
||||||
@@ -278,10 +304,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
3. Для каждого товара создаем Sale (автоматический FIFO-список)
|
3. Для каждого товара создаем Sale (автоматический FIFO-список)
|
||||||
4. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
|
4. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
|
||||||
5. Обновляем флаг is_returned
|
5. Обновляем флаг is_returned
|
||||||
|
|
||||||
|
ПРИМЕЧАНИЕ: Если у Order установлен атрибут skip_sale_creation=True,
|
||||||
|
создание Sale пропускается (используется в POS для создания Sale после применения скидок).
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# === ПРОВЕРКА: Пропуск создания Sale по флагу ===
|
||||||
|
# Используется в POS checkout, где Sale создаётся явно после применения скидок
|
||||||
|
if is_skip_sale_creation():
|
||||||
|
logger.info(
|
||||||
|
f"ℹ️ Заказ {instance.order_number}: skip_sale_creation=True (thread-local), "
|
||||||
|
f"пропускаем автоматическое создание Sale"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
return # Только для обновлений
|
return # Только для обновлений
|
||||||
|
|
||||||
@@ -325,16 +363,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
if not is_positive_end:
|
if not is_positive_end:
|
||||||
return # Только для положительных финальных статусов (completed и т.п.)
|
return # Только для положительных финальных статусов (completed и т.п.)
|
||||||
|
|
||||||
|
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
|
||||||
|
# Проверяем, есть ли уже Sale для этого заказа
|
||||||
|
if Sale.objects.filter(order=instance).exists():
|
||||||
|
logger.info(f"Заказ {instance.order_number}: Sale уже существуют, пропускаем")
|
||||||
|
update_is_returned_flag(instance)
|
||||||
|
return
|
||||||
|
|
||||||
# === ЗАЩИТА ОТ RACE CONDITION: Проверяем предыдущий статус ===
|
# === ЗАЩИТА ОТ RACE CONDITION: Проверяем предыдущий статус ===
|
||||||
# Если уже были в completed и снова переходим в completed (например completed → draft → completed),
|
# Если уже были в completed и снова переходим в completed (например completed → draft → completed),
|
||||||
# проверяем наличие Sale чтобы избежать дублирования
|
# проверяем наличие Sale чтобы избежать дублирования
|
||||||
previous_status = getattr(instance, '_previous_status', None)
|
previous_status = getattr(instance, '_previous_status', None)
|
||||||
if previous_status and previous_status.is_positive_end:
|
if previous_status and previous_status.is_positive_end:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
|
f"Заказ {instance.order_number}: повторный переход в положительный статус "
|
||||||
f"({previous_status.name} → {instance.status.name}). Проверяем Sale..."
|
f"({previous_status.name} → {instance.status.name}). Проверяем Sale..."
|
||||||
)
|
)
|
||||||
# Проверяем есть ли уже Sale
|
|
||||||
if Sale.objects.filter(order=instance).exists():
|
if Sale.objects.filter(order=instance).exists():
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем создание"
|
f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем создание"
|
||||||
@@ -342,15 +386,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
update_is_returned_flag(instance)
|
update_is_returned_flag(instance)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
|
|
||||||
if Sale.objects.filter(order=instance).exists():
|
|
||||||
# Продажи уже созданы — просто обновляем флаг is_returned и выходим
|
|
||||||
logger.info(
|
|
||||||
f"✓ Заказ {instance.order_number}: Sale уже существуют (проверка до создания)"
|
|
||||||
)
|
|
||||||
update_is_returned_flag(instance)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Проверяем наличие резервов для этого заказа
|
# Проверяем наличие резервов для этого заказа
|
||||||
# Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката)
|
# Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката)
|
||||||
# Исключаем уже обработанные 'converted_to_sale'
|
# Исключаем уже обработанные 'converted_to_sale'
|
||||||
@@ -419,13 +454,66 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# === РАСЧЕТ ЦЕНЫ ===
|
||||||
|
# Рассчитываем фактическую стоимость продажи всего комплекта с учетом скидок
|
||||||
|
# 1. Базовая стоимость позиции
|
||||||
|
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
|
||||||
|
|
||||||
|
# 2. Скидки
|
||||||
|
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
|
||||||
|
|
||||||
|
# Скидка на заказ (распределенная)
|
||||||
|
instance.refresh_from_db()
|
||||||
|
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
|
||||||
|
delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0')
|
||||||
|
order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
|
||||||
|
|
||||||
|
if order_total > 0 and order_discount_amount > 0:
|
||||||
|
item_order_discount = order_discount_amount * (item_subtotal / order_total)
|
||||||
|
else:
|
||||||
|
item_order_discount = Decimal('0')
|
||||||
|
|
||||||
|
kit_net_total = item_subtotal - item_discount - item_order_discount
|
||||||
|
if kit_net_total < 0:
|
||||||
|
kit_net_total = Decimal('0')
|
||||||
|
|
||||||
|
# 3. Суммарная каталожная стоимость всех компонентов (для пропорции)
|
||||||
|
total_catalog_price = Decimal('0')
|
||||||
|
for reservation in kit_reservations:
|
||||||
|
qty = reservation.quantity_base or reservation.quantity
|
||||||
|
price = reservation.product.actual_price or Decimal('0')
|
||||||
|
total_catalog_price += price * qty
|
||||||
|
|
||||||
|
# 4. Коэффициент распределения
|
||||||
|
if total_catalog_price > 0:
|
||||||
|
ratio = kit_net_total / total_catalog_price
|
||||||
|
else:
|
||||||
|
# Если каталожная цена 0, распределяем просто по количеству или 0
|
||||||
|
ratio = Decimal('0')
|
||||||
|
|
||||||
# Создаем Sale для каждого компонента комплекта
|
# Создаем Sale для каждого компонента комплекта
|
||||||
for reservation in kit_reservations:
|
for reservation in kit_reservations:
|
||||||
try:
|
try:
|
||||||
# Рассчитываем цену продажи компонента пропорционально цене комплекта
|
# Рассчитываем цену продажи компонента пропорционально
|
||||||
# Используем actual_price компонента как цену продажи
|
catalog_price = reservation.product.actual_price or Decimal('0')
|
||||||
component_sale_price = reservation.product.actual_price
|
|
||||||
|
|
||||||
|
if ratio > 0:
|
||||||
|
# Распределяем реальную выручку
|
||||||
|
component_sale_price = catalog_price * ratio
|
||||||
|
else:
|
||||||
|
# Если выручка 0 или каталожные цены 0
|
||||||
|
if total_catalog_price == 0 and kit_net_total > 0:
|
||||||
|
# Крайний случай: товаров на 0 руб, а продали за деньги (услуга?)
|
||||||
|
# Распределяем равномерно
|
||||||
|
count = kit_reservations.count()
|
||||||
|
component_qty = reservation.quantity_base or reservation.quantity
|
||||||
|
if count > 0 and component_qty > 0:
|
||||||
|
component_sale_price = (kit_net_total / count) / component_qty
|
||||||
|
else:
|
||||||
|
component_sale_price = Decimal('0')
|
||||||
|
else:
|
||||||
|
component_sale_price = Decimal('0')
|
||||||
|
|
||||||
sale = SaleProcessor.create_sale(
|
sale = SaleProcessor.create_sale(
|
||||||
product=reservation.product,
|
product=reservation.product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
@@ -437,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
sales_created.append(sale)
|
sales_created.append(sale)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✓ Sale создан для компонента комплекта '{kit.name}': "
|
f"✓ Sale создан для компонента комплекта '{kit.name}': "
|
||||||
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. (базовых единиц)"
|
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. "
|
||||||
|
f"(цена: {component_sale_price})"
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -480,12 +569,60 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
f"Используем quantity_in_base_units: {sale_quantity}"
|
f"Используем quantity_in_base_units: {sale_quantity}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Цена за единицу с учётом всех скидок (позиция + заказ)
|
||||||
|
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
|
||||||
|
|
||||||
|
# Скидка на позицию
|
||||||
|
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
|
||||||
|
|
||||||
|
# Скидка на заказ (распределяется пропорционально доле позиции в заказе)
|
||||||
|
# ВАЖНО: Обновляем Order из БД, чтобы получить актуальный total_amount после применения скидок
|
||||||
|
instance.refresh_from_db()
|
||||||
|
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
|
||||||
|
# Скидка = subtotal - (total_amount - delivery) (вычитаем доставку, если есть)
|
||||||
|
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')
|
||||||
|
|
||||||
|
total_discount = item_discount + item_order_discount
|
||||||
|
|
||||||
|
if total_discount and item.quantity > 0:
|
||||||
|
price_with_discount = (item_subtotal - total_discount) / Decimal(str(item.quantity))
|
||||||
|
else:
|
||||||
|
price_with_discount = Decimal(str(item.price))
|
||||||
|
|
||||||
|
# Пересчитываем цену в базовые единицы
|
||||||
|
if item.sales_unit and item.conversion_factor_snapshot:
|
||||||
|
base_price = price_with_discount * item.conversion_factor_snapshot
|
||||||
|
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 (с автоматическим FIFO-списанием)
|
||||||
sale = SaleProcessor.create_sale(
|
sale = SaleProcessor.create_sale(
|
||||||
product=product,
|
product=product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
quantity=sale_quantity,
|
quantity=sale_quantity,
|
||||||
sale_price=Decimal(str(item.price)),
|
sale_price=base_price,
|
||||||
order=instance,
|
order=instance,
|
||||||
document_number=instance.order_number,
|
document_number=instance.order_number,
|
||||||
sales_unit=item.sales_unit # Передаем sales_unit в Sale
|
sales_unit=item.sales_unit # Передаем sales_unit в Sale
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Закупочная цена</th>
|
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Закупочная цена</th>
|
||||||
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Сумма</th>
|
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Сумма</th>
|
||||||
<th scope="col" class="px-3 py-2">Примечания</th>
|
<th scope="col" class="px-3 py-2">Примечания</th>
|
||||||
|
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Текущая цена продажи</th>
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<th scope="col" class="px-3 py-2" style="width: 100px;"></th>
|
<th scope="col" class="px-3 py-2" style="width: 100px;"></th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -200,18 +201,26 @@
|
|||||||
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||||
<span class="item-quantity-display">{{ item.quantity|smart_quantity }}</span>
|
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<input type="number" class="form-control form-control-sm item-quantity-input"
|
<span class="editable-quantity"
|
||||||
value="{{ item.quantity|stringformat:'g' }}" step="0.001" min="0.001"
|
data-item-id="{{ item.id }}"
|
||||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
data-current-value="{{ item.quantity }}"
|
||||||
|
title="Количество (клик для редактирования)"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
{{ item.quantity|smart_quantity }}
|
||||||
|
</span>
|
||||||
|
<input type="number" class="form-control form-control-sm item-quantity-input"
|
||||||
|
value="{{ item.quantity|stringformat:'g' }}" step="0.001" min="0.001"
|
||||||
|
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||||
|
{% else %}
|
||||||
|
<span>{{ item.quantity|smart_quantity }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||||
<span class="item-cost-price-display">{{ item.cost_price|floatformat:2 }}</span>
|
<span class="item-cost-price-display">{{ item.cost_price|floatformat:2 }}</span>
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
||||||
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
||||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -221,11 +230,45 @@
|
|||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<span class="item-notes-display">{% if item.notes %}{{ item.notes|truncatechars:50 }}{% else %}-{% endif %}</span>
|
<span class="item-notes-display">{% if item.notes %}{{ item.notes|truncatechars:50 }}{% else %}-{% endif %}</span>
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<input type="text" class="form-control form-control-sm item-notes-input"
|
<input type="text" class="form-control form-control-sm item-notes-input"
|
||||||
value="{{ item.notes }}" placeholder="Примечания"
|
value="{{ item.notes }}" placeholder="Примечания"
|
||||||
style="display: none;">
|
style="display: none;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||||
|
{% if item.product.sale_price %}
|
||||||
|
<div class="text-decoration-line-through text-muted small">{{ item.product.price|floatformat:2 }} руб.</div>
|
||||||
|
{% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %}
|
||||||
|
<strong class="text-danger editable-price"
|
||||||
|
data-product-id="{{ item.product.pk }}"
|
||||||
|
data-field="sale_price"
|
||||||
|
data-current-value="{{ item.product.sale_price }}"
|
||||||
|
title="Цена со скидкой (клик для редактирования)"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
{{ item.product.sale_price|floatformat:2 }} руб.
|
||||||
|
</strong>
|
||||||
|
{% else %}
|
||||||
|
<strong class="text-danger">
|
||||||
|
{{ item.product.sale_price|floatformat:2 }} руб.
|
||||||
|
</strong>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %}
|
||||||
|
<strong class="editable-price"
|
||||||
|
data-product-id="{{ item.product.pk }}"
|
||||||
|
data-field="price"
|
||||||
|
data-current-value="{{ item.product.price }}"
|
||||||
|
title="Цена продажи (клик для редактирования)"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
{{ item.product.price|floatformat:2 }} руб.
|
||||||
|
</strong>
|
||||||
|
{% else %}
|
||||||
|
<strong>
|
||||||
|
{{ item.product.price|floatformat:2 }} руб.
|
||||||
|
</strong>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<td class="px-3 py-2 text-end" style="width: 100px;">
|
<td class="px-3 py-2 text-end" style="width: 100px;">
|
||||||
<div class="btn-group btn-group-sm item-action-buttons">
|
<div class="btn-group btn-group-sm item-action-buttons">
|
||||||
@@ -256,7 +299,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{% if document.can_edit %}6{% else %}5{% endif %}" class="px-3 py-4 text-center text-muted">
|
<td colspan="{% if document.can_edit %}7{% else %}6{% endif %}" class="px-3 py-4 text-center text-muted">
|
||||||
<i class="bi bi-inbox fs-3 d-block mb-2"></i>
|
<i class="bi bi-inbox fs-3 d-block mb-2"></i>
|
||||||
Позиций пока нет
|
Позиций пока нет
|
||||||
</td>
|
</td>
|
||||||
@@ -268,7 +311,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="px-3 py-2 fw-semibold">Итого:</td>
|
<td class="px-3 py-2 fw-semibold">Итого:</td>
|
||||||
<td class="px-3 py-2 fw-semibold text-end">{{ document.total_quantity|smart_quantity }}</td>
|
<td class="px-3 py-2 fw-semibold text-end">{{ document.total_quantity|smart_quantity }}</td>
|
||||||
<td colspan="2" class="px-3 py-2 fw-semibold text-end">{{ document.total_cost|floatformat:2 }}</td>
|
<td colspan="3" class="px-3 py-2 fw-semibold text-end">{{ document.total_cost|floatformat:2 }}</td>
|
||||||
<td colspan="{% if document.can_edit %}2{% else %}1{% endif %}"></td>
|
<td colspan="{% if document.can_edit %}2{% else %}1{% endif %}"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -283,6 +326,7 @@
|
|||||||
|
|
||||||
<!-- JS для компонента поиска -->
|
<!-- JS для компонента поиска -->
|
||||||
<script src="{% static 'products/js/product-search-picker.js' %}?v=3"></script>
|
<script src="{% static 'products/js/product-search-picker.js' %}?v=3"></script>
|
||||||
|
<script src="{% static 'products/js/inline-price-edit.js' %}?v=1.5"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Элементы формы
|
// Элементы формы
|
||||||
@@ -523,7 +567,184 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Inline редактирование количества
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function initInlineQuantityEdit() {
|
||||||
|
// Проверяем, есть ли на странице редактируемые количества
|
||||||
|
const editableQuantities = document.querySelectorAll('.editable-quantity');
|
||||||
|
if (editableQuantities.length === 0) {
|
||||||
|
return; // Нет элементов для редактирования
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик клика на редактируемое количество
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const quantitySpan = e.target.closest('.editable-quantity');
|
||||||
|
if (!quantitySpan) return;
|
||||||
|
|
||||||
|
// Предотвращаем повторное срабатывание, если уже редактируем
|
||||||
|
if (quantitySpan.querySelector('input')) return;
|
||||||
|
|
||||||
|
const itemId = quantitySpan.dataset.itemId;
|
||||||
|
const currentValue = quantitySpan.dataset.currentValue;
|
||||||
|
|
||||||
|
// Сохраняем оригинальный HTML
|
||||||
|
const originalHTML = quantitySpan.innerHTML;
|
||||||
|
|
||||||
|
// Создаем input для редактирования
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = 'form-control form-control-sm';
|
||||||
|
input.style.width = '100px';
|
||||||
|
input.style.textAlign = 'right';
|
||||||
|
input.value = parseFloat(currentValue).toFixed(3);
|
||||||
|
input.step = '0.001';
|
||||||
|
input.min = '0.001';
|
||||||
|
input.placeholder = 'Количество';
|
||||||
|
|
||||||
|
// Заменяем содержимое на input
|
||||||
|
quantitySpan.innerHTML = '';
|
||||||
|
quantitySpan.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
// Функция сохранения
|
||||||
|
const saveQuantity = async () => {
|
||||||
|
let newValue = input.value.trim();
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!newValue || parseFloat(newValue) <= 0) {
|
||||||
|
alert('Количество должно быть больше нуля');
|
||||||
|
quantitySpan.innerHTML = originalHTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, изменилось ли значение
|
||||||
|
if (parseFloat(newValue) === parseFloat(currentValue)) {
|
||||||
|
// Значение не изменилось
|
||||||
|
quantitySpan.innerHTML = originalHTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем загрузку
|
||||||
|
input.disabled = true;
|
||||||
|
input.style.opacity = '0.5';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем текущие значения других полей
|
||||||
|
const row = quantitySpan.closest('tr');
|
||||||
|
const costPrice = row.querySelector('.item-cost-price-input').value;
|
||||||
|
const notes = row.querySelector('.item-notes-input').value;
|
||||||
|
|
||||||
|
const response = await fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
quantity: newValue,
|
||||||
|
cost_price: costPrice,
|
||||||
|
notes: notes
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Обновляем отображение
|
||||||
|
let formattedQty = parseFloat(newValue);
|
||||||
|
if (formattedQty === Math.floor(formattedQty)) {
|
||||||
|
formattedQty = Math.floor(formattedQty).toString();
|
||||||
|
} else {
|
||||||
|
formattedQty = formattedQty.toString().replace('.', ',');
|
||||||
|
}
|
||||||
|
quantitySpan.textContent = formattedQty;
|
||||||
|
quantitySpan.dataset.currentValue = newValue;
|
||||||
|
|
||||||
|
// Пересчитываем сумму
|
||||||
|
const totalCost = (parseFloat(newValue) * parseFloat(costPrice)).toFixed(2);
|
||||||
|
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
||||||
|
|
||||||
|
// Обновляем итого
|
||||||
|
updateTotals();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Ошибка при обновлении количества');
|
||||||
|
quantitySpan.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка сети при обновлении количества');
|
||||||
|
quantitySpan.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция отмены
|
||||||
|
const cancelEdit = () => {
|
||||||
|
quantitySpan.innerHTML = originalHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enter - сохранить
|
||||||
|
input.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveQuantity();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Потеря фокуса - сохранить
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
setTimeout(saveQuantity, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция обновления итоговых сумм
|
||||||
|
function updateTotals() {
|
||||||
|
// Можно реализовать пересчет итогов, если нужно
|
||||||
|
// Пока оставим как есть, так как сервер возвращает обновленные данные
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация inline редактирования количества
|
||||||
|
initInlineQuantityEdit();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Стили для редактируемых цен */
|
||||||
|
.editable-price {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-price:hover {
|
||||||
|
color: #0d6efd !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-edit-container {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для редактируемого количества */
|
||||||
|
.editable-quantity {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-quantity:hover {
|
||||||
|
color: #0d6efd !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,12 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
<a href="{% url 'products:product-detail' item.product.id %}">{{
|
||||||
|
item.product.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
|
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
|
||||||
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
|
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.
|
||||||
|
</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -132,9 +134,11 @@
|
|||||||
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
||||||
</a>
|
</a>
|
||||||
|
<!--
|
||||||
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
|
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
|
||||||
<i class="bi bi-trash me-1"></i>Удалить
|
<i class="bi bi-trash me-1"></i>Удалить
|
||||||
</a>
|
</a>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,13 +147,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.breadcrumb-sm {
|
.breadcrumb-sm {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-hover tbody tr:hover {
|
.table-hover tbody tr:hover {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -39,9 +39,11 @@
|
|||||||
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<!--
|
||||||
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</a>
|
</a>
|
||||||
|
-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1066,10 +1066,6 @@ class OrderStatusTransitionCriticalTest(TestCase):
|
|||||||
order.save()
|
order.save()
|
||||||
order.refresh_from_db()
|
order.refresh_from_db()
|
||||||
|
|
||||||
# Проверяем, что прошли через draft (автоматический промежуточный переход)
|
|
||||||
history = order.history.all()
|
|
||||||
self.assertGreaterEqual(history.count(), 2, "[STEP 7] Должна быть история переходов")
|
|
||||||
|
|
||||||
# Проверки после автоматического перехода
|
# Проверки после автоматического перехода
|
||||||
self._assert_stock_state(
|
self._assert_stock_state(
|
||||||
available=Decimal('90.00'),
|
available=Decimal('90.00'),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# Initialize environment variables
|
# Initialize environment variables
|
||||||
env = environ.Env(
|
env = environ.Env(
|
||||||
# Set casting and default values
|
# Set casting and default values
|
||||||
DEBUG=(bool, False), # Security: default False
|
DEBUG=(bool, True), # Debug mode enabled
|
||||||
SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'),
|
SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY:
|
|||||||
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
|
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
|
||||||
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,11 +101,19 @@ class OrderFilter(django_filters.FilterSet):
|
|||||||
widget=forms.Select(attrs={'class': 'form-select'})
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Фильтр: показывать все заказы, включая завершённые и отменённые
|
||||||
|
show_all_orders = django_filters.BooleanFilter(
|
||||||
|
method='filter_show_all_orders',
|
||||||
|
label='Включая завершённые',
|
||||||
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ['search', 'status', 'delivery_type', 'payment_status',
|
fields = ['search', 'status', 'delivery_type', 'payment_status',
|
||||||
'delivery_date_after', 'delivery_date_before',
|
'delivery_date_after', 'delivery_date_before',
|
||||||
'created_at_after', 'created_at_before']
|
'created_at_after', 'created_at_before',
|
||||||
|
'show_all_orders']
|
||||||
|
|
||||||
def filter_search(self, queryset, name, value):
|
def filter_search(self, queryset, name, value):
|
||||||
"""
|
"""
|
||||||
@@ -134,3 +142,18 @@ class OrderFilter(django_filters.FilterSet):
|
|||||||
elif value == 'pickup':
|
elif value == 'pickup':
|
||||||
return queryset.filter(delivery__delivery_type=Delivery.DELIVERY_TYPE_PICKUP)
|
return queryset.filter(delivery__delivery_type=Delivery.DELIVERY_TYPE_PICKUP)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def filter_show_all_orders(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Фильтр для показа всех заказов.
|
||||||
|
- Если False или не передан: только активные заказы
|
||||||
|
(статусы с is_positive_end=False И is_negative_end=False)
|
||||||
|
- Если True: все заказы без ограничений
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
# Активные заказы = НЕ (is_positive_end OR is_negative_end)
|
||||||
|
return queryset.filter(
|
||||||
|
Q(status__isnull=True) |
|
||||||
|
(Q(status__is_positive_end=False) & Q(status__is_negative_end=False))
|
||||||
|
)
|
||||||
|
return queryset
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2026-01-21 07:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orders', '0003_order_summary'),
|
||||||
|
('products', '0001_add_sales_unit_to_kititem'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='kititemsnapshot',
|
||||||
|
name='conversion_factor',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=6, help_text='Сколько единиц продажи в 1 базовой единице товара', max_digits=15, null=True, verbose_name='Коэффициент конверсии'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='kititemsnapshot',
|
||||||
|
name='original_sales_unit',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Единица продажи на момент создания снимка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -140,6 +140,25 @@ class KitItemSnapshot(models.Model):
|
|||||||
verbose_name="Группа вариантов"
|
verbose_name="Группа вариантов"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
original_sales_unit = models.ForeignKey(
|
||||||
|
'products.ProductSalesUnit',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='kit_item_snapshots',
|
||||||
|
verbose_name="Единица продажи",
|
||||||
|
help_text="Единица продажи на момент создания снимка"
|
||||||
|
)
|
||||||
|
|
||||||
|
conversion_factor = models.DecimalField(
|
||||||
|
max_digits=15,
|
||||||
|
decimal_places=6,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Коэффициент конверсии",
|
||||||
|
help_text="Сколько единиц продажи в 1 базовой единице товара"
|
||||||
|
)
|
||||||
|
|
||||||
quantity = models.DecimalField(
|
quantity = models.DecimalField(
|
||||||
max_digits=10,
|
max_digits=10,
|
||||||
decimal_places=3,
|
decimal_places=3,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% load inventory_filters %}
|
||||||
|
|
||||||
{% block title %}Заказ {{ order.order_number }}{% endblock %}
|
{% block title %}Заказ {{ order.order_number }}{% endblock %}
|
||||||
|
|
||||||
@@ -337,7 +338,7 @@
|
|||||||
<!-- Кнопка "Применить максимум" -->
|
<!-- Кнопка "Применить максимум" -->
|
||||||
<form method="post" action="{% url 'orders:apply-wallet' order.order_number %}" class="mb-2">
|
<form method="post" action="{% url 'orders:apply-wallet' order.order_number %}" class="mb-2">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="wallet_amount" value="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}">
|
<input type="hidden" name="wallet_amount" value="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|format_decimal:2 }}{% else %}{{ order.amount_due|format_decimal:2 }}{% endif %}">
|
||||||
<button type="submit" class="btn btn-success w-100">
|
<button type="submit" class="btn btn-success w-100">
|
||||||
<i class="bi bi-wallet2"></i> Применить максимум
|
<i class="bi bi-wallet2"></i> Применить максимум
|
||||||
</button>
|
</button>
|
||||||
@@ -351,7 +352,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
max="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}"
|
max="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|format_decimal:2 }}{% else %}{{ order.amount_due|format_decimal:2 }}{% endif %}"
|
||||||
name="wallet_amount"
|
name="wallet_amount"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Сумма"
|
placeholder="Сумма"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="get">
|
<form method="get" id="order-filter-form">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Поиск -->
|
<!-- Поиск -->
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -86,6 +86,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Тумблер "Включая завершённые" -->
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" name="show_all_orders" class="form-check-input" id="id_show_all_orders"
|
||||||
|
{% if request.GET.show_all_orders %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="id_show_all_orders">
|
||||||
|
Включая завершённые
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Календарный фильтр по дате доставки (вторая строка) -->
|
<!-- Календарный фильтр по дате доставки (вторая строка) -->
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -388,6 +401,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Тумблер "Включая завершённые" - автоматическая отправка формы
|
||||||
|
const showAllOrdersSwitch = document.getElementById('id_show_all_orders');
|
||||||
|
const filterForm = document.getElementById('order-filter-form');
|
||||||
|
if (showAllOrdersSwitch && filterForm) {
|
||||||
|
showAllOrdersSwitch.addEventListener('change', function() {
|
||||||
|
filterForm.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ def order_create(request):
|
|||||||
order.recipient = None
|
order.recipient = None
|
||||||
|
|
||||||
# Статус берём из формы (в том числе может быть "Черновик")
|
# Статус берём из формы (в том числе может быть "Черновик")
|
||||||
order.modified_by = request.user
|
from accounts.models import CustomUser
|
||||||
|
if isinstance(request.user, CustomUser):
|
||||||
|
order.modified_by = request.user
|
||||||
|
else:
|
||||||
|
order.modified_by = None
|
||||||
|
|
||||||
# Сохраняем заказ в БД (теперь у него есть pk)
|
# Сохраняем заказ в БД (теперь у него есть pk)
|
||||||
order.save()
|
order.save()
|
||||||
@@ -367,11 +371,16 @@ def order_update(request, order_number):
|
|||||||
# Сохраняем получателя: если новый - создаем, если существующий - обновляем
|
# Сохраняем получателя: если новый - создаем, если существующий - обновляем
|
||||||
recipient.save() # Django автоматически определит create или update
|
recipient.save() # Django автоматически определит create или update
|
||||||
order.recipient = recipient
|
order.recipient = recipient
|
||||||
else:
|
|
||||||
# Если покупатель является получателем
|
# Если покупатель является получателем
|
||||||
order.recipient = None
|
order.recipient = None
|
||||||
|
|
||||||
order.modified_by = request.user
|
from accounts.models import CustomUser
|
||||||
|
if isinstance(request.user, CustomUser):
|
||||||
|
order.modified_by = request.user
|
||||||
|
else:
|
||||||
|
# Если это админ платформы, не перезаписываем поле (оставляем как есть)
|
||||||
|
pass
|
||||||
|
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
editingCartKey = cartKey;
|
editingCartKey = cartKey;
|
||||||
basePrice = parseFloat(item.price) || 0;
|
basePrice = parseFloat(item.price) || 0;
|
||||||
|
|
||||||
|
// Проверяем, является ли товар витринным комплектом
|
||||||
|
const isShowcaseKit = item.type === 'showcase_kit';
|
||||||
|
|
||||||
// Заполнение полей
|
// Заполнение полей
|
||||||
document.getElementById('editModalProductName').textContent = item.name || '—';
|
document.getElementById('editModalProductName').textContent = item.name || '—';
|
||||||
|
|
||||||
@@ -48,6 +51,17 @@
|
|||||||
document.getElementById('editModalPrice').value = roundPrice(basePrice);
|
document.getElementById('editModalPrice').value = roundPrice(basePrice);
|
||||||
document.getElementById('editModalQuantity').value = item.qty || 1;
|
document.getElementById('editModalQuantity').value = item.qty || 1;
|
||||||
|
|
||||||
|
// Для витринных комплектов блокируем изменение количества
|
||||||
|
const qtyInput = document.getElementById('editModalQuantity');
|
||||||
|
const qtyHint = document.getElementById('editModalQtyHint');
|
||||||
|
if (isShowcaseKit) {
|
||||||
|
qtyInput.disabled = true;
|
||||||
|
qtyHint.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
qtyInput.disabled = false;
|
||||||
|
qtyHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Бейдж единицы измерения
|
// Бейдж единицы измерения
|
||||||
const unitBadge = document.getElementById('editModalUnitBadge');
|
const unitBadge = document.getElementById('editModalUnitBadge');
|
||||||
if (item.unit_name) {
|
if (item.unit_name) {
|
||||||
@@ -99,8 +113,13 @@
|
|||||||
// Используем roundQuantity из terminal.js
|
// Используем roundQuantity из terminal.js
|
||||||
const rndQty = typeof roundQuantity === 'function' ? roundQuantity : (v, d) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d);
|
const rndQty = typeof roundQuantity === 'function' ? roundQuantity : (v, d) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d);
|
||||||
|
|
||||||
|
const isShowcaseKit = item.type === 'showcase_kit';
|
||||||
|
|
||||||
item.price = newPrice;
|
item.price = newPrice;
|
||||||
item.qty = rndQty(newQty, 3);
|
// Для витринных комплектов не меняем количество
|
||||||
|
if (!isShowcaseKit) {
|
||||||
|
item.qty = rndQty(newQty, 3);
|
||||||
|
}
|
||||||
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
||||||
|
|
||||||
window.cart.set(editingCartKey, item);
|
window.cart.set(editingCartKey, item);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,9 @@
|
|||||||
<label for="editModalQuantity" class="form-label fw-semibold">Количество</label>
|
<label for="editModalQuantity" class="form-label fw-semibold">Количество</label>
|
||||||
<input type="number" class="form-control" id="editModalQuantity"
|
<input type="number" class="form-control" id="editModalQuantity"
|
||||||
min="0.001" step="0.001" value="1">
|
min="0.001" step="0.001" value="1">
|
||||||
|
<div id="editModalQtyHint" class="text-muted small mt-1" style="display: none;">
|
||||||
|
<i class="bi bi-info-circle"></i> Количество нельзя изменить для витринного комплекта (собранный товар с резервами)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Итого -->
|
<!-- Итого -->
|
||||||
|
|||||||
@@ -218,7 +218,15 @@
|
|||||||
<label for="tempKitDescription" class="form-label">Описание (опционально)</label>
|
<label for="tempKitDescription" class="form-label">Описание (опционально)</label>
|
||||||
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
|
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Дата размещения на витрине -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="showcaseCreatedAt" class="form-label">Дата размещения на витрине</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="showcaseCreatedAt"
|
||||||
|
placeholder="Выберите дату и время">
|
||||||
|
<small class="text-muted">Оставьте пустым для текущего времени</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Загрузка фото -->
|
<!-- Загрузка фото -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
|
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
|
||||||
@@ -322,6 +330,11 @@
|
|||||||
<i class="bi bi-scissors"></i> Разобрать букет
|
<i class="bi bi-scissors"></i> Разобрать букет
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Кнопка "Списать" (отображается только в режиме редактирования) -->
|
||||||
|
<button type="button" class="btn btn-warning me-auto" id="writeOffKitBtn" style="display: none;">
|
||||||
|
<i class="bi bi-file-earmark-x"></i> Списать букет
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Правая группа кнопок -->
|
<!-- Правая группа кнопок -->
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
|
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ urlpatterns = [
|
|||||||
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
|
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
|
||||||
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
|
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
|
||||||
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
||||||
|
# Списать витринный комплект (создать документ списания с компонентами) [POST]
|
||||||
|
path('api/product-kits/<int:kit_id>/write-off/', views.write_off_showcase_kit, name='write-off-showcase-kit'),
|
||||||
# Создать временный комплект и зарезервировать на витрину [POST]
|
# Создать временный комплект и зарезервировать на витрину [POST]
|
||||||
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
|
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
|
||||||
# Создать заказ и провести оплату в POS [POST]
|
# Создать заказ и провести оплату в POS [POST]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
@@ -13,8 +14,9 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from products.models import Product, ProductCategory, ProductKit, KitItem
|
from products.models import Product, ProductCategory, ProductKit, KitItem
|
||||||
from inventory.models import Showcase, Reservation, Warehouse, Stock
|
from inventory.models import Showcase, Reservation, Warehouse, Stock, ShowcaseItem
|
||||||
from inventory.services import ShowcaseManager
|
from inventory.services import ShowcaseManager
|
||||||
|
from inventory.signals import skip_sale_creation, reset_sale_creation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ def get_showcase_kits_for_pos():
|
|||||||
'product_kit__price',
|
'product_kit__price',
|
||||||
'product_kit__sale_price',
|
'product_kit__sale_price',
|
||||||
'product_kit__base_price',
|
'product_kit__base_price',
|
||||||
|
'product_kit__showcase_created_at',
|
||||||
'showcase_id',
|
'showcase_id',
|
||||||
'showcase__name'
|
'showcase__name'
|
||||||
).annotate(
|
).annotate(
|
||||||
@@ -160,7 +163,9 @@ def get_showcase_kits_for_pos():
|
|||||||
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
|
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
|
||||||
'showcase_item_ids': available_item_ids, # IDs только доступных
|
'showcase_item_ids': available_item_ids, # IDs только доступных
|
||||||
# Флаг неактуальной цены
|
# Флаг неактуальной цены
|
||||||
'price_outdated': price_outdated
|
'price_outdated': price_outdated,
|
||||||
|
# Дата размещения на витрине
|
||||||
|
'showcase_created_at': item.get('product_kit__showcase_created_at')
|
||||||
})
|
})
|
||||||
|
|
||||||
return showcase_kits
|
return showcase_kits
|
||||||
@@ -262,13 +267,25 @@ def pos_terminal(request):
|
|||||||
|
|
||||||
if showcase_item_ids:
|
if showcase_item_ids:
|
||||||
# Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя
|
# Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя
|
||||||
locked_items = ShowcaseItem.objects.filter(
|
from accounts.models import CustomUser
|
||||||
id__in=showcase_item_ids,
|
if isinstance(request.user, CustomUser):
|
||||||
product_kit=kit,
|
locked_items = ShowcaseItem.objects.filter(
|
||||||
status='in_cart',
|
id__in=showcase_item_ids,
|
||||||
locked_by_user=request.user,
|
product_kit=kit,
|
||||||
cart_lock_expires_at__gt=timezone.now()
|
status='in_cart',
|
||||||
)
|
locked_by_user=request.user,
|
||||||
|
cart_lock_expires_at__gt=timezone.now()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Для PlatformAdmin используем проверку по сессии
|
||||||
|
session_id = request.session.session_key or ''
|
||||||
|
locked_items = ShowcaseItem.objects.filter(
|
||||||
|
id__in=showcase_item_ids,
|
||||||
|
product_kit=kit,
|
||||||
|
status='in_cart',
|
||||||
|
cart_session_id=session_id,
|
||||||
|
cart_lock_expires_at__gt=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
locked_count = locked_items.count()
|
locked_count = locked_items.count()
|
||||||
|
|
||||||
@@ -654,11 +671,22 @@ def remove_showcase_kit_from_cart(request, kit_id):
|
|||||||
showcase_item_ids = []
|
showcase_item_ids = []
|
||||||
|
|
||||||
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
|
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
|
||||||
qs = ShowcaseItem.objects.filter(
|
from accounts.models import CustomUser
|
||||||
product_kit=kit,
|
|
||||||
status='in_cart',
|
if isinstance(request.user, CustomUser):
|
||||||
locked_by_user=request.user
|
qs = ShowcaseItem.objects.filter(
|
||||||
)
|
product_kit=kit,
|
||||||
|
status='in_cart',
|
||||||
|
locked_by_user=request.user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Для PlatformAdmin используем проверку по сессии
|
||||||
|
session_id = request.session.session_key or ''
|
||||||
|
qs = ShowcaseItem.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
status='in_cart',
|
||||||
|
cart_session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
# Если указаны конкретные ID - фильтруем только их
|
# Если указаны конкретные ID - фильтруем только их
|
||||||
if showcase_item_ids:
|
if showcase_item_ids:
|
||||||
@@ -709,10 +737,22 @@ def release_all_my_showcase_locks(request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Снимаем ВСЕ блокировки текущего пользователя
|
# Снимаем ВСЕ блокировки текущего пользователя
|
||||||
updated_count = ShowcaseItem.objects.filter(
|
from accounts.models import CustomUser
|
||||||
status='in_cart',
|
|
||||||
locked_by_user=request.user
|
if isinstance(request.user, CustomUser):
|
||||||
).update(
|
qs_to_release = ShowcaseItem.objects.filter(
|
||||||
|
status='in_cart',
|
||||||
|
locked_by_user=request.user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Для PlatformAdmin фильтруем по сессии
|
||||||
|
session_id = request.session.session_key or ''
|
||||||
|
qs_to_release = ShowcaseItem.objects.filter(
|
||||||
|
status='in_cart',
|
||||||
|
cart_session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count = qs_to_release.update(
|
||||||
status='available',
|
status='available',
|
||||||
locked_by_user=None,
|
locked_by_user=None,
|
||||||
cart_lock_expires_at=None,
|
cart_lock_expires_at=None,
|
||||||
@@ -1016,7 +1056,8 @@ def get_product_kit_details(request, kit_id):
|
|||||||
'final_price': str(kit.actual_price),
|
'final_price': str(kit.actual_price),
|
||||||
'showcase_id': showcase_id,
|
'showcase_id': showcase_id,
|
||||||
'items': items,
|
'items': items,
|
||||||
'photo_url': photo_url
|
'photo_url': photo_url,
|
||||||
|
'showcase_created_at': kit.showcase_created_at.isoformat() if kit.showcase_created_at else None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except ProductKit.DoesNotExist:
|
except ProductKit.DoesNotExist:
|
||||||
@@ -1051,7 +1092,8 @@ def create_temp_kit_to_showcase(request):
|
|||||||
sale_price_str = request.POST.get('sale_price', '')
|
sale_price_str = request.POST.get('sale_price', '')
|
||||||
photo_file = request.FILES.get('photo')
|
photo_file = request.FILES.get('photo')
|
||||||
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
|
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
|
||||||
|
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||||
|
|
||||||
# Парсим items из JSON
|
# Парсим items из JSON
|
||||||
items = json.loads(items_json)
|
items = json.loads(items_json)
|
||||||
|
|
||||||
@@ -1064,7 +1106,24 @@ def create_temp_kit_to_showcase(request):
|
|||||||
sale_price = None
|
sale_price = None
|
||||||
except (ValueError, InvalidOperation):
|
except (ValueError, InvalidOperation):
|
||||||
sale_price = None
|
sale_price = None
|
||||||
|
|
||||||
|
# Showcase created at (опционально)
|
||||||
|
showcase_created_at = None
|
||||||
|
if showcase_created_at_str:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
pass # Неверный формат, оставляем как None
|
||||||
|
|
||||||
|
# Если не указана - устанавливаем текущее время для новых комплектов
|
||||||
|
if not showcase_created_at:
|
||||||
|
showcase_created_at = timezone.now()
|
||||||
|
|
||||||
# Валидация
|
# Валидация
|
||||||
if not kit_name:
|
if not kit_name:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
@@ -1125,7 +1184,8 @@ def create_temp_kit_to_showcase(request):
|
|||||||
price_adjustment_type=price_adjustment_type,
|
price_adjustment_type=price_adjustment_type,
|
||||||
price_adjustment_value=price_adjustment_value,
|
price_adjustment_value=price_adjustment_value,
|
||||||
sale_price=sale_price,
|
sale_price=sale_price,
|
||||||
showcase=showcase
|
showcase=showcase,
|
||||||
|
showcase_created_at=showcase_created_at
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Создаём KitItem для каждого товара из корзины
|
# 2. Создаём KitItem для каждого товара из корзины
|
||||||
@@ -1260,7 +1320,8 @@ def update_product_kit(request, kit_id):
|
|||||||
sale_price_str = request.POST.get('sale_price', '')
|
sale_price_str = request.POST.get('sale_price', '')
|
||||||
photo_file = request.FILES.get('photo')
|
photo_file = request.FILES.get('photo')
|
||||||
remove_photo = request.POST.get('remove_photo', '') == '1'
|
remove_photo = request.POST.get('remove_photo', '') == '1'
|
||||||
|
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||||
|
|
||||||
items = json.loads(items_json)
|
items = json.loads(items_json)
|
||||||
|
|
||||||
sale_price = None
|
sale_price = None
|
||||||
@@ -1271,7 +1332,24 @@ def update_product_kit(request, kit_id):
|
|||||||
sale_price = None
|
sale_price = None
|
||||||
except (ValueError, InvalidOperation):
|
except (ValueError, InvalidOperation):
|
||||||
sale_price = None
|
sale_price = None
|
||||||
|
|
||||||
|
# Showcase created at (опционально)
|
||||||
|
showcase_created_at = None
|
||||||
|
if showcase_created_at_str:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
pass # Неверный формат, оставляем как есть
|
||||||
|
|
||||||
|
# Делаем datetime timezone-aware
|
||||||
|
if showcase_created_at and showcase_created_at.tzinfo is None:
|
||||||
|
from django.utils import timezone
|
||||||
|
showcase_created_at = timezone.make_aware(showcase_created_at)
|
||||||
|
|
||||||
# Валидация
|
# Валидация
|
||||||
if not kit_name:
|
if not kit_name:
|
||||||
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
|
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
|
||||||
@@ -1345,6 +1423,8 @@ def update_product_kit(request, kit_id):
|
|||||||
kit.price_adjustment_type = price_adjustment_type
|
kit.price_adjustment_type = price_adjustment_type
|
||||||
kit.price_adjustment_value = price_adjustment_value
|
kit.price_adjustment_value = price_adjustment_value
|
||||||
kit.sale_price = sale_price
|
kit.sale_price = sale_price
|
||||||
|
if showcase_created_at is not None: # Обновляем только если передана
|
||||||
|
kit.showcase_created_at = showcase_created_at
|
||||||
kit.save()
|
kit.save()
|
||||||
|
|
||||||
# Обновляем состав
|
# Обновляем состав
|
||||||
@@ -1457,6 +1537,88 @@ def disassemble_product_kit(request, kit_id):
|
|||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def write_off_showcase_kit(request, kit_id):
|
||||||
|
"""
|
||||||
|
Списывает витринный комплект с созданием документа списания.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP запрос
|
||||||
|
kit_id: ID комплекта для списания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
'success': bool,
|
||||||
|
'document_id': int,
|
||||||
|
'document_number': str,
|
||||||
|
'redirect_url': str,
|
||||||
|
'message': str,
|
||||||
|
'error': str (если failed)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Получаем комплект с витриной (только временные комплекты)
|
||||||
|
kit = ProductKit.objects.select_related('showcase').get(id=kit_id, is_temporary=True)
|
||||||
|
|
||||||
|
# Проверяем, что комплект ещё не разобран
|
||||||
|
if kit.status == 'discontinued':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект уже разобран (статус: Снят)'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Проверяем, что у комплекта есть привязанная витрина
|
||||||
|
if not kit.showcase:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект не привязан к витрине'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Находим экземпляр на витрине
|
||||||
|
showcase_item = ShowcaseItem.objects.filter(
|
||||||
|
showcase=kit.showcase,
|
||||||
|
product_kit=kit,
|
||||||
|
status='available'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not showcase_item:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Экземпляр комплекта не найден на витрине'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Создаём документ списания
|
||||||
|
result = ShowcaseManager.write_off_from_showcase(
|
||||||
|
showcase_item=showcase_item,
|
||||||
|
reason='spoilage',
|
||||||
|
notes=f'Витринный букет: {kit.name}',
|
||||||
|
created_by=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result['success']:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': result['message']
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Формируем URL для перенаправления
|
||||||
|
redirect_url = reverse('inventory:writeoff-document-detail', kwargs={'pk': result['document_id']})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'document_id': result['document_id'],
|
||||||
|
'document_number': result['document_number'],
|
||||||
|
'redirect_url': redirect_url,
|
||||||
|
'message': result['message']
|
||||||
|
})
|
||||||
|
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def pos_checkout(request):
|
def pos_checkout(request):
|
||||||
@@ -1526,13 +1688,17 @@ def pos_checkout(request):
|
|||||||
|
|
||||||
# Атомарная операция
|
# Атомарная операция
|
||||||
with db_transaction.atomic():
|
with db_transaction.atomic():
|
||||||
|
# ВАЖНО: Устанавливаем флаг для пропуска автоматического создания Sale в сигнале.
|
||||||
|
# Sale будет создан ЯВНО после применения всех скидок.
|
||||||
|
skip_sale_creation()
|
||||||
|
|
||||||
# 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk)
|
# 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk)
|
||||||
from django.utils import timezone as tz
|
from django.utils import timezone as tz
|
||||||
from orders.models import Delivery
|
from orders.models import Delivery
|
||||||
now_utc = tz.now() # Текущее время в UTC
|
now_utc = tz.now() # Текущее время в UTC
|
||||||
now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk)
|
now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk)
|
||||||
current_time = now_local.time() # Извлекаем время в минском часовом поясе
|
current_time = now_local.time() # Извлекаем время в минском часовом поясе
|
||||||
|
|
||||||
order = Order.objects.create(
|
order = Order.objects.create(
|
||||||
customer=customer,
|
customer=customer,
|
||||||
status=completed_status, # Сразу "Выполнен"
|
status=completed_status, # Сразу "Выполнен"
|
||||||
@@ -1714,6 +1880,11 @@ def pos_checkout(request):
|
|||||||
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
|
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
|
||||||
cache.delete(cart_key)
|
cache.delete(cart_key)
|
||||||
|
|
||||||
|
# 7. Явно создаём Sale после применения всех скидок
|
||||||
|
# Сбрасываем флаг пропуска и вызываем save() для активации сигнала
|
||||||
|
reset_sale_creation()
|
||||||
|
order.save() # Триггерит сигнал create_sale_on_order_completion
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': True,
|
'success': True,
|
||||||
'order_number': order.order_number,
|
'order_number': order.order_number,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
|||||||
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
|
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
|
||||||
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
|
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
|
||||||
from .models import UnitOfMeasure, ProductSalesUnit
|
from .models import UnitOfMeasure, ProductSalesUnit
|
||||||
|
from .models import BouquetName
|
||||||
from .admin_displays import (
|
from .admin_displays import (
|
||||||
format_quality_badge,
|
format_quality_badge,
|
||||||
format_quality_display,
|
format_quality_display,
|
||||||
@@ -1086,3 +1087,42 @@ class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
count
|
count
|
||||||
)
|
)
|
||||||
get_options_count.short_description = 'Вариантов'
|
get_options_count.short_description = 'Вариантов'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BouquetName)
|
||||||
|
class BouquetNameAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Административный интерфейс для управления названиями букетов
|
||||||
|
"""
|
||||||
|
list_display = ('name', 'language', 'is_approved', 'usage_count', 'generated_at')
|
||||||
|
list_filter = ('language', 'is_approved')
|
||||||
|
search_fields = ('name',)
|
||||||
|
filter_horizontal = ('color_tags', 'occasion_tags', 'style_tags')
|
||||||
|
actions = ['approve_selected', 'reject_selected']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Основная информация', {
|
||||||
|
'fields': ('name', 'language', 'is_approved')
|
||||||
|
}),
|
||||||
|
('Теги', {
|
||||||
|
'fields': ('color_tags', 'occasion_tags', 'style_tags')
|
||||||
|
}),
|
||||||
|
('Статистика', {
|
||||||
|
'fields': ('usage_count', 'generated_at', 'approved_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('usage_count', 'generated_at', 'approved_at')
|
||||||
|
|
||||||
|
def approve_selected(self, request, queryset):
|
||||||
|
from django.db import models
|
||||||
|
queryset.update(is_approved=True, approved_at=models.DateTimeField(auto_now=True))
|
||||||
|
self.message_user(request, "Выбранные названия были одобрены")
|
||||||
|
|
||||||
|
approve_selected.short_description = "Одобрить выбранные названия"
|
||||||
|
|
||||||
|
def reject_selected(self, request, queryset):
|
||||||
|
queryset.update(is_approved=False, approved_at=None)
|
||||||
|
self.message_user(request, "Выбранные названия были отклонены")
|
||||||
|
|
||||||
|
reject_selected.short_description = "Отклонить выбранные названия"
|
||||||
|
|||||||
@@ -313,15 +313,17 @@ class KitItemForm(forms.ModelForm):
|
|||||||
"""
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = KitItem
|
model = KitItem
|
||||||
fields = ['product', 'variant_group', 'quantity']
|
fields = ['product', 'variant_group', 'sales_unit', 'quantity']
|
||||||
labels = {
|
labels = {
|
||||||
'product': 'Конкретный товар',
|
'product': 'Конкретный товар',
|
||||||
'variant_group': 'Группа вариантов',
|
'variant_group': 'Группа вариантов',
|
||||||
|
'sales_unit': 'Единица продажи',
|
||||||
'quantity': 'Количество'
|
'quantity': 'Количество'
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'product': forms.Select(attrs={'class': 'form-control'}),
|
'product': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'variant_group': forms.Select(attrs={'class': 'form-control'}),
|
'variant_group': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'sales_unit': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}),
|
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,24 +337,35 @@ class KitItemForm(forms.ModelForm):
|
|||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
product = cleaned_data.get('product')
|
product = cleaned_data.get('product')
|
||||||
variant_group = cleaned_data.get('variant_group')
|
variant_group = cleaned_data.get('variant_group')
|
||||||
|
sales_unit = cleaned_data.get('sales_unit')
|
||||||
quantity = cleaned_data.get('quantity')
|
quantity = cleaned_data.get('quantity')
|
||||||
|
|
||||||
# Если оба поля пусты - это пустая форма (не валидируем, она будет удалена)
|
# Подсчитываем, сколько полей заполнено
|
||||||
if not product and not variant_group:
|
filled_fields = sum([bool(product), bool(variant_group), bool(sales_unit)])
|
||||||
|
|
||||||
|
# Если все поля пусты - это пустая форма (не валидируем, она будет удалена)
|
||||||
|
if filled_fields == 0:
|
||||||
# Для пустых форм обнуляем количество
|
# Для пустых форм обнуляем количество
|
||||||
cleaned_data['quantity'] = None
|
cleaned_data['quantity'] = None
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
# Валидация: должен быть указан либо product, либо variant_group (но не оба)
|
# Валидация несовместимых полей
|
||||||
if product and variant_group:
|
if variant_group and (product or sales_unit):
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
|
"Нельзя указывать группу вариантов одновременно с товаром или единицей продажи."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Если выбрана единица продажи, товар обязателен
|
||||||
|
if sales_unit and not product:
|
||||||
|
raise forms.ValidationError("Для единицы продажи должен быть выбран товар.")
|
||||||
|
|
||||||
# Валидация: если выбран товар/группа, количество обязательно и должно быть > 0
|
# Валидация: если выбран товар/группа/единица продажи, количество обязательно и должно быть > 0
|
||||||
if (product or variant_group):
|
if not quantity or quantity <= 0:
|
||||||
if not quantity or quantity <= 0:
|
raise forms.ValidationError('Необходимо указать количество больше 0')
|
||||||
raise forms.ValidationError('Необходимо указать количество больше 0')
|
|
||||||
|
# Валидация: если выбрана единица продажи, проверяем, что она принадлежит выбранному продукту
|
||||||
|
if sales_unit and product and sales_unit.product != product:
|
||||||
|
raise forms.ValidationError('Выбранная единица продажи не принадлежит указанному товару.')
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
@@ -367,6 +380,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
|
|||||||
|
|
||||||
products = []
|
products = []
|
||||||
variant_groups = []
|
variant_groups = []
|
||||||
|
sales_units = []
|
||||||
|
|
||||||
for form in self.forms:
|
for form in self.forms:
|
||||||
if self.can_delete and self._should_delete_form(form):
|
if self.can_delete and self._should_delete_form(form):
|
||||||
@@ -374,6 +388,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
|
|||||||
|
|
||||||
product = form.cleaned_data.get('product')
|
product = form.cleaned_data.get('product')
|
||||||
variant_group = form.cleaned_data.get('variant_group')
|
variant_group = form.cleaned_data.get('variant_group')
|
||||||
|
sales_unit = form.cleaned_data.get('sales_unit')
|
||||||
|
|
||||||
# Проверка дубликатов товаров
|
# Проверка дубликатов товаров
|
||||||
if product:
|
if product:
|
||||||
@@ -393,13 +408,22 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
|
|||||||
)
|
)
|
||||||
variant_groups.append(variant_group)
|
variant_groups.append(variant_group)
|
||||||
|
|
||||||
|
# Проверка дубликатов единиц продажи
|
||||||
|
if sales_unit:
|
||||||
|
if sales_unit in sales_units:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f'Единица продажи "{sales_unit.name}" добавлена в комплект более одного раза. '
|
||||||
|
f'Каждая единица продажи может быть добавлена только один раз.'
|
||||||
|
)
|
||||||
|
sales_units.append(sales_unit)
|
||||||
|
|
||||||
# Формсет для создания комплектов (с пустой формой для удобства)
|
# Формсет для создания комплектов (с пустой формой для удобства)
|
||||||
KitItemFormSetCreate = inlineformset_factory(
|
KitItemFormSetCreate = inlineformset_factory(
|
||||||
ProductKit,
|
ProductKit,
|
||||||
KitItem,
|
KitItem,
|
||||||
form=KitItemForm,
|
form=KitItemForm,
|
||||||
formset=BaseKitItemFormSet,
|
formset=BaseKitItemFormSet,
|
||||||
fields=['product', 'variant_group', 'quantity'],
|
fields=['product', 'variant_group', 'sales_unit', 'quantity'],
|
||||||
extra=1, # Показать 1 пустую форму для первого компонента
|
extra=1, # Показать 1 пустую форму для первого компонента
|
||||||
can_delete=True, # Разрешить удаление компонентов
|
can_delete=True, # Разрешить удаление компонентов
|
||||||
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
|
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
|
||||||
@@ -413,7 +437,7 @@ KitItemFormSetUpdate = inlineformset_factory(
|
|||||||
KitItem,
|
KitItem,
|
||||||
form=KitItemForm,
|
form=KitItemForm,
|
||||||
formset=BaseKitItemFormSet,
|
formset=BaseKitItemFormSet,
|
||||||
fields=['product', 'variant_group', 'quantity'],
|
fields=['product', 'variant_group', 'sales_unit', 'quantity'],
|
||||||
extra=0, # НЕ показывать пустые формы при редактировании
|
extra=0, # НЕ показывать пустые формы при редактировании
|
||||||
can_delete=True, # Разрешить удаление компонентов
|
can_delete=True, # Разрешить удаление компонентов
|
||||||
min_num=0, # Минимум 0 компонентов
|
min_num=0, # Минимум 0 компонентов
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated migration file for adding sales_unit field to KitItem model
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0005_base_unit_nullable'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='kititem',
|
||||||
|
name='sales_unit',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='kit_items',
|
||||||
|
to='products.productsalesunit',
|
||||||
|
verbose_name='Единица продажи'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
myproject/products/migrations/0002_bouquetname.py
Normal file
33
myproject/products/migrations/0002_bouquetname.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2026-01-22 10:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0001_add_sales_unit_to_kititem'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BouquetName',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True, verbose_name='Название букета')),
|
||||||
|
('language', models.CharField(default='russian', max_length=10, verbose_name='Язык')),
|
||||||
|
('is_approved', models.BooleanField(default=False, verbose_name='Одобрено для использования')),
|
||||||
|
('usage_count', models.PositiveIntegerField(default=0, verbose_name='Количество использований')),
|
||||||
|
('generated_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата генерации')),
|
||||||
|
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата одобрения')),
|
||||||
|
('color_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_color', to='products.producttag', verbose_name='Цветные теги')),
|
||||||
|
('occasion_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_occasion', to='products.producttag', verbose_name='Теги по поводу')),
|
||||||
|
('style_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_style', to='products.producttag', verbose_name='Теги по стилю')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Название букета',
|
||||||
|
'verbose_name_plural': 'Названия букетов',
|
||||||
|
'indexes': [models.Index(fields=['language', 'is_approved'], name='products_bo_languag_8622de_idx'), models.Index(fields=['usage_count'], name='products_bo_usage_c_4ce5b8_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='Дата размещения на витрине'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
myproject/products/migrations/0005_base_unit_nullable.py
Normal file
19
myproject/products/migrations/0005_base_unit_nullable.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2026-01-20 21:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0004_add_unit_price_to_kit_item'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='base_unit',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='products.unitofmeasure', verbose_name='Базовая единица'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -49,6 +49,9 @@ from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPho
|
|||||||
# Задачи импорта
|
# Задачи импорта
|
||||||
from .import_job import ProductImportJob
|
from .import_job import ProductImportJob
|
||||||
|
|
||||||
|
# Названия букетов
|
||||||
|
from .bouquet_names import BouquetName
|
||||||
|
|
||||||
# Явно указываем, что экспортируется при импорте *
|
# Явно указываем, что экспортируется при импорте *
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Managers
|
# Managers
|
||||||
@@ -98,4 +101,7 @@ __all__ = [
|
|||||||
|
|
||||||
# Import Jobs
|
# Import Jobs
|
||||||
'ProductImportJob',
|
'ProductImportJob',
|
||||||
|
|
||||||
|
# Bouquet Names
|
||||||
|
'BouquetName',
|
||||||
]
|
]
|
||||||
|
|||||||
73
myproject/products/models/bouquet_names.py
Normal file
73
myproject/products/models/bouquet_names.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from django.db import models
|
||||||
|
from .categories import ProductTag
|
||||||
|
|
||||||
|
|
||||||
|
class BouquetName(models.Model):
|
||||||
|
"""
|
||||||
|
Модель для хранения предопределенных названий букетов с метаинформацией
|
||||||
|
"""
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Название букета"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Категории характеристик
|
||||||
|
color_tags = models.ManyToManyField(
|
||||||
|
ProductTag,
|
||||||
|
blank=True,
|
||||||
|
related_name='bouquet_names_by_color',
|
||||||
|
verbose_name="Цветные теги"
|
||||||
|
)
|
||||||
|
|
||||||
|
occasion_tags = models.ManyToManyField(
|
||||||
|
ProductTag,
|
||||||
|
blank=True,
|
||||||
|
related_name='bouquet_names_by_occasion',
|
||||||
|
verbose_name="Теги по поводу"
|
||||||
|
)
|
||||||
|
|
||||||
|
style_tags = models.ManyToManyField(
|
||||||
|
ProductTag,
|
||||||
|
blank=True,
|
||||||
|
related_name='bouquet_names_by_style',
|
||||||
|
verbose_name="Теги по стилю"
|
||||||
|
)
|
||||||
|
|
||||||
|
language = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
default='russian',
|
||||||
|
verbose_name="Язык"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_approved = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Одобрено для использования"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name="Количество использований"
|
||||||
|
)
|
||||||
|
|
||||||
|
generated_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата генерации"
|
||||||
|
)
|
||||||
|
|
||||||
|
approved_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Дата одобрения"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Название букета"
|
||||||
|
verbose_name_plural = "Названия букетов"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['language', 'is_approved']),
|
||||||
|
models.Index(fields=['usage_count']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
@@ -93,6 +93,14 @@ class ProductKit(BaseProductEntity):
|
|||||||
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
|
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Showcase creation date - editable date for when the bouquet was put on display
|
||||||
|
showcase_created_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Дата размещения на витрине",
|
||||||
|
help_text="Дата создания букета для витрины (редактируемая)"
|
||||||
|
)
|
||||||
|
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
'orders.Order',
|
'orders.Order',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -163,7 +171,11 @@ class ProductKit(BaseProductEntity):
|
|||||||
total = Decimal('0')
|
total = Decimal('0')
|
||||||
for item in self.kit_items.all():
|
for item in self.kit_items.all():
|
||||||
qty = item.quantity or Decimal('1')
|
qty = item.quantity or Decimal('1')
|
||||||
if item.product:
|
if item.sales_unit:
|
||||||
|
# Для sales_unit используем цену единицы продажи
|
||||||
|
unit_price = item.sales_unit.actual_price or Decimal('0')
|
||||||
|
total += unit_price * qty
|
||||||
|
elif item.product:
|
||||||
# Используем зафиксированную цену если есть, иначе актуальную цену товара
|
# Используем зафиксированную цену если есть, иначе актуальную цену товара
|
||||||
if item.unit_price is not None:
|
if item.unit_price is not None:
|
||||||
unit_price = item.unit_price
|
unit_price = item.unit_price
|
||||||
@@ -213,7 +225,11 @@ class ProductKit(BaseProductEntity):
|
|||||||
# Пересчитаем базовую цену из компонентов
|
# Пересчитаем базовую цену из компонентов
|
||||||
total = Decimal('0')
|
total = Decimal('0')
|
||||||
for item in self.kit_items.all():
|
for item in self.kit_items.all():
|
||||||
if item.product:
|
if item.sales_unit:
|
||||||
|
actual_price = item.sales_unit.actual_price or Decimal('0')
|
||||||
|
qty = item.quantity or Decimal('1')
|
||||||
|
total += actual_price * qty
|
||||||
|
elif item.product:
|
||||||
actual_price = item.product.actual_price or Decimal('0')
|
actual_price = item.product.actual_price or Decimal('0')
|
||||||
qty = item.quantity or Decimal('1')
|
qty = item.quantity or Decimal('1')
|
||||||
total += actual_price * qty
|
total += actual_price * qty
|
||||||
@@ -301,7 +317,12 @@ class ProductKit(BaseProductEntity):
|
|||||||
min_available = kits_from_this_component
|
min_available = kits_from_this_component
|
||||||
|
|
||||||
# Возвращаем целую часть (нельзя собрать половину комплекта)
|
# Возвращаем целую часть (нельзя собрать половину комплекта)
|
||||||
return Decimal(int(min_available)) if min_available is not None else Decimal('0')
|
# Нельзя собрать отрицательное количество комплектов
|
||||||
|
if min_available is not None:
|
||||||
|
if min_available <= 0:
|
||||||
|
return Decimal('0')
|
||||||
|
return Decimal(int(min_available))
|
||||||
|
return Decimal('0')
|
||||||
|
|
||||||
def make_permanent(self):
|
def make_permanent(self):
|
||||||
"""
|
"""
|
||||||
@@ -319,17 +340,6 @@ class ProductKit(BaseProductEntity):
|
|||||||
self.save(update_fields=['is_temporary', 'order'])
|
self.save(update_fields=['is_temporary', 'order'])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
"""Soft delete вместо hard delete - марк как удаленный"""
|
|
||||||
self.is_deleted = True
|
|
||||||
self.deleted_at = timezone.now()
|
|
||||||
self.save(update_fields=['is_deleted', 'deleted_at'])
|
|
||||||
return 1, {self.__class__._meta.label: 1}
|
|
||||||
|
|
||||||
def hard_delete(self):
|
|
||||||
"""Полное удаление из БД (необратимо!)"""
|
|
||||||
super().delete()
|
|
||||||
|
|
||||||
def create_snapshot(self):
|
def create_snapshot(self):
|
||||||
"""
|
"""
|
||||||
Создает снимок текущего состояния комплекта.
|
Создает снимок текущего состояния комплекта.
|
||||||
@@ -369,6 +379,8 @@ class ProductKit(BaseProductEntity):
|
|||||||
product_sku=item.product.sku if item.product else '',
|
product_sku=item.product.sku if item.product else '',
|
||||||
product_price=product_price,
|
product_price=product_price,
|
||||||
variant_group_name=item.variant_group.name if item.variant_group else '',
|
variant_group_name=item.variant_group.name if item.variant_group else '',
|
||||||
|
original_sales_unit=item.sales_unit,
|
||||||
|
conversion_factor=item.sales_unit.conversion_factor if item.sales_unit else None,
|
||||||
quantity=item.quantity or Decimal('1'),
|
quantity=item.quantity or Decimal('1'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -377,8 +389,8 @@ class ProductKit(BaseProductEntity):
|
|||||||
|
|
||||||
class KitItem(models.Model):
|
class KitItem(models.Model):
|
||||||
"""
|
"""
|
||||||
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
|
Состав комплекта: связь между ProductKit и Product, ProductVariantGroup или ProductSalesUnit.
|
||||||
Позиция может быть либо конкретным товаром, либо группой вариантов.
|
Позиция может быть либо конкретным товаром, либо группой вариантов, либо конкретной единицей продажи.
|
||||||
"""
|
"""
|
||||||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
|
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
|
||||||
verbose_name="Комплект")
|
verbose_name="Комплект")
|
||||||
@@ -398,6 +410,14 @@ class KitItem(models.Model):
|
|||||||
related_name='kit_items',
|
related_name='kit_items',
|
||||||
verbose_name="Группа вариантов"
|
verbose_name="Группа вариантов"
|
||||||
)
|
)
|
||||||
|
sales_unit = models.ForeignKey(
|
||||||
|
'ProductSalesUnit',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='kit_items',
|
||||||
|
verbose_name="Единица продажи"
|
||||||
|
)
|
||||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
|
quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
|
||||||
unit_price = models.DecimalField(
|
unit_price = models.DecimalField(
|
||||||
max_digits=10,
|
max_digits=10,
|
||||||
@@ -423,21 +443,46 @@ class KitItem(models.Model):
|
|||||||
return f"{self.kit.name} - {self.get_display_name()}"
|
return f"{self.kit.name} - {self.get_display_name()}"
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
|
"""Валидация: должна быть указана группа вариантов ИЛИ (товар [плюс опционально единица продажи])"""
|
||||||
if self.product and self.variant_group:
|
|
||||||
raise ValidationError(
|
has_variant = bool(self.variant_group)
|
||||||
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
|
has_product = bool(self.product)
|
||||||
)
|
has_sales_unit = bool(self.sales_unit)
|
||||||
if not self.product and not self.variant_group:
|
|
||||||
|
# 1. Проверка на пустоту
|
||||||
|
if not (has_variant or has_product or has_sales_unit):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Необходимо указать либо товар, либо группу вариантов."
|
"Необходимо указать либо товар, либо группу вариантов."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 2. Несовместимость: Группа вариантов VS Товар/Единица
|
||||||
|
if has_variant and (has_product or has_sales_unit):
|
||||||
|
raise ValidationError(
|
||||||
|
"Нельзя указывать группу вариантов одновременно с товаром или единицей продажи."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Зависимость: Если есть sales_unit, должен быть product
|
||||||
|
if has_sales_unit and not has_product:
|
||||||
|
raise ValidationError(
|
||||||
|
"Если указана единица продажи, должен быть выбран соответствующий товар."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Проверка принадлежности
|
||||||
|
if has_sales_unit and has_product and self.sales_unit.product != self.product:
|
||||||
|
raise ValidationError(
|
||||||
|
"Выбранная единица продажи не принадлежит указанному товару."
|
||||||
|
)
|
||||||
|
|
||||||
def get_display_name(self):
|
def get_display_name(self):
|
||||||
"""Возвращает строку для отображения названия компонента"""
|
"""Возвращает строку для отображения названия компонента"""
|
||||||
if self.variant_group:
|
# Приоритет: сначала единица продажи, затем товар, затем группа вариантов
|
||||||
|
if self.sales_unit:
|
||||||
|
return f"[Единица продажи] {self.sales_unit.name}"
|
||||||
|
elif self.product:
|
||||||
|
return self.product.name
|
||||||
|
elif self.variant_group:
|
||||||
return f"[Варианты] {self.variant_group.name}"
|
return f"[Варианты] {self.variant_group.name}"
|
||||||
return self.product.name if self.product else "Не указан"
|
return "Не указан"
|
||||||
|
|
||||||
def has_priorities_set(self):
|
def has_priorities_set(self):
|
||||||
"""Проверяет, настроены ли приоритеты замены для данного компонента"""
|
"""Проверяет, настроены ли приоритеты замены для данного компонента"""
|
||||||
@@ -447,10 +492,16 @@ class KitItem(models.Model):
|
|||||||
"""
|
"""
|
||||||
Возвращает список доступных товаров для этого компонента.
|
Возвращает список доступных товаров для этого компонента.
|
||||||
|
|
||||||
|
Если указана единица продажи - возвращает товар, к которому она относится.
|
||||||
Если указан конкретный товар - возвращает его.
|
Если указан конкретный товар - возвращает его.
|
||||||
Если указаны приоритеты - возвращает товары в порядке приоритета.
|
Если указаны приоритеты - возвращает товары в порядке приоритета.
|
||||||
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
|
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
|
||||||
"""
|
"""
|
||||||
|
# Приоритет: сначала единица продажи, затем товар, затем группа вариантов
|
||||||
|
if self.sales_unit:
|
||||||
|
# Если указана единица продажи, возвращаем товар, к которому она относится
|
||||||
|
return [self.sales_unit.product]
|
||||||
|
|
||||||
if self.product:
|
if self.product:
|
||||||
# Если указан конкретный товар, возвращаем только его
|
# Если указан конкретный товар, возвращаем только его
|
||||||
return [self.product]
|
return [self.product]
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class Product(BaseProductEntity):
|
|||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='products',
|
related_name='products',
|
||||||
verbose_name="Базовая единица",
|
verbose_name="Базовая единица",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах."
|
help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,6 +141,14 @@ class Product(BaseProductEntity):
|
|||||||
from ..services.cost_calculator import ProductCostCalculator
|
from ..services.cost_calculator import ProductCostCalculator
|
||||||
return ProductCostCalculator.get_cost_details(self)
|
return ProductCostCalculator.get_cost_details(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kit_items_using_as_sales_unit(self):
|
||||||
|
"""
|
||||||
|
Возвращает QuerySet KitItem, где этот товар используется как единица продажи.
|
||||||
|
"""
|
||||||
|
from .kits import KitItem
|
||||||
|
return KitItem.objects.filter(sales_unit__product=self)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Используем сервис для подготовки к сохранению
|
# Используем сервис для подготовки к сохранению
|
||||||
ProductSaveService.prepare_product_for_save(self)
|
ProductSaveService.prepare_product_for_save(self)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Сервисы для бизнес-логики products приложения.
|
Сервисы для бизнес-логики products приложения.
|
||||||
Следует принципу "Skinny Models, Fat Services".
|
Следует принципу "Тонкие модели, толстые сервисы".
|
||||||
"""
|
"""
|
||||||
from .unit_service import UnitOfMeasureService
|
from .unit_service import UnitOfMeasureService
|
||||||
|
from .ai.bouquet_names import BouquetNameGenerator
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'UnitOfMeasureService',
|
'UnitOfMeasureService',
|
||||||
|
'BouquetNameGenerator',
|
||||||
]
|
]
|
||||||
|
|||||||
6
myproject/products/services/ai/__init__.py
Normal file
6
myproject/products/services/ai/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
AI-сервисы для products приложения.
|
||||||
|
|
||||||
|
Содержит инструменты для взаимодействия с нейросетями для решения специфичных
|
||||||
|
бизнес-задач, таких как генерация названий продуктов, описаний, классификация и т.д.
|
||||||
|
"""
|
||||||
48
myproject/products/services/ai/base.py
Normal file
48
myproject/products/services/ai/base.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Tuple, Optional, Dict
|
||||||
|
from integrations.services.ai_services.glm_service import GLMIntegrationService
|
||||||
|
from integrations.services.ai_services.openrouter_service import OpenRouterIntegrationService
|
||||||
|
from integrations.models.ai_services.glm import GLMIntegration
|
||||||
|
from integrations.models.ai_services.openrouter import OpenRouterIntegration
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAIProductService(ABC):
|
||||||
|
"""
|
||||||
|
Абстрактный базовый класс для AI-сервисов продуктов
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, **kwargs) -> Tuple[bool, str, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
Основной метод генерации
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_glm_service(cls) -> Optional[GLMIntegrationService]:
|
||||||
|
"""
|
||||||
|
Получить сервис GLM из активной интеграции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
integration = GLMIntegration.objects.filter(is_active=True).first()
|
||||||
|
if integration:
|
||||||
|
return GLMIntegrationService(integration)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении GLM сервиса: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_openrouter_service(cls) -> Optional[OpenRouterIntegrationService]:
|
||||||
|
"""
|
||||||
|
Получить сервис OpenRouter из активной интеграции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
integration = OpenRouterIntegration.objects.filter(is_active=True).first()
|
||||||
|
if integration:
|
||||||
|
return OpenRouterIntegrationService(integration)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении OpenRouter сервиса: {str(e)}")
|
||||||
|
return None
|
||||||
272
myproject/products/services/ai/bouquet_names.py
Normal file
272
myproject/products/services/ai/bouquet_names.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
from typing import Tuple, Optional, Dict, List
|
||||||
|
from .base import BaseAIProductService
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BouquetNameGenerator(BaseAIProductService):
|
||||||
|
"""
|
||||||
|
Сервис для генерации и управления названиями букетов
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_SYSTEM_PROMPT = (
|
||||||
|
"Вы эксперт в создании красивых, привлекательных и продаваемых названий для букетов цветов. "
|
||||||
|
"Ваша цель — генерировать запоминающиеся и выразительные названия, которые привлекут покупателей. "
|
||||||
|
"Названия должны быть краткими (2-4 слов), креативными и соответствующими характеристикам букета. "
|
||||||
|
"Избегайте общих терминов. Фокусируйтесь на эмоциях, эстетике"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Константы
|
||||||
|
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,
|
||||||
|
characteristics: Optional[str] = None,
|
||||||
|
occasion: Optional[str] = None,
|
||||||
|
language: str = "russian"
|
||||||
|
) -> Tuple[bool, str, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
Генерация названий букетов
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Количество названий для генерации
|
||||||
|
characteristics: Характеристики букетов (например, "розы, лилии, яркий")
|
||||||
|
occasion: П'occasion (например, "день рождения, Valentine's Day")
|
||||||
|
language: Язык генерации
|
||||||
|
|
||||||
|
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-сервис
|
||||||
|
service = self.get_glm_service() or self.get_openrouter_service()
|
||||||
|
if not service:
|
||||||
|
return False, "Нет активных AI-интеграций", None
|
||||||
|
|
||||||
|
# Формируем промпт
|
||||||
|
prompt = f"Сгенерируй {count} креативных и привлекательных названий для букетов цветов"
|
||||||
|
|
||||||
|
if characteristics:
|
||||||
|
prompt += f" с следующими характеристиками: {characteristics}"
|
||||||
|
|
||||||
|
if occasion:
|
||||||
|
prompt += f" для праздника: {occasion}"
|
||||||
|
|
||||||
|
prompt += (
|
||||||
|
"\n\nТребования к каждому названию:\n"
|
||||||
|
"- 2, 3 или 4 слова в равных пропорциях\n"
|
||||||
|
"- Выразительные и эмоциональные\n"
|
||||||
|
"- Продаваемые и запоминающиеся\n"
|
||||||
|
"- Избегайте общих названий типа 'Букет #1'\n"
|
||||||
|
"- Фокусируйтесь на красоте, романтике и подарках\n"
|
||||||
|
"- Используйте прилагательные и описательные слова\n"
|
||||||
|
"- Не используйте символы пунктуации в середине названий\n"
|
||||||
|
"\nВерните названия в виде нумерованного списка, по одному на строку.\n"
|
||||||
|
"Примеры хороших названий:\n"
|
||||||
|
"- 2 слова: 'Весенние Розы', 'Летнее Сияние', 'Нежность', 'Романтика'\n"
|
||||||
|
"- 3 слова: 'Весенний Вальс', 'Нежность Роз', 'Сияние Любви', 'Танец Цветов'\n"
|
||||||
|
"- 4 слова: 'Шепот Весенней Нежности', 'Сияние Розовой Любви', 'Танец Цветов Весны', 'Шёпот Сердечной Романтики'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Вызов AI-сервиса
|
||||||
|
success, msg, response = service.generate_text(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=self.DEFAULT_SYSTEM_PROMPT,
|
||||||
|
max_tokens=3000 # Увеличиваем лимит для большего числа названий
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return False, msg, None
|
||||||
|
|
||||||
|
# Парсим результат
|
||||||
|
names = self._parse_response(response.get('generated_text', ''))
|
||||||
|
|
||||||
|
return True, f"Сгенерировано {len(names)} названий для букетов", {
|
||||||
|
'names': names,
|
||||||
|
'model': response.get('model'),
|
||||||
|
'usage': response.get('usage')
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_response(self, text: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Парсит текстовый ответ AI и извлекает названия букетов
|
||||||
|
"""
|
||||||
|
names = []
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
# Пропускаем пустые строки и заголовки
|
||||||
|
if not line or any(line.lower().startswith(prefix) for prefix in self.SKIP_PREFIXES):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Удаляем номера списка
|
||||||
|
if line and (line[0].isdigit() or line[0] == '-'):
|
||||||
|
# Удаляем номер и точку или дефис
|
||||||
|
if '.' in line:
|
||||||
|
line = line.split('.', 1)[1].strip()
|
||||||
|
else:
|
||||||
|
line = line[1:].strip()
|
||||||
|
|
||||||
|
# Пропускаем строки, которые стали пустыми после удаления номера
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Удаляем markdown форматирование (жирный, курсив)
|
||||||
|
line = line.replace('**', '').replace('*', '').replace('"', '').replace("'", '').strip()
|
||||||
|
|
||||||
|
if line:
|
||||||
|
# Приводим к нужному формату: первое слово с заглавной, остальные строчные
|
||||||
|
normalized_line = self._normalize_case(line)
|
||||||
|
names.append(normalized_line)
|
||||||
|
|
||||||
|
# Фильтруем и сортируем названия по длине для равномерного распределения
|
||||||
|
names_by_length = {2: [], 3: [], 4: []}
|
||||||
|
|
||||||
|
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_list.append(name)
|
||||||
|
names_by_length[length] = unique_list
|
||||||
|
|
||||||
|
# Объединяем названия в один список в пропорциях 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,
|
||||||
|
count: int = 500,
|
||||||
|
characteristics: Optional[str] = None,
|
||||||
|
occasion: Optional[str] = None,
|
||||||
|
language: str = "russian"
|
||||||
|
) -> Tuple[bool, str, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
Генерирует названия и сохраняет в базу данных
|
||||||
|
"""
|
||||||
|
from products.models import BouquetName
|
||||||
|
|
||||||
|
success, msg, data = self.generate(count, characteristics, occasion, language)
|
||||||
|
|
||||||
|
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,
|
||||||
|
defaults={
|
||||||
|
'is_approved': False
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stored_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка сохранения названия '{name}': {e}")
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
success_msg = f"Сгенерировано и сохранено {stored_count} названий для букетов"
|
||||||
|
if failed_count > 0:
|
||||||
|
success_msg += f", не удалось сохранить {failed_count} названий"
|
||||||
|
|
||||||
|
return True, success_msg, data
|
||||||
|
|
||||||
|
return success, msg, data
|
||||||
|
|
||||||
|
def get_approved_names(
|
||||||
|
self,
|
||||||
|
color_tags: Optional[List[str]] = None,
|
||||||
|
occasion_tags: Optional[List[str]] = None,
|
||||||
|
style_tags: Optional[List[str]] = None,
|
||||||
|
language: str = "russian",
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Получает одобренные названия с фильтрацией по тегам
|
||||||
|
"""
|
||||||
|
from products.models import BouquetName
|
||||||
|
|
||||||
|
queryset = BouquetName.objects.filter(
|
||||||
|
is_approved=True,
|
||||||
|
language=language
|
||||||
|
)
|
||||||
|
|
||||||
|
if color_tags:
|
||||||
|
queryset = queryset.filter(color_tags__name__in=color_tags)
|
||||||
|
|
||||||
|
if occasion_tags:
|
||||||
|
queryset = queryset.filter(occasion_tags__name__in=occasion_tags)
|
||||||
|
|
||||||
|
if style_tags:
|
||||||
|
queryset = queryset.filter(style_tags__name__in=style_tags)
|
||||||
|
|
||||||
|
# Сортируем по популярности
|
||||||
|
queryset = queryset.order_by('-usage_count')
|
||||||
|
|
||||||
|
return list(queryset.values_list('name', flat=True)[:limit])
|
||||||
|
|
||||||
|
def mark_as_used(self, name: str, language: str = "russian") -> None:
|
||||||
|
"""
|
||||||
|
Увеличивает счетчик использования названия
|
||||||
|
"""
|
||||||
|
from products.models import BouquetName
|
||||||
|
|
||||||
|
BouquetName.objects.filter(
|
||||||
|
name=name,
|
||||||
|
language=language
|
||||||
|
).update(
|
||||||
|
usage_count=models.F('usage_count') + 1
|
||||||
|
)
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
{% elif item.item_type == 'kit' %}
|
{% elif item.item_type == 'kit' %}
|
||||||
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
||||||
<a href="{% url 'products:kit-detail' item.pk %}"
|
<a href="{% url 'products:productkit-detail' item.pk %}"
|
||||||
style="color: #6c757d;">{{ item.name }}</a>
|
style="color: #6c757d;">{{ item.name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
{% elif item.item_type == 'product' %}
|
{% elif item.item_type == 'product' %}
|
||||||
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||||
{% elif item.item_type == 'kit' %}
|
{% elif item.item_type == 'kit' %}
|
||||||
<a href="{% url 'products:kit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
<a href="{% url 'products:productkit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -8,25 +8,33 @@
|
|||||||
|
|
||||||
<div id="kititem-forms">
|
<div id="kititem-forms">
|
||||||
{% for kititem_form in kititem_formset %}
|
{% for kititem_form in kititem_formset %}
|
||||||
<div class="card mb-2 kititem-form border"
|
<div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}"
|
||||||
data-form-index="{{ forloop.counter0 }}"
|
data-product-id="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.id }}{% endif %}"
|
||||||
data-product-id="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.id }}{% endif %}"
|
data-product-price="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.actual_price|default:0 }}{% else %}0{% endif %}">
|
||||||
data-product-price="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.actual_price|default:0 }}{% else %}0{% endif %}">
|
|
||||||
{{ kititem_form.id }}
|
{{ kititem_form.id }}
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
{% if kititem_form.non_field_errors %}
|
{% if kititem_form.non_field_errors %}
|
||||||
<div class="alert alert-danger alert-sm mb-2">
|
<div class="alert alert-danger alert-sm mb-2">
|
||||||
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
|
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
<!-- ТОВАР -->
|
<!-- ТОВАР -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<label class="form-label small text-muted mb-1">Товар</label>
|
<label class="form-label small text-muted mb-1">Товар</label>
|
||||||
{{ kititem_form.product }}
|
{{ kititem_form.product }}
|
||||||
{% if kititem_form.product.errors %}
|
{% if kititem_form.product.errors %}
|
||||||
<div class="text-danger small">{{ kititem_form.product.errors }}</div>
|
<div class="text-danger small">{{ kititem_form.product.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ЕДИНИЦА ПРОДАЖИ -->
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small text-muted mb-1">Единица продажи</label>
|
||||||
|
{{ kititem_form.sales_unit }}
|
||||||
|
{% if kititem_form.sales_unit.errors %}
|
||||||
|
<div class="text-danger small">{{ kititem_form.sales_unit.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,19 +42,18 @@
|
|||||||
<div class="col-md-1 d-flex justify-content-center align-items-center">
|
<div class="col-md-1 d-flex justify-content-center align-items-center">
|
||||||
<div class="kit-item-separator">
|
<div class="kit-item-separator">
|
||||||
<span class="separator-text">ИЛИ</span>
|
<span class="separator-text">ИЛИ</span>
|
||||||
<i class="bi bi-info-circle separator-help"
|
<i class="bi bi-info-circle separator-help" data-bs-toggle="tooltip"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-placement="top"
|
||||||
data-bs-placement="top"
|
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
|
||||||
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ГРУППА ВАРИАНТОВ -->
|
<!-- ГРУППА ВАРИАНТОВ -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<label class="form-label small text-muted mb-1">Группа вариантов</label>
|
<label class="form-label small text-muted mb-1">Группа вариантов</label>
|
||||||
{{ kititem_form.variant_group }}
|
{{ kititem_form.variant_group }}
|
||||||
{% if kititem_form.variant_group.errors %}
|
{% if kititem_form.variant_group.errors %}
|
||||||
<div class="text-danger small">{{ kititem_form.variant_group.errors }}</div>
|
<div class="text-danger small">{{ kititem_form.variant_group.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,17 +62,19 @@
|
|||||||
<label class="form-label small text-muted mb-1">Кол-во</label>
|
<label class="form-label small text-muted mb-1">Кол-во</label>
|
||||||
{{ kititem_form.quantity|smart_quantity }}
|
{{ kititem_form.quantity|smart_quantity }}
|
||||||
{% if kititem_form.quantity.errors %}
|
{% if kititem_form.quantity.errors %}
|
||||||
<div class="text-danger small">{{ kititem_form.quantity.errors }}</div>
|
<div class="text-danger small">{{ kititem_form.quantity.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- УДАЛЕНИЕ -->
|
<!-- УДАЛЕНИЕ -->
|
||||||
<div class="col-md-1 text-end">
|
<div class="col-md-1 text-end">
|
||||||
{% if kititem_form.DELETE %}
|
{% if kititem_form.DELETE %}
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none'; if(typeof calculateFinalPrice === 'function') calculateFinalPrice();" title="Удалить">
|
<button type="button" class="btn btn-sm btn-link text-danger p-0"
|
||||||
<i class="bi bi-x-lg"></i>
|
onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none'; if(typeof calculateFinalPrice === 'function') calculateFinalPrice();"
|
||||||
</button>
|
title="Удалить">
|
||||||
{{ kititem_form.DELETE }}
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
{{ kititem_form.DELETE }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,4 +90,4 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
/**
|
/**
|
||||||
* Инициализирует Select2 для элемента с AJAX поиском товаров
|
* Инициализирует Select2 для элемента с AJAX поиском товаров
|
||||||
* @param {Element} element - DOM элемент select
|
* @param {Element} element - DOM элемент select
|
||||||
* @param {string} type - Тип поиска ('product' или 'variant')
|
* @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
|
||||||
* @param {string} apiUrl - URL API для поиска
|
* @param {string} apiUrl - URL API для поиска
|
||||||
* @returns {boolean} - true если инициализация прошла успешно, false иначе
|
* @returns {boolean} - true если инициализация прошла успешно, false иначе
|
||||||
*/
|
*/
|
||||||
@@ -70,60 +70,92 @@
|
|||||||
|
|
||||||
var placeholders = {
|
var placeholders = {
|
||||||
'product': 'Начните вводить название товара...',
|
'product': 'Начните вводить название товара...',
|
||||||
'variant': 'Начните вводить название группы...'
|
'variant': 'Начните вводить название группы...',
|
||||||
|
'sales_unit': 'Выберите единицу продажи...'
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
// Для единиц продажи используем другой подход - не AJAX, а загрузка при выборе товара
|
||||||
$element.select2({
|
if (type === 'sales_unit') {
|
||||||
theme: 'bootstrap-5',
|
try {
|
||||||
placeholder: placeholders[type] || 'Выберите...',
|
$element.select2({
|
||||||
allowClear: true,
|
theme: 'bootstrap-5',
|
||||||
width: '100%',
|
placeholder: placeholders[type] || 'Выберите...',
|
||||||
language: 'ru',
|
allowClear: true,
|
||||||
minimumInputLength: 0,
|
width: '100%',
|
||||||
dropdownAutoWidth: false,
|
language: 'ru',
|
||||||
ajax: {
|
minimumInputLength: 0,
|
||||||
url: apiUrl,
|
dropdownAutoWidth: false,
|
||||||
dataType: 'json',
|
// Для единиц продажи не используем AJAX, т.к. они загружаются при выборе товара
|
||||||
delay: 250,
|
disabled: true, // Изначально отключен до выбора товара
|
||||||
data: function (params) {
|
templateResult: formatSelectResult,
|
||||||
return {
|
templateSelection: formatSelectSelection
|
||||||
q: params.term || '',
|
});
|
||||||
type: type,
|
console.log('initProductSelect2: successfully initialized sales_unit for', element.name);
|
||||||
page: params.page || 1
|
return true;
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error('initProductSelect2: initialization error for sales_unit', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Для товаров и вариантов используем AJAX
|
||||||
|
try {
|
||||||
|
$element.select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
placeholder: placeholders[type] || 'Выберите...',
|
||||||
|
allowClear: true,
|
||||||
|
width: '100%',
|
||||||
|
language: 'ru',
|
||||||
|
minimumInputLength: 0,
|
||||||
|
dropdownAutoWidth: false,
|
||||||
|
ajax: {
|
||||||
|
url: apiUrl,
|
||||||
|
dataType: 'json',
|
||||||
|
delay: 250,
|
||||||
|
data: function (params) {
|
||||||
|
return {
|
||||||
|
q: params.term || '',
|
||||||
|
type: type,
|
||||||
|
page: params.page || 1
|
||||||
|
};
|
||||||
|
},
|
||||||
|
processResults: function (data) {
|
||||||
|
return {
|
||||||
|
results: data.results,
|
||||||
|
pagination: {
|
||||||
|
more: data.pagination.more
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cache: true
|
||||||
},
|
},
|
||||||
processResults: function (data) {
|
templateResult: formatSelectResult,
|
||||||
return {
|
templateSelection: formatSelectSelection
|
||||||
results: data.results,
|
});
|
||||||
pagination: {
|
console.log('initProductSelect2: successfully initialized for', element.name);
|
||||||
more: data.pagination.more
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
};
|
console.error('initProductSelect2: initialization error', error);
|
||||||
},
|
return false;
|
||||||
cache: true
|
}
|
||||||
},
|
|
||||||
templateResult: formatSelectResult,
|
|
||||||
templateSelection: formatSelectSelection
|
|
||||||
});
|
|
||||||
console.log('initProductSelect2: successfully initialized for', element.name);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('initProductSelect2: initialization error', error);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инициализирует Select2 для всех селектов, совпадающих с паттерном
|
* Инициализирует Select2 для всех селектов, совпадающих с паттерном
|
||||||
* @param {string} fieldPattern - Паттерн name атрибута (например: 'items-', 'kititem-')
|
* @param {string} fieldPattern - Паттерн name атрибута (например: 'items-', 'kititem-')
|
||||||
* @param {string} type - Тип поиска ('product' или 'variant')
|
* @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
|
||||||
* @param {string} apiUrl - URL API для поиска
|
* @param {string} apiUrl - URL API для поиска
|
||||||
*/
|
*/
|
||||||
window.initAllProductSelect2 = function(fieldPattern, type, apiUrl) {
|
window.initAllProductSelect2 = function(fieldPattern, type, apiUrl) {
|
||||||
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-product"]').forEach(function(element) {
|
if (type === 'sales_unit') {
|
||||||
window.initProductSelect2(element, type, apiUrl);
|
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-sales_unit"]').forEach(function(element) {
|
||||||
});
|
window.initProductSelect2(element, type, apiUrl);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-product"]').forEach(function(element) {
|
||||||
|
window.initProductSelect2(element, type, apiUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -362,8 +362,115 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Комплекты, содержащие этот товар как единицу продажи -->
|
||||||
|
{% if kit_items_using_sales_units %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Комплекты, содержащие этот товар как единицу продажи</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Комплект</th>
|
||||||
|
<th>Количество в комплекте</th>
|
||||||
|
<th>Цена за единицу</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for kit_item in kit_items_using_sales_units %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
|
||||||
|
{{ kit_item.kit.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ kit_item.quantity|default:"1" }}</td>
|
||||||
|
<td>{{ kit_item.sales_unit.actual_price|default:"0.00" }} руб.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Комплекты, содержащие этот товар напрямую -->
|
||||||
|
{% if kit_items_using_products %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Комплекты, содержащие этот товар напрямую</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Комплект</th>
|
||||||
|
<th>Количество в комплекте</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for kit_item in kit_items_using_products %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
|
||||||
|
{{ kit_item.kit.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ kit_item.quantity|default:"1" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Комплекты, содержащие этот товар как часть группы вариантов -->
|
||||||
|
{% if variant_group_kit_items %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Комплекты, содержащие этот товар как часть группы вариантов</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Комплект</th>
|
||||||
|
<th>Группа вариантов</th>
|
||||||
|
<th>Количество в комплекте</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for variant_group_item in variant_group_kit_items %}
|
||||||
|
{% for kit_item in variant_group_item.variant_group.kit_items.all %}
|
||||||
|
{% if kit_item.product == product %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
|
||||||
|
{{ kit_item.kit.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ variant_group_item.variant_group.name }}</td>
|
||||||
|
<td>{{ kit_item.quantity|default:"1" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ forloop.counter }}</td>
|
<td>{{ forloop.counter }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.product %}
|
{% if item.sales_unit %}
|
||||||
|
<a href="{% url 'products:product-detail' item.sales_unit.product.pk %}">
|
||||||
|
{{ item.sales_unit.name }}
|
||||||
|
</a>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">Единица продажи: {{ item.sales_unit.product.name }}</small>
|
||||||
|
{% elif item.product %}
|
||||||
<a href="{% url 'products:product-detail' item.product.pk %}">
|
<a href="{% url 'products:product-detail' item.product.pk %}">
|
||||||
{{ item.product.name }}
|
{{ item.product.name }}
|
||||||
</a>
|
</a>
|
||||||
@@ -149,7 +155,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.product %}
|
{% if item.sales_unit %}
|
||||||
|
<span class="badge bg-info">Единица продажи</span>
|
||||||
|
{% elif item.product %}
|
||||||
<span class="badge bg-success">Товар</span>
|
<span class="badge bg-success">Товар</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-primary">Варианты</span>
|
<span class="badge bg-primary">Варианты</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="container-fluid mt-4">
|
<div class="container-fluid mt-4">
|
||||||
<h2 class="mb-4">
|
<h2 class="mb-4">
|
||||||
<i class="bi bi-box-seam"></i> Товары я они все комплекты
|
<i class="bi bi-box-seam"></i> Товары и комплекты
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Панель фильтрации и действий -->
|
<!-- Панель фильтрации и действий -->
|
||||||
|
|||||||
290
myproject/products/tests/test_ai_bouquet_names.py
Normal file
290
myproject/products/tests/test_ai_bouquet_names.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
from django_tenants.test.cases import TenantTestCase
|
||||||
|
from products.services import BouquetNameGenerator
|
||||||
|
from products.models import BouquetName, ProductTag
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
class BouquetNameGeneratorTestCase(TenantTestCase):
|
||||||
|
"""
|
||||||
|
Тесты для сервиса генерации названий букетов
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""
|
||||||
|
Создаем экземпляр сервиса для тестирования
|
||||||
|
"""
|
||||||
|
self.generator = BouquetNameGenerator()
|
||||||
|
|
||||||
|
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||||
|
def test_generate_with_mock_glm(self, mock_get_glm_service):
|
||||||
|
"""
|
||||||
|
Тест генерации названий с мок-объектом GLM сервиса
|
||||||
|
"""
|
||||||
|
# Создаем мок-объект сервиса
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.generate_text.return_value = (
|
||||||
|
True,
|
||||||
|
"Текст успешно сгенерирован",
|
||||||
|
{
|
||||||
|
'generated_text': (
|
||||||
|
"1. Розавая мечта\n"
|
||||||
|
"2. Лиловые настроения\n"
|
||||||
|
"3. Яркий букет для дня рождения\n"
|
||||||
|
"4. Сладкий сюрприз\n"
|
||||||
|
"5. Романтическое вдохновение"
|
||||||
|
),
|
||||||
|
'model': 'glm-4',
|
||||||
|
'usage': {'prompt_tokens': 100, 'completion_tokens': 50}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_get_glm_service.return_value = mock_service
|
||||||
|
|
||||||
|
# Вызываем метод генерации
|
||||||
|
success, msg, data = self.generator.generate(count=5)
|
||||||
|
|
||||||
|
# Проверки
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertIn("Сгенерировано 5 названий для букетов", msg)
|
||||||
|
self.assertIsNotNone(data)
|
||||||
|
self.assertIn('names', data)
|
||||||
|
self.assertEqual(len(data['names']), 5)
|
||||||
|
self.assertEqual(data['model'], 'glm-4')
|
||||||
|
self.assertIn('usage', data)
|
||||||
|
|
||||||
|
# Проверяем, что названия содержат нужные слова
|
||||||
|
expected_names = [
|
||||||
|
"Розавая мечта",
|
||||||
|
"Лиловые настроения",
|
||||||
|
"Яркий букет для дня рождения",
|
||||||
|
"Сладкий сюрприз",
|
||||||
|
"Романтическое вдохновение"
|
||||||
|
]
|
||||||
|
self.assertEqual(data['names'], expected_names)
|
||||||
|
|
||||||
|
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||||
|
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_openrouter_service')
|
||||||
|
def test_no_active_integration(self, mock_get_openrouter, mock_get_glm):
|
||||||
|
"""
|
||||||
|
Тест случая, когда нет активных интеграций
|
||||||
|
"""
|
||||||
|
mock_get_glm.return_value = None
|
||||||
|
mock_get_openrouter.return_value = None
|
||||||
|
|
||||||
|
success, msg, data = self.generator.generate(count=10)
|
||||||
|
|
||||||
|
self.assertFalse(success)
|
||||||
|
self.assertEqual(msg, "Нет активных AI-интеграций")
|
||||||
|
self.assertIsNone(data)
|
||||||
|
|
||||||
|
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||||
|
def test_generate_with_characteristics(self, mock_get_glm_service):
|
||||||
|
"""
|
||||||
|
Тест генерации с характеристиками
|
||||||
|
"""
|
||||||
|
# Создаем мок-объект сервиса
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.generate_text.return_value = (
|
||||||
|
True,
|
||||||
|
"Текст успешно сгенерирован",
|
||||||
|
{
|
||||||
|
'generated_text': (
|
||||||
|
"1. Ромашковое небо\n"
|
||||||
|
"2. Лавандовый спокойствие\n"
|
||||||
|
"3. Свежие ароматы\n"
|
||||||
|
"4. Милая композиция\n"
|
||||||
|
"5. Нежный букет"
|
||||||
|
),
|
||||||
|
'model': 'glm-4',
|
||||||
|
'usage': {'prompt_tokens': 120, 'completion_tokens': 45}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_get_glm_service.return_value = mock_service
|
||||||
|
|
||||||
|
success, msg, data = self.generator.generate(
|
||||||
|
count=5,
|
||||||
|
characteristics="ромашки, лаванда, свежие",
|
||||||
|
occasion="день матери"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertIn("Сгенерировано 5 названий для букетов", msg)
|
||||||
|
self.assertEqual(len(data['names']), 5)
|
||||||
|
# Проверяем, что сервис был вызван с нужными параметрами
|
||||||
|
mock_service.generate_text.assert_called_once()
|
||||||
|
|
||||||
|
def test_parse_response_with_markdown(self):
|
||||||
|
"""
|
||||||
|
Тест парсинга ответа с Markdown форматированием
|
||||||
|
"""
|
||||||
|
response_text = """
|
||||||
|
Here are 3 beautiful bouquet names for you:
|
||||||
|
|
||||||
|
1. **Spring Blossom Delight**
|
||||||
|
2. *Romantic Rose Elegance*
|
||||||
|
3. "Sunny Daisy Joy"
|
||||||
|
|
||||||
|
I hope you love these!
|
||||||
|
"""
|
||||||
|
|
||||||
|
names = self.generator._parse_response(response_text)
|
||||||
|
self.assertEqual(len(names), 3)
|
||||||
|
self.assertEqual(names[0], "Spring Blossom Delight")
|
||||||
|
self.assertEqual(names[1], "Romantic Rose Elegance")
|
||||||
|
self.assertEqual(names[2], "Sunny Daisy Joy")
|
||||||
|
|
||||||
|
def test_parse_response_with_duplicates(self):
|
||||||
|
"""
|
||||||
|
Тест парсинга ответа с дубликатами
|
||||||
|
"""
|
||||||
|
response_text = """
|
||||||
|
1. Розавая мечта
|
||||||
|
2. Лиловые настроения
|
||||||
|
3. Розавая мечта
|
||||||
|
4. Сладкий сюрприз
|
||||||
|
5. Лиловые настроения
|
||||||
|
"""
|
||||||
|
|
||||||
|
names = self.generator._parse_response(response_text)
|
||||||
|
self.assertEqual(len(names), 3)
|
||||||
|
self.assertIn("Розавая мечта", names)
|
||||||
|
self.assertIn("Лиловые настроения", names)
|
||||||
|
self.assertIn("Сладкий сюрприз", names)
|
||||||
|
|
||||||
|
def test_parse_response_empty(self):
|
||||||
|
"""
|
||||||
|
Тест парсинга пустого ответа
|
||||||
|
"""
|
||||||
|
response_text = """
|
||||||
|
"""
|
||||||
|
names = self.generator._parse_response(response_text)
|
||||||
|
self.assertEqual(len(names), 0)
|
||||||
|
|
||||||
|
def test_parse_response_no_names(self):
|
||||||
|
"""
|
||||||
|
Тест парсинга ответа без названий
|
||||||
|
"""
|
||||||
|
response_text = """
|
||||||
|
I'm sorry, but I can't help with that right now.
|
||||||
|
"""
|
||||||
|
names = self.generator._parse_response(response_text)
|
||||||
|
self.assertEqual(len(names), 0)
|
||||||
|
|
||||||
|
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||||
|
def test_generate_and_store(self, mock_get_glm_service):
|
||||||
|
"""
|
||||||
|
Тест генерации и сохранения названий в базе данных
|
||||||
|
"""
|
||||||
|
# Создаем мок-объект сервиса
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.generate_text.return_value = (
|
||||||
|
True,
|
||||||
|
"Текст успешно сгенерирован",
|
||||||
|
{
|
||||||
|
'generated_text': (
|
||||||
|
"1. Розавая мечта\n"
|
||||||
|
"2. Лиловые настроения\n"
|
||||||
|
"3. Яркий букет для дня рождения"
|
||||||
|
),
|
||||||
|
'model': 'glm-4',
|
||||||
|
'usage': {'prompt_tokens': 100, 'completion_tokens': 50}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_get_glm_service.return_value = mock_service
|
||||||
|
|
||||||
|
# Очищаем базу перед тестом
|
||||||
|
BouquetName.objects.all().delete()
|
||||||
|
|
||||||
|
# Вызываем метод генерации и сохранения
|
||||||
|
success, msg, data = self.generator.generate_and_store(count=3)
|
||||||
|
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertIn("Сгенерировано и сохранено 3 названий для букетов", msg)
|
||||||
|
self.assertEqual(BouquetName.objects.count(), 3)
|
||||||
|
|
||||||
|
def test_mark_as_used(self):
|
||||||
|
"""
|
||||||
|
Тест увеличения счетчика использования названия
|
||||||
|
"""
|
||||||
|
# Создаем тестовое название
|
||||||
|
bouquet_name = BouquetName.objects.create(
|
||||||
|
name="Тестовый букет",
|
||||||
|
language="russian",
|
||||||
|
is_approved=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем начальное значение счетчика
|
||||||
|
self.assertEqual(bouquet_name.usage_count, 0)
|
||||||
|
|
||||||
|
# Увеличиваем счетчик
|
||||||
|
self.generator.mark_as_used("Тестовый букет", "russian")
|
||||||
|
|
||||||
|
# Проверяем обновленное значение
|
||||||
|
bouquet_name.refresh_from_db()
|
||||||
|
self.assertEqual(bouquet_name.usage_count, 1)
|
||||||
|
|
||||||
|
def test_get_approved_names(self):
|
||||||
|
"""
|
||||||
|
Тест получения одобренных названий
|
||||||
|
"""
|
||||||
|
# Очищаем базу перед тестом
|
||||||
|
BouquetName.objects.all().delete()
|
||||||
|
|
||||||
|
# Создаем тестовые данные
|
||||||
|
BouquetName.objects.create(
|
||||||
|
name="Одобренный букет 1",
|
||||||
|
language="russian",
|
||||||
|
is_approved=True
|
||||||
|
)
|
||||||
|
BouquetName.objects.create(
|
||||||
|
name="Одобренный букет 2",
|
||||||
|
language="russian",
|
||||||
|
is_approved=True
|
||||||
|
)
|
||||||
|
BouquetName.objects.create(
|
||||||
|
name="Неодобренный букет",
|
||||||
|
language="russian",
|
||||||
|
is_approved=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем одобренные названия
|
||||||
|
approved_names = self.generator.get_approved_names(language="russian")
|
||||||
|
|
||||||
|
self.assertEqual(len(approved_names), 2)
|
||||||
|
self.assertIn("Одобренный букет 1", approved_names)
|
||||||
|
self.assertIn("Одобренный букет 2", approved_names)
|
||||||
|
self.assertNotIn("Неодобренный букет", approved_names)
|
||||||
|
|
||||||
|
def test_bouquet_name_model(self):
|
||||||
|
"""
|
||||||
|
Тест создания и работы с моделью BouquetName
|
||||||
|
"""
|
||||||
|
# Создаем тестовые теги
|
||||||
|
red_tag = ProductTag.objects.create(name="красный", slug="krasny")
|
||||||
|
romantic_tag = ProductTag.objects.create(name="романтический", slug="romanticheskiy")
|
||||||
|
|
||||||
|
# Создаем экземпляр модели
|
||||||
|
bouquet_name = BouquetName.objects.create(
|
||||||
|
name="Романтический букет",
|
||||||
|
language="russian",
|
||||||
|
is_approved=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем теги
|
||||||
|
bouquet_name.color_tags.add(red_tag)
|
||||||
|
bouquet_name.style_tags.add(romantic_tag)
|
||||||
|
|
||||||
|
# Проверяем сохраненные значения
|
||||||
|
self.assertEqual(bouquet_name.name, "Романтический букет")
|
||||||
|
self.assertEqual(bouquet_name.language, "russian")
|
||||||
|
self.assertTrue(bouquet_name.is_approved)
|
||||||
|
self.assertEqual(bouquet_name.usage_count, 0)
|
||||||
|
self.assertIn(red_tag, bouquet_name.color_tags.all())
|
||||||
|
self.assertIn(romantic_tag, bouquet_name.style_tags.all())
|
||||||
|
|
||||||
|
# Обновляем поле
|
||||||
|
bouquet_name.usage_count = 5
|
||||||
|
bouquet_name.save()
|
||||||
|
|
||||||
|
# Проверяем обновление
|
||||||
|
updated_name = BouquetName.objects.get(id=bouquet_name.id)
|
||||||
|
self.assertEqual(updated_name.usage_count, 5)
|
||||||
@@ -42,6 +42,7 @@ urlpatterns = [
|
|||||||
path('kit/photo/<int:pk>/set-main/', views.productkit_photo_set_main, name='productkit-photo-set-main'),
|
path('kit/photo/<int:pk>/set-main/', views.productkit_photo_set_main, name='productkit-photo-set-main'),
|
||||||
path('kit/photo/<int:pk>/move-up/', views.productkit_photo_move_up, name='productkit-photo-move-up'),
|
path('kit/photo/<int:pk>/move-up/', views.productkit_photo_move_up, name='productkit-photo-move-up'),
|
||||||
path('kit/photo/<int:pk>/move-down/', views.productkit_photo_move_down, name='productkit-photo-move-down'),
|
path('kit/photo/<int:pk>/move-down/', views.productkit_photo_move_down, name='productkit-photo-move-down'),
|
||||||
|
path('kit/photos/delete-bulk/', views.productkit_photos_delete_bulk, name='productkit-photos-delete-bulk'),
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
|
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
|
||||||
@@ -55,6 +56,10 @@ urlpatterns = [
|
|||||||
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'),
|
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'),
|
||||||
path('api/filtered-items-ids/', api_views.get_filtered_items_ids, name='api-filtered-items-ids'),
|
path('api/filtered-items-ids/', api_views.get_filtered_items_ids, name='api-filtered-items-ids'),
|
||||||
path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'),
|
path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'),
|
||||||
|
path('api/bouquet-names/random/', api_views.RandomBouquetNamesView.as_view(), name='api-random-bouquet-names'),
|
||||||
|
path('api/bouquet-names/generate/', api_views.GenerateBouquetNamesView.as_view(), name='api-generate-bouquet-names'),
|
||||||
|
path('api/bouquet-names/<int:pk>/delete/', api_views.DeleteBouquetNameView.as_view(), name='api-delete-bouquet-name'),
|
||||||
|
path('api/bouquet-names/count/', api_views.GetBouquetNamesCountView.as_view(), name='api-get-bouquet-names-count'),
|
||||||
|
|
||||||
# Photo processing status API (for AJAX polling)
|
# Photo processing status API (for AJAX polling)
|
||||||
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||||
|
|||||||
@@ -158,49 +158,41 @@ class ImageProcessor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _resize_image(img, size):
|
def _resize_image(img, size):
|
||||||
"""
|
"""
|
||||||
Изменяет размер изображения с сохранением пропорций.
|
Изменяет размер изображения с center-crop до точного квадратного размера.
|
||||||
НЕ увеличивает маленькие изображения (сохраняет качество).
|
НЕ увеличивает маленькие изображения (сохраняет качество).
|
||||||
Создает адаптивный квадрат по размеру реального изображения.
|
Создает квадратное изображение без белых полей.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
img: PIL Image object
|
img: PIL Image object
|
||||||
size: Кортеж (width, height) - максимальный целевой размер
|
size: Кортеж (width, height) - целевой размер (обычно квадратный)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image object - квадратное изображение с минимальным белым фоном
|
PIL Image object - квадратное изображение без белых полей
|
||||||
"""
|
"""
|
||||||
# Копируем изображение, чтобы не модифицировать оригинал
|
|
||||||
img_copy = img.copy()
|
img_copy = img.copy()
|
||||||
|
target_width, target_height = size
|
||||||
|
|
||||||
# Вычисляем пропорции исходного изображения и целевого размера
|
# Шаг 1: Center crop для получения квадрата
|
||||||
img_aspect = img_copy.width / img_copy.height
|
# Определяем минимальную сторону (будет размер квадрата)
|
||||||
target_aspect = size[0] / size[1]
|
min_side = min(img_copy.width, img_copy.height)
|
||||||
|
|
||||||
# Определяем, какой размер будет ограничивающим при масштабировании
|
# Вычисляем координаты для обрезки из центра
|
||||||
if img_aspect > target_aspect:
|
left = (img_copy.width - min_side) // 2
|
||||||
# Изображение шире - ограничиваемый размер это ширина
|
top = (img_copy.height - min_side) // 2
|
||||||
new_width = min(img_copy.width, size[0])
|
right = left + min_side
|
||||||
new_height = int(new_width / img_aspect)
|
bottom = top + min_side
|
||||||
|
|
||||||
|
# Обрезаем до квадрата
|
||||||
|
img_cropped = img_copy.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
# Шаг 2: Масштабируем до целевого размера (если исходный квадрат больше цели)
|
||||||
|
# Не увеличиваем маленькие изображения
|
||||||
|
if min_side > target_width:
|
||||||
|
img_resized = img_cropped.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||||
else:
|
else:
|
||||||
# Изображение выше - ограничиваемый размер это высота
|
img_resized = img_cropped
|
||||||
new_height = min(img_copy.height, size[1])
|
|
||||||
new_width = int(new_height * img_aspect)
|
|
||||||
|
|
||||||
# Масштабируем только если необходимо (не увеличиваем маленькие изображения)
|
return img_resized
|
||||||
if img_copy.width > new_width or img_copy.height > new_height:
|
|
||||||
img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Создаем адаптивный квадрат по размеру реального изображения (а не по конфигурации)
|
|
||||||
# Это позволяет избежать огромных белых полей для маленьких фото
|
|
||||||
square_size = max(img_copy.width, img_copy.height)
|
|
||||||
new_img = Image.new('RGB', (square_size, square_size), (255, 255, 255))
|
|
||||||
|
|
||||||
# Центрируем исходное изображение на белом фоне
|
|
||||||
offset_x = (square_size - img_copy.width) // 2
|
|
||||||
offset_y = (square_size - img_copy.height) // 2
|
|
||||||
new_img.paste(img_copy, (offset_x, offset_y))
|
|
||||||
|
|
||||||
return new_img
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _make_square_image(img, max_size):
|
def _make_square_image(img, max_size):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .photo_management import (
|
|||||||
productkit_photo_set_main,
|
productkit_photo_set_main,
|
||||||
productkit_photo_move_up,
|
productkit_photo_move_up,
|
||||||
productkit_photo_move_down,
|
productkit_photo_move_down,
|
||||||
|
productkit_photos_delete_bulk,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Управление фотографиями (Category)
|
# Управление фотографиями (Category)
|
||||||
@@ -114,7 +115,14 @@ from .attribute_views import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# API представления
|
# API представления
|
||||||
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
|
from .api_views import (
|
||||||
|
search_products_and_variants,
|
||||||
|
validate_kit_cost,
|
||||||
|
create_temporary_kit_api,
|
||||||
|
create_tag_api,
|
||||||
|
RandomBouquetNamesView,
|
||||||
|
GenerateBouquetNamesView,
|
||||||
|
)
|
||||||
|
|
||||||
# Каталог
|
# Каталог
|
||||||
from .catalog_views import CatalogView
|
from .catalog_views import CatalogView
|
||||||
@@ -149,6 +157,7 @@ __all__ = [
|
|||||||
'productkit_photo_set_main',
|
'productkit_photo_set_main',
|
||||||
'productkit_photo_move_up',
|
'productkit_photo_move_up',
|
||||||
'productkit_photo_move_down',
|
'productkit_photo_move_down',
|
||||||
|
'productkit_photos_delete_bulk',
|
||||||
|
|
||||||
# Управление фотографиями Category
|
# Управление фотографиями Category
|
||||||
'category_photo_delete',
|
'category_photo_delete',
|
||||||
@@ -225,6 +234,8 @@ __all__ = [
|
|||||||
'validate_kit_cost',
|
'validate_kit_cost',
|
||||||
'create_temporary_kit_api',
|
'create_temporary_kit_api',
|
||||||
'create_tag_api',
|
'create_tag_api',
|
||||||
|
'RandomBouquetNamesView',
|
||||||
|
'GenerateBouquetNamesView',
|
||||||
|
|
||||||
# Каталог
|
# Каталог
|
||||||
'CatalogView',
|
'CatalogView',
|
||||||
|
|||||||
@@ -1800,3 +1800,72 @@ def bulk_update_categories(request):
|
|||||||
'success': False,
|
'success': False,
|
||||||
'message': f'Произошла ошибка: {str(e)}'
|
'message': f'Произошла ошибка: {str(e)}'
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Генератор названий букетов ==========
|
||||||
|
|
||||||
|
from django.views import View
|
||||||
|
from ..models import BouquetName
|
||||||
|
from ..services import BouquetNameGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class RandomBouquetNamesView(View):
|
||||||
|
"""Возвращает случайные названия из базы"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
count = int(request.GET.get('count', 3))
|
||||||
|
# Ограничиваем максимум до 100
|
||||||
|
count = min(count, 100)
|
||||||
|
|
||||||
|
# Получаем случайные названия с 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):
|
||||||
|
"""Генерирует новые названия через LLM и сохраняет в базу"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
count = int(request.POST.get('count', 10))
|
||||||
|
# Ограничиваем максимум до 500
|
||||||
|
count = min(count, 500)
|
||||||
|
|
||||||
|
generator = BouquetNameGenerator()
|
||||||
|
|
||||||
|
success, msg, data = generator.generate_and_store(
|
||||||
|
count=count,
|
||||||
|
language='russian'
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': msg,
|
||||||
|
'count': len(data.get('names', []))
|
||||||
|
})
|
||||||
|
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})
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class TreeItem:
|
|||||||
if item_type == 'product':
|
if item_type == 'product':
|
||||||
self.price = obj.sale_price
|
self.price = obj.sale_price
|
||||||
elif item_type == 'kit':
|
elif item_type == 'kit':
|
||||||
self.price = obj.get_sale_price()
|
self.price = obj.actual_price
|
||||||
else:
|
else:
|
||||||
self.price = None
|
self.price = None
|
||||||
|
|
||||||
|
|||||||
@@ -380,3 +380,67 @@ def product_photos_delete_bulk(request):
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': f'Ошибка сервера: {str(e)}'
|
'error': f'Ошибка сервера: {str(e)}'
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@login_required
|
||||||
|
def productkit_photos_delete_bulk(request):
|
||||||
|
"""
|
||||||
|
AJAX endpoint для массового удаления фотографий комплекта.
|
||||||
|
|
||||||
|
Ожидает JSON: {photo_ids: [1, 2, 3]}
|
||||||
|
Возвращает JSON: {success: true, deleted: 3} или {success: false, error: "..."}
|
||||||
|
"""
|
||||||
|
# Проверка прав доступа
|
||||||
|
if not request.user.has_perm('products.change_productkit'):
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'У вас нет прав для удаления фотографий'
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем список photo_ids из JSON тела запроса
|
||||||
|
data = json.loads(request.body)
|
||||||
|
photo_ids = data.get('photo_ids', [])
|
||||||
|
|
||||||
|
if not photo_ids or not isinstance(photo_ids, list):
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Неверный формат: требуется список photo_ids'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Удаляем фотографии
|
||||||
|
deleted_count = 0
|
||||||
|
for photo_id in photo_ids:
|
||||||
|
try:
|
||||||
|
photo = ProductKitPhoto.objects.get(pk=photo_id)
|
||||||
|
photo.delete() # Это вызовет ImageProcessor.delete_all_versions()
|
||||||
|
deleted_count += 1
|
||||||
|
except ProductKitPhoto.DoesNotExist:
|
||||||
|
# Если фото не найдена, просто пропускаем
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
# Логируем ошибку но продолжаем удаление остальных
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error deleting kit photo {photo_id}: {str(e)}", exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'deleted': deleted_count
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Неверный JSON формат'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Bulk kit photo deletion error: {str(e)}", exc_info=True)
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Ошибка сервера: {str(e)}'
|
||||||
|
}, status=500)
|
||||||
|
|||||||
@@ -208,6 +208,15 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
|
|||||||
# Единицы продажи (активные, отсортированные)
|
# Единицы продажи (активные, отсортированные)
|
||||||
context['sales_units'] = self.object.sales_units.filter(is_active=True).order_by('position', 'name')
|
context['sales_units'] = self.object.sales_units.filter(is_active=True).order_by('position', 'name')
|
||||||
|
|
||||||
|
# Комплекты, в которых этот товар используется как единица продажи
|
||||||
|
context['kit_items_using_sales_units'] = self.object.kit_items_using_as_sales_unit.select_related('kit', 'sales_unit').prefetch_related('kit__photos')
|
||||||
|
|
||||||
|
# Комплекты, в которых этот товар используется напрямую
|
||||||
|
context['kit_items_using_products'] = self.object.kit_items_direct.select_related('kit').prefetch_related('kit__photos')
|
||||||
|
|
||||||
|
# Комплекты, в которых этот товар используется как часть группы вариантов
|
||||||
|
context['variant_group_kit_items'] = self.object.variant_group_items.select_related('variant_group').prefetch_related('variant_group__kit_items__kit__photos')
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ from django.shortcuts import redirect
|
|||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
|
|
||||||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||||||
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
|
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName, ProductSalesUnit
|
||||||
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
||||||
from .utils import handle_photos
|
from .utils import handle_photos
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||||||
@@ -97,6 +98,37 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
form_class = ProductKitForm
|
form_class = ProductKitForm
|
||||||
template_name = 'products/productkit_create.html'
|
template_name = 'products/productkit_create.html'
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
initial = super().get_initial()
|
||||||
|
copy_id = self.request.GET.get('copy_from')
|
||||||
|
if copy_id:
|
||||||
|
try:
|
||||||
|
kit = ProductKit.objects.get(pk=copy_id)
|
||||||
|
|
||||||
|
# Generate unique name
|
||||||
|
base_name = f"{kit.name} (Копия)"
|
||||||
|
new_name = base_name
|
||||||
|
counter = 1
|
||||||
|
while ProductKit.objects.filter(name=new_name).exists():
|
||||||
|
counter += 1
|
||||||
|
new_name = f"{base_name} {counter}"
|
||||||
|
|
||||||
|
initial.update({
|
||||||
|
'name': new_name,
|
||||||
|
'description': kit.description,
|
||||||
|
'short_description': kit.short_description,
|
||||||
|
'categories': list(kit.categories.values_list('pk', flat=True)),
|
||||||
|
'tags': list(kit.tags.values_list('pk', flat=True)),
|
||||||
|
'sale_price': kit.sale_price,
|
||||||
|
'price_adjustment_type': kit.price_adjustment_type,
|
||||||
|
'price_adjustment_value': kit.price_adjustment_value,
|
||||||
|
'external_category': kit.external_category,
|
||||||
|
'status': 'active', # Default to active for new kits
|
||||||
|
})
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
pass
|
||||||
|
return initial
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
||||||
@@ -113,6 +145,12 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
# Извлекаем числовой ID из "product_123"
|
# Извлекаем числовой ID из "product_123"
|
||||||
numeric_id = value.split('_')[1]
|
numeric_id = value.split('_')[1]
|
||||||
post_data[key] = numeric_id
|
post_data[key] = numeric_id
|
||||||
|
elif key.endswith('-sales_unit') and post_data[key]:
|
||||||
|
value = post_data[key]
|
||||||
|
if '_' in value:
|
||||||
|
# Извлекаем числовой ID из "sales_unit_123"
|
||||||
|
numeric_id = value.split('_')[1]
|
||||||
|
post_data[key] = numeric_id
|
||||||
|
|
||||||
# Заменяем request.POST на очищенные данные
|
# Заменяем request.POST на очищенные данные
|
||||||
request.POST = post_data
|
request.POST = post_data
|
||||||
@@ -126,9 +164,9 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
||||||
|
|
||||||
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
|
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
|
||||||
from ..models import Product, ProductVariantGroup
|
|
||||||
selected_products = {}
|
selected_products = {}
|
||||||
selected_variants = {}
|
selected_variants = {}
|
||||||
|
selected_sales_units = {}
|
||||||
|
|
||||||
for key, value in self.request.POST.items():
|
for key, value in self.request.POST.items():
|
||||||
if '-product' in key and value:
|
if '-product' in key and value:
|
||||||
@@ -168,10 +206,120 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
except ProductVariantGroup.DoesNotExist:
|
except ProductVariantGroup.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if '-sales_unit' in key and value:
|
||||||
|
try:
|
||||||
|
sales_unit = ProductSalesUnit.objects.select_related('product').get(id=value)
|
||||||
|
|
||||||
|
text = f"{sales_unit.name} ({sales_unit.product.name})"
|
||||||
|
# Получаем actual_price: приоритет sale_price > price
|
||||||
|
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
|
||||||
|
selected_sales_units[key] = {
|
||||||
|
'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'
|
||||||
|
}
|
||||||
|
except ProductSalesUnit.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
context['selected_products'] = selected_products
|
context['selected_products'] = selected_products
|
||||||
context['selected_variants'] = selected_variants
|
context['selected_variants'] = selected_variants
|
||||||
|
context['selected_sales_units'] = selected_sales_units
|
||||||
else:
|
else:
|
||||||
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
|
# 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()
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@@ -208,6 +356,48 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
|||||||
# Обработка фотографий
|
# Обработка фотографий
|
||||||
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||||||
|
|
||||||
|
# Handle copied photos
|
||||||
|
copied_photo_ids = self.request.POST.getlist('copied_photos')
|
||||||
|
print(f"DEBUG: copied_photo_ids in POST: {copied_photo_ids}")
|
||||||
|
|
||||||
|
if copied_photo_ids:
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
original_photos = ProductKitPhoto.objects.filter(id__in=copied_photo_ids)
|
||||||
|
print(f"DEBUG: Found {original_photos.count()} original photos to copy")
|
||||||
|
|
||||||
|
# Get max order from existing photos (uploaded via handle_photos)
|
||||||
|
from django.db.models import Max
|
||||||
|
max_order = self.object.photos.aggregate(Max('order'))['order__max']
|
||||||
|
next_order = 0 if max_order is None else max_order + 1
|
||||||
|
print(f"DEBUG: Starting order for copies: {next_order}")
|
||||||
|
|
||||||
|
for photo in original_photos:
|
||||||
|
try:
|
||||||
|
# Open the original image file
|
||||||
|
if photo.image:
|
||||||
|
print(f"DEBUG: Processing photo {photo.id}: {photo.image.name}")
|
||||||
|
with photo.image.open('rb') as f:
|
||||||
|
image_content = f.read()
|
||||||
|
|
||||||
|
# Create a new ContentFile
|
||||||
|
new_image_name = f"copy_{self.object.id}_{os.path.basename(photo.image.name)}"
|
||||||
|
print(f"DEBUG: New image name: {new_image_name}")
|
||||||
|
|
||||||
|
# Create new photo instance
|
||||||
|
new_photo = ProductKitPhoto(kit=self.object, order=next_order)
|
||||||
|
# Save the image file (this also saves the model instance)
|
||||||
|
new_photo.image.save(new_image_name, ContentFile(image_content))
|
||||||
|
print(f"DEBUG: Successfully saved copy for photo {photo.id}")
|
||||||
|
|
||||||
|
next_order += 1
|
||||||
|
else:
|
||||||
|
print(f"DEBUG: Photo {photo.id} has no image file")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error copying photo {photo.id}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
continue
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
f'Комплект "{self.object.name}" успешно создан!'
|
f'Комплект "{self.object.name}" успешно создан!'
|
||||||
@@ -271,6 +461,12 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
|||||||
# Извлекаем числовой ID из "product_123"
|
# Извлекаем числовой ID из "product_123"
|
||||||
numeric_id = value.split('_')[1]
|
numeric_id = value.split('_')[1]
|
||||||
post_data[key] = numeric_id
|
post_data[key] = numeric_id
|
||||||
|
elif key.endswith('-sales_unit') and post_data[key]:
|
||||||
|
value = post_data[key]
|
||||||
|
if '_' in value:
|
||||||
|
# Извлекаем числовой ID из "sales_unit_123"
|
||||||
|
numeric_id = value.split('_')[1]
|
||||||
|
post_data[key] = numeric_id
|
||||||
|
|
||||||
# Заменяем request.POST на очищенные данные
|
# Заменяем request.POST на очищенные данные
|
||||||
request.POST = post_data
|
request.POST = post_data
|
||||||
@@ -284,8 +480,10 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
|||||||
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
|
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
|
||||||
|
|
||||||
# При ошибке валидации - подготавливаем данные для Select2
|
# При ошибке валидации - подготавливаем данные для Select2
|
||||||
|
from ..models import Product, ProductVariantGroup, ProductSalesUnit
|
||||||
selected_products = {}
|
selected_products = {}
|
||||||
selected_variants = {}
|
selected_variants = {}
|
||||||
|
selected_sales_units = {}
|
||||||
|
|
||||||
for key, value in self.request.POST.items():
|
for key, value in self.request.POST.items():
|
||||||
if '-product' in key and value:
|
if '-product' in key and value:
|
||||||
@@ -328,14 +526,35 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
|||||||
except ProductVariantGroup.DoesNotExist:
|
except ProductVariantGroup.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if '-sales_unit' in key and value:
|
||||||
|
try:
|
||||||
|
# Очищаем ID от префикса если есть
|
||||||
|
numeric_value = value.split('_')[1] if '_' in value else value
|
||||||
|
sales_unit = ProductSalesUnit.objects.select_related('product').get(id=numeric_value)
|
||||||
|
|
||||||
|
text = f"{sales_unit.name} ({sales_unit.product.name})"
|
||||||
|
# Получаем actual_price: приоритет sale_price > price
|
||||||
|
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
|
||||||
|
selected_sales_units[key] = {
|
||||||
|
'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'
|
||||||
|
}
|
||||||
|
except ProductSalesUnit.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
context['selected_products'] = selected_products
|
context['selected_products'] = selected_products
|
||||||
context['selected_variants'] = selected_variants
|
context['selected_variants'] = selected_variants
|
||||||
|
context['selected_sales_units'] = selected_sales_units
|
||||||
else:
|
else:
|
||||||
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
|
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
|
||||||
|
|
||||||
# Подготавливаем данные для предзагрузки в Select2
|
# Подготавливаем данные для предзагрузки в Select2
|
||||||
|
from ..models import Product, ProductVariantGroup, ProductSalesUnit
|
||||||
selected_products = {}
|
selected_products = {}
|
||||||
selected_variants = {}
|
selected_variants = {}
|
||||||
|
selected_sales_units = {}
|
||||||
|
|
||||||
for item in self.object.kit_items.all():
|
for item in self.object.kit_items.all():
|
||||||
form_prefix = f"kititem-{item.id}"
|
form_prefix = f"kititem-{item.id}"
|
||||||
@@ -354,6 +573,17 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
|||||||
'actual_price': str(actual_price) if actual_price else '0'
|
'actual_price': str(actual_price) if actual_price else '0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if item.sales_unit:
|
||||||
|
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:
|
if item.variant_group:
|
||||||
variant_group = ProductVariantGroup.objects.prefetch_related(
|
variant_group = ProductVariantGroup.objects.prefetch_related(
|
||||||
'items__product'
|
'items__product'
|
||||||
@@ -373,6 +603,7 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
|||||||
|
|
||||||
context['selected_products'] = selected_products
|
context['selected_products'] = selected_products
|
||||||
context['selected_variants'] = selected_variants
|
context['selected_variants'] = selected_variants
|
||||||
|
context['selected_sales_units'] = selected_sales_units
|
||||||
|
|
||||||
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||||||
context['photos_count'] = self.object.photos.count()
|
context['photos_count'] = self.object.photos.count()
|
||||||
|
|||||||
120
myproject/reproduce_issue.py
Normal file
120
myproject/reproduce_issue.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import django
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Setup Django
|
||||||
|
sys.path.append(os.getcwd())
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
from customers.models import Customer
|
||||||
|
from inventory.models import Warehouse, Sale
|
||||||
|
from products.models import Product, UnitOfMeasure
|
||||||
|
from pos.views import pos_checkout
|
||||||
|
from orders.models import OrderStatus
|
||||||
|
|
||||||
|
def run():
|
||||||
|
# Setup Data
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.first()
|
||||||
|
if not user:
|
||||||
|
print("No user found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create/Get Customer
|
||||||
|
customer, _ = Customer.objects.get_or_create(
|
||||||
|
name="Test Customer",
|
||||||
|
defaults={'phone': '+375291112233'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create/Get Warehouse
|
||||||
|
warehouse, _ = Warehouse.objects.get_or_create(
|
||||||
|
name="Test Warehouse",
|
||||||
|
defaults={'is_active': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create product
|
||||||
|
product, _ = Product.objects.get_or_create(
|
||||||
|
name="Test Product Debug",
|
||||||
|
defaults={
|
||||||
|
'sku': 'DEBUG001',
|
||||||
|
'buying_price': 10,
|
||||||
|
'actual_price': 50,
|
||||||
|
'warehouse': warehouse
|
||||||
|
}
|
||||||
|
)
|
||||||
|
product.actual_price = 50
|
||||||
|
product.save()
|
||||||
|
|
||||||
|
# Ensure OrderStatus exists
|
||||||
|
OrderStatus.objects.get_or_create(code='completed', is_system=True, defaults={'name': 'Completed', 'is_positive_end': True})
|
||||||
|
OrderStatus.objects.get_or_create(code='draft', is_system=True, defaults={'name': 'Draft'})
|
||||||
|
|
||||||
|
# Prepare Request
|
||||||
|
factory = RequestFactory()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"warehouse_id": warehouse.id,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "product",
|
||||||
|
"id": product.id,
|
||||||
|
"quantity": 1,
|
||||||
|
"price": 100.00, # Custom price
|
||||||
|
"quantity_base": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payments": [
|
||||||
|
{"payment_method": "cash", "amount": 100.00}
|
||||||
|
],
|
||||||
|
"notes": "Debug Sale"
|
||||||
|
}
|
||||||
|
|
||||||
|
request = factory.post(
|
||||||
|
'/pos/api/checkout/',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
print("Executing pos_checkout...")
|
||||||
|
response = pos_checkout(request)
|
||||||
|
print(f"Response: {response.content}")
|
||||||
|
|
||||||
|
# Verify Sale
|
||||||
|
sales = Sale.objects.filter(product=product).order_by('-id')[:1]
|
||||||
|
if sales:
|
||||||
|
sale = sales[0]
|
||||||
|
print(f"Sale created. ID: {sale.id}")
|
||||||
|
print(f"Sale Quantity: {sale.quantity}")
|
||||||
|
print(f"Sale Price: {sale.sale_price}")
|
||||||
|
if sale.sale_price == 0:
|
||||||
|
print("FAILURE: Sale price is 0!")
|
||||||
|
else:
|
||||||
|
print(f"SUCCESS: Sale price is {sale.sale_price}")
|
||||||
|
else:
|
||||||
|
print("FAILURE: No Sale created!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
# Replace with actual schema name if needed, assuming 'public' for now or the default tenant
|
||||||
|
# Since I don't know the tenant, I'll try to run in the current context.
|
||||||
|
# But usually need to set schema.
|
||||||
|
# Let's try to find a tenant.
|
||||||
|
from tenants.models import Client
|
||||||
|
tenant = Client.objects.first()
|
||||||
|
if tenant:
|
||||||
|
print(f"Running in tenant: {tenant.schema_name}")
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
run()
|
||||||
|
else:
|
||||||
|
print("No tenant found, running in public?")
|
||||||
|
run()
|
||||||
@@ -153,8 +153,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
statusBadge.style.display = 'inline';
|
statusBadge.style.display = 'inline';
|
||||||
|
|
||||||
// Построить форму
|
// Построить форму (теперь асинхронно)
|
||||||
buildForm(data.fields, data.data || {});
|
await buildForm(data.fields, data.data || {});
|
||||||
|
|
||||||
// Показать/скрыть кнопку тестирования
|
// Показать/скрыть кнопку тестирования
|
||||||
const testBtn = document.getElementById('test-connection-btn');
|
const testBtn = document.getElementById('test-connection-btn');
|
||||||
@@ -173,14 +173,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Построение формы из метаданных полей
|
// Построение формы из метаданных полей
|
||||||
function buildForm(fields, data) {
|
async function buildForm(fields, data) {
|
||||||
const container = document.getElementById('settings-fields');
|
const container = document.getElementById('settings-fields');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
fields.forEach(field => {
|
for (const field of fields) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'mb-3';
|
div.className = 'mb-3';
|
||||||
|
|
||||||
if (field.type === 'checkbox') {
|
if (field.type === 'checkbox') {
|
||||||
div.className = 'form-check mb-3';
|
div.className = 'form-check mb-3';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
@@ -189,43 +189,100 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
|
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
|
||||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
} else if (field.type === 'select') {
|
} else if (field.type === 'select') {
|
||||||
div.innerHTML = `
|
let optionsHtml = '';
|
||||||
<label class="form-label" for="field-${field.name}">
|
|
||||||
${field.label}
|
if (field.dynamic_choices) {
|
||||||
${field.required ? '<span class="text-danger">*</span>' : ''}
|
// Динамическая загрузка options
|
||||||
</label>
|
optionsHtml = '<option value="">Загрузка моделей...</option>';
|
||||||
<select class="form-select" id="field-${field.name}"
|
|
||||||
name="${field.name}"
|
div.innerHTML = `
|
||||||
${field.required ? 'required' : ''}>
|
<label class="form-label" for="field-${field.name}">
|
||||||
${field.choices.map(choice => `
|
${field.label}
|
||||||
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
|
${field.required ? '<span class="text-danger">*</span>' : ''}
|
||||||
${choice[1]}
|
</label>
|
||||||
</option>
|
<select class="form-select" id="field-${field.name}"
|
||||||
`).join('')}
|
name="${field.name}"
|
||||||
</select>
|
${field.required ? 'required' : ''}>
|
||||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
${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 {
|
} else {
|
||||||
|
// text, password, url
|
||||||
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
|
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
|
||||||
const value = data[field.name] || '';
|
let value = data[field.name] || '';
|
||||||
const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : '';
|
const isMasked = value === '••••••••';
|
||||||
|
const placeholder = isMasked ? 'Ключ сохранён. Оставьте пустым, чтобы не менять' : '';
|
||||||
|
|
||||||
|
// Для password полей показываем звёздочки (8 штук как индикатор сохранённого ключа)
|
||||||
|
const inputValue = (field.type === 'password' && isMasked) ? '********' : value;
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<label class="form-label" for="field-${field.name}">
|
<label class="form-label" for="field-${field.name}">
|
||||||
${field.label}
|
${field.label}
|
||||||
${field.required ? '<span class="text-danger">*</span>' : ''}
|
${field.required ? '<span class="text-danger">*</span>' : ''}
|
||||||
</label>
|
</label>
|
||||||
<input type="${inputType}" class="form-control" id="field-${field.name}"
|
<input type="${inputType}" class="form-control" id="field-${field.name}"
|
||||||
name="${field.name}" value="${value !== '........' ? value : ''}"
|
name="${field.name}" value="${inputValue}"
|
||||||
placeholder="${placeholder}"
|
placeholder="${placeholder}"
|
||||||
${field.required ? 'required' : ''}>
|
${field.required && !isMasked ? 'required' : ''}>
|
||||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(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()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
// Пропустить пустые password поля (не менять если не введено)
|
// Пропустить пустые password поля или звёздочки (не менять если не введено новое значение)
|
||||||
const input = document.getElementById(`field-${key}`);
|
const input = document.getElementById(`field-${key}`);
|
||||||
if (input && input.type === 'password' && !value) continue;
|
if (input && input.type === 'password' && (!value || value === '********')) continue;
|
||||||
data[key] = value;
|
data[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
prepare_js.py
Normal file
29
prepare_js.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
file_path = r'c:\Users\team_\Desktop\test_qwen\myproject\products\templates\products\productkit_edit.html'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"File not found: {file_path}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Extract script part (approx lines 451 to 1321)
|
||||||
|
# Note: lines are 0-indexed in list
|
||||||
|
script_lines = lines[450:1322]
|
||||||
|
script_content = "".join(script_lines)
|
||||||
|
|
||||||
|
# Replace Django tags
|
||||||
|
# Replace {% ... %} with "TEMPLATETAG"
|
||||||
|
script_content = re.sub(r'\{%.*?%\}', '"TEMPLATETAG"', script_content)
|
||||||
|
# Replace {{ ... }} with "VARIABLE" or {}
|
||||||
|
script_content = re.sub(r'\{\{.*?\}\}', '{}', script_content)
|
||||||
|
|
||||||
|
# Save to temp js file
|
||||||
|
temp_js_path = r'c:\Users\team_\Desktop\test_qwen\temp_check.js'
|
||||||
|
with open(temp_js_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(script_content)
|
||||||
|
|
||||||
|
print(f"Written to {temp_js_path}")
|
||||||
54
test_bouquet_api.py
Normal file
54
test_bouquet_api.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Простой тест для проверки API-эндпоинтов генератора названий букетов
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
# Настройка Django
|
||||||
|
sys.path.append(r'c:\Users\team_\Desktop\test_qwen')
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
def test_bouquet_api_endpoints():
|
||||||
|
client = Client()
|
||||||
|
|
||||||
|
print("Тестируем API-эндпоинты для названий букетов...")
|
||||||
|
|
||||||
|
# Тестируем получение случайных названий
|
||||||
|
print("\n1. Тестируем получение случайных названий...")
|
||||||
|
response = client.get('/products/api/bouquet-names/random/?count=3')
|
||||||
|
print(f"Статус: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Получено названий: {len(data.get('names', []))}")
|
||||||
|
print(f"Примеры: {data.get('names', [])[:2]}")
|
||||||
|
else:
|
||||||
|
print(f"Ошибка: {response.content.decode()}")
|
||||||
|
|
||||||
|
# Тестируем получение количества названий
|
||||||
|
print("\n2. Тестируем получение количества названий...")
|
||||||
|
response = client.get('/products/api/bouquet-names/count/')
|
||||||
|
print(f"Статус: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Количество названий в базе: {data.get('count', 0)}")
|
||||||
|
else:
|
||||||
|
print(f"Ошибка: {response.content.decode()}")
|
||||||
|
|
||||||
|
# Попробуем сгенерировать названия (только если есть настройки для AI)
|
||||||
|
print("\n3. Попробуем сгенерировать названия...")
|
||||||
|
try:
|
||||||
|
response = client.post('/products/api/bouquet-names/generate/', {'count': 5})
|
||||||
|
print(f"Статус: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Результат генерации: {data}")
|
||||||
|
else:
|
||||||
|
print(f"Ошибка генерации: {response.content.decode()}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Исключение при генерации: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_bouquet_api_endpoints()
|
||||||
Reference in New Issue
Block a user