Compare commits
89 Commits
4fc4405a29
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 17d85e96d6 | |||
| 0839b0a507 | |||
| eb7569f983 | |||
| 0ee6391810 | |||
| c0e9b92e4a | |||
| 9dccc1f4b5 | |||
| dbadd14913 | |||
| 907c21f22e | |||
| 90906944dd | |||
| 7b6a86bdf2 | |||
| c80e2a5eca | |||
| 2aed6f60e5 | |||
| 2dc397b3ce | |||
| 9a7c0728f0 | |||
| 67ad0e50ee | |||
| 32bc0d2c39 | |||
| f140469a56 | |||
| d947f4eee7 | |||
| 5700314b10 | |||
| 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 | |||
| 0b35b80ee7 | |||
| 229fb18440 | |||
| d87c602f5a | |||
| 2778796118 | |||
| 392471ff06 | |||
| b188f5c2df | |||
| 1b749ebe63 | |||
| 017fa4b744 | |||
| 961cfcb9cd | |||
| b6206ebe09 | |||
| e3949d249f | |||
| e10f2c413b | |||
| 1d4bbf6a6d | |||
| b31961f939 | |||
| 1400514fd3 | |||
| 0d882781da | |||
| ab1e8ebd18 | |||
| d182a7b16d | |||
| c4e7efc3b1 | |||
| 3205f5a2ce | |||
| 5ca474a133 | |||
| aac47afcb9 | |||
| b88ec3997e | |||
| 3006207812 | |||
| c0401176a9 | |||
| 0060f746c8 | |||
| 2f8a78cfa7 | |||
| 16194a1167 |
@@ -75,6 +75,11 @@ class CustomUser(AbstractBaseUser):
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Отображаемое имя пользователя: имя если есть, иначе email"""
|
||||
return self.name or self.email
|
||||
|
||||
def has_perm(self, perm, obj=None):
|
||||
"""
|
||||
Проверка разрешения через authentication backends.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "system_settings/base_settings.html" %}
|
||||
{% load inventory_filters %}
|
||||
|
||||
{% block title %}{% if is_edit %}Редактирование скидки{% else %}Создание скидки{% endif %}{% endblock %}
|
||||
|
||||
@@ -33,13 +34,13 @@
|
||||
<div class="col-md-6">
|
||||
<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"
|
||||
value="{% if form.name.value %}{{ form.name.value }}{% endif %}"
|
||||
value="{{ form.name.value|default_if_none:'' }}"
|
||||
maxlength="200" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="id_priority" class="form-label">Приоритет</label>
|
||||
<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">
|
||||
<div class="form-text">Выше = применяется раньше</div>
|
||||
</div>
|
||||
@@ -47,7 +48,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
|
||||
<!-- Параметры скидки -->
|
||||
@@ -57,23 +58,28 @@
|
||||
<label for="id_discount_type" class="form-label">Тип скидки *</label>
|
||||
<select class="form-select" id="id_discount_type" name="discount_type" required>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="percentage" {% if form.discount_type.value == 'percentage' %}selected{% endif %}>Процент</option>
|
||||
<option value="fixed_amount" {% if form.discount_type.value == 'fixed_amount' %}selected{% endif %}>Фиксированная сумма (руб.)</option>
|
||||
<option value="percentage"
|
||||
{% if form.discount_type.value == 'percentage' %}selected{% endif %}>Процент</option>
|
||||
<option value="fixed_amount"
|
||||
{% if form.discount_type.value == 'fixed_amount' %}selected{% endif %}>Фиксированная сумма (руб.)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="id_value" class="form-label">Значение *</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="id_scope" class="form-label">Область действия *</label>
|
||||
<select class="form-select" id="id_scope" name="scope" required>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="order" {% if form.scope.value == 'order' %}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>
|
||||
<option value="order"
|
||||
{% if form.scope.value == 'order' %}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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +98,7 @@
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<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>
|
||||
@@ -104,13 +110,16 @@
|
||||
<div class="mb-3">
|
||||
<label for="id_combine_mode" class="form-label">Режим объединения с другими скидками</label>
|
||||
<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 value="stack" {% if form.combine_mode.value == 'stack' %}selected{% endif %}>
|
||||
<option value="stack"
|
||||
{% if form.combine_mode.value == 'stack' %}selected{% endif %}>
|
||||
📚 Складывать (суммировать с другими)
|
||||
</option>
|
||||
<option value="exclusive" {% if form.combine_mode.value == 'exclusive' %}selected{% endif %}>
|
||||
<option value="exclusive"
|
||||
{% if form.combine_mode.value == 'exclusive' %}selected{% endif %}>
|
||||
🚫 Исключающая (отменяет остальные скидки)
|
||||
</option>
|
||||
</select>
|
||||
@@ -123,14 +132,14 @@
|
||||
<div class="col-md-6">
|
||||
<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"
|
||||
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">
|
||||
<div class="form-text">Для скидок на заказ</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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"
|
||||
value="{% if form.max_usage_count.value %}{{ form.max_usage_count.value }}{% endif %}"
|
||||
value="{{ form.max_usage_count.value|default_if_none:'' }}"
|
||||
min="1">
|
||||
<div class="form-text">Оставьте пустым для безлимитного использования</div>
|
||||
</div>
|
||||
@@ -140,12 +149,12 @@
|
||||
<div class="col-md-6">
|
||||
<label for="id_start_date" class="form-label">Дата начала</label>
|
||||
<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 class="col-md-6">
|
||||
<label for="id_end_date" class="form-label">Дата окончания</label>
|
||||
<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>
|
||||
|
||||
@@ -174,7 +183,6 @@
|
||||
{% if not all_categories %}
|
||||
<option value="" disabled>Нет доступных категорий</option>
|
||||
{% endif %}
|
||||
%}
|
||||
</select>
|
||||
<div class="form-text">Удерживайте Ctrl для выбора нескольких категорий</div>
|
||||
</div>
|
||||
@@ -204,4 +212,46 @@
|
||||
</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 %}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
# Список доступных моделей OpenRouter (бесплатные)
|
||||
OPENROUTER_MODEL_CHOICES = [
|
||||
('xiaomi/mimo-v2-flash:free', 'Xiaomi MIMO v2 Flash (Бесплатная)'),
|
||||
('mistralai/devstral-2512:free', 'Mistral Devstral 2512 (Бесплатная)'),
|
||||
('z-ai/glm-4.5-air:free', 'Z.AI GLM-4.5 Air (Бесплатная)'),
|
||||
('qwen/qwen3-coder:free', 'Qwen 3 Coder (Бесплатная)'),
|
||||
]
|
||||
|
||||
# Предустановленные значения температуры
|
||||
OPENROUTER_TEMPERATURE_CHOICES = [
|
||||
(0.1, '0.1 - Очень консервативно'),
|
||||
@@ -59,11 +51,11 @@ class OpenRouterIntegration(AIIntegration):
|
||||
)
|
||||
|
||||
model_name = models.CharField(
|
||||
max_length=100,
|
||||
default="xiaomi/mimo-v2-flash:free",
|
||||
choices=OPENROUTER_MODEL_CHOICES,
|
||||
max_length=200,
|
||||
default="",
|
||||
blank=True,
|
||||
verbose_name="Название модели",
|
||||
help_text="Название используемой модели OpenRouter"
|
||||
help_text="Название используемой модели OpenRouter (загружается автоматически)"
|
||||
)
|
||||
|
||||
temperature = models.FloatField(
|
||||
|
||||
@@ -3,28 +3,45 @@ from ..base import BaseIntegrationService
|
||||
from .config import get_openrouter_config
|
||||
import logging
|
||||
import sys
|
||||
import locale
|
||||
import traceback
|
||||
|
||||
# Патч для исправления проблемы с кодировкой в httpx на Windows
|
||||
# Устанавливаем кодировку по умолчанию для Python
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
import httpx._models
|
||||
original_normalize_header_value = httpx._models._normalize_header_value
|
||||
|
||||
# Сохраняем оригинальную функцию, если она есть
|
||||
_original_normalize_header_value = getattr(httpx._models, '_normalize_header_value', None)
|
||||
|
||||
def patched_normalize_header_value(value, encoding):
|
||||
"""Патч для использования UTF-8 вместо ASCII для заголовков"""
|
||||
# Если значение уже bytes, возвращаем его как есть
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
# Всегда используем UTF-8 вместо ASCII
|
||||
encoding = encoding or 'utf-8'
|
||||
if encoding.lower() == 'ascii':
|
||||
encoding = 'utf-8'
|
||||
return value.encode(encoding)
|
||||
try:
|
||||
# Если значение уже bytes, возвращаем его как есть
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
|
||||
# Если значение не строка и не байты, приводим к строке
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
|
||||
# Всегда используем UTF-8 вместо ASCII
|
||||
encoding = encoding or 'utf-8'
|
||||
if encoding.lower() == 'ascii':
|
||||
encoding = 'utf-8'
|
||||
|
||||
return value.encode(encoding)
|
||||
except Exception as e:
|
||||
# В случае ошибки логируем и пробуем максимально безопасный вариант
|
||||
logging.getLogger(__name__).error(f"Error in patched_normalize_header_value: {e}. Value: {repr(value)}")
|
||||
if isinstance(value, str):
|
||||
return value.encode('utf-8', errors='ignore')
|
||||
return b''
|
||||
|
||||
httpx._models._normalize_header_value = patched_normalize_header_value
|
||||
logging.getLogger(__name__).info("Applied patch for httpx header encoding on Windows")
|
||||
logging.getLogger(__name__).info("Applied robust patch for httpx header encoding on Windows")
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).warning("httpx module not found, patch skipped")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"Failed to apply httpx patch: {e}")
|
||||
|
||||
@@ -148,8 +165,10 @@ class OpenRouterIntegrationService(BaseIntegrationService):
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {str(e)}")
|
||||
return False, f"Ошибка генерации: {str(e)}", None
|
||||
error_msg = str(e)
|
||||
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {error_msg}")
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Ошибка генерации: {error_msg}", None
|
||||
|
||||
def generate_code(self,
|
||||
prompt: str,
|
||||
@@ -196,5 +215,7 @@ class OpenRouterIntegrationService(BaseIntegrationService):
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {str(e)}")
|
||||
return False, f"Ошибка генерации кода: {str(e)}", None
|
||||
error_msg = str(e)
|
||||
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {error_msg}")
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Ошибка генерации кода: {error_msg}", None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import requests
|
||||
from typing import Tuple
|
||||
from .base import MarketplaceService
|
||||
|
||||
@@ -5,16 +6,58 @@ from .base import MarketplaceService
|
||||
class WooCommerceService(MarketplaceService):
|
||||
"""Сервис для работы с 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]:
|
||||
"""Проверить соединение с WooCommerce API"""
|
||||
"""
|
||||
Проверить соединение с WooCommerce API.
|
||||
|
||||
Использует endpoint /wp-json/wc/v3/ для проверки.
|
||||
Аутентификация через HTTP Basic Auth.
|
||||
"""
|
||||
if not self.config.store_url:
|
||||
return False, 'Не указан URL магазина'
|
||||
|
||||
if not self.config.consumer_key or not self.config.consumer_secret:
|
||||
return False, 'Не указаны ключи API'
|
||||
|
||||
# TODO: реализовать проверку соединения с WooCommerce API
|
||||
return True, 'Соединение успешно (заглушка)'
|
||||
url = self._get_api_url()
|
||||
|
||||
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]:
|
||||
"""Выполнить синхронизацию с WooCommerce"""
|
||||
|
||||
@@ -6,6 +6,7 @@ from .views import (
|
||||
get_integration_form_data,
|
||||
test_integration_connection,
|
||||
RecommerceBatchSyncView,
|
||||
get_openrouter_models,
|
||||
)
|
||||
|
||||
app_name = 'integrations'
|
||||
@@ -22,4 +23,7 @@ urlpatterns = [
|
||||
|
||||
# Синхронизация
|
||||
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
|
||||
|
||||
# OpenRouter модели
|
||||
path("openrouter/models/", get_openrouter_models, name="openrouter_models"),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
from django.views.generic import TemplateView
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.http import require_POST, require_GET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from user_roles.mixins import OwnerRequiredMixin
|
||||
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
|
||||
@@ -170,8 +173,8 @@ def get_integration_service(integration_id: str, instance):
|
||||
from .services.marketplaces.recommerce import RecommerceService
|
||||
return RecommerceService(instance)
|
||||
elif integration_id == 'woocommerce':
|
||||
# TODO: WooCommerceService
|
||||
return None
|
||||
from .services.marketplaces.woocommerce import WooCommerceService
|
||||
return WooCommerceService(instance)
|
||||
elif integration_id == 'glm':
|
||||
from .services.ai_services.glm_service import GLMIntegrationService
|
||||
return GLMIntegrationService(instance)
|
||||
@@ -181,6 +184,44 @@ def get_integration_service(integration_id: str, instance):
|
||||
return None
|
||||
|
||||
|
||||
@require_GET
|
||||
def get_openrouter_models(request):
|
||||
"""
|
||||
GET /settings/integrations/openrouter/models/
|
||||
Возвращает список моделей OpenRouter (бесплатные сверху)
|
||||
"""
|
||||
import requests
|
||||
|
||||
try:
|
||||
response = requests.get('https://openrouter.ai/api/v1/models', timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models = data.get('data', [])
|
||||
|
||||
# Разделить на бесплатные и платные
|
||||
free_models = []
|
||||
paid_models = []
|
||||
|
||||
for model in models:
|
||||
model_id = model.get('id', '')
|
||||
model_name = model.get('name', model_id)
|
||||
|
||||
if ':free' in model_id:
|
||||
free_models.append({'id': model_id, 'name': f"{model_name} (Бесплатная)"})
|
||||
else:
|
||||
paid_models.append({'id': model_id, 'name': model_name})
|
||||
|
||||
# Бесплатные сверху
|
||||
all_models = free_models + paid_models
|
||||
|
||||
return JsonResponse({'models': all_models})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching OpenRouter models: {e}")
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
class RecommerceBatchSyncView(TemplateView):
|
||||
"""
|
||||
API View для запуска массовой синхронизации с Recommerce.
|
||||
@@ -363,7 +404,46 @@ def get_form_fields_meta(model):
|
||||
'label': getattr(field, 'verbose_name', field_name),
|
||||
'help_text': getattr(field, 'help_text', ''),
|
||||
'required': not getattr(field, 'blank', True),
|
||||
'type': 'text', # default
|
||||
'type': 'password' if field_name == 'api_key' else 'text',
|
||||
}
|
||||
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'
|
||||
elif 'URLField' in field.__class__.__name__:
|
||||
field_info['type'] = 'url'
|
||||
elif 'secret' in field_name.lower() or 'token' in field_name.lower() or 'key' in field_name.lower():
|
||||
elif 'secret' in field_name.lower() or 'key' in field_name.lower():
|
||||
field_info['type'] = 'password'
|
||||
|
||||
fields.append(field_info)
|
||||
elif field_name in ['model_name', 'temperature']:
|
||||
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)
|
||||
else:
|
||||
# Для других интеграций - все редактируемые поля
|
||||
|
||||
@@ -667,7 +667,16 @@ class ShowcaseItem(models.Model):
|
||||
from datetime import timedelta
|
||||
|
||||
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_session_id = session_id
|
||||
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:
|
||||
# Цена из OrderItem
|
||||
sale_price = reservation.order_item.price
|
||||
item = reservation.order_item
|
||||
# Цена за единицу с учётом всех скидок (позиция + заказ)
|
||||
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:
|
||||
# Цена из товара
|
||||
sale_price = reservation.product.actual_price or Decimal('0')
|
||||
|
||||
@@ -162,8 +162,6 @@ class ShowcaseManager:
|
||||
Raises:
|
||||
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
|
||||
"""
|
||||
from inventory.services.sale_processor import SaleProcessor
|
||||
|
||||
sold_count = 0
|
||||
order = order_item.order
|
||||
|
||||
@@ -207,17 +205,9 @@ class ShowcaseManager:
|
||||
|
||||
# Сначала устанавливаем order_item для правильного определения цены
|
||||
reservation.order_item = order_item
|
||||
reservation.save()
|
||||
|
||||
# Теперь создаём продажу с правильной ценой из OrderItem
|
||||
SaleProcessor.create_sale_from_reservation(
|
||||
reservation=reservation,
|
||||
order=order
|
||||
)
|
||||
|
||||
# Обновляем статус резерва
|
||||
reservation.status = 'converted_to_sale'
|
||||
reservation.converted_at = timezone.now()
|
||||
# ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
|
||||
# Это сделает сигнал create_sale_on_order_completion автоматически.
|
||||
# Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
|
||||
reservation.save()
|
||||
|
||||
sold_count += 1
|
||||
@@ -666,6 +656,113 @@ class ShowcaseManager:
|
||||
'message': f'Ошибка разбора: {str(e)}'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def write_off_from_showcase(showcase_item, reason='spoilage', notes=None, created_by=None):
|
||||
"""
|
||||
Списывает экземпляр витринного комплекта:
|
||||
1. Создаёт документ списания с компонентами комплекта
|
||||
2. Преобразует резервы комплекта в позиции документа списания
|
||||
3. Помечает экземпляр как разобранный
|
||||
|
||||
Args:
|
||||
showcase_item: ShowcaseItem - экземпляр для списания
|
||||
reason: str - причина списания (spoilage по умолчанию)
|
||||
notes: str - примечания
|
||||
created_by: User - пользователь
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'success': bool,
|
||||
'document_id': int,
|
||||
'document_number': str,
|
||||
'items_count': int,
|
||||
'message': str,
|
||||
'error': str (при ошибке)
|
||||
}
|
||||
"""
|
||||
from inventory.services.writeoff_document_service import WriteOffDocumentService
|
||||
|
||||
# Проверка статуса
|
||||
if showcase_item.status == 'sold':
|
||||
return {
|
||||
'success': False,
|
||||
'document_id': None,
|
||||
'message': 'Нельзя списать проданный экземпляр'
|
||||
}
|
||||
|
||||
if showcase_item.status == 'dismantled':
|
||||
return {
|
||||
'success': False,
|
||||
'document_id': None,
|
||||
'message': 'Экземпляр уже разобран'
|
||||
}
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
warehouse = showcase_item.showcase.warehouse
|
||||
product_kit = showcase_item.product_kit
|
||||
|
||||
# Создаём документ списания (черновик)
|
||||
document = WriteOffDocumentService.create_document(
|
||||
warehouse=warehouse,
|
||||
date=timezone.now().date(),
|
||||
notes=f'Списание витринного комплекта: {product_kit.name}',
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
# Получаем резервы этого экземпляра
|
||||
reservations = Reservation.objects.filter(
|
||||
showcase_item=showcase_item,
|
||||
status='reserved'
|
||||
).select_related('product')
|
||||
|
||||
items_count = 0
|
||||
|
||||
for reservation in reservations:
|
||||
# Добавляем позицию в документ списания
|
||||
# Используем add_item без создания резерва (меняем статус существующего)
|
||||
from inventory.models import WriteOffDocumentItem
|
||||
|
||||
item = WriteOffDocumentItem.objects.create(
|
||||
document=document,
|
||||
product=reservation.product,
|
||||
quantity=reservation.quantity,
|
||||
reason=reason,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
# Привязываем существующий резерв к позиции документа
|
||||
reservation.writeoff_document_item = item
|
||||
reservation.status = 'converted_to_writeoff'
|
||||
reservation.converted_at = timezone.now()
|
||||
reservation.save(update_fields=['writeoff_document_item', 'status', 'converted_at'])
|
||||
|
||||
items_count += 1
|
||||
|
||||
# Помечаем экземпляр как разобранный
|
||||
showcase_item.status = 'dismantled'
|
||||
showcase_item.save(update_fields=['status'])
|
||||
|
||||
# Помечаем шаблон комплекта как снятый
|
||||
if product_kit.status != 'discontinued':
|
||||
product_kit.status = 'discontinued'
|
||||
product_kit.save(update_fields=['status'])
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'document_id': document.id,
|
||||
'document_number': document.document_number,
|
||||
'items_count': items_count,
|
||||
'message': f'Создан документ {document.document_number} с {items_count} позициями'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'document_id': None,
|
||||
'message': f'Ошибка списания: {str(e)}'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_showcase_items_for_pos(showcase=None):
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
Подключаются при создании, изменении и удалении заказов.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from django.db.models.signals import post_save, pre_delete, post_delete, pre_save
|
||||
from django.db.models import Q
|
||||
from django.db import transaction
|
||||
@@ -19,6 +20,26 @@ from inventory.services import SaleProcessor
|
||||
from inventory.services.batch_manager import StockBatchManager
|
||||
# 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
|
||||
@@ -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'):
|
||||
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] += (
|
||||
kit_item.quantity * Decimal(str(instance.quantity))
|
||||
component_qty_base * Decimal(str(instance.quantity))
|
||||
)
|
||||
|
||||
# Создаём по одному резерву на каждый уникальный товар
|
||||
@@ -278,10 +304,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
3. Для каждого товара создаем Sale (автоматический FIFO-список)
|
||||
4. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
|
||||
5. Обновляем флаг is_returned
|
||||
|
||||
ПРИМЕЧАНИЕ: Если у Order установлен атрибут skip_sale_creation=True,
|
||||
создание Sale пропускается (используется в POS для создания Sale после применения скидок).
|
||||
"""
|
||||
import logging
|
||||
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:
|
||||
return # Только для обновлений
|
||||
|
||||
@@ -325,16 +363,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
if not is_positive_end:
|
||||
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: Проверяем предыдущий статус ===
|
||||
# Если уже были в completed и снова переходим в completed (например completed → draft → completed),
|
||||
# проверяем наличие Sale чтобы избежать дублирования
|
||||
previous_status = getattr(instance, '_previous_status', None)
|
||||
if previous_status and previous_status.is_positive_end:
|
||||
logger.info(
|
||||
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
|
||||
f"Заказ {instance.order_number}: повторный переход в положительный статус "
|
||||
f"({previous_status.name} → {instance.status.name}). Проверяем Sale..."
|
||||
)
|
||||
# Проверяем есть ли уже Sale
|
||||
if Sale.objects.filter(order=instance).exists():
|
||||
logger.info(
|
||||
f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем создание"
|
||||
@@ -342,15 +386,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
update_is_returned_flag(instance)
|
||||
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' (после отката)
|
||||
# Исключаем уже обработанные 'converted_to_sale'
|
||||
@@ -419,12 +454,65 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
)
|
||||
continue
|
||||
|
||||
# === РАСЧЕТ ЦЕНЫ ===
|
||||
# Рассчитываем фактическую стоимость продажи всего комплекта с учетом скидок
|
||||
# 1. Базовая стоимость позиции
|
||||
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
|
||||
|
||||
# 2. Скидки
|
||||
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
|
||||
|
||||
# Скидка на заказ (распределенная)
|
||||
instance.refresh_from_db()
|
||||
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
|
||||
delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0')
|
||||
order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
|
||||
|
||||
if order_total > 0 and order_discount_amount > 0:
|
||||
item_order_discount = order_discount_amount * (item_subtotal / order_total)
|
||||
else:
|
||||
item_order_discount = Decimal('0')
|
||||
|
||||
kit_net_total = item_subtotal - item_discount - item_order_discount
|
||||
if kit_net_total < 0:
|
||||
kit_net_total = Decimal('0')
|
||||
|
||||
# 3. Суммарная каталожная стоимость всех компонентов (для пропорции)
|
||||
total_catalog_price = Decimal('0')
|
||||
for reservation in kit_reservations:
|
||||
qty = reservation.quantity_base or reservation.quantity
|
||||
price = reservation.product.actual_price or Decimal('0')
|
||||
total_catalog_price += price * qty
|
||||
|
||||
# 4. Коэффициент распределения
|
||||
if total_catalog_price > 0:
|
||||
ratio = kit_net_total / total_catalog_price
|
||||
else:
|
||||
# Если каталожная цена 0, распределяем просто по количеству или 0
|
||||
ratio = Decimal('0')
|
||||
|
||||
# Создаем Sale для каждого компонента комплекта
|
||||
for reservation in kit_reservations:
|
||||
try:
|
||||
# Рассчитываем цену продажи компонента пропорционально цене комплекта
|
||||
# Используем actual_price компонента как цену продажи
|
||||
component_sale_price = reservation.product.actual_price
|
||||
# Рассчитываем цену продажи компонента пропорционально
|
||||
catalog_price = reservation.product.actual_price or Decimal('0')
|
||||
|
||||
if ratio > 0:
|
||||
# Распределяем реальную выручку
|
||||
component_sale_price = catalog_price * ratio
|
||||
else:
|
||||
# Если выручка 0 или каталожные цены 0
|
||||
if total_catalog_price == 0 and kit_net_total > 0:
|
||||
# Крайний случай: товаров на 0 руб, а продали за деньги (услуга?)
|
||||
# Распределяем равномерно
|
||||
count = kit_reservations.count()
|
||||
component_qty = reservation.quantity_base or reservation.quantity
|
||||
if count > 0 and component_qty > 0:
|
||||
component_sale_price = (kit_net_total / count) / component_qty
|
||||
else:
|
||||
component_sale_price = Decimal('0')
|
||||
else:
|
||||
component_sale_price = Decimal('0')
|
||||
|
||||
sale = SaleProcessor.create_sale(
|
||||
product=reservation.product,
|
||||
@@ -437,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
sales_created.append(sale)
|
||||
logger.info(
|
||||
f"✓ Sale создан для компонента комплекта '{kit.name}': "
|
||||
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. (базовых единиц)"
|
||||
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. "
|
||||
f"(цена: {component_sale_price})"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
@@ -480,12 +569,60 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
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 = SaleProcessor.create_sale(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=sale_quantity,
|
||||
sale_price=Decimal(str(item.price)),
|
||||
sale_price=base_price,
|
||||
order=instance,
|
||||
document_number=instance.order_number,
|
||||
sales_unit=item.sales_unit # Передаем sales_unit в Sale
|
||||
@@ -1801,8 +1938,13 @@ def update_kit_prices_on_product_change(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
return
|
||||
|
||||
# Находим все KitItem с этим товаром
|
||||
kit_items = KitItem.objects.filter(product=instance)
|
||||
# Находим все KitItem с этим товаром, исключая временные (витринные) комплекты
|
||||
# Витринные комплекты имеют зафиксированную цену и не должны обновляться автоматически
|
||||
kit_items = KitItem.objects.filter(
|
||||
product=instance
|
||||
).select_related('kit').exclude(
|
||||
kit__is_temporary=True
|
||||
)
|
||||
|
||||
if not kit_items.exists():
|
||||
return # Товар не используется в комплектах
|
||||
|
||||
@@ -394,14 +394,21 @@
|
||||
const emptyMessage = document.getElementById('empty-lines-message');
|
||||
if (emptyMessage) emptyMessage.remove();
|
||||
|
||||
// Добавляем новую строку
|
||||
// Добавляем новую строку в начало таблицы
|
||||
const newRow = self.createLineRow(data.line);
|
||||
tbody.appendChild(newRow);
|
||||
tbody.insertBefore(newRow, tbody.firstChild);
|
||||
|
||||
// Включаем кнопку завершения
|
||||
const completeBtn = document.getElementById('complete-inventory-btn');
|
||||
if (completeBtn) completeBtn.disabled = false;
|
||||
|
||||
// Фокус на поле ввода количества в новой строке
|
||||
const quantityInput = newRow.querySelector('.quantity-fact-input');
|
||||
if (quantityInput) {
|
||||
quantityInput.focus();
|
||||
quantityInput.select();
|
||||
}
|
||||
|
||||
this.showNotification('Товар добавлен', 'success');
|
||||
} else {
|
||||
this.showNotification('Ошибка: ' + (data.error || 'Не удалось добавить товар'), 'error');
|
||||
|
||||
@@ -688,7 +688,13 @@
|
||||
<td><span class="badge bg-info">{{ doc.get_receipt_type_display }}</span></td>
|
||||
<td class="text-muted-small">{{ doc.date|date:"d.m.Y" }}</td>
|
||||
<td>{{ doc.supplier_name|default:"-" }}</td>
|
||||
<td class="text-muted-small">{{ doc.created_by.name|default:doc.created_by.email|default:"-" }}</td>
|
||||
<td class="text-muted-small">
|
||||
{% if doc.created_by %}
|
||||
{{ doc.created_by.name|default:doc.created_by.email }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted-small">
|
||||
{% if doc.confirmed_by %}
|
||||
{{ doc.confirmed_by.name|default:doc.confirmed_by.email }} ({{ doc.confirmed_at|date:"d.m H:i" }})
|
||||
|
||||
@@ -20,19 +20,22 @@
|
||||
<div class="row g-3">
|
||||
<!-- Основной контент - одна колонка -->
|
||||
<div class="col-12">
|
||||
<!-- Информация о документе -->
|
||||
<!-- Информация о документе - свернута по умолчанию -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
|
||||
{% if document.status == 'draft' %}
|
||||
<span class="badge bg-warning text-dark ms-2">Черновик</span>
|
||||
{% elif document.status == 'confirmed' %}
|
||||
<span class="badge bg-success ms-2">Проведён</span>
|
||||
{% elif document.status == 'cancelled' %}
|
||||
<span class="badge bg-secondary ms-2">Отменён</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
|
||||
<button class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2" type="button" data-bs-toggle="collapse" data-bs-target="#document-info-collapse" aria-expanded="false" aria-controls="document-info-collapse">
|
||||
<i class="bi bi-chevron-down" id="document-info-collapse-icon"></i>
|
||||
<span>
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
|
||||
{% if document.status == 'draft' %}
|
||||
<span class="badge bg-warning text-dark ms-2">Черновик</span>
|
||||
{% elif document.status == 'confirmed' %}
|
||||
<span class="badge bg-success ms-2">Проведён</span>
|
||||
{% elif document.status == 'cancelled' %}
|
||||
<span class="badge bg-secondary ms-2">Отменён</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
{% if document.can_edit %}
|
||||
<div class="btn-group">
|
||||
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
|
||||
@@ -50,65 +53,67 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Склад</p>
|
||||
<p class="fw-semibold">{{ document.warehouse.name }}</p>
|
||||
<div class="collapse" id="document-info-collapse">
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Склад</p>
|
||||
<p class="fw-semibold">{{ document.warehouse.name }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Дата документа</p>
|
||||
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Тип поступления</p>
|
||||
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Создан</p>
|
||||
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Дата документа</p>
|
||||
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Тип поступления</p>
|
||||
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Создан</p>
|
||||
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if document.supplier_name %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Поставщик</p>
|
||||
<p class="fw-semibold">{{ document.supplier_name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.notes %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Примечания</p>
|
||||
<p>{{ document.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.confirmed_at %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Проведён</p>
|
||||
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
|
||||
{% if document.supplier_name %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Поставщик</p>
|
||||
<p class="fw-semibold">{{ document.supplier_name }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Провёл</p>
|
||||
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if document.notes %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Примечания</p>
|
||||
<p>{{ document.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.confirmed_at %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Проведён</p>
|
||||
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Провёл</p>
|
||||
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Добавление позиции -->
|
||||
{% if document.can_edit %}
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light py-3">
|
||||
<div class="card-header bg-light py-1">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить позицию в документ</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Компонент поиска товаров -->
|
||||
<div class="mb-3">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
|
||||
<div class="card-body p-2">
|
||||
<!-- Компонент поиска товаров - компактный -->
|
||||
<div class="mb-2">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар...' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать' content_height='150px' %}
|
||||
</div>
|
||||
|
||||
<!-- Информация о выбранном товаре -->
|
||||
@@ -188,6 +193,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">Примечания</th>
|
||||
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Текущая цена продажи</th>
|
||||
{% if document.can_edit %}
|
||||
<th scope="col" class="px-3 py-2" style="width: 100px;"></th>
|
||||
{% endif %}
|
||||
@@ -200,19 +206,35 @@
|
||||
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
||||
</td>
|
||||
<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 %}
|
||||
<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;">
|
||||
<span class="editable-quantity"
|
||||
data-item-id="{{ item.id }}"
|
||||
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 %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||
<span class="item-cost-price-display">{{ item.cost_price|floatformat:2 }}</span>
|
||||
{% if document.can_edit %}
|
||||
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
||||
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||
<span class="editable-cost-price"
|
||||
data-item-id="{{ item.id }}"
|
||||
data-current-value="{{ item.cost_price }}"
|
||||
title="Закупочная цена (клик для редактирования)"
|
||||
style="cursor: pointer;">
|
||||
{{ item.cost_price|floatformat:2 }}
|
||||
</span>
|
||||
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
||||
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||
{% else %}
|
||||
<span>{{ item.cost_price|floatformat:2 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||
@@ -226,26 +248,49 @@
|
||||
style="display: none;">
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<td class="px-3 py-2 text-end" style="width: 100px;">
|
||||
<div class="btn-group btn-group-sm item-action-buttons">
|
||||
<button type="button" class="btn btn-outline-primary btn-edit-item" title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="if(confirm('Удалить позицию?')) document.getElementById('delete-form-{{ item.id }}').submit();"
|
||||
title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm item-edit-buttons" style="display: none;">
|
||||
<button type="button" class="btn btn-success btn-save-item" title="Сохранить">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-cancel-edit" title="Отменить">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form id="delete-form-{{ item.id }}" method="post"
|
||||
action="{% url 'inventory:incoming-remove-item' document.pk item.pk %}"
|
||||
style="display: none;">
|
||||
@@ -256,7 +301,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<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>
|
||||
Позиций пока нет
|
||||
</td>
|
||||
@@ -268,7 +313,7 @@
|
||||
<tr>
|
||||
<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 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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -283,6 +328,7 @@
|
||||
|
||||
<!-- JS для компонента поиска -->
|
||||
<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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Элементы формы
|
||||
@@ -307,6 +353,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Анимация для иконки сворачивания/разворачивания информации о документе
|
||||
const documentInfoCollapse = document.getElementById('document-info-collapse');
|
||||
const documentInfoCollapseIcon = document.getElementById('document-info-collapse-icon');
|
||||
|
||||
if (documentInfoCollapse && documentInfoCollapseIcon) {
|
||||
documentInfoCollapse.addEventListener('show.bs.collapse', function() {
|
||||
documentInfoCollapseIcon.classList.remove('bi-chevron-down');
|
||||
documentInfoCollapseIcon.classList.add('bi-chevron-up');
|
||||
});
|
||||
|
||||
documentInfoCollapse.addEventListener('hide.bs.collapse', function() {
|
||||
documentInfoCollapseIcon.classList.remove('bi-chevron-up');
|
||||
documentInfoCollapseIcon.classList.add('bi-chevron-down');
|
||||
});
|
||||
}
|
||||
|
||||
// Функция выбора товара
|
||||
function selectProduct(product) {
|
||||
const productId = String(product.id).replace('product_', '');
|
||||
@@ -372,158 +434,328 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============================================
|
||||
// Inline редактирование позиций в таблице
|
||||
// Inline редактирование количества и цены
|
||||
// ============================================
|
||||
|
||||
// Хранилище оригинальных значений при редактировании
|
||||
const originalValues = {};
|
||||
function initInlineQuantityEdit() {
|
||||
// Проверяем, есть ли на странице редактируемые количества
|
||||
const editableQuantities = document.querySelectorAll('.editable-quantity');
|
||||
if (editableQuantities.length === 0) {
|
||||
return; // Нет элементов для редактирования
|
||||
}
|
||||
|
||||
// Обработчики для кнопок редактирования
|
||||
document.querySelectorAll('.btn-edit-item').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
// Обработчик клика на редактируемое количество
|
||||
document.addEventListener('click', function(e) {
|
||||
const quantitySpan = e.target.closest('.editable-quantity');
|
||||
if (!quantitySpan) return;
|
||||
|
||||
// Сохраняем оригинальные значения
|
||||
originalValues[itemId] = {
|
||||
quantity: row.querySelector('.item-quantity-input').value,
|
||||
cost_price: row.querySelector('.item-cost-price-input').value,
|
||||
notes: row.querySelector('.item-notes-input').value
|
||||
// Предотвращаем повторное срабатывание, если уже редактируем
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Переключаем в режим редактирования
|
||||
toggleEditMode(row, true);
|
||||
});
|
||||
});
|
||||
// Функция отмены
|
||||
const cancelEdit = () => {
|
||||
quantitySpan.innerHTML = originalHTML;
|
||||
};
|
||||
|
||||
// Обработчики для кнопок сохранения
|
||||
document.querySelectorAll('.btn-save-item').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
saveItemChanges(itemId, row);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики для кнопок отмены
|
||||
document.querySelectorAll('.btn-cancel-edit').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
|
||||
// Восстанавливаем оригинальные значения
|
||||
if (originalValues[itemId]) {
|
||||
row.querySelector('.item-quantity-input').value = originalValues[itemId].quantity;
|
||||
row.querySelector('.item-cost-price-input').value = originalValues[itemId].cost_price;
|
||||
row.querySelector('.item-notes-input').value = originalValues[itemId].notes;
|
||||
}
|
||||
|
||||
// Выходим из режима редактирования
|
||||
toggleEditMode(row, false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Переключение режима редактирования строки
|
||||
*/
|
||||
function toggleEditMode(row, isEditing) {
|
||||
// Переключаем видимость полей отображения/ввода
|
||||
row.querySelectorAll('.item-quantity-display, .item-cost-price-display, .item-notes-display').forEach(el => {
|
||||
el.style.display = isEditing ? 'none' : '';
|
||||
});
|
||||
row.querySelectorAll('.item-quantity-input, .item-cost-price-input, .item-notes-input').forEach(el => {
|
||||
el.style.display = isEditing ? '' : 'none';
|
||||
});
|
||||
|
||||
// Переключаем видимость кнопок
|
||||
row.querySelector('.item-action-buttons').style.display = isEditing ? 'none' : '';
|
||||
row.querySelector('.item-edit-buttons').style.display = isEditing ? '' : 'none';
|
||||
|
||||
// Фокус на поле количества при входе в режим редактирования
|
||||
if (isEditing) {
|
||||
const qtyInput = row.querySelector('.item-quantity-input');
|
||||
if (qtyInput) {
|
||||
qtyInput.focus();
|
||||
qtyInput.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение изменений позиции
|
||||
*/
|
||||
function saveItemChanges(itemId, row) {
|
||||
const quantity = row.querySelector('.item-quantity-input').value;
|
||||
const costPrice = row.querySelector('.item-cost-price-input').value;
|
||||
const notes = row.querySelector('.item-notes-input').value;
|
||||
|
||||
// Валидация
|
||||
if (!quantity || parseFloat(quantity) <= 0) {
|
||||
alert('Количество должно быть больше нуля');
|
||||
return;
|
||||
}
|
||||
if (!costPrice || parseFloat(costPrice) < 0) {
|
||||
alert('Закупочная цена не может быть отрицательной');
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем на сервер
|
||||
const formData = new FormData();
|
||||
formData.append('quantity', quantity);
|
||||
formData.append('cost_price', costPrice);
|
||||
formData.append('notes', notes);
|
||||
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
|
||||
|
||||
// Блокируем кнопки во время сохранения
|
||||
const saveBtn = row.querySelector('.btn-save-item');
|
||||
const cancelBtn = row.querySelector('.btn-cancel-edit');
|
||||
saveBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||||
|
||||
fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Обновляем отображение
|
||||
let formattedQty = parseFloat(quantity);
|
||||
if (formattedQty === Math.floor(formattedQty)) {
|
||||
formattedQty = Math.floor(formattedQty).toString();
|
||||
} else {
|
||||
formattedQty = formattedQty.toString().replace('.', ',');
|
||||
// Enter - сохранить
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveQuantity();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
row.querySelector('.item-quantity-display').textContent = formattedQty;
|
||||
row.querySelector('.item-cost-price-display').textContent = parseFloat(costPrice).toFixed(2);
|
||||
row.querySelector('.item-notes-display').textContent = notes || '-';
|
||||
});
|
||||
|
||||
// Пересчитываем сумму
|
||||
const totalCost = (parseFloat(quantity) * parseFloat(costPrice)).toFixed(2);
|
||||
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
||||
|
||||
// Выходим из режима редактирования
|
||||
toggleEditMode(row, false);
|
||||
} else {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при сохранении');
|
||||
})
|
||||
.finally(() => {
|
||||
saveBtn.disabled = false;
|
||||
cancelBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
||||
// Потеря фокуса - сохранить
|
||||
input.addEventListener('blur', function() {
|
||||
setTimeout(saveQuantity, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initInlineCostPriceEdit() {
|
||||
// Проверяем, есть ли на странице редактируемые цены
|
||||
const editableCostPrices = document.querySelectorAll('.editable-cost-price');
|
||||
if (editableCostPrices.length === 0) {
|
||||
return; // Нет элементов для редактирования
|
||||
}
|
||||
|
||||
// Обработчик клика на редактируемую цену
|
||||
document.addEventListener('click', function(e) {
|
||||
const costPriceSpan = e.target.closest('.editable-cost-price');
|
||||
if (!costPriceSpan) return;
|
||||
|
||||
// Предотвращаем повторное срабатывание, если уже редактируем
|
||||
if (costPriceSpan.querySelector('input')) return;
|
||||
|
||||
const itemId = costPriceSpan.dataset.itemId;
|
||||
const currentValue = costPriceSpan.dataset.currentValue;
|
||||
|
||||
// Сохраняем оригинальный HTML
|
||||
const originalHTML = costPriceSpan.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(2);
|
||||
input.step = '0.01';
|
||||
input.min = '0';
|
||||
input.placeholder = 'Цена';
|
||||
|
||||
// Заменяем содержимое на input
|
||||
costPriceSpan.innerHTML = '';
|
||||
costPriceSpan.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
// Функция сохранения
|
||||
const saveCostPrice = async () => {
|
||||
let newValue = input.value.trim();
|
||||
|
||||
// Валидация
|
||||
if (!newValue || parseFloat(newValue) < 0) {
|
||||
alert('Закупочная цена не может быть отрицательной');
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, изменилось ли значение
|
||||
if (parseFloat(newValue) === parseFloat(currentValue)) {
|
||||
// Значение не изменилось
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем загрузку
|
||||
input.disabled = true;
|
||||
input.style.opacity = '0.5';
|
||||
|
||||
try {
|
||||
// Получаем текущие значения других полей
|
||||
const row = costPriceSpan.closest('tr');
|
||||
const quantity = row.querySelector('.item-quantity-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: quantity,
|
||||
cost_price: newValue,
|
||||
notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Обновляем отображение
|
||||
const formattedPrice = parseFloat(newValue).toFixed(2);
|
||||
costPriceSpan.textContent = formattedPrice;
|
||||
costPriceSpan.dataset.currentValue = newValue;
|
||||
|
||||
// Пересчитываем сумму
|
||||
const totalCost = (parseFloat(quantity) * parseFloat(newValue)).toFixed(2);
|
||||
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
||||
|
||||
// Обновляем итого
|
||||
updateTotals();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при обновлении цены');
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка сети при обновлении цены');
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
}
|
||||
};
|
||||
|
||||
// Функция отмены
|
||||
const cancelEdit = () => {
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
};
|
||||
|
||||
// Enter - сохранить
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveCostPrice();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
});
|
||||
|
||||
// Потеря фокуса - сохранить
|
||||
input.addEventListener('blur', function() {
|
||||
setTimeout(saveCostPrice, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Функция обновления итоговых сумм
|
||||
function updateTotals() {
|
||||
// Можно реализовать пересчет итогов, если нужно
|
||||
// Пока оставим как есть, так как сервер возвращает обновленные данные
|
||||
}
|
||||
|
||||
// Инициализация inline редактирования
|
||||
initInlineQuantityEdit();
|
||||
initInlineCostPriceEdit();
|
||||
});
|
||||
</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;
|
||||
}
|
||||
|
||||
/* Стили для редактируемой цены */
|
||||
.editable-cost-price {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.editable-cost-price:hover {
|
||||
color: #0d6efd !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -19,52 +19,76 @@
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Информация об инвентаризации - свернута по умолчанию -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h5>Информация</h5>
|
||||
<table class="table table-borderless">
|
||||
{% if inventory.document_number %}
|
||||
<tr>
|
||||
<th>Номер документа:</th>
|
||||
<td><strong>{{ inventory.document_number }}</strong></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Склад:</th>
|
||||
<td><strong>{{ inventory.warehouse.name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус:</th>
|
||||
<td>
|
||||
{% if inventory.status == 'draft' %}
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2">
|
||||
<i class="bi bi-file-earmark"></i> Черновик
|
||||
</span>
|
||||
{% elif inventory.status == 'processing' %}
|
||||
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
|
||||
<i class="bi bi-hourglass-split"></i> В обработке
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success fs-6 px-3 py-2">
|
||||
<i class="bi bi-check-circle-fill"></i> Завершена
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата:</th>
|
||||
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
{% if inventory.conducted_by %}
|
||||
<tr>
|
||||
<th>Провёл:</th>
|
||||
<td>{{ inventory.conducted_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
<button class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#inventory-info-collapse" aria-expanded="false" aria-controls="inventory-info-collapse">
|
||||
<i class="bi bi-chevron-down" id="info-collapse-icon"></i>
|
||||
<span>Информация</span>
|
||||
</button>
|
||||
<div class="collapse" id="inventory-info-collapse">
|
||||
<table class="table table-borderless">
|
||||
{% if inventory.document_number %}
|
||||
<tr>
|
||||
<th>Номер документа:</th>
|
||||
<td><strong>{{ inventory.document_number }}</strong></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Склад:</th>
|
||||
<td><strong>{{ inventory.warehouse.name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус:</th>
|
||||
<td>
|
||||
{% if inventory.status == 'draft' %}
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2">
|
||||
<i class="bi bi-file-earmark"></i> Черновик
|
||||
</span>
|
||||
{% elif inventory.status == 'processing' %}
|
||||
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
|
||||
<i class="bi bi-hourglass-split"></i> В обработке
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success fs-6 px-3 py-2">
|
||||
<i class="bi bi-check-circle-fill"></i> Завершена
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата:</th>
|
||||
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
{% if inventory.conducted_by %}
|
||||
<tr>
|
||||
<th>Провёл:</th>
|
||||
<td>{{ inventory.conducted_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Добавляем простую анимацию для иконки при сворачивании/разворачивании
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const collapseElement = document.getElementById('inventory-info-collapse');
|
||||
const collapseIcon = document.getElementById('info-collapse-icon');
|
||||
|
||||
collapseElement.addEventListener('show.bs.collapse', function() {
|
||||
collapseIcon.classList.remove('bi-chevron-down');
|
||||
collapseIcon.classList.add('bi-chevron-up');
|
||||
});
|
||||
|
||||
collapseElement.addEventListener('hide.bs.collapse', function() {
|
||||
collapseIcon.classList.remove('bi-chevron-up');
|
||||
collapseIcon.classList.add('bi-chevron-down');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if inventory.status == 'completed' %}
|
||||
<!-- Информация о созданных документах -->
|
||||
<div class="alert alert-info mb-4">
|
||||
@@ -100,14 +124,14 @@
|
||||
|
||||
<h5>Строки инвентаризации</h5>
|
||||
|
||||
<!-- Компонент поиска товаров (только если не завершена) -->
|
||||
<!-- Компонент поиска товаров - открыт по умолчанию, компактный -->
|
||||
{% if inventory.status != 'completed' %}
|
||||
<div class="card border-primary mb-4" id="product-search-section">
|
||||
<div class="card-header bg-light">
|
||||
<div class="card-header bg-light py-1">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара для инвентаризации...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить товар' content_height='250px' %}
|
||||
<div class="card-body p-2">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить' content_height='150px' skip_stock_filter=True %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -288,11 +312,15 @@
|
||||
<script src="{% static 'inventory/js/inventory_detail.js' %}" onerror="console.error('Failed to load inventory_detail.js');"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM loaded, initializing inventory components...');
|
||||
|
||||
// Проверка загрузки ProductSearchPicker
|
||||
if (typeof ProductSearchPicker === 'undefined') {
|
||||
console.error('ProductSearchPicker is not defined. Check if product-search-picker.js loaded correctly.');
|
||||
console.error('Script URL: {% static "products/js/product-search-picker.js" %}');
|
||||
return;
|
||||
} else {
|
||||
console.log('ProductSearchPicker is available');
|
||||
}
|
||||
|
||||
// Инициализация компонента поиска товаров
|
||||
@@ -303,8 +331,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Initializing ProductSearchPicker for inventory...');
|
||||
const picker = ProductSearchPicker.init('#inventory-product-picker', {
|
||||
apiUrl: '{% url "products:api-search-products-variants" %}', // Явно указываем URL API
|
||||
excludeKits: true, // Исключаем комплекты из поиска
|
||||
onSelect: function(product, instance) {
|
||||
console.log('Product selected:', product);
|
||||
},
|
||||
onAddSelected: function(product, instance) {
|
||||
console.log('Adding selected product to inventory:', product);
|
||||
if (product) {
|
||||
addInventoryLine(product.id);
|
||||
instance.clearSelection();
|
||||
@@ -314,17 +349,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
if (!picker) {
|
||||
console.error('Failed to initialize ProductSearchPicker');
|
||||
} else {
|
||||
console.log('ProductSearchPicker initialized successfully');
|
||||
}
|
||||
{% else %}
|
||||
console.log('Inventory is completed, skipping product picker initialization');
|
||||
{% endif %}
|
||||
|
||||
// Инициализация обработчиков
|
||||
const inventoryId = {{ inventory.pk }};
|
||||
console.log('Initializing inventory detail handlers for ID:', inventoryId);
|
||||
window.inventoryDetailHandlers = initInventoryDetailHandlers(inventoryId, {
|
||||
addLineUrl: '{% url "inventory:inventory-line-add" inventory.pk %}',
|
||||
updateLineUrl: '{% url "inventory:inventory-line-update" inventory.pk 999 %}',
|
||||
deleteLineUrl: '{% url "inventory:inventory-line-delete" inventory.pk 999 %}',
|
||||
completeUrl: '{% url "inventory:inventory-complete" inventory.pk %}'
|
||||
});
|
||||
|
||||
console.log('Inventory detail handlers initialized');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -74,10 +74,12 @@
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
||||
<a href="{% url 'products:product-detail' item.product.id %}">{{
|
||||
item.product.name }}</a>
|
||||
</td>
|
||||
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
|
||||
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
|
||||
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
||||
</td>
|
||||
@@ -132,9 +134,11 @@
|
||||
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
||||
</a>
|
||||
<!--
|
||||
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash me-1"></i>Удалить
|
||||
</a>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,13 +147,13 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.breadcrumb-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.breadcrumb-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -39,9 +39,11 @@
|
||||
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<!--
|
||||
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
-->
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1066,10 +1066,6 @@ class OrderStatusTransitionCriticalTest(TestCase):
|
||||
order.save()
|
||||
order.refresh_from_db()
|
||||
|
||||
# Проверяем, что прошли через draft (автоматический промежуточный переход)
|
||||
history = order.history.all()
|
||||
self.assertGreaterEqual(history.count(), 2, "[STEP 7] Должна быть история переходов")
|
||||
|
||||
# Проверки после автоматического перехода
|
||||
self._assert_stock_state(
|
||||
available=Decimal('90.00'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Отладочные view для суперюзеров.
|
||||
Отладочные view для owner и manager.
|
||||
Для мониторинга работы системы инвентаризации.
|
||||
"""
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
@@ -15,16 +15,16 @@ from products.models import Product
|
||||
from inventory.models import Warehouse
|
||||
|
||||
|
||||
def is_superuser(user):
|
||||
"""Проверка что пользователь - суперюзер."""
|
||||
return user.is_superuser
|
||||
def is_owner_or_manager(user):
|
||||
"""Проверка что пользователь - owner или manager."""
|
||||
return user.is_owner or user.is_manager
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(is_superuser)
|
||||
@user_passes_test(is_owner_or_manager)
|
||||
def debug_inventory_page(request):
|
||||
"""
|
||||
Отладочная страница для суперюзеров.
|
||||
Отладочная страница для owner и manager.
|
||||
Показывает полную картину по инвентаризации: партии, остатки, резервы, продажи.
|
||||
"""
|
||||
# Получаем параметры фильтров
|
||||
|
||||
@@ -18,7 +18,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# Initialize environment variables
|
||||
env = environ.Env(
|
||||
# Set casting and default values
|
||||
DEBUG=(bool, False), # Security: default False
|
||||
DEBUG=(bool, True), # Debug mode enabled
|
||||
SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'),
|
||||
)
|
||||
|
||||
@@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY:
|
||||
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
|
||||
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ class OrderFilter(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
# Фильтр по диапазону дат доставки (используем delivery__delivery_date после рефакторинга)
|
||||
# Кастомные методы для показа заказов без даты в нейтральных статусах
|
||||
delivery_date_after = django_filters.DateFilter(
|
||||
field_name='delivery__delivery_date',
|
||||
lookup_expr='gte',
|
||||
method='filter_delivery_date_after',
|
||||
label='Дата доставки от',
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'form-control date-input',
|
||||
@@ -43,8 +43,7 @@ class OrderFilter(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
delivery_date_before = django_filters.DateFilter(
|
||||
field_name='delivery__delivery_date',
|
||||
lookup_expr='lte',
|
||||
method='filter_delivery_date_before',
|
||||
label='Дата доставки до',
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'form-control date-input',
|
||||
@@ -101,11 +100,19 @@ class OrderFilter(django_filters.FilterSet):
|
||||
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:
|
||||
model = Order
|
||||
fields = ['search', 'status', 'delivery_type', 'payment_status',
|
||||
'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):
|
||||
"""
|
||||
@@ -134,3 +141,60 @@ class OrderFilter(django_filters.FilterSet):
|
||||
elif value == 'pickup':
|
||||
return queryset.filter(delivery__delivery_type=Delivery.DELIVERY_TYPE_PICKUP)
|
||||
return queryset
|
||||
|
||||
def _get_null_date_condition(self):
|
||||
"""
|
||||
Условие для заказов без даты в нейтральных статусах.
|
||||
Такие заказы всегда должны быть видны, чтобы не потерялись.
|
||||
"""
|
||||
# Заказы без даты И в нейтральном статусе (не завершены положительно/отрицательно)
|
||||
return (
|
||||
Q(delivery__delivery_date__isnull=True) &
|
||||
(
|
||||
Q(status__isnull=True) |
|
||||
(Q(status__is_positive_end=False) & Q(status__is_negative_end=False))
|
||||
)
|
||||
)
|
||||
|
||||
def filter_delivery_date_after(self, queryset, name, value):
|
||||
"""
|
||||
Фильтр по дате доставки (от).
|
||||
Всегда показывает заказы без даты в нейтральных статусах.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Заказы с датой >= value ИЛИ без даты в нейтральных статусах
|
||||
return queryset.filter(
|
||||
Q(delivery__delivery_date__gte=value) |
|
||||
self._get_null_date_condition()
|
||||
)
|
||||
|
||||
def filter_delivery_date_before(self, queryset, name, value):
|
||||
"""
|
||||
Фильтр по дате доставки (до).
|
||||
Всегда показывает заказы без даты в нейтральных статусах.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Заказы с датой <= value ИЛИ без даты в нейтральных статусах
|
||||
return queryset.filter(
|
||||
Q(delivery__delivery_date__lte=value) |
|
||||
self._get_null_date_condition()
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -425,7 +425,8 @@ class OrderForm(forms.ModelForm):
|
||||
|
||||
has_address = (
|
||||
(address_mode == 'history' and address_from_history) or
|
||||
(address_mode == 'new' and address_street)
|
||||
(address_mode == 'new' and address_street) or
|
||||
address_mode == 'empty' # Разрешаем "Без адреса (заполнить позже)"
|
||||
)
|
||||
|
||||
if not has_address:
|
||||
@@ -461,11 +462,15 @@ class OrderItemForm(forms.ModelForm):
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'})
|
||||
)
|
||||
|
||||
# Поле DELETE, которое автоматически добавляется в inline формсете
|
||||
DELETE = forms.BooleanField(required=False, widget=forms.HiddenInput())
|
||||
|
||||
class Meta:
|
||||
model = OrderItem
|
||||
fields = ['product', 'product_kit', 'sales_unit', 'quantity', 'price', 'is_custom_price', 'is_from_showcase']
|
||||
# ВАЖНО: НЕ включаем 'id' в fields - это предотвращает ошибку валидации
|
||||
fields = ['id', 'product', 'product_kit', 'sales_unit', 'quantity', 'price', 'is_custom_price', 'is_from_showcase']
|
||||
# ВАЖНО: Теперь включаем 'id' в fields для правильной работы inline формсета
|
||||
widgets = {
|
||||
'id': forms.HiddenInput(), # Скрываем поле id, но оставляем его для формсета
|
||||
'quantity': forms.NumberInput(attrs={'min': 1}),
|
||||
# Скрываем поля product и product_kit - они будут заполняться через JS
|
||||
'product': forms.HiddenInput(),
|
||||
|
||||
@@ -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='Единица продажи'),
|
||||
),
|
||||
]
|
||||
@@ -132,35 +132,10 @@ class Delivery(models.Model):
|
||||
})
|
||||
return
|
||||
|
||||
# Для не-черновиков полная валидация
|
||||
|
||||
# Проверка: дата доставки обязательна
|
||||
if not self.delivery_date:
|
||||
raise ValidationError({
|
||||
'delivery_date': 'Для не-черновиков дата доставки обязательна'
|
||||
})
|
||||
|
||||
# Проверка: для курьерской доставки должен быть адрес
|
||||
if self.delivery_type == self.DELIVERY_TYPE_COURIER:
|
||||
if not self.address:
|
||||
raise ValidationError({
|
||||
'address': 'Для курьерской доставки необходимо указать адрес'
|
||||
})
|
||||
if self.pickup_warehouse:
|
||||
raise ValidationError({
|
||||
'pickup_warehouse': 'Для курьерской доставки склад не указывается'
|
||||
})
|
||||
|
||||
# Проверка: для самовывоза должен быть склад
|
||||
if self.delivery_type == self.DELIVERY_TYPE_PICKUP:
|
||||
if not self.pickup_warehouse:
|
||||
raise ValidationError({
|
||||
'pickup_warehouse': 'Для самовывоза необходимо указать склад'
|
||||
})
|
||||
if self.address:
|
||||
raise ValidationError({
|
||||
'address': 'Для самовывоза адрес не указывается'
|
||||
})
|
||||
# Для не-черновиков ранее действовала строгая валидация даты и адреса.
|
||||
# В рамках новой логики разрешаем сохранять заказы в любом статусе без адреса
|
||||
# и без обязательной даты доставки. Сохраняем только базовые проверки
|
||||
# непротиворечивости данных.
|
||||
|
||||
# Проверка: время "до" не может быть раньше времени "от" (равные времена разрешены для POS)
|
||||
if self.time_from and self.time_to and self.time_from > self.time_to:
|
||||
@@ -168,7 +143,8 @@ class Delivery(models.Model):
|
||||
'time_to': 'Время окончания доставки не может быть раньше времени начала'
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def save(self, *args, validate=True, **kwargs):
|
||||
"""Переопределение save для вызова валидации"""
|
||||
self.full_clean()
|
||||
if validate:
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -140,6 +140,25 @@ class KitItemSnapshot(models.Model):
|
||||
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(
|
||||
max_digits=10,
|
||||
decimal_places=3,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load inventory_filters %}
|
||||
|
||||
{% block title %}Заказ {{ order.order_number }}{% endblock %}
|
||||
|
||||
@@ -337,7 +338,7 @@
|
||||
<!-- Кнопка "Применить максимум" -->
|
||||
<form method="post" action="{% url 'orders:apply-wallet' order.order_number %}" class="mb-2">
|
||||
{% 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">
|
||||
<i class="bi bi-wallet2"></i> Применить максимум
|
||||
</button>
|
||||
@@ -351,7 +352,7 @@
|
||||
type="number"
|
||||
step="0.01"
|
||||
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"
|
||||
class="form-control"
|
||||
placeholder="Сумма"
|
||||
|
||||
@@ -581,7 +581,7 @@
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="{{ form.address_street.id_for_label }}" class="form-label">
|
||||
Улица <span class="text-danger">*</span>
|
||||
Улица
|
||||
</label>
|
||||
{{ form.address_street }}
|
||||
{% if form.address_street.errors %}
|
||||
@@ -590,7 +590,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="{{ form.address_building_number.id_for_label }}" class="form-label">
|
||||
Дом <span class="text-danger">*</span>
|
||||
Дом
|
||||
</label>
|
||||
{{ form.address_building_number }}
|
||||
{% if form.address_building_number.errors %}
|
||||
@@ -1542,6 +1542,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Убедимся, что все поля имеют правильные имена и ID
|
||||
const fields = newForm.querySelectorAll('[name]');
|
||||
fields.forEach(field => {
|
||||
const name = field.getAttribute('name');
|
||||
if (name && name.includes('__prefix__')) {
|
||||
const newName = name.replace(/__prefix__/g, formCount);
|
||||
field.setAttribute('name', newName);
|
||||
}
|
||||
|
||||
const id = field.getAttribute('id');
|
||||
if (id && id.includes('__prefix__')) {
|
||||
const newId = id.replace(/__prefix__/g, formCount);
|
||||
field.setAttribute('id', newId);
|
||||
}
|
||||
});
|
||||
|
||||
updateTotalDisplay();
|
||||
|
||||
return newForm;
|
||||
@@ -1560,6 +1576,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Сохранённая форма - помечаем на удаление
|
||||
console.log('[removeForm] Помечаем сохранённую форму на удаление (ID:', idField.value, ')');
|
||||
deleteCheckbox.checked = true;
|
||||
// Также добавляем скрытое поле, чтобы гарантировать удаление
|
||||
if (!deleteCheckbox.value) {
|
||||
deleteCheckbox.value = 'on';
|
||||
}
|
||||
form.classList.add('deleted');
|
||||
form.style.display = 'none';
|
||||
updateTotalDisplay();
|
||||
@@ -1587,8 +1607,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log(`[removeForm] Пересчёт индексов для ${remainingForms.length} оставшихся форм...`);
|
||||
|
||||
remainingForms.forEach((currentForm, newIndex) => {
|
||||
// Находим все поля с name="items-N-..."
|
||||
const fields = currentForm.querySelectorAll('[name^="items-"]');
|
||||
// Обновляем data-атрибут индекса формы
|
||||
currentForm.setAttribute('data-form-index', newIndex);
|
||||
|
||||
// Находим все поля с name="items-N-..." и select элементы
|
||||
const fields = currentForm.querySelectorAll('[name^="items-"], select[name^="items-"]');
|
||||
fields.forEach(field => {
|
||||
const name = field.getAttribute('name');
|
||||
// Меняем индекс: items-СТАРЫЙ-поле → items-НОВЫЙ-поле
|
||||
@@ -1602,6 +1625,41 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const newId = field.id.replace(/^id_items-\d+/, `id_items-${newIndex}`);
|
||||
field.setAttribute('id', newId);
|
||||
}
|
||||
|
||||
// Обновляем for атрибут у label, если есть
|
||||
const label = document.querySelector(`label[for="${field.id}"]`);
|
||||
if (label) {
|
||||
label.setAttribute('for', newId);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем data-атрибут у select2 элементов
|
||||
if (field.classList.contains('select2-order-item')) {
|
||||
field.setAttribute('data-form-index', newIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем select элементы, если есть
|
||||
const selects = currentForm.querySelectorAll('select');
|
||||
selects.forEach(select => {
|
||||
const name = select.getAttribute('name');
|
||||
if (name && name.startsWith('items-')) {
|
||||
const newName = name.replace(/^items-\d+/, `items-${newIndex}`);
|
||||
if (name !== newName) {
|
||||
select.setAttribute('name', newName);
|
||||
|
||||
// Обновляем ID тоже (для связи с label)
|
||||
if (select.id) {
|
||||
const newId = select.id.replace(/^id_items-\d+/, `id_items-${newIndex}`);
|
||||
select.setAttribute('id', newId);
|
||||
|
||||
// Обновляем for атрибут у label, если есть
|
||||
const label = document.querySelector(`label[for="${select.id}"]`);
|
||||
if (label) {
|
||||
label.setAttribute('for', newId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1656,6 +1714,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Валидация перед отправкой
|
||||
document.getElementById('order-form').addEventListener('submit', function(e) {
|
||||
// Убедимся, что все удаленные формы действительно отмечены для удаления
|
||||
const deletedForms = document.querySelectorAll('.order-item-form.deleted');
|
||||
deletedForms.forEach(form => {
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
if (deleteCheckbox) {
|
||||
deleteCheckbox.checked = true;
|
||||
// Убедимся, что значение установлено
|
||||
deleteCheckbox.value = 'on';
|
||||
}
|
||||
});
|
||||
|
||||
// Заказ можно сохранить без товаров
|
||||
});
|
||||
|
||||
@@ -1808,7 +1877,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Функция заполнения формы данными комплекта
|
||||
function fillFormWithKit(form, kitData) {
|
||||
if (!kitData || !kitData.kit_id || !kitData.kit_name || !kitData.kit_price) {
|
||||
if (!kitData || !kitData.kit_id || !kitData.kit_name || kitData.kit_price === undefined) {
|
||||
console.error('Invalid kit data:', kitData);
|
||||
alert('Ошибка: неверные данные комплекта');
|
||||
return;
|
||||
@@ -1819,6 +1888,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const quantityInput = form.querySelector('[name$="-quantity"]');
|
||||
const priceInput = form.querySelector('[name$="-price"]');
|
||||
|
||||
// ВАЖНО: Находим скрытые поля для product и product_kit
|
||||
const productField = form.querySelector('[name$="-product"]');
|
||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
|
||||
|
||||
if (!kitSelect) {
|
||||
console.error('Kit select not found in form');
|
||||
return;
|
||||
@@ -1826,7 +1900,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Используем Select2 API для добавления опции
|
||||
const newOption = new Option(kitData.kit_name, `kit_${kitData.kit_id}`, true, true);
|
||||
$(kitSelect).append(newOption);
|
||||
$(kitSelect).append(newOption).trigger('change');
|
||||
|
||||
// КЛЮЧЕВОЕ ИСПРАВЛЕНИЕ: Устанавливаем скрытые поля напрямую
|
||||
// Это комплект, поэтому очищаем product и устанавливаем product_kit
|
||||
if (productField) productField.value = '';
|
||||
if (kitField) kitField.value = kitData.kit_id;
|
||||
|
||||
console.log('[fillFormWithKit] Установлены скрытые поля:', {
|
||||
product: productField ? productField.value : 'not found',
|
||||
product_kit: kitField ? kitField.value : 'not found'
|
||||
});
|
||||
|
||||
// Устанавливаем количество и цену
|
||||
if (quantityInput) quantityInput.value = '1';
|
||||
@@ -1835,6 +1919,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
priceInput.dataset.originalPrice = kitData.kit_price;
|
||||
}
|
||||
|
||||
// Сбрасываем флаг кастомной цены
|
||||
if (isCustomPriceField) {
|
||||
isCustomPriceField.value = 'false';
|
||||
}
|
||||
|
||||
// Скрываем единицы продажи для комплектов (у комплектов их нет)
|
||||
const salesUnitContainer = form.querySelector('.sales-unit-container');
|
||||
if (salesUnitContainer) {
|
||||
salesUnitContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Обновляем сумму товаров
|
||||
if (typeof window.updateOrderItemsTotal === 'function') {
|
||||
window.updateOrderItemsTotal();
|
||||
}
|
||||
|
||||
// Явно вызываем событие select2:select для запуска автосохранения
|
||||
$(kitSelect).trigger('select2:select', {
|
||||
params: {
|
||||
|
||||
@@ -10,24 +10,21 @@
|
||||
max-width: 280px;
|
||||
}
|
||||
.order-summary-text {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #212529;
|
||||
}
|
||||
.order-summary-text:hover {
|
||||
color: #0d6efd;
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.order-summary-text.expanded {
|
||||
-webkit-line-clamp: unset;
|
||||
max-height: none;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
.table tbody tr {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
.table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.table tbody tr[data-edit-url] {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -44,7 +41,7 @@
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get">
|
||||
<form method="get" id="order-filter-form">
|
||||
<div class="row g-3">
|
||||
<!-- Поиск -->
|
||||
<div class="col-md-3">
|
||||
@@ -89,6 +86,19 @@
|
||||
</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="col-12">
|
||||
@@ -118,7 +128,6 @@
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Номер</th>
|
||||
<th>Дата</th>
|
||||
<th>Время</th>
|
||||
<th>Тип</th>
|
||||
@@ -127,16 +136,13 @@
|
||||
<th>Сумма</th>
|
||||
<th>Оплата</th>
|
||||
<th>Действия</th>
|
||||
<th>Номер заказа</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in page_obj %}
|
||||
<tr {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}class="table-warning"{% endif %}>
|
||||
<td>
|
||||
<a href="{% url 'orders:order-detail' order.order_number %}" class="text-decoration-none">
|
||||
<strong>{{ order.order_number }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
<tr {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}class="table-warning"{% endif %}
|
||||
data-edit-url="{% url 'orders:order-update' order.order_number %}">
|
||||
<td>
|
||||
{% if order.delivery_date %}
|
||||
{{ order.delivery_date|date:"d.m.Y" }}
|
||||
@@ -160,7 +166,7 @@
|
||||
</td>
|
||||
<td class="order-summary-cell">
|
||||
{% if order.summary %}
|
||||
<div class="order-summary-text" title="Клик для раскрытия/сворачивания">{{ order.summary|safe }}</div>
|
||||
<div class="order-summary-text">{{ order.summary|safe }}</div>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
@@ -215,6 +221,11 @@
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'orders:order-detail' order.order_number %}" class="text-decoration-none">
|
||||
<strong>{{ order.order_number }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -381,12 +392,24 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle для раскрытия/сворачивания резюме заказа
|
||||
document.querySelectorAll('.order-summary-text').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
this.classList.toggle('expanded');
|
||||
// Двойной клик на строку для перехода к редактированию
|
||||
document.querySelectorAll('tbody tr[data-edit-url]').forEach(function(row) {
|
||||
row.addEventListener('dblclick', function() {
|
||||
const editUrl = this.dataset.editUrl;
|
||||
if (editUrl) {
|
||||
window.location.href = editUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Тумблер "Включая завершённые" - автоматическая отправка формы
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,11 +16,18 @@ from inventory.models import Reservation
|
||||
import json
|
||||
|
||||
|
||||
from django.utils import timezone # Added for default date filter
|
||||
|
||||
def order_list(request):
|
||||
"""
|
||||
Список всех заказов с фильтрацией и поиском
|
||||
Использует django-filter для фильтрации данных
|
||||
"""
|
||||
# Если параметров нет вообще (первый заход), редиректим на "Сегодня"
|
||||
if not request.GET:
|
||||
today = timezone.localdate().isoformat()
|
||||
return redirect(f'{request.path}?delivery_date_after={today}&delivery_date_before={today}')
|
||||
|
||||
# Базовый queryset с оптимизацией запросов
|
||||
orders = Order.objects.select_related(
|
||||
'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
|
||||
@@ -68,26 +75,7 @@ def order_create(request):
|
||||
draft_items = []
|
||||
|
||||
if request.method == 'POST':
|
||||
# Логирование POST-данных для отладки
|
||||
print("\n=== POST DATA ===")
|
||||
print(f"items-TOTAL_FORMS: {request.POST.get('items-TOTAL_FORMS')}")
|
||||
print(f"items-INITIAL_FORMS: {request.POST.get('items-INITIAL_FORMS')}")
|
||||
print(f"items-MIN_NUM_FORMS: {request.POST.get('items-MIN_NUM_FORMS')}")
|
||||
print(f"items-MAX_NUM_FORMS: {request.POST.get('items-MAX_NUM_FORMS')}")
|
||||
|
||||
# Показываем все формы товаров
|
||||
total_forms = int(request.POST.get('items-TOTAL_FORMS', 0))
|
||||
for i in range(total_forms):
|
||||
product = request.POST.get(f'items-{i}-product', '')
|
||||
kit = request.POST.get(f'items-{i}-product_kit', '')
|
||||
quantity = request.POST.get(f'items-{i}-quantity', '')
|
||||
price = request.POST.get(f'items-{i}-price', '')
|
||||
print(f"\nForm {i}:")
|
||||
print(f" product: {product or '(пусто)'}")
|
||||
print(f" kit: {kit or '(пусто)'}")
|
||||
print(f" quantity: {quantity or '(пусто)'}")
|
||||
print(f" price: {price or '(пусто)'}")
|
||||
print("=== END POST DATA ===\n")
|
||||
|
||||
form = OrderForm(request.POST)
|
||||
formset = OrderItemFormSet(request.POST)
|
||||
@@ -110,7 +98,11 @@ def order_create(request):
|
||||
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)
|
||||
order.save()
|
||||
@@ -175,13 +167,61 @@ def order_create(request):
|
||||
# Проверяем, является ли заказ черновиком
|
||||
is_draft = order.status and order.status.code == 'draft'
|
||||
|
||||
# Получаем данные из формы (уже провалидированы)
|
||||
delivery_type = form.cleaned_data.get('delivery_type')
|
||||
delivery_date = form.cleaned_data.get('delivery_date')
|
||||
time_from = form.cleaned_data.get('time_from')
|
||||
time_to = form.cleaned_data.get('time_to')
|
||||
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
|
||||
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
|
||||
# ВАЖНО: Поля доставки НЕ включены в Meta.fields формы OrderForm,
|
||||
# поэтому они не попадают в form.cleaned_data!
|
||||
# Читаем их напрямую из request.POST и обрабатываем вручную
|
||||
|
||||
# Получаем данные доставки из POST
|
||||
delivery_type = request.POST.get('delivery_type', None)
|
||||
|
||||
# Обрабатываем дату доставки
|
||||
delivery_date_str = request.POST.get('delivery_date', None)
|
||||
delivery_date = None
|
||||
if delivery_date_str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
delivery_date = datetime.strptime(delivery_date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Обрабатываем время
|
||||
time_from_str = request.POST.get('time_from', None)
|
||||
time_from = None
|
||||
if time_from_str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
time_from = datetime.strptime(time_from_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
time_to_str = request.POST.get('time_to', None)
|
||||
time_to = None
|
||||
if time_to_str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
time_to = datetime.strptime(time_to_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Обрабатываем стоимость доставки
|
||||
delivery_cost_str = request.POST.get('delivery_cost', '0')
|
||||
delivery_cost = Decimal('0')
|
||||
if delivery_cost_str:
|
||||
try:
|
||||
delivery_cost = Decimal(delivery_cost_str.replace(',', '.'))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
delivery_cost = Decimal('0')
|
||||
|
||||
# Обрабатываем склад самовывоза
|
||||
pickup_warehouse_id = request.POST.get('pickup_warehouse', None)
|
||||
pickup_warehouse = None
|
||||
if pickup_warehouse_id:
|
||||
try:
|
||||
from inventory.models import Warehouse
|
||||
pickup_warehouse = Warehouse.objects.get(pk=pickup_warehouse_id)
|
||||
except (Warehouse.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Обрабатываем адрес для курьерской доставки (даже для черновиков, если указан)
|
||||
address = None
|
||||
@@ -192,7 +232,8 @@ def order_create(request):
|
||||
address.save()
|
||||
|
||||
# Создаем или обновляем Delivery
|
||||
# Для черновиков создаем Delivery без обязательных проверок, чтобы сохранить адрес
|
||||
# Ранее для не-черновиков адрес курьерской доставки был обязателен.
|
||||
# Теперь разрешаем сохранять заказ в любом статусе без адреса.
|
||||
if is_draft:
|
||||
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
|
||||
if address or delivery_type or pickup_warehouse or delivery_date:
|
||||
@@ -209,34 +250,23 @@ def order_create(request):
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Для не-черновиков проверяем обязательные поля
|
||||
if not delivery_type or not delivery_date:
|
||||
raise ValidationError('Необходимо указать способ доставки и дату доставки')
|
||||
# Для не-черновиков больше не требуем обязательного адреса.
|
||||
# Если пользователь вообще не указал тип доставки и дату, просто не создаём Delivery.
|
||||
if address or delivery_type or pickup_warehouse or delivery_date:
|
||||
delivery = Delivery.objects.create(
|
||||
order=order,
|
||||
delivery_type=delivery_type or Delivery.DELIVERY_TYPE_COURIER,
|
||||
delivery_date=delivery_date,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
address=address,
|
||||
pickup_warehouse=pickup_warehouse,
|
||||
cost=delivery_cost if delivery_cost else Decimal('0')
|
||||
)
|
||||
|
||||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
||||
# Для курьерской доставки нужен адрес
|
||||
if not address:
|
||||
raise ValidationError('Для курьерской доставки необходимо указать адрес')
|
||||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
||||
# Для самовывоза нужен склад
|
||||
if not pickup_warehouse:
|
||||
raise ValidationError('Для самовывоза необходимо выбрать склад')
|
||||
|
||||
# Создаем Delivery
|
||||
delivery = Delivery.objects.create(
|
||||
order=order,
|
||||
delivery_type=delivery_type,
|
||||
delivery_date=delivery_date,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
address=address,
|
||||
pickup_warehouse=pickup_warehouse,
|
||||
cost=delivery_cost if delivery_cost else Decimal('0')
|
||||
)
|
||||
|
||||
# Пересчитываем стоимость доставки если она не установлена вручную
|
||||
if not delivery.cost or delivery.cost <= 0:
|
||||
order.reset_delivery_cost()
|
||||
# Пересчитываем стоимость доставки если она не установлена вручную
|
||||
if not delivery.cost or delivery.cost <= 0:
|
||||
order.reset_delivery_cost()
|
||||
|
||||
# Пересчитываем итоговую стоимость
|
||||
order.calculate_total()
|
||||
@@ -323,6 +353,10 @@ def order_update(request, order_number):
|
||||
form = OrderForm(request.POST, instance=order)
|
||||
formset = OrderItemFormSet(request.POST, instance=order)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if form.is_valid() and formset.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
@@ -334,24 +368,83 @@ def order_update(request, order_number):
|
||||
# Сохраняем получателя: если новый - создаем, если существующий - обновляем
|
||||
recipient.save() # Django автоматически определит create или update
|
||||
order.recipient = recipient
|
||||
else:
|
||||
# Если покупатель является получателем
|
||||
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()
|
||||
|
||||
|
||||
|
||||
formset.save()
|
||||
|
||||
# Проверяем, является ли заказ черновиком
|
||||
is_draft = order.status and order.status.code == 'draft'
|
||||
|
||||
# Получаем данные из формы (уже провалидированы)
|
||||
delivery_type = form.cleaned_data.get('delivery_type')
|
||||
delivery_date = form.cleaned_data.get('delivery_date')
|
||||
time_from = form.cleaned_data.get('time_from')
|
||||
time_to = form.cleaned_data.get('time_to')
|
||||
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
|
||||
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
|
||||
# ВАЖНО: Поля доставки НЕ включены в Meta.fields формы OrderForm,
|
||||
# поэтому они не попадают в form.cleaned_data!
|
||||
# Читаем их напрямую из request.POST и обрабатываем вручную
|
||||
|
||||
# Получаем данные доставки из POST
|
||||
delivery_type = request.POST.get('delivery_type', None)
|
||||
|
||||
# Обрабатываем дату доставки
|
||||
delivery_date_str = request.POST.get('delivery_date', None)
|
||||
delivery_date = None
|
||||
if delivery_date_str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
delivery_date = datetime.strptime(delivery_date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Обрабатываем время
|
||||
time_from_str = request.POST.get('time_from', None)
|
||||
time_from = None
|
||||
if time_from_str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
time_from = datetime.strptime(time_from_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
print(f"[DEBUG] Error parsing time_from: {time_from_str}")
|
||||
|
||||
time_to_str = request.POST.get('time_to', None)
|
||||
time_to = None
|
||||
if time_to_str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
time_to = datetime.strptime(time_to_str, '%H:%M').time()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Обрабатываем стоимость доставки
|
||||
delivery_cost_str = request.POST.get('delivery_cost', '0')
|
||||
delivery_cost = Decimal('0')
|
||||
if delivery_cost_str:
|
||||
try:
|
||||
delivery_cost = Decimal(delivery_cost_str.replace(',', '.'))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
delivery_cost = Decimal('0')
|
||||
|
||||
# Обрабатываем склад самовывоза
|
||||
pickup_warehouse_id = request.POST.get('pickup_warehouse', None)
|
||||
pickup_warehouse = None
|
||||
if pickup_warehouse_id:
|
||||
try:
|
||||
from inventory.models import Warehouse
|
||||
pickup_warehouse = Warehouse.objects.get(pk=pickup_warehouse_id)
|
||||
except (Warehouse.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
# Обрабатываем адрес для курьерской доставки (даже для черновиков, если указан)
|
||||
address = None
|
||||
@@ -362,11 +455,12 @@ def order_update(request, order_number):
|
||||
address.save()
|
||||
|
||||
# Создаем или обновляем Delivery
|
||||
# Для черновиков создаем Delivery без обязательных проверок, чтобы сохранить адрес
|
||||
# Ранее для не-черновиков адрес курьерской доставки был обязателен.
|
||||
# Теперь разрешаем сохранять заказ в любом статусе без адреса.
|
||||
if is_draft:
|
||||
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
|
||||
if address or delivery_type or pickup_warehouse or delivery_date:
|
||||
Delivery.objects.update_or_create(
|
||||
delivery_obj, created = Delivery.objects.update_or_create(
|
||||
order=order,
|
||||
defaults={
|
||||
'delivery_type': delivery_type or Delivery.DELIVERY_TYPE_COURIER,
|
||||
@@ -379,36 +473,28 @@ def order_update(request, order_number):
|
||||
}
|
||||
)
|
||||
elif hasattr(order, 'delivery'):
|
||||
# Если заказ стал черновиком и нет данных доставки, удаляем Delivery
|
||||
# Если все данные доставки очищены, удаляем существующую Delivery
|
||||
order.delivery.delete()
|
||||
else:
|
||||
# Для не-черновиков проверяем обязательные поля
|
||||
if not delivery_type or not delivery_date:
|
||||
raise ValidationError('Необходимо указать способ доставки и дату доставки')
|
||||
|
||||
if delivery_type == Delivery.DELIVERY_TYPE_COURIER:
|
||||
# Для курьерской доставки нужен адрес
|
||||
if not address:
|
||||
raise ValidationError('Для курьерской доставки необходимо указать адрес')
|
||||
elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP:
|
||||
# Для самовывоза нужен склад
|
||||
if not pickup_warehouse:
|
||||
raise ValidationError('Для самовывоза необходимо выбрать склад')
|
||||
|
||||
# Создаем или обновляем Delivery
|
||||
delivery, created = Delivery.objects.update_or_create(
|
||||
order=order,
|
||||
defaults={
|
||||
'delivery_type': delivery_type,
|
||||
'delivery_date': delivery_date,
|
||||
'time_from': time_from,
|
||||
'time_to': time_to,
|
||||
'address': address,
|
||||
'pickup_warehouse': pickup_warehouse,
|
||||
'cost': delivery_cost if delivery_cost else Decimal('0')
|
||||
}
|
||||
)
|
||||
|
||||
# Для не-черновиков больше не требуем обязательного адреса.
|
||||
# Если пользователь вообще не указал данные доставки, удаляем Delivery (если она была).
|
||||
if not (address or delivery_type or pickup_warehouse or delivery_date):
|
||||
if hasattr(order, 'delivery'):
|
||||
order.delivery.delete()
|
||||
else:
|
||||
# Создаем или обновляем Delivery с теми данными, что есть.
|
||||
delivery, created = Delivery.objects.update_or_create(
|
||||
order=order,
|
||||
defaults={
|
||||
'delivery_type': delivery_type or Delivery.DELIVERY_TYPE_COURIER,
|
||||
'delivery_date': delivery_date,
|
||||
'time_from': time_from,
|
||||
'time_to': time_to,
|
||||
'address': address,
|
||||
'pickup_warehouse': pickup_warehouse,
|
||||
'cost': delivery_cost if delivery_cost else Decimal('0')
|
||||
}
|
||||
)
|
||||
# Пересчитываем итоговую стоимость
|
||||
order.calculate_total()
|
||||
order.update_payment_status()
|
||||
@@ -429,17 +515,7 @@ def order_update(request, order_number):
|
||||
# Транзакция откатилась, статус НЕ изменился
|
||||
messages.error(request, f'Ошибка при сохранении заказа: {e}')
|
||||
else:
|
||||
# Логируем ошибки для отладки
|
||||
print("\n=== ОШИБКИ ВАЛИДАЦИИ ФОРМЫ ===")
|
||||
if not form.is_valid():
|
||||
print(f"OrderForm errors: {form.errors}")
|
||||
if not formset.is_valid():
|
||||
print(f"OrderItemFormSet errors: {formset.errors}")
|
||||
print(f"OrderItemFormSet non_form_errors: {formset.non_form_errors()}")
|
||||
for i, item_form in enumerate(formset):
|
||||
if item_form.errors:
|
||||
print(f" Item form {i} errors: {item_form.errors}")
|
||||
print("=== КОНЕЦ ОШИБОК ===\n")
|
||||
|
||||
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
|
||||
else:
|
||||
form = OrderForm(instance=order)
|
||||
|
||||
@@ -32,6 +32,14 @@ body {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* 3 колонки для товаров и категорий на экранах от 400px */
|
||||
@media (min-width: 400px) {
|
||||
.col-custom-3 {
|
||||
flex: 0 0 33.333%;
|
||||
max-width: 33.333%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 5 колонок для товаров и категорий на экранах от 1100px */
|
||||
@media (min-width: 1100px) {
|
||||
.col-lg-custom-5 {
|
||||
@@ -845,3 +853,62 @@ body {
|
||||
margin-top: 90px; /* учитываем поиск и категории */
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
МОБИЛЬНЫЙ DROPDOWN "ЕЩЁ"
|
||||
============================================================ */
|
||||
|
||||
/* Кнопка dropdown */
|
||||
.mobile-cart-actions .dropdown-toggle {
|
||||
min-width: 44px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Меню dropdown */
|
||||
.mobile-cart-actions .dropdown-menu {
|
||||
min-width: 180px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Пункты меню */
|
||||
.mobile-cart-actions .dropdown-item {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-cart-actions .dropdown-item i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ИНТЕРАКТИВНОСТЬ СТРОКИ КОРЗИНЫ (редактирование товара)
|
||||
============================================================ */
|
||||
|
||||
/* Интерактивность строки корзины при наведении */
|
||||
.cart-item {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cart-item:hover {
|
||||
background-color: #f8f9fa !important;
|
||||
border-radius: 4px;
|
||||
padding-left: 0.5rem !important;
|
||||
padding-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Исключаем hover для витринных комплектов - они сохраняют свой фон */
|
||||
.cart-item[style*="background-color"]:hover {
|
||||
background-color: #ffe6a0 !important; /* чуть светлее желтого */
|
||||
}
|
||||
|
||||
/* Индикатор изменённой цены */
|
||||
.cart-item.price-overridden .item-name-price .text-muted {
|
||||
color: #f59e0b !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cart-item.price-overridden .item-name-price .text-muted::after {
|
||||
content: ' *';
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
263
myproject/pos/static/pos/js/cart-item-editor.js
Normal file
263
myproject/pos/static/pos/js/cart-item-editor.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Модуль редактирования товара в корзине POS-терминала
|
||||
* Отвечает за открытие модалки и сохранение изменений
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let editingCartKey = null;
|
||||
let basePrice = 0;
|
||||
|
||||
/**
|
||||
* Округление цены до 2 знаков
|
||||
*/
|
||||
function roundPrice(value) {
|
||||
if (value === null || value === undefined || isNaN(value)) return '0.00';
|
||||
return (Number(value)).toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Округление количества
|
||||
*/
|
||||
function roundQty(value, decimals = 3) {
|
||||
if (value === null || value === undefined || isNaN(value)) return 0;
|
||||
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование денег
|
||||
*/
|
||||
function fmtMoney(value) {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return '0.00';
|
||||
return num.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Открытие модалки редактирования
|
||||
* @param {string} cartKey - ключ товара в корзине
|
||||
*/
|
||||
function openModal(cartKey) {
|
||||
const cart = window.cart;
|
||||
if (!cart) {
|
||||
console.error('CartItemEditor: window.cart not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cart.get(cartKey);
|
||||
if (!item) {
|
||||
console.error('CartItemEditor: Item not found for key:', cartKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем наличие модалки
|
||||
const modalEl = document.getElementById('editCartItemModal');
|
||||
if (!modalEl) {
|
||||
console.error('CartItemEditor: Modal element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
editingCartKey = cartKey;
|
||||
basePrice = parseFloat(item.price) || 0;
|
||||
|
||||
// Проверяем, является ли товар витринным комплектом
|
||||
const isShowcaseKit = item.type === 'showcase_kit';
|
||||
|
||||
// Заполнение полей
|
||||
const nameEl = document.getElementById('editModalProductName');
|
||||
const basePriceEl = document.getElementById('editModalBasePrice');
|
||||
const priceInput = document.getElementById('editModalPrice');
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
|
||||
if (nameEl) nameEl.textContent = item.name || '—';
|
||||
if (basePriceEl) basePriceEl.textContent = fmtMoney(basePrice) + ' руб.';
|
||||
if (priceInput) priceInput.value = roundPrice(basePrice);
|
||||
if (qtyInput) qtyInput.value = item.qty || 1;
|
||||
|
||||
// Для витринных комплектов блокируем изменение количества
|
||||
const qtyHint = document.getElementById('editModalQtyHint');
|
||||
if (isShowcaseKit) {
|
||||
if (qtyInput) qtyInput.disabled = true;
|
||||
if (qtyHint) qtyHint.style.display = 'block';
|
||||
} else {
|
||||
if (qtyInput) qtyInput.disabled = false;
|
||||
if (qtyHint) qtyHint.style.display = 'none';
|
||||
}
|
||||
|
||||
// Бейдж единицы измерения
|
||||
const unitBadge = document.getElementById('editModalUnitBadge');
|
||||
if (unitBadge) {
|
||||
if (item.unit_name) {
|
||||
unitBadge.textContent = item.unit_name;
|
||||
unitBadge.style.display = 'inline-block';
|
||||
} else {
|
||||
unitBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateTotal();
|
||||
|
||||
// Показ модалки
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
|
||||
console.log('CartItemEditor: Modal opened for', item.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление суммы в модалке
|
||||
*/
|
||||
function updateTotal() {
|
||||
const priceInput = document.getElementById('editModalPrice');
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
const totalEl = document.getElementById('editModalTotal');
|
||||
const warningEl = document.getElementById('editModalPriceWarning');
|
||||
|
||||
if (!priceInput || !qtyInput) return;
|
||||
|
||||
const price = parseFloat(priceInput.value) || 0;
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
|
||||
if (totalEl) {
|
||||
totalEl.textContent = fmtMoney(price * qty) + ' руб.';
|
||||
}
|
||||
|
||||
// Индикатор изменения цены
|
||||
if (warningEl) {
|
||||
if (Math.abs(price - basePrice) > 0.01) {
|
||||
warningEl.style.display = 'block';
|
||||
} else {
|
||||
warningEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение изменений
|
||||
*/
|
||||
function saveChanges() {
|
||||
if (!editingCartKey) return;
|
||||
|
||||
const priceInput = document.getElementById('editModalPrice');
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
|
||||
if (!priceInput) return;
|
||||
|
||||
const newPrice = parseFloat(priceInput.value) || 0;
|
||||
const newQty = parseFloat(qtyInput?.value) || 1;
|
||||
|
||||
const cart = window.cart;
|
||||
if (!cart) {
|
||||
console.error('CartItemEditor: window.cart not found during save!');
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cart.get(editingCartKey);
|
||||
if (item) {
|
||||
const isShowcaseKit = item.type === 'showcase_kit';
|
||||
|
||||
item.price = newPrice;
|
||||
// Для витринных комплектов не меняем количество
|
||||
if (!isShowcaseKit) {
|
||||
item.qty = roundQty(newQty, 3);
|
||||
}
|
||||
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
||||
|
||||
// Обновляем в корзине
|
||||
cart.items.set(editingCartKey, item);
|
||||
|
||||
// Уведомляем слушателей
|
||||
cart._notify();
|
||||
|
||||
// Планируем сохранение
|
||||
cart.scheduleSave();
|
||||
|
||||
// Перерисовка корзины
|
||||
if (typeof window.renderCart === 'function') {
|
||||
window.renderCart();
|
||||
}
|
||||
|
||||
console.log('CartItemEditor: Changes saved for', item.name);
|
||||
}
|
||||
|
||||
// Закрытие модалки
|
||||
const modalEl = document.getElementById('editCartItemModal');
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс состояния модалки
|
||||
*/
|
||||
function reset() {
|
||||
editingCartKey = null;
|
||||
basePrice = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация модуля
|
||||
*/
|
||||
function init() {
|
||||
const priceInput = document.getElementById('editModalPrice');
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
const confirmBtn = document.getElementById('confirmEditCartItem');
|
||||
|
||||
if (!priceInput || !confirmBtn) {
|
||||
console.warn('CartItemEditor: Required elements not found, deferring init...');
|
||||
// Повторная попытка через короткое время
|
||||
setTimeout(init, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('CartItemEditor: Initialized successfully');
|
||||
|
||||
// Обновление суммы при изменении полей
|
||||
priceInput.addEventListener('input', updateTotal);
|
||||
if (qtyInput) qtyInput.addEventListener('input', updateTotal);
|
||||
|
||||
// Авто-выделение всего текста при фокусе
|
||||
priceInput.addEventListener('focus', function() {
|
||||
this.select();
|
||||
});
|
||||
if (qtyInput) {
|
||||
qtyInput.addEventListener('focus', function() {
|
||||
this.select();
|
||||
});
|
||||
}
|
||||
|
||||
// Кнопка сохранения
|
||||
confirmBtn.addEventListener('click', saveChanges);
|
||||
|
||||
// Сброс при закрытии модалки
|
||||
const modalEl = document.getElementById('editCartItemModal');
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('hidden.bs.modal', reset);
|
||||
}
|
||||
|
||||
// Enter для сохранения
|
||||
priceInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') saveChanges();
|
||||
});
|
||||
if (qtyInput) {
|
||||
qtyInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') saveChanges();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Экспорт функций для использования из terminal.js
|
||||
window.CartItemEditor = {
|
||||
openModal: openModal,
|
||||
init: init
|
||||
};
|
||||
|
||||
console.log('CartItemEditor: Module loaded');
|
||||
|
||||
// Автоинициализация при загрузке
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
541
myproject/pos/static/pos/js/cart.js
Normal file
541
myproject/pos/static/pos/js/cart.js
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* Модуль управления корзиной POS Terminal
|
||||
* Класс Cart инкапсулирует всю логику работы с корзиной
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import {
|
||||
roundQuantity,
|
||||
getCsrfToken,
|
||||
showToast,
|
||||
safeFetch,
|
||||
formatMoney
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} CartItem
|
||||
* @property {number} id - ID товара/комплекта
|
||||
* @property {string} name - Название
|
||||
* @property {number} price - Цена
|
||||
* @property {number} qty - Количество
|
||||
* @property {string} type - Тип: 'product', 'kit', 'showcase_kit'
|
||||
* @property {number} [sales_unit_id] - ID единицы продажи
|
||||
* @property {string} [unit_name] - Название единицы продажи
|
||||
* @property {number} [quantity_step] - Шаг количества
|
||||
* @property {boolean} [price_overridden] - Цена изменена вручную
|
||||
* @property {number[]} [showcase_item_ids] - ID витринных экземпляров
|
||||
* @property {string} [lock_expires_at] - Время истечения блокировки
|
||||
*/
|
||||
|
||||
export class Cart {
|
||||
constructor() {
|
||||
/** @type {Map<string, CartItem>} */
|
||||
this.items = new Map();
|
||||
|
||||
/** @type {number|null} */
|
||||
this.saveTimeout = null;
|
||||
|
||||
/** @type {Function[]} */
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет слушатель изменений корзины
|
||||
* @param {Function} callback - Функция обратного вызова
|
||||
*/
|
||||
addListener(callback) {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет слушатель изменений корзины
|
||||
* @param {Function} callback - Функция обратного вызова
|
||||
*/
|
||||
removeListener(callback) {
|
||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомляет всех слушателей об изменении
|
||||
* @private
|
||||
*/
|
||||
_notify() {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(this.items);
|
||||
} catch (error) {
|
||||
console.error('Ошибка в слушателе корзины:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует ключ для элемента корзины
|
||||
* @param {string} type - Тип элемента
|
||||
* @param {number} id - ID элемента
|
||||
* @param {number} [salesUnitId] - ID единицы продажи
|
||||
* @returns {string} Ключ корзины
|
||||
*/
|
||||
static generateKey(type, id, salesUnitId = null) {
|
||||
if (salesUnitId) {
|
||||
return `${type}-${id}-${salesUnitId}`;
|
||||
}
|
||||
return `${type}-${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет товар в корзину
|
||||
* @param {string} key - Ключ корзины
|
||||
* @param {CartItem} item - Элемент для добавления
|
||||
* @param {boolean} [merge=true] - Объединять ли с существующим
|
||||
*/
|
||||
add(key, item, merge = true) {
|
||||
if (this.items.has(key) && merge) {
|
||||
const existing = this.items.get(key);
|
||||
existing.qty = roundQuantity(existing.qty + item.qty);
|
||||
// Обновляем цену если передана новая
|
||||
if (item.price !== undefined) {
|
||||
existing.price = item.price;
|
||||
}
|
||||
} else {
|
||||
this.items.set(key, { ...item });
|
||||
}
|
||||
this._notify();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет количество элемента
|
||||
* @param {string} key - Ключ корзины
|
||||
* @param {number} qty - Новое количество
|
||||
* @returns {boolean} Успешно ли обновление
|
||||
*/
|
||||
updateQuantity(key, qty) {
|
||||
const item = this.items.get(key);
|
||||
if (!item) return false;
|
||||
|
||||
const roundedQty = roundQuantity(qty);
|
||||
|
||||
if (roundedQty <= 0) {
|
||||
this.remove(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
item.qty = roundedQty;
|
||||
this._notify();
|
||||
this.scheduleSave();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет элемент из корзины
|
||||
* @param {string} key - Ключ корзины
|
||||
* @returns {boolean} Успешно ли удаление
|
||||
*/
|
||||
remove(key) {
|
||||
const existed = this.items.has(key);
|
||||
this.items.delete(key);
|
||||
if (existed) {
|
||||
this._notify();
|
||||
this.scheduleSave();
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает корзину
|
||||
*/
|
||||
clear() {
|
||||
this.items.clear();
|
||||
this._notify();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает элемент корзины
|
||||
* @param {string} key - Ключ корзины
|
||||
* @returns {CartItem|undefined} Элемент корзины
|
||||
*/
|
||||
get(key) {
|
||||
return this.items.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие элемента
|
||||
* @param {string} key - Ключ корзины
|
||||
* @returns {boolean} Есть ли элемент
|
||||
*/
|
||||
has(key) {
|
||||
return this.items.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает размер корзины
|
||||
* @returns {number} Количество элементов
|
||||
*/
|
||||
get size() {
|
||||
return this.items.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, пуста ли корзина
|
||||
* @returns {boolean} Пуста ли корзина
|
||||
*/
|
||||
get isEmpty() {
|
||||
return this.items.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет общую сумму корзины
|
||||
* @returns {number} Общая сумма
|
||||
*/
|
||||
get total() {
|
||||
let total = 0;
|
||||
this.items.forEach(item => {
|
||||
total += item.qty * item.price;
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет общее количество товаров
|
||||
* @returns {number} Общее количество
|
||||
*/
|
||||
get totalQuantity() {
|
||||
let count = 0;
|
||||
this.items.forEach(item => {
|
||||
count += item.qty;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие витринных комплектов
|
||||
* @returns {boolean} Есть ли витринные комплекты
|
||||
*/
|
||||
hasShowcaseKits() {
|
||||
for (const item of this.items.values()) {
|
||||
if (item.type === 'showcase_kit') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие обычных комплектов
|
||||
* @returns {boolean} Есть ли комплекты
|
||||
*/
|
||||
hasKits() {
|
||||
for (const item of this.items.values()) {
|
||||
if (item.type === 'kit') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все элементы как массив
|
||||
* @returns {Array<{key: string, item: CartItem}>} Массив элементов
|
||||
*/
|
||||
toArray() {
|
||||
const result = [];
|
||||
this.items.forEach((item, key) => {
|
||||
result.push({ key, item });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует корзину в обычный объект для JSON
|
||||
* @returns {Object} Объект корзины
|
||||
*/
|
||||
toJSON() {
|
||||
const obj = {};
|
||||
this.items.forEach((value, key) => {
|
||||
obj[key] = value;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает корзину из объекта
|
||||
* @param {Object} data - Данные корзины
|
||||
*/
|
||||
fromJSON(data) {
|
||||
this.items.clear();
|
||||
if (data && typeof data === 'object') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
this.items.set(key, value);
|
||||
});
|
||||
}
|
||||
this._notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Планирует сохранение корзины на сервер
|
||||
*/
|
||||
scheduleSave() {
|
||||
// Отменяем предыдущий таймер
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
|
||||
// Устанавливаем новый таймер
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.saveToServer();
|
||||
}, CONFIG.TIMEOUTS.DEBOUNCE_CART_SAVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет корзину на сервер
|
||||
* @returns {Promise<boolean>} Успешно ли сохранение
|
||||
*/
|
||||
async saveToServer() {
|
||||
try {
|
||||
const response = await fetch(CONFIG.API.SAVE_CART, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ cart: this.toJSON() })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
console.error('Ошибка сохранения корзины:', data.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении корзины:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет витринный комплект с блокировкой на сервере
|
||||
* @param {Object} item - Данные комплекта
|
||||
* @param {number} quantity - Количество
|
||||
* @returns {Promise<{success: boolean, error?: string, data?: Object}>}
|
||||
*/
|
||||
async addShowcaseKit(item, quantity = 1) {
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.SHOWCASE_KIT_ADD(item.id),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ quantity })
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || 'Не удалось добавить букет в корзину'
|
||||
};
|
||||
}
|
||||
|
||||
// Успешно заблокировали - добавляем/обновляем в корзине
|
||||
const cartKey = Cart.generateKey('showcase_kit', item.id);
|
||||
const lockedItemIds = data.locked_item_ids || [];
|
||||
|
||||
if (this.has(cartKey)) {
|
||||
const existing = this.get(cartKey);
|
||||
existing.qty = roundQuantity(existing.qty + lockedItemIds.length);
|
||||
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
|
||||
} else {
|
||||
this.add(cartKey, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: Number(item.price),
|
||||
qty: lockedItemIds.length,
|
||||
type: 'showcase_kit',
|
||||
showcase_item_ids: lockedItemIds,
|
||||
lock_expires_at: data.lock_expires_at
|
||||
}, false);
|
||||
}
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
console.error('Ошибка при добавлении витринного комплекта:', error);
|
||||
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет витринный комплект со снятием блокировки
|
||||
* @param {string} key - Ключ корзины
|
||||
* @returns {Promise<{success: boolean, error?: string}>}
|
||||
*/
|
||||
async removeShowcaseKit(key) {
|
||||
const item = this.get(key);
|
||||
if (!item || item.type !== 'showcase_kit') {
|
||||
this.remove(key);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {};
|
||||
if (item.showcase_item_ids && item.showcase_item_ids.length > 0) {
|
||||
body.showcase_item_ids = item.showcase_item_ids;
|
||||
}
|
||||
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.SHOWCASE_KIT_REMOVE(item.id),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Ошибка при снятии блокировки:', data.error);
|
||||
}
|
||||
|
||||
// Удаляем из корзины даже при ошибке
|
||||
this.remove(key);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Ошибка при снятии блокировки витринного комплекта:', error);
|
||||
// Удаляем из корзины даже при ошибке
|
||||
this.remove(key);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Увеличивает количество витринного комплекта
|
||||
* @param {string} key - Ключ корзины
|
||||
* @returns {Promise<{success: boolean, error?: string}>}
|
||||
*/
|
||||
async increaseShowcaseKitQty(key) {
|
||||
const item = this.get(key);
|
||||
if (!item || item.type !== 'showcase_kit') {
|
||||
return { success: false, error: 'Элемент не найден' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.SHOWCASE_KIT_ADD(item.id),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ quantity: 1 })
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || 'Нет доступных экземпляров этого букета на витрине'
|
||||
};
|
||||
}
|
||||
|
||||
// Успешно заблокировали - обновляем корзину
|
||||
const lockedItemIds = data.locked_item_ids || [];
|
||||
item.qty = roundQuantity(item.qty + lockedItemIds.length);
|
||||
item.showcase_item_ids = [...(item.showcase_item_ids || []), ...lockedItemIds];
|
||||
|
||||
this._notify();
|
||||
this.scheduleSave();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
console.error('Ошибка при увеличении количества витринного комплекта:', error);
|
||||
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уменьшает количество витринного комплекта
|
||||
* @param {string} key - Ключ корзины
|
||||
* @returns {Promise<{success: boolean, error?: string}>}
|
||||
*/
|
||||
async decreaseShowcaseKitQty(key) {
|
||||
const item = this.get(key);
|
||||
if (!item || item.type !== 'showcase_kit') {
|
||||
return { success: false, error: 'Элемент не найден' };
|
||||
}
|
||||
|
||||
// Если количество = 1, удаляем полностью
|
||||
if (item.qty <= 1) {
|
||||
return this.removeShowcaseKit(key);
|
||||
}
|
||||
|
||||
try {
|
||||
// Снимаем блокировку с последнего экземпляра
|
||||
const showcaseItemIds = item.showcase_item_ids || [];
|
||||
if (showcaseItemIds.length === 0) {
|
||||
return this.removeShowcaseKit(key);
|
||||
}
|
||||
|
||||
// Берем последний ID из списка
|
||||
const itemIdToRelease = showcaseItemIds[showcaseItemIds.length - 1];
|
||||
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.SHOWCASE_KIT_REMOVE(item.id),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ showcase_item_ids: [itemIdToRelease] })
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Ошибка при снятии блокировки:', data.error);
|
||||
}
|
||||
|
||||
// Обновляем корзину
|
||||
item.qty = roundQuantity(item.qty - 1);
|
||||
item.showcase_item_ids = showcaseItemIds.filter(id => id !== itemIdToRelease);
|
||||
|
||||
this._notify();
|
||||
this.scheduleSave();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Ошибка при уменьшении количества витринного комплекта:', error);
|
||||
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает все блокировки витринных комплектов
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async releaseAllLocks() {
|
||||
try {
|
||||
await safeFetch(
|
||||
CONFIG.API.SHOWCASE_KIT_RELEASE_ALL,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': getCsrfToken() }
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка сброса блокировок:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
852
myproject/pos/static/pos/js/checkout.js
Normal file
852
myproject/pos/static/pos/js/checkout.js
Normal file
@@ -0,0 +1,852 @@
|
||||
/**
|
||||
* Модуль оформления заказа (checkout) POS Terminal
|
||||
* Управление оплатой, скидками, промокодами
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import {
|
||||
safeJSONParse,
|
||||
roundQuantity,
|
||||
formatMoney,
|
||||
getCsrfToken,
|
||||
showToast,
|
||||
safeFetch,
|
||||
getCombineModeIcon,
|
||||
getCombineModeTitle
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} PaymentMethod
|
||||
* @property {string} id - ID метода оплаты
|
||||
* @property {string} name - Название
|
||||
* @property {number} amount - Сумма
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Discount
|
||||
* @property {number} id - ID скидки
|
||||
* @property {string} name - Название
|
||||
* @property {string} type - Тип: percent, fixed
|
||||
* @property {number} value - Значение
|
||||
* @property {string} combine_mode - Режим объединения
|
||||
*/
|
||||
|
||||
export class CheckoutManager {
|
||||
/**
|
||||
* @param {Object} options - Опции
|
||||
* @param {import('./cart.js').Cart} options.cart - Экземпляр корзины
|
||||
* @param {import('./customer.js').CustomerManager} options.customerManager - Менеджер клиентов
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.cart = options.cart;
|
||||
this.customerManager = options.customerManager;
|
||||
this.currentWarehouse = options.currentWarehouse || { id: null };
|
||||
|
||||
// Состояние checkout
|
||||
this.appliedPromoCode = null;
|
||||
this.appliedManualDiscount = null;
|
||||
this.appliedCustomDiscount = null;
|
||||
this.availableDiscounts = [];
|
||||
this.skipAutoDiscount = false;
|
||||
|
||||
this.cartDiscounts = {
|
||||
orderDiscounts: [],
|
||||
itemDiscounts: [],
|
||||
totalDiscount: 0,
|
||||
excludedBy: null
|
||||
};
|
||||
|
||||
// Оплата
|
||||
this.paymentMode = 'single'; // 'single' | 'mixed'
|
||||
this.paymentMethods = [];
|
||||
this.walletPaymentAmount = 0;
|
||||
|
||||
// Модальное окно
|
||||
this.modalInstance = null;
|
||||
|
||||
// Callback'и
|
||||
this.onCheckoutComplete = options.onCheckoutComplete || null;
|
||||
this.onCheckoutError = options.onCheckoutError || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует checkout модальное окно
|
||||
*/
|
||||
init() {
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модальное окно checkout
|
||||
*/
|
||||
open() {
|
||||
if (this.cart.isEmpty) {
|
||||
showToast('error', 'Корзина пуста. Добавьте товары перед продажей.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderCheckoutModal();
|
||||
|
||||
const modalEl = document.getElementById('checkoutModal');
|
||||
if (!modalEl) return;
|
||||
|
||||
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||
this.modalInstance.show();
|
||||
|
||||
// Загружаем доступные скидки
|
||||
this.loadAvailableDiscounts();
|
||||
|
||||
// Рассчитываем скидки
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрывает модальное окно checkout
|
||||
*/
|
||||
close() {
|
||||
if (this.modalInstance) {
|
||||
this.modalInstance.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит содержимое модального окна checkout
|
||||
*/
|
||||
renderCheckoutModal() {
|
||||
this.renderOrderItems();
|
||||
this.renderPaymentWidget();
|
||||
this.updateFinalPrice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит список товаров в заказе
|
||||
*/
|
||||
renderOrderItems() {
|
||||
const container = document.getElementById('checkoutItems');
|
||||
if (!container) return;
|
||||
|
||||
let html = '';
|
||||
|
||||
this.cart.toArray().forEach(({ key, item }) => {
|
||||
const itemTotal = item.price * item.qty;
|
||||
const typeIcon = (item.type === 'kit' || item.type === 'showcase_kit')
|
||||
? '<i class="bi bi-box-seam text-info"></i> '
|
||||
: '';
|
||||
|
||||
html += `
|
||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom">
|
||||
<div class="flex-grow-1">
|
||||
<div class="small">${typeIcon}${item.name}</div>
|
||||
<div class="text-muted" style="font-size: 0.8rem;">
|
||||
${formatMoney(item.price)} × ${roundQuantity(item.qty)}
|
||||
${item.unit_name ? ' ' + item.unit_name : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fw-semibold">${formatMoney(itemTotal)}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html || '<p class="text-muted text-center py-2">Корзина пуста</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит виджет оплаты
|
||||
*/
|
||||
renderPaymentWidget() {
|
||||
const container = document.getElementById('paymentWidgetContainer');
|
||||
if (!container) return;
|
||||
|
||||
const total = this.getFinalTotal();
|
||||
const customer = this.customerManager.getCurrentCustomer();
|
||||
const walletBalance = customer?.wallet_balance || 0;
|
||||
|
||||
if (this.paymentMode === 'single') {
|
||||
container.innerHTML = this.renderSinglePaymentWidget(total, walletBalance);
|
||||
} else {
|
||||
container.innerHTML = this.renderMixedPaymentWidget(total, walletBalance);
|
||||
}
|
||||
|
||||
this.initPaymentEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит виджет одиночной оплаты
|
||||
*/
|
||||
renderSinglePaymentWidget(total, walletBalance) {
|
||||
const isSystemCustomer = this.customerManager.isSystemCustomer();
|
||||
|
||||
return `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small mb-1">Способ оплаты</label>
|
||||
<select class="form-select form-select-sm" id="singlePaymentMethod">
|
||||
<option value="cash">Наличные</option>
|
||||
<option value="card">Карта</option>
|
||||
<option value="bank_transfer">Банковский перевод</option>
|
||||
${!isSystemCustomer && walletBalance > 0 ? `<option value="wallet">Кошелёк клиента (${formatMoney(walletBalance)} руб.)</option>` : ''}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="cashChangeBlock" style="display: none;">
|
||||
<label class="form-label small mb-1">Получено наличных</label>
|
||||
<input type="number" class="form-control form-control-sm" id="cashReceived"
|
||||
placeholder="0.00" step="0.01" min="0">
|
||||
<div class="mt-1 small">
|
||||
<span class="text-muted">Сдача:</span>
|
||||
<span class="fw-semibold" id="cashChange">0.00 руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит виджет смешанной оплаты
|
||||
*/
|
||||
renderMixedPaymentWidget(total, walletBalance) {
|
||||
const isSystemCustomer = this.customerManager.isSystemCustomer();
|
||||
let html = `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small mb-1">Распределение оплаты</label>
|
||||
<div class="border rounded p-2">
|
||||
`;
|
||||
|
||||
// Наличные
|
||||
html += `
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<label class="small mb-0">Наличные</label>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||
data-method="cash" placeholder="0.00" step="0.01" min="0" style="width: 100px;">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Карта
|
||||
html += `
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<label class="small mb-0">Карта</label>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||
data-method="card" placeholder="0.00" step="0.01" min="0" style="width: 100px;">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Кошелёк (если не системный клиент)
|
||||
if (!isSystemCustomer && walletBalance > 0) {
|
||||
html += `
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="flex-grow-1">
|
||||
<label class="small mb-0">Кошелёк</label>
|
||||
<small class="text-muted d-block">Доступно: ${formatMoney(walletBalance)}</small>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||
data-method="wallet" placeholder="0.00" step="0.01" min="0" max="${walletBalance}" style="width: 100px;">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
<div class="mt-2 d-flex justify-content-between small">
|
||||
<span class="text-muted">Введено:</span>
|
||||
<span id="mixedPaymentTotal">0.00 руб.</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small">
|
||||
<span class="text-muted">Осталось:</span>
|
||||
<span id="mixedPaymentRemaining" class="fw-semibold">${formatMoney(total)} руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики событий оплаты
|
||||
*/
|
||||
initPaymentEventListeners() {
|
||||
// Переключение режима оплаты
|
||||
document.getElementById('singlePaymentMode')?.addEventListener('click', () => {
|
||||
this.paymentMode = 'single';
|
||||
document.getElementById('singlePaymentMode')?.classList.add('active');
|
||||
document.getElementById('mixedPaymentMode')?.classList.remove('active');
|
||||
this.renderPaymentWidget();
|
||||
});
|
||||
|
||||
document.getElementById('mixedPaymentMode')?.addEventListener('click', () => {
|
||||
this.paymentMode = 'mixed';
|
||||
document.getElementById('mixedPaymentMode')?.classList.add('active');
|
||||
document.getElementById('singlePaymentMode')?.classList.remove('active');
|
||||
this.renderPaymentWidget();
|
||||
});
|
||||
|
||||
// Показ/скрытие блока сдачи
|
||||
document.getElementById('singlePaymentMethod')?.addEventListener('change', (e) => {
|
||||
const cashBlock = document.getElementById('cashChangeBlock');
|
||||
if (cashBlock) {
|
||||
cashBlock.style.display = e.target.value === 'cash' ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Расчёт сдачи
|
||||
document.getElementById('cashReceived')?.addEventListener('input', (e) => {
|
||||
const received = parseFloat(e.target.value) || 0;
|
||||
const total = this.getFinalTotal();
|
||||
const change = Math.max(0, received - total);
|
||||
document.getElementById('cashChange').textContent = formatMoney(change) + ' руб.';
|
||||
});
|
||||
|
||||
// Смешанная оплата - пересчёт сумм
|
||||
document.querySelectorAll('.mixed-payment-input')?.forEach(input => {
|
||||
input.addEventListener('input', () => this.updateMixedPaymentTotals());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет суммы при смешанной оплате
|
||||
*/
|
||||
updateMixedPaymentTotals() {
|
||||
const total = this.getFinalTotal();
|
||||
let entered = 0;
|
||||
|
||||
document.querySelectorAll('.mixed-payment-input').forEach(input => {
|
||||
entered += parseFloat(input.value) || 0;
|
||||
});
|
||||
|
||||
const remaining = Math.max(0, total - entered);
|
||||
|
||||
const totalEl = document.getElementById('mixedPaymentTotal');
|
||||
const remainingEl = document.getElementById('mixedPaymentRemaining');
|
||||
|
||||
if (totalEl) totalEl.textContent = formatMoney(entered) + ' руб.';
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = formatMoney(remaining) + ' руб.';
|
||||
remainingEl.className = remaining > 0 ? 'fw-semibold text-danger' : 'fw-semibold text-success';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики событий
|
||||
*/
|
||||
initEventListeners() {
|
||||
// Промокод
|
||||
document.getElementById('applyPromoBtn')?.addEventListener('click', () => this.applyPromoCode());
|
||||
document.getElementById('removePromoBtn')?.addEventListener('click', () => this.removePromoCode());
|
||||
document.getElementById('promoCodeInput')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.applyPromoCode();
|
||||
});
|
||||
|
||||
// Произвольная скидка
|
||||
document.getElementById('applyCustomDiscountBtn')?.addEventListener('click', () => this.applyCustomDiscount());
|
||||
document.getElementById('removeCustomDiscountBtn')?.addEventListener('click', () => this.removeCustomDiscount());
|
||||
document.getElementById('customDiscountInput')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.applyCustomDiscount();
|
||||
});
|
||||
|
||||
// Отмена автоматической скидки
|
||||
document.getElementById('skipAutoDiscountBtn')?.addEventListener('click', () => this.skipAutoDiscounts());
|
||||
|
||||
// Удаление ручной скидки
|
||||
document.getElementById('removeManualDiscountBtn')?.addEventListener('click', () => this.removeManualDiscount());
|
||||
|
||||
// Подтверждение продажи
|
||||
document.getElementById('confirmCheckoutBtn')?.addEventListener('click', () => this.confirmCheckout());
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает доступные скидки
|
||||
*/
|
||||
async loadAvailableDiscounts() {
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.DISCOUNTS_AVAILABLE);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.availableDiscounts = data.discounts || [];
|
||||
this.renderDiscountsDropdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки скидок:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит dropdown со скидками
|
||||
*/
|
||||
renderDiscountsDropdown() {
|
||||
const list = document.getElementById('discountsDropdownList');
|
||||
if (!list) return;
|
||||
|
||||
if (this.availableDiscounts.length === 0) {
|
||||
list.innerHTML = '<li><span class="dropdown-item-text small text-muted">Нет доступных скидок</span></li>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
this.availableDiscounts.forEach(discount => {
|
||||
const icon = getCombineModeIcon(discount.combine_mode);
|
||||
const valueText = discount.type === 'percent'
|
||||
? `${discount.value}%`
|
||||
: `${formatMoney(discount.value)} руб.`;
|
||||
|
||||
html += `
|
||||
<li>
|
||||
<button class="dropdown-item small" type="button" data-discount-id="${discount.id}">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>${discount.name}</span>
|
||||
<span class="badge bg-primary ms-2">${valueText}</span>
|
||||
</div>
|
||||
<small class="text-muted">${icon} ${getCombineModeTitle(discount.combine_mode)}</small>
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
|
||||
// Добавляем обработчики
|
||||
list.querySelectorAll('[data-discount-id]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const discountId = parseInt(e.currentTarget.dataset.discountId);
|
||||
this.applyManualDiscount(discountId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитывает скидки
|
||||
*/
|
||||
async calculateDiscounts() {
|
||||
try {
|
||||
const items = this.cart.toArray().map(({ item }) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
price: item.price,
|
||||
quantity: item.qty
|
||||
}));
|
||||
|
||||
const requestBody = {
|
||||
items: items,
|
||||
customer_id: this.customerManager.getCurrentCustomer()?.id,
|
||||
skip_auto: this.skipAutoDiscount,
|
||||
manual_discount_id: this.appliedManualDiscount?.id || null,
|
||||
custom_discount: this.appliedCustomDiscount,
|
||||
promo_code: this.appliedPromoCode
|
||||
};
|
||||
|
||||
const response = await safeFetch(CONFIG.API.DISCOUNTS_CALCULATE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.cartDiscounts = {
|
||||
orderDiscounts: data.order_discounts || [],
|
||||
itemDiscounts: data.item_discounts || [],
|
||||
totalDiscount: data.total_discount || 0,
|
||||
excludedBy: data.excluded_by || null
|
||||
};
|
||||
this.renderDiscounts();
|
||||
this.updateFinalPrice();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка расчёта скидок:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит информацию о скидках
|
||||
*/
|
||||
renderDiscounts() {
|
||||
// Автоматические скидки
|
||||
const autoContainer = document.getElementById('autoDiscountsContainer');
|
||||
const autoList = document.getElementById('autoDiscountsList');
|
||||
const skipBtn = document.getElementById('skipAutoDiscountBtn');
|
||||
|
||||
if (this.cartDiscounts.orderDiscounts.length > 0 && !this.skipAutoDiscount) {
|
||||
if (autoContainer) autoContainer.style.display = 'block';
|
||||
if (skipBtn) skipBtn.style.display = 'block';
|
||||
|
||||
if (autoList) {
|
||||
autoList.innerHTML = this.cartDiscounts.orderDiscounts.map(d => {
|
||||
const valueText = d.type === 'percent' ? `${d.value}%` : `${formatMoney(d.value)} руб.`;
|
||||
return `<div>${d.name}: ${valueText}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
if (autoContainer) autoContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Ручная скидка
|
||||
const manualContainer = document.getElementById('manualDiscountContainer');
|
||||
if (this.appliedManualDiscount && manualContainer) {
|
||||
manualContainer.style.display = 'block';
|
||||
document.getElementById('manualDiscountName').textContent = this.appliedManualDiscount.name;
|
||||
const valueText = this.appliedManualDiscount.type === 'percent'
|
||||
? `${this.appliedManualDiscount.value}%`
|
||||
: `${formatMoney(this.appliedManualDiscount.value)} руб.`;
|
||||
document.getElementById('manualDiscountAmount').textContent = valueText;
|
||||
} else if (manualContainer) {
|
||||
manualContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Промокод
|
||||
const promoSuccess = document.getElementById('promoCodeSuccess');
|
||||
const removePromoBtn = document.getElementById('removePromoBtn');
|
||||
const applyPromoBtn = document.getElementById('applyPromoBtn');
|
||||
|
||||
if (this.appliedPromoCode) {
|
||||
if (promoSuccess) {
|
||||
promoSuccess.style.display = 'block';
|
||||
promoSuccess.textContent = `Промокод "${this.appliedPromoCode.code}" применён: -${formatMoney(this.appliedPromoCode.discount)} руб.`;
|
||||
}
|
||||
if (removePromoBtn) removePromoBtn.style.display = 'block';
|
||||
if (applyPromoBtn) applyPromoBtn.style.display = 'none';
|
||||
} else {
|
||||
if (promoSuccess) promoSuccess.style.display = 'none';
|
||||
if (removePromoBtn) removePromoBtn.style.display = 'none';
|
||||
if (applyPromoBtn) applyPromoBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
// Произвольная скидка
|
||||
const removeCustomBtn = document.getElementById('removeCustomDiscountBtn');
|
||||
const applyCustomBtn = document.getElementById('applyCustomDiscountBtn');
|
||||
|
||||
if (this.appliedCustomDiscount) {
|
||||
if (removeCustomBtn) removeCustomBtn.style.display = 'block';
|
||||
if (applyCustomBtn) applyCustomBtn.style.display = 'none';
|
||||
} else {
|
||||
if (removeCustomBtn) removeCustomBtn.style.display = 'none';
|
||||
if (applyCustomBtn) applyCustomBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
// Итоговая информация
|
||||
const summary = document.getElementById('discountsSummary');
|
||||
if (this.cartDiscounts.totalDiscount > 0 && summary) {
|
||||
summary.style.display = 'block';
|
||||
document.getElementById('discountsSubtotal').textContent = formatMoney(this.cart.total) + ' руб.';
|
||||
document.getElementById('discountsTotalDiscount').textContent = '-' + formatMoney(this.cartDiscounts.totalDiscount) + ' руб.';
|
||||
} else if (summary) {
|
||||
summary.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет промокод
|
||||
*/
|
||||
async applyPromoCode() {
|
||||
const input = document.getElementById('promoCodeInput');
|
||||
const code = input?.value.trim().toUpperCase();
|
||||
|
||||
if (!code) {
|
||||
this.showPromoError('Введите промокод');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.VALIDATE_PROMO, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
cart_total: this.cart.total,
|
||||
customer_id: this.customerManager.getCurrentCustomer()?.id
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.appliedPromoCode = {
|
||||
code: code,
|
||||
discount: data.discount_amount,
|
||||
promoCodeId: data.promo_code_id
|
||||
};
|
||||
this.hidePromoError();
|
||||
this.calculateDiscounts();
|
||||
} else {
|
||||
this.showPromoError(data.error || 'Недействительный промокод');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showPromoError('Ошибка при проверке промокода');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет промокод
|
||||
*/
|
||||
removePromoCode() {
|
||||
this.appliedPromoCode = null;
|
||||
const input = document.getElementById('promoCodeInput');
|
||||
if (input) input.value = '';
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает ошибку промокода
|
||||
*/
|
||||
showPromoError(message) {
|
||||
const errorEl = document.getElementById('promoCodeError');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрывает ошибку промокода
|
||||
*/
|
||||
hidePromoError() {
|
||||
const errorEl = document.getElementById('promoCodeError');
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет ручную скидку
|
||||
*/
|
||||
applyManualDiscount(discountId) {
|
||||
const discount = this.availableDiscounts.find(d => d.id === discountId);
|
||||
if (discount) {
|
||||
this.appliedManualDiscount = discount;
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет ручную скидку
|
||||
*/
|
||||
removeManualDiscount() {
|
||||
this.appliedManualDiscount = null;
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет произвольную скидку
|
||||
*/
|
||||
applyCustomDiscount() {
|
||||
const input = document.getElementById('customDiscountInput');
|
||||
const isPercentCheckbox = document.getElementById('customDiscountIsPercent');
|
||||
|
||||
const value = parseFloat(input?.value);
|
||||
if (!value || value <= 0) {
|
||||
this.showCustomDiscountError('Введите положительное значение');
|
||||
return;
|
||||
}
|
||||
|
||||
const isPercent = isPercentCheckbox?.checked;
|
||||
|
||||
if (isPercent && value > 100) {
|
||||
this.showCustomDiscountError('Процент не может превышать 100%');
|
||||
return;
|
||||
}
|
||||
|
||||
this.appliedCustomDiscount = {
|
||||
type: isPercent ? 'percent' : 'fixed',
|
||||
value: value
|
||||
};
|
||||
|
||||
this.hideCustomDiscountError();
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет произвольную скидку
|
||||
*/
|
||||
removeCustomDiscount() {
|
||||
this.appliedCustomDiscount = null;
|
||||
const input = document.getElementById('customDiscountInput');
|
||||
if (input) input.value = '';
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает ошибку произвольной скидки
|
||||
*/
|
||||
showCustomDiscountError(message) {
|
||||
const errorEl = document.getElementById('customDiscountError');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрывает ошибку произвольной скидки
|
||||
*/
|
||||
hideCustomDiscountError() {
|
||||
const errorEl = document.getElementById('customDiscountError');
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Пропускает автоматические скидки
|
||||
*/
|
||||
skipAutoDiscounts() {
|
||||
this.skipAutoDiscount = true;
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает итоговую сумму к оплате
|
||||
*/
|
||||
getFinalTotal() {
|
||||
return Math.max(0, this.cart.total - this.cartDiscounts.totalDiscount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет отображение итоговой цены
|
||||
*/
|
||||
updateFinalPrice() {
|
||||
const finalPriceEl = document.getElementById('checkoutFinalPrice');
|
||||
if (finalPriceEl) {
|
||||
finalPriceEl.textContent = formatMoney(this.getFinalTotal()) + ' руб.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает данные об оплате
|
||||
*/
|
||||
getPaymentData() {
|
||||
if (this.paymentMode === 'single') {
|
||||
const method = document.getElementById('singlePaymentMethod')?.value || 'cash';
|
||||
const amount = this.getFinalTotal();
|
||||
|
||||
const payments = [{ payment_method: method, amount }];
|
||||
|
||||
// Если оплата наличными, добавляем информацию о сдаче
|
||||
if (method === 'cash') {
|
||||
const received = parseFloat(document.getElementById('cashReceived')?.value) || amount;
|
||||
return {
|
||||
payments,
|
||||
cash_received: received,
|
||||
change: Math.max(0, received - amount)
|
||||
};
|
||||
}
|
||||
|
||||
return { payments };
|
||||
} else {
|
||||
const payments = [];
|
||||
document.querySelectorAll('.mixed-payment-input').forEach(input => {
|
||||
const amount = parseFloat(input.value) || 0;
|
||||
if (amount > 0) {
|
||||
payments.push({
|
||||
payment_method: input.dataset.method,
|
||||
amount: amount
|
||||
});
|
||||
}
|
||||
});
|
||||
return { payments };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждает продажу
|
||||
*/
|
||||
async confirmCheckout() {
|
||||
const paymentData = this.getPaymentData();
|
||||
const total = this.getFinalTotal();
|
||||
|
||||
// Проверяем сумму оплаты
|
||||
const totalPaid = paymentData.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
if (this.paymentMode === 'mixed' && Math.abs(totalPaid - total) > 0.01) {
|
||||
showToast('error', `Сумма оплаты (${formatMoney(totalPaid)}) не совпадает с итогом (${formatMoney(total)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем данные заказа
|
||||
const orderData = {
|
||||
items: this.cart.toArray().map(({ item }) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
quantity: item.qty,
|
||||
price: item.price,
|
||||
sales_unit_id: item.sales_unit_id || null
|
||||
})),
|
||||
customer_id: this.customerManager.getCurrentCustomer()?.id,
|
||||
warehouse_id: this.currentWarehouse?.id,
|
||||
payments: paymentData.payments,
|
||||
discounts: {
|
||||
order_discounts: this.cartDiscounts.orderDiscounts,
|
||||
total_discount: this.cartDiscounts.totalDiscount,
|
||||
promo_code: this.appliedPromoCode,
|
||||
manual_discount: this.appliedManualDiscount,
|
||||
custom_discount: this.appliedCustomDiscount
|
||||
},
|
||||
note: document.getElementById('orderNote')?.value || '',
|
||||
...paymentData
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.CHECKOUT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(orderData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('success', `Продажа оформлена! Заказ #${data.order_id}`);
|
||||
|
||||
// Очищаем корзину
|
||||
this.cart.clear();
|
||||
|
||||
// Закрываем модалку
|
||||
this.close();
|
||||
|
||||
// Вызываем callback
|
||||
if (this.onCheckoutComplete) {
|
||||
this.onCheckoutComplete(data);
|
||||
}
|
||||
|
||||
// Переход к чеку или обновление страницы
|
||||
if (data.receipt_url) {
|
||||
window.open(data.receipt_url, '_blank');
|
||||
}
|
||||
} else {
|
||||
showToast('error', data.error || 'Ошибка при оформлении продажи');
|
||||
if (this.onCheckoutError) {
|
||||
this.onCheckoutError(data.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при оформлении продажи:', error);
|
||||
showToast('error', 'Ошибка сети при оформлении продажи');
|
||||
if (this.onCheckoutError) {
|
||||
this.onCheckoutError('Network error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает состояние checkout
|
||||
*/
|
||||
reset() {
|
||||
this.appliedPromoCode = null;
|
||||
this.appliedManualDiscount = null;
|
||||
this.appliedCustomDiscount = null;
|
||||
this.skipAutoDiscount = false;
|
||||
this.paymentMode = 'single';
|
||||
this.walletPaymentAmount = 0;
|
||||
this.cartDiscounts = {
|
||||
orderDiscounts: [],
|
||||
itemDiscounts: [],
|
||||
totalDiscount: 0,
|
||||
excludedBy: null
|
||||
};
|
||||
}
|
||||
}
|
||||
85
myproject/pos/static/pos/js/config.js
Normal file
85
myproject/pos/static/pos/js/config.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Конфигурация POS Terminal
|
||||
* Централизованное хранение констант и настроек
|
||||
*/
|
||||
|
||||
const CONFIG = {
|
||||
// API Endpoints
|
||||
API: {
|
||||
SAVE_CART: '/pos/api/save-cart/',
|
||||
SET_CUSTOMER: '/pos/api/set-customer/',
|
||||
ITEMS: '/pos/api/items/',
|
||||
SHOWCASE_KITS: '/pos/api/showcase-kits/',
|
||||
SHOWCASE_KIT_ADD: (id) => `/pos/api/showcase-kits/${id}/add-to-cart/`,
|
||||
SHOWCASE_KIT_REMOVE: (id) => `/pos/api/showcase-kits/${id}/remove-from-cart/`,
|
||||
SHOWCASE_KIT_RELEASE_ALL: '/pos/api/showcase-kits/release-all-my-locks/',
|
||||
PRODUCT_KIT: (id) => `/pos/api/product-kits/${id}/`,
|
||||
PRODUCT_KIT_UPDATE: (id) => `/pos/api/product-kits/${id}/update/`,
|
||||
PRODUCT_KIT_DISASSEMBLE: (id) => `/pos/api/product-kits/${id}/disassemble/`,
|
||||
PRODUCT_KIT_WRITE_OFF: (id) => `/pos/api/product-kits/${id}/write-off/`,
|
||||
CREATE_TEMP_KIT: '/pos/api/create-temp-kit/',
|
||||
GET_SHOWCASES: '/pos/api/get-showcases/',
|
||||
SALES_UNITS: (productId) => `/products/api/products/${productId}/sales-units/`,
|
||||
CHECKOUT: '/pos/api/checkout/',
|
||||
SET_WAREHOUSE: (id) => `/pos/api/set-warehouse/${id}/`,
|
||||
DISCOUNTS_CALCULATE: '/pos/api/discounts/calculate/',
|
||||
DISCOUNTS_AVAILABLE: '/pos/api/discounts/available/',
|
||||
VALIDATE_PROMO: '/pos/api/discounts/validate-promo/',
|
||||
CREATE_DEFERRED_ORDER: '/orders/api/create-from-pos/',
|
||||
CUSTOMER_SEARCH: '/customers/api/search/',
|
||||
CUSTOMER_CREATE: '/customers/api/create/',
|
||||
},
|
||||
|
||||
// Таймауты (в миллисекундах)
|
||||
TIMEOUTS: {
|
||||
DEBOUNCE_SEARCH: 300,
|
||||
DEBOUNCE_CART_SAVE: 500,
|
||||
TOAST_DELAY: 5000,
|
||||
SELECT2_DELAY: 300,
|
||||
},
|
||||
|
||||
// Пагинация
|
||||
PAGINATION: {
|
||||
PAGE_SIZE: 60,
|
||||
ROOT_MARGIN: '200px',
|
||||
},
|
||||
|
||||
// Форматирование чисел
|
||||
NUMBERS: {
|
||||
DECIMALS_QUANTITY: 3,
|
||||
DECIMALS_MONEY: 2,
|
||||
QUANTITY_STEP: 0.001,
|
||||
},
|
||||
|
||||
// Поиск
|
||||
SEARCH: {
|
||||
MIN_LENGTH: 3,
|
||||
},
|
||||
|
||||
// CSS классы
|
||||
CSS: {
|
||||
STOCK_GOOD: '#28a745',
|
||||
STOCK_LOW: '#ffc107',
|
||||
STOCK_NONE: '#dc3545',
|
||||
SHOWCASE_BG: '#fff3cd',
|
||||
SHOWCASE_BORDER: '#ffc107',
|
||||
},
|
||||
|
||||
// Мобильные устройства
|
||||
MOBILE: {
|
||||
SCREEN_WIDTH_THRESHOLD: 768,
|
||||
USER_AGENTS: ['Android', 'webOS', 'iPhone', 'iPad', 'iPod', 'BlackBerry', 'Windows Phone'],
|
||||
},
|
||||
};
|
||||
|
||||
// Заморозить конфигурацию для предотвращения изменений
|
||||
Object.freeze(CONFIG);
|
||||
Object.freeze(CONFIG.API);
|
||||
Object.freeze(CONFIG.TIMEOUTS);
|
||||
Object.freeze(CONFIG.PAGINATION);
|
||||
Object.freeze(CONFIG.NUMBERS);
|
||||
Object.freeze(CONFIG.SEARCH);
|
||||
Object.freeze(CONFIG.CSS);
|
||||
Object.freeze(CONFIG.MOBILE);
|
||||
|
||||
export default CONFIG;
|
||||
290
myproject/pos/static/pos/js/customer.js
Normal file
290
myproject/pos/static/pos/js/customer.js
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Модуль управления клиентами POS Terminal
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import { getCsrfToken, safeFetch, showToast } from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Customer
|
||||
* @property {number} id - ID клиента
|
||||
* @property {string} name - Имя клиента
|
||||
* @property {number} [wallet_balance] - Баланс кошелька
|
||||
* @property {string} [phone] - Телефон
|
||||
* @property {string} [email] - Email
|
||||
*/
|
||||
|
||||
export class CustomerManager {
|
||||
/**
|
||||
* @param {Customer} systemCustomer - Системный клиент по умолчанию
|
||||
*/
|
||||
constructor(systemCustomer) {
|
||||
/** @type {Customer} */
|
||||
this.systemCustomer = systemCustomer;
|
||||
|
||||
/** @type {Customer} */
|
||||
this.selectedCustomer = systemCustomer;
|
||||
|
||||
/** @type {Function[]} */
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет слушатель изменений
|
||||
* @param {Function} callback - Функция обратного вызова
|
||||
*/
|
||||
addListener(callback) {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет слушатель изменений
|
||||
* @param {Function} callback - Функция обратного вызова
|
||||
*/
|
||||
removeListener(callback) {
|
||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомляет всех слушателей
|
||||
* @private
|
||||
*/
|
||||
_notify() {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(this.selectedCustomer);
|
||||
} catch (error) {
|
||||
console.error('Ошибка в слушателе клиента:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, выбран ли системный клиент
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSystemCustomer() {
|
||||
return Number(this.selectedCustomer.id) === Number(this.systemCustomer.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущего клиента
|
||||
* @returns {Customer}
|
||||
*/
|
||||
getCurrentCustomer() {
|
||||
return this.selectedCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает отображаемое имя клиента
|
||||
* @returns {string}
|
||||
*/
|
||||
getDisplayName() {
|
||||
return this.isSystemCustomer() ? 'Системный клиент' : this.selectedCustomer.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает клиента
|
||||
* @param {Customer} customer - Данные клиента
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async selectCustomer(customer) {
|
||||
this.selectedCustomer = customer;
|
||||
this._notify();
|
||||
|
||||
// Сохраняем выбор на сервере
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
`${CONFIG.API.SET_CUSTOMER}${customer.id}/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
console.error('Ошибка сохранения клиента:', data.error);
|
||||
showToast('error', 'Ошибка сохранения выбора клиента');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Обновляем баланс из ответа сервера
|
||||
this.selectedCustomer.wallet_balance = data.wallet_balance || 0;
|
||||
this._notify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении клиента:', error);
|
||||
showToast('error', 'Ошибка сети при сохранении клиента');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает на системного клиента
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async resetToSystem() {
|
||||
return this.selectCustomer(this.systemCustomer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт нового клиента
|
||||
* @param {Object} data - Данные клиента
|
||||
* @param {string} data.name - Имя
|
||||
* @param {string} [data.phone] - Телефон
|
||||
* @param {string} [data.email] - Email
|
||||
* @returns {Promise<{success: boolean, customer?: Customer, error?: string}>}
|
||||
*/
|
||||
async createCustomer(data) {
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.CUSTOMER_CREATE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
phone: data.phone || null,
|
||||
email: data.email || null
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const customer = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
wallet_balance: result.wallet_balance || 0
|
||||
};
|
||||
|
||||
// Автоматически выбираем созданного клиента
|
||||
await this.selectCustomer(customer);
|
||||
|
||||
showToast('success', `Клиент "${result.name}" успешно создан!`);
|
||||
return { success: true, customer };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Ошибка при создании клиента' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error);
|
||||
return { success: false, error: 'Ошибка сети при создании клиента' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует Select2 для поиска клиента
|
||||
* @param {string} selector - CSS селектор
|
||||
* @param {Object} options - Дополнительные опции
|
||||
*/
|
||||
initSelect2(selector, options = {}) {
|
||||
const $searchInput = $(selector);
|
||||
const modalId = options.modalId || '#selectCustomerModal';
|
||||
|
||||
$searchInput.select2({
|
||||
theme: 'bootstrap-5',
|
||||
dropdownParent: $(modalId),
|
||||
placeholder: 'Начните вводить имя, телефон или email (минимум 3 символа)',
|
||||
minimumInputLength: 3,
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: CONFIG.API.CUSTOMER_SEARCH,
|
||||
dataType: 'json',
|
||||
delay: CONFIG.TIMEOUTS.SELECT2_DELAY,
|
||||
data: function (params) {
|
||||
return { q: params.term };
|
||||
},
|
||||
processResults: function (data) {
|
||||
return { results: data.results };
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
templateResult: this._formatCustomerOption,
|
||||
templateSelection: this._formatCustomerSelection
|
||||
});
|
||||
|
||||
// Обработка выбора
|
||||
$searchInput.on('select2:select', async (e) => {
|
||||
const data = e.params.data;
|
||||
|
||||
// Проверяем это не опция "Создать нового клиента"
|
||||
if (data.id === 'create_new') {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
|
||||
modal.hide();
|
||||
|
||||
if (options.onCreateNew) {
|
||||
options.onCreateNew(data.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Выбираем клиента
|
||||
await this.selectCustomer({
|
||||
id: parseInt(data.id),
|
||||
name: data.name,
|
||||
wallet_balance: data.wallet_balance || 0
|
||||
});
|
||||
|
||||
// Закрываем модалку
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
|
||||
modal.hide();
|
||||
|
||||
// Очищаем Select2
|
||||
$searchInput.val(null).trigger('change');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует опцию клиента в выпадающем списке
|
||||
* @private
|
||||
*/
|
||||
_formatCustomerOption(customer) {
|
||||
if (customer.loading) {
|
||||
return customer.text;
|
||||
}
|
||||
|
||||
// Если это опция "Создать нового клиента"
|
||||
if (customer.id === 'create_new') {
|
||||
return $('<span><i class="bi bi-person-plus"></i> ' + customer.text + '</span>');
|
||||
}
|
||||
|
||||
// Формируем текст в одну строку
|
||||
const parts = [];
|
||||
|
||||
// Имя
|
||||
const name = customer.name || customer.text;
|
||||
parts.push('<span class="fw-bold">' + $('<div>').text(name).html() + '</span>');
|
||||
|
||||
// Телефон и Email
|
||||
const contactInfo = [];
|
||||
if (customer.phone) {
|
||||
contactInfo.push($('<div>').text(customer.phone).html());
|
||||
}
|
||||
if (customer.email) {
|
||||
contactInfo.push($('<div>').text(customer.email).html());
|
||||
}
|
||||
|
||||
if (contactInfo.length > 0) {
|
||||
parts.push('<span class="text-muted small"> (' + contactInfo.join(', ') + ')</span>');
|
||||
}
|
||||
|
||||
return $('<span>' + parts.join('') + '</span>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует выбранного клиента
|
||||
* @private
|
||||
*/
|
||||
_formatCustomerSelection(customer) {
|
||||
return customer.name || customer.text;
|
||||
}
|
||||
}
|
||||
36
myproject/pos/static/pos/js/modules.js
Normal file
36
myproject/pos/static/pos/js/modules.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Точка входа для модульной архитектуры POS Terminal
|
||||
* Загружает все модули и инициализирует приложение
|
||||
*/
|
||||
|
||||
// Импорт конфигурации
|
||||
import CONFIG from './config.js';
|
||||
|
||||
// Импорт утилит
|
||||
import * as utils from './utils.js';
|
||||
|
||||
// Импорт менеджеров
|
||||
import { Cart } from './cart.js';
|
||||
import { CustomerManager } from './customer.js';
|
||||
import { ProductManager } from './products.js';
|
||||
|
||||
// Экспорт всего для глобального доступа
|
||||
export {
|
||||
CONFIG,
|
||||
utils,
|
||||
Cart,
|
||||
CustomerManager,
|
||||
ProductManager
|
||||
};
|
||||
|
||||
// Инициализация при загрузке
|
||||
export function initPOS() {
|
||||
// Создаем глобальные ссылки для обратной совместимости
|
||||
window.POS = {
|
||||
CONFIG,
|
||||
utils,
|
||||
Cart,
|
||||
CustomerManager,
|
||||
ProductManager
|
||||
};
|
||||
}
|
||||
623
myproject/pos/static/pos/js/products.js
Normal file
623
myproject/pos/static/pos/js/products.js
Normal file
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* Модуль управления товарами и витриной POS Terminal
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import {
|
||||
safeJSONParse,
|
||||
roundQuantity,
|
||||
formatMoney,
|
||||
formatDaysAgo,
|
||||
safeFetch,
|
||||
escapeHtml
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Product
|
||||
* @property {number} id - ID товара
|
||||
* @property {string} name - Название
|
||||
* @property {string} [sku] - Артикул
|
||||
* @property {number} price - Цена
|
||||
* @property {number} [price_in_unit] - Цена в единице продажи
|
||||
* @property {number} [available_qty] - Доступное количество
|
||||
* @property {number} [reserved_qty] - Зарезервированное количество
|
||||
* @property {boolean} has_sales_units - Есть ли единицы продажи
|
||||
* @property {number} sales_units_count - Количество единиц продажи
|
||||
* @property {Object} [default_sales_unit] - Единица продажи по умолчанию
|
||||
* @property {string} [image] - URL изображения
|
||||
* @property {string} type - Тип: 'product', 'kit', 'showcase_kit'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ShowcaseKit
|
||||
* @property {number} id - ID комплекта
|
||||
* @property {string} name - Название
|
||||
* @property {string} [sku] - Артикул
|
||||
* @property {number} price - Цена
|
||||
* @property {number} available_count - Доступное количество
|
||||
* @property {number} total_count - Общее количество
|
||||
* @property {string} [showcase_created_at] - Дата создания на витрине
|
||||
* @property {boolean} is_locked - Заблокирован ли
|
||||
* @property {boolean} locked_by_me - Заблокирован текущим пользователем
|
||||
* @property {string} [locked_by_user] - Кем заблокирован
|
||||
* @property {boolean} price_outdated - Неактуальная цена
|
||||
* @property {string} [image] - URL изображения
|
||||
*/
|
||||
|
||||
export class ProductManager {
|
||||
/**
|
||||
* @param {Object} options - Опции
|
||||
* @param {Object} options.currentWarehouse - Текущий склад
|
||||
* @param {Function} options.onError - Callback при ошибке
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.currentWarehouse = options.currentWarehouse || { id: null };
|
||||
this.onError = options.onError || console.error;
|
||||
|
||||
/** @type {Product[]} */
|
||||
this.items = [];
|
||||
|
||||
/** @type {ShowcaseKit[]} */
|
||||
this.showcaseKits = [];
|
||||
|
||||
/** @type {number} */
|
||||
this.currentPage = 1;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.hasMoreItems = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.isLoading = false;
|
||||
|
||||
/** @type {string} */
|
||||
this.currentSearchQuery = '';
|
||||
|
||||
/** @type {number|null} */
|
||||
this.currentCategoryId = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.isShowcaseView = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает товары через API
|
||||
* @param {Object} options - Опции загрузки
|
||||
* @param {boolean} options.append - Добавлять к существующим
|
||||
* @param {number} [options.categoryId] - ID категории
|
||||
* @param {string} [options.searchQuery] - Поисковый запрос
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadItems(options = {}) {
|
||||
if (this.isLoading) return false;
|
||||
|
||||
const { append = false, categoryId = null, searchQuery = '' } = options;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
if (!append) {
|
||||
this.currentPage = 1;
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
page_size: CONFIG.PAGINATION.PAGE_SIZE
|
||||
});
|
||||
|
||||
// При активном поиске игнорируем категорию
|
||||
if (categoryId && !searchQuery) {
|
||||
params.append('category_id', categoryId);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
params.append('query', searchQuery);
|
||||
}
|
||||
|
||||
const response = await safeFetch(`${CONFIG.API.ITEMS}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (append) {
|
||||
this.items = this.items.concat(data.items);
|
||||
} else {
|
||||
this.items = data.items;
|
||||
}
|
||||
|
||||
this.hasMoreItems = data.has_more;
|
||||
|
||||
if (data.has_more) {
|
||||
this.currentPage = data.next_page;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.onError('Ошибка загрузки товаров:', error);
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает витринные комплекты
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadShowcaseKits() {
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.SHOWCASE_KITS);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showcaseKits = data.items;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.onError('Ошибка загрузки витринных комплектов:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает единицы продажи для товара
|
||||
* @param {number} productId - ID товара
|
||||
* @returns {Promise<Array|null>}
|
||||
*/
|
||||
async loadSalesUnits(productId) {
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
`${CONFIG.API.SALES_UNITS(productId)}?warehouse=${this.currentWarehouse.id}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.sales_units) {
|
||||
return data.sales_units;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.onError('Ошибка загрузки единиц продажи:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает infinite scroll
|
||||
* @param {Function} onLoad - Callback при загрузке новых элементов
|
||||
*/
|
||||
setupInfiniteScroll(onLoad) {
|
||||
const grid = document.getElementById('productGrid');
|
||||
if (!grid) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && this.hasMoreItems && !this.isLoading && !this.isShowcaseView) {
|
||||
this.loadItems({ append: true }).then(() => {
|
||||
if (onLoad) onLoad(this.items);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: CONFIG.PAGINATION.ROOT_MARGIN }
|
||||
);
|
||||
|
||||
// Наблюдаем за концом грида
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
sentinel.style.height = '1px';
|
||||
grid.parentElement.appendChild(sentinel);
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает отфильтрованные элементы для отображения
|
||||
* @param {Object} options - Опции фильтрации
|
||||
* @param {boolean} options.wasShowcaseViewBeforeSearch - Была ли витрина активна
|
||||
* @returns {Array} Отфильтрованные элементы
|
||||
*/
|
||||
getFilteredItems(options = {}) {
|
||||
const { wasShowcaseViewBeforeSearch = false } = options;
|
||||
|
||||
// Если активен поиск (3+ символов)
|
||||
if (this.currentSearchQuery && this.currentSearchQuery.length >= CONFIG.SEARCH.MIN_LENGTH) {
|
||||
let filtered = [...this.items];
|
||||
|
||||
// Если перед началом поиска была активна витрина - добавляем витринные комплекты
|
||||
if (wasShowcaseViewBeforeSearch) {
|
||||
const searchTerm = this.currentSearchQuery.toLowerCase().trim();
|
||||
const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0);
|
||||
|
||||
const filteredShowcaseKits = this.showcaseKits.filter(item => {
|
||||
const name = (item.name || '').toLowerCase();
|
||||
const sku = (item.sku || '').toLowerCase();
|
||||
return tokens.every(token => name.includes(token) || sku.includes(token));
|
||||
});
|
||||
|
||||
filtered = [...filtered, ...filteredShowcaseKits];
|
||||
}
|
||||
|
||||
return filtered;
|
||||
} else if (this.isShowcaseView) {
|
||||
// При отображении витрины возвращаем showcaseKits
|
||||
return this.showcaseKits || [];
|
||||
} else {
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит карточку товара
|
||||
* @param {Product|ShowcaseKit} item - Товар или комплект
|
||||
* @param {Object} options - Опции рендера
|
||||
* @param {Map} options.cart - Корзина для проверки наличия
|
||||
* @returns {HTMLElement} DOM элемент карточки
|
||||
*/
|
||||
renderProductCard(item, options = {}) {
|
||||
const { cart = new Map() } = options;
|
||||
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card product-card';
|
||||
card.style.position = 'relative';
|
||||
|
||||
// Обработка витринных комплектов
|
||||
if (item.type === 'showcase_kit') {
|
||||
this._renderShowcaseKitBadges(card, item, cart);
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
|
||||
// Изображение
|
||||
const imageDiv = this._createImageElement(item.image, item.name);
|
||||
|
||||
// Информация о товаре
|
||||
const info = this._createProductInfo(item, cart);
|
||||
|
||||
body.appendChild(imageDiv);
|
||||
body.appendChild(info);
|
||||
card.appendChild(body);
|
||||
col.appendChild(card);
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает элемент изображения
|
||||
* @private
|
||||
*/
|
||||
_createImageElement(imageUrl, alt) {
|
||||
const imageDiv = document.createElement('div');
|
||||
imageDiv.className = 'product-image';
|
||||
|
||||
if (imageUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = imageUrl;
|
||||
img.alt = alt;
|
||||
img.loading = 'lazy';
|
||||
imageDiv.appendChild(img);
|
||||
} else {
|
||||
imageDiv.innerHTML = '<i class="bi bi-image"></i>';
|
||||
}
|
||||
|
||||
return imageDiv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает информацию о товаре
|
||||
* @private
|
||||
*/
|
||||
_createProductInfo(item, cart) {
|
||||
const info = document.createElement('div');
|
||||
info.className = 'product-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'product-name';
|
||||
name.textContent = item.name;
|
||||
|
||||
const stock = document.createElement('div');
|
||||
stock.className = 'product-stock';
|
||||
this._renderStockInfo(stock, item, cart);
|
||||
|
||||
const sku = document.createElement('div');
|
||||
sku.className = 'product-sku';
|
||||
|
||||
const skuText = document.createElement('span');
|
||||
skuText.textContent = item.sku || 'н/д';
|
||||
|
||||
const priceSpan = document.createElement('span');
|
||||
priceSpan.className = 'product-price';
|
||||
const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price;
|
||||
priceSpan.textContent = formatMoney(itemPrice);
|
||||
|
||||
sku.appendChild(skuText);
|
||||
sku.appendChild(priceSpan);
|
||||
|
||||
info.appendChild(name);
|
||||
info.appendChild(stock);
|
||||
info.appendChild(sku);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит информацию об остатках
|
||||
* @private
|
||||
*/
|
||||
_renderStockInfo(element, item, cart) {
|
||||
// Для витринных комплектов
|
||||
if (item.type === 'showcase_kit') {
|
||||
this._renderShowcaseKitStock(element, item);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для обычных товаров с единицами продажи
|
||||
if (item.type === 'product' && item.default_sales_unit) {
|
||||
this._renderProductStockWithUnit(element, item, cart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для обычных товаров без единиц продажи
|
||||
if (item.type === 'product') {
|
||||
this._renderProductStock(element, item, cart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для комплектов
|
||||
if (item.type === 'kit' && item.free_qty !== undefined) {
|
||||
this._renderKitStock(element, item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки витринного комплекта
|
||||
* @private
|
||||
*/
|
||||
_renderShowcaseKitStock(element, item) {
|
||||
const availableCount = item.available_count || 0;
|
||||
const totalCount = item.total_count || availableCount;
|
||||
const inCart = totalCount - availableCount;
|
||||
|
||||
let badgeClass = availableCount > 0 ? 'bg-success' : 'bg-secondary';
|
||||
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
|
||||
let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
|
||||
|
||||
const daysAgo = formatDaysAgo(item.showcase_created_at);
|
||||
const daysBadge = daysAgo ? ` <span class="badge bg-info ms-auto">${daysAgo}</span>` : '';
|
||||
|
||||
element.innerHTML = `<span class="badge ${badgeClass}" style="font-size: 0.9rem;">${badgeText}</span>${daysBadge}${cartInfo}`;
|
||||
element.style.display = 'flex';
|
||||
element.style.justifyContent = 'space-between';
|
||||
element.style.alignItems = 'center';
|
||||
element.style.color = '#856404';
|
||||
element.style.fontWeight = 'bold';
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки товара с единицей продажи
|
||||
* @private
|
||||
*/
|
||||
_renderProductStockWithUnit(element, item, cart) {
|
||||
const unit = item.default_sales_unit;
|
||||
const conversionFactor = parseFloat(unit.conversion_factor) || 1;
|
||||
|
||||
const cartKey = `product-${item.id}-${unit.id}`;
|
||||
const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||
|
||||
const availableInUnit = parseFloat(item.available_qty_in_unit) || 0;
|
||||
const reservedInUnit = (parseFloat(item.reserved_qty) || 0) * conversionFactor;
|
||||
const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty;
|
||||
const freeRounded = roundQuantity(freeInUnit, 1);
|
||||
|
||||
const freeSpan = document.createElement('span');
|
||||
freeSpan.style.fontSize = '1.1em';
|
||||
freeSpan.style.fontWeight = 'bold';
|
||||
|
||||
const qtyText = document.createElement('span');
|
||||
qtyText.textContent = freeRounded;
|
||||
freeSpan.appendChild(qtyText);
|
||||
|
||||
const unitBadge = document.createElement('span');
|
||||
unitBadge.className = 'badge bg-secondary ms-1';
|
||||
unitBadge.style.fontSize = '0.7rem';
|
||||
unitBadge.textContent = unit.name;
|
||||
freeSpan.appendChild(unitBadge);
|
||||
|
||||
if (inCartBaseQty > 0) {
|
||||
const suffixSpan = document.createElement('span');
|
||||
suffixSpan.textContent = ` (−${roundQuantity(inCartBaseQty, 1)}🛒)`;
|
||||
suffixSpan.style.fontSize = '0.85em';
|
||||
suffixSpan.style.marginLeft = '3px';
|
||||
|
||||
element.appendChild(freeSpan);
|
||||
element.appendChild(suffixSpan);
|
||||
} else {
|
||||
element.appendChild(freeSpan);
|
||||
}
|
||||
|
||||
// Цветовая индикация
|
||||
if (freeInUnit <= 0) {
|
||||
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||
} else if (freeInUnit < 5) {
|
||||
element.style.color = CONFIG.CSS.STOCK_LOW;
|
||||
} else {
|
||||
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки товара без единицы продажи
|
||||
* @private
|
||||
*/
|
||||
_renderProductStock(element, item, cart) {
|
||||
const available = parseFloat(item.available_qty) || 0;
|
||||
const reserved = parseFloat(item.reserved_qty) || 0;
|
||||
const cartKey = `product-${item.id}`;
|
||||
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||
|
||||
const free = available - reserved - inCart;
|
||||
const freeRounded = roundQuantity(free);
|
||||
|
||||
const freeSpan = document.createElement('span');
|
||||
freeSpan.textContent = freeRounded;
|
||||
freeSpan.style.fontSize = '1.1em';
|
||||
freeSpan.style.fontWeight = 'bold';
|
||||
|
||||
const suffixParts = [];
|
||||
if (reserved > 0) {
|
||||
suffixParts.push(`−${roundQuantity(reserved)}`);
|
||||
}
|
||||
if (inCart > 0) {
|
||||
suffixParts.push(`−${roundQuantity(inCart)}🛒`);
|
||||
}
|
||||
|
||||
if (suffixParts.length > 0) {
|
||||
const suffixSpan = document.createElement('span');
|
||||
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
|
||||
suffixSpan.style.fontSize = '0.85em';
|
||||
suffixSpan.style.marginLeft = '3px';
|
||||
|
||||
element.appendChild(freeSpan);
|
||||
element.appendChild(suffixSpan);
|
||||
} else {
|
||||
element.appendChild(freeSpan);
|
||||
}
|
||||
|
||||
if (free <= 0) {
|
||||
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||
} else if (free < 5) {
|
||||
element.style.color = CONFIG.CSS.STOCK_LOW;
|
||||
} else {
|
||||
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки комплекта
|
||||
* @private
|
||||
*/
|
||||
_renderKitStock(element, item) {
|
||||
const availableKits = parseFloat(item.free_qty) || 0;
|
||||
if (availableKits > 0) {
|
||||
element.textContent = `В наличии: ${Math.floor(availableKits)} компл.`;
|
||||
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||
} else {
|
||||
element.textContent = 'Под заказ';
|
||||
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит бейджи для витринного комплекта
|
||||
* @private
|
||||
*/
|
||||
_renderShowcaseKitBadges(card, item, cart) {
|
||||
// Кнопка редактирования (только если не заблокирован другим)
|
||||
if (!item.is_locked || item.locked_by_me) {
|
||||
// Индикатор неактуальной цены (показываем первым, если есть)
|
||||
if (item.price_outdated) {
|
||||
const outdatedBadge = document.createElement('button');
|
||||
outdatedBadge.className = 'btn btn-sm p-0';
|
||||
outdatedBadge.style.position = 'absolute';
|
||||
outdatedBadge.style.top = '8px';
|
||||
outdatedBadge.style.right = '45px';
|
||||
outdatedBadge.style.zIndex = '10';
|
||||
outdatedBadge.style.width = '28px';
|
||||
outdatedBadge.style.height = '28px';
|
||||
outdatedBadge.style.borderRadius = '50%';
|
||||
outdatedBadge.style.display = 'flex';
|
||||
outdatedBadge.style.alignItems = 'center';
|
||||
outdatedBadge.style.justifyContent = 'center';
|
||||
outdatedBadge.style.backgroundColor = '#ff6b6b';
|
||||
outdatedBadge.style.border = '2px solid #fff';
|
||||
outdatedBadge.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
|
||||
outdatedBadge.style.cursor = 'pointer';
|
||||
outdatedBadge.style.transition = 'all 0.2s ease';
|
||||
outdatedBadge.title = 'Цена неактуальна - требуется обновление';
|
||||
outdatedBadge.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-white" style="font-size: 14px;"></i>';
|
||||
outdatedBadge.onmouseenter = function() {
|
||||
this.style.transform = 'scale(1.1)';
|
||||
this.style.boxShadow = '0 3px 6px rgba(0,0,0,0.3)';
|
||||
};
|
||||
outdatedBadge.onmouseleave = function() {
|
||||
this.style.transform = 'scale(1)';
|
||||
this.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
|
||||
};
|
||||
outdatedBadge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.showcaseManager) {
|
||||
window.showcaseManager.openEditModal(item.id);
|
||||
}
|
||||
};
|
||||
card.appendChild(outdatedBadge);
|
||||
}
|
||||
|
||||
// Кнопка редактирования (карандаш)
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-sm p-0';
|
||||
editBtn.style.position = 'absolute';
|
||||
editBtn.style.top = '8px';
|
||||
editBtn.style.right = '8px';
|
||||
editBtn.style.zIndex = '10';
|
||||
editBtn.style.width = '32px';
|
||||
editBtn.style.height = '32px';
|
||||
editBtn.style.borderRadius = '6px';
|
||||
editBtn.style.display = 'flex';
|
||||
editBtn.style.alignItems = 'center';
|
||||
editBtn.style.justifyContent = 'center';
|
||||
editBtn.style.backgroundColor = '#4dabf7';
|
||||
editBtn.style.border = '2px solid #fff';
|
||||
editBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
|
||||
editBtn.style.cursor = 'pointer';
|
||||
editBtn.style.transition = 'all 0.2s ease';
|
||||
editBtn.title = 'Редактировать комплект';
|
||||
editBtn.innerHTML = '<i class="bi bi-pencil-fill text-white" style="font-size: 14px;"></i>';
|
||||
editBtn.onmouseenter = function() {
|
||||
this.style.backgroundColor = '#339af0';
|
||||
this.style.transform = 'scale(1.05)';
|
||||
this.style.boxShadow = '0 3px 6px rgba(0,0,0,0.3)';
|
||||
};
|
||||
editBtn.onmouseleave = function() {
|
||||
this.style.backgroundColor = '#4dabf7';
|
||||
this.style.transform = 'scale(1)';
|
||||
this.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
|
||||
};
|
||||
editBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.showcaseManager) {
|
||||
window.showcaseManager.openEditModal(item.id);
|
||||
}
|
||||
};
|
||||
card.appendChild(editBtn);
|
||||
}
|
||||
|
||||
// Индикация блокировки
|
||||
if (item.is_locked) {
|
||||
const lockBadge = document.createElement('div');
|
||||
lockBadge.style.position = 'absolute';
|
||||
lockBadge.style.top = '5px';
|
||||
lockBadge.style.left = '5px';
|
||||
lockBadge.style.zIndex = '10';
|
||||
|
||||
if (item.locked_by_me) {
|
||||
lockBadge.className = 'badge bg-success';
|
||||
lockBadge.innerHTML = '<i class="bi bi-cart-check"></i> В корзине';
|
||||
lockBadge.title = 'Добавлен в вашу корзину';
|
||||
} else {
|
||||
lockBadge.className = 'badge bg-danger';
|
||||
lockBadge.innerHTML = '<i class="bi bi-lock-fill"></i> Занят';
|
||||
lockBadge.title = `В корзине ${item.locked_by_user}`;
|
||||
|
||||
card.style.opacity = '0.5';
|
||||
card.style.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
card.appendChild(lockBadge);
|
||||
}
|
||||
}
|
||||
}
|
||||
817
myproject/pos/static/pos/js/showcase.js
Normal file
817
myproject/pos/static/pos/js/showcase.js
Normal file
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* Модуль управления витриной POS Terminal
|
||||
* Создание временных комплектов, управление букетами на витрине
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import {
|
||||
safeJSONParse,
|
||||
roundQuantity,
|
||||
formatMoney,
|
||||
getCsrfToken,
|
||||
showToast,
|
||||
safeFetch,
|
||||
escapeHtml
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Showcase
|
||||
* @property {number} id - ID витрины
|
||||
* @property {string} name - Название
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TempKitData
|
||||
* @property {string} name - Название комплекта
|
||||
* @property {number} showcaseId - ID витрины
|
||||
* @property {number} quantity - Количество букетов
|
||||
* @property {string} [description] - Описание
|
||||
* @property {string} [createdAt] - Дата создания
|
||||
* @property {number} basePrice - Базовая цена
|
||||
* @property {string} adjustmentType - Тип корректировки цены
|
||||
* @property {number} adjustmentValue - Значение корректировки
|
||||
* @property {boolean} useSalePrice - Использовать свою цену
|
||||
* @property {number} [salePrice] - Цена продажи
|
||||
*/
|
||||
|
||||
export class ShowcaseManager {
|
||||
/**
|
||||
* @param {Object} options - Опции
|
||||
* @param {import('./cart.js').Cart} options.cart - Экземпляр корзины
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.cart = options.cart;
|
||||
|
||||
// Состояние
|
||||
this.isEditMode = false;
|
||||
this.editingKitId = null;
|
||||
this.showcases = [];
|
||||
this.tempCart = new Map(); // Временная корзина для модального окна
|
||||
|
||||
// Модальное окно
|
||||
this.modalInstance = null;
|
||||
|
||||
// Callback'и
|
||||
this.onKitCreated = options.onKitCreated || null;
|
||||
this.onKitUpdated = options.onKitUpdated || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует модуль
|
||||
*/
|
||||
init() {
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики событий
|
||||
*/
|
||||
initEventListeners() {
|
||||
// Кнопка "На витрину"
|
||||
document.getElementById('addToShowcaseBtn')?.addEventListener('click', () => this.openCreateModal());
|
||||
document.getElementById('mobileAddToShowcaseBtn')?.addEventListener('click', () => this.openCreateModal());
|
||||
|
||||
// Кнопки модального окна
|
||||
document.getElementById('confirmCreateTempKit')?.addEventListener('click', () => this.confirmCreateKit());
|
||||
document.getElementById('disassembleKitBtn')?.addEventListener('click', () => this.disassembleKit());
|
||||
document.getElementById('writeOffKitBtn')?.addEventListener('click', () => this.writeOffKit());
|
||||
|
||||
// Загрузка фото
|
||||
document.getElementById('tempKitPhoto')?.addEventListener('change', (e) => this.handlePhotoUpload(e));
|
||||
document.getElementById('removePhoto')?.addEventListener('click', () => this.removePhoto());
|
||||
|
||||
// Корректировка цены
|
||||
document.getElementById('priceAdjustmentType')?.addEventListener('change', () => this.updatePriceCalculation());
|
||||
document.getElementById('priceAdjustmentValue')?.addEventListener('input', () => this.updatePriceCalculation());
|
||||
document.getElementById('useSalePrice')?.addEventListener('change', () => this.updatePriceCalculation());
|
||||
document.getElementById('salePrice')?.addEventListener('input', () => this.updatePriceCalculation());
|
||||
|
||||
// Кнопка пересчета цен
|
||||
document.getElementById('recalculatePricesBtn')?.addEventListener('click', () => this.recalculatePrices());
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модальное окно создания комплекта
|
||||
*/
|
||||
async openCreateModal() {
|
||||
if (this.cart.isEmpty) {
|
||||
showToast('error', 'Корзина пуста. Добавьте товары для создания комплекта.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEditMode = false;
|
||||
this.editingKitId = null;
|
||||
this.tempCart = new Map(this.cart.items);
|
||||
|
||||
// Загружаем список витрин
|
||||
await this.loadShowcases();
|
||||
|
||||
// Сбрасываем поля формы
|
||||
this.resetForm();
|
||||
|
||||
// Заполняем список товаров
|
||||
this.renderTempKitItems();
|
||||
|
||||
// Рассчитываем цены
|
||||
this.updatePriceCalculation();
|
||||
|
||||
// Показываем модалку
|
||||
const modalEl = document.getElementById('createTempKitModal');
|
||||
if (!modalEl) return;
|
||||
|
||||
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||
this.modalInstance.show();
|
||||
|
||||
// Обновляем UI для режима создания
|
||||
this.updateModalUIForMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модальное окно редактирования комплекта
|
||||
* @param {number} kitId - ID комплекта
|
||||
*/
|
||||
async openEditModal(kitId) {
|
||||
this.isEditMode = true;
|
||||
this.editingKitId = kitId;
|
||||
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.PRODUCT_KIT(kitId));
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
showToast('error', 'Не удалось загрузить данные комплекта');
|
||||
return;
|
||||
}
|
||||
|
||||
const kit = data.kit;
|
||||
|
||||
// Загружаем список витрин
|
||||
await this.loadShowcases();
|
||||
|
||||
// Заполняем поля формы
|
||||
document.getElementById('tempKitName').value = kit.name;
|
||||
document.getElementById('showcaseSelect').value = kit.showcase_id || '';
|
||||
document.getElementById('tempKitDescription').value = kit.description || '';
|
||||
document.getElementById('showcaseCreatedAt').value = kit.showcase_created_at || '';
|
||||
|
||||
// Скрываем блок количества при редактировании
|
||||
const qtyBlock = document.getElementById('showcaseKitQuantityBlock');
|
||||
if (qtyBlock) qtyBlock.style.display = 'none';
|
||||
|
||||
// Заполняем временную корзину
|
||||
this.tempCart.clear();
|
||||
let hasOutdatedPrices = false;
|
||||
if (kit.items) {
|
||||
kit.items.forEach(item => {
|
||||
const key = `product-${item.product_id}-${item.sales_unit_id || 'base'}`;
|
||||
const actualPrice = item.actual_catalog_price ? parseFloat(item.actual_catalog_price) : null;
|
||||
const currentPrice = parseFloat(item.price) || 0;
|
||||
const isOutdated = item.price_outdated || (actualPrice !== null && Math.abs(currentPrice - actualPrice) > 0.01);
|
||||
|
||||
if (isOutdated) {
|
||||
hasOutdatedPrices = true;
|
||||
}
|
||||
|
||||
this.tempCart.set(key, {
|
||||
id: item.product_id,
|
||||
name: item.name || item.product_name, // Сервер отдаёт name
|
||||
price: currentPrice,
|
||||
qty: parseFloat(item.qty || item.quantity) || 1, // Сервер отдаёт qty
|
||||
type: 'product',
|
||||
sales_unit_id: item.sales_unit_id,
|
||||
unit_name: item.unit_name,
|
||||
actual_catalog_price: actualPrice, // Сохраняем актуальную цену для пересчета
|
||||
price_outdated: isOutdated
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Показываем блок предупреждения о неактуальных ценах
|
||||
this.updatePriceOutdatedWarning(hasOutdatedPrices);
|
||||
|
||||
// Заполняем цены
|
||||
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type || 'none';
|
||||
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value || 0;
|
||||
|
||||
// Если sale_price установлен, автоматически включаем useSalePrice
|
||||
const salePriceValue = kit.sale_price || '';
|
||||
const hasSalePrice = salePriceValue && parseFloat(salePriceValue) > 0;
|
||||
document.getElementById('useSalePrice').checked = hasSalePrice;
|
||||
document.getElementById('salePrice').value = salePriceValue;
|
||||
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
|
||||
// Показываем модалку
|
||||
const modalEl = document.getElementById('createTempKitModal');
|
||||
if (!modalEl) return;
|
||||
|
||||
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||
this.modalInstance.show();
|
||||
|
||||
// Обновляем UI для режима редактирования
|
||||
this.updateModalUIForMode();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки комплекта:', error);
|
||||
showToast('error', 'Ошибка при загрузке данных комплекта');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет UI модального окна в зависимости от режима
|
||||
*/
|
||||
updateModalUIForMode() {
|
||||
const title = document.getElementById('createTempKitModalLabel');
|
||||
const confirmBtn = document.getElementById('confirmCreateTempKit');
|
||||
const disassembleBtn = document.getElementById('disassembleKitBtn');
|
||||
const writeOffBtn = document.getElementById('writeOffKitBtn');
|
||||
const addProductBlock = document.getElementById('addProductBlock');
|
||||
const qtyBlock = document.getElementById('showcaseKitQuantityBlock');
|
||||
|
||||
if (this.isEditMode) {
|
||||
if (title) title.innerHTML = '<i class="bi bi-pencil-square"></i> Редактировать комплект на витрине';
|
||||
if (confirmBtn) {
|
||||
confirmBtn.innerHTML = '<i class="bi bi-check-circle"></i> Сохранить изменения';
|
||||
}
|
||||
if (disassembleBtn) disassembleBtn.style.display = 'block';
|
||||
if (writeOffBtn) writeOffBtn.style.display = 'block';
|
||||
if (addProductBlock) addProductBlock.style.display = 'block';
|
||||
if (qtyBlock) qtyBlock.style.display = 'none';
|
||||
} else {
|
||||
if (title) title.innerHTML = '<i class="bi bi-flower1"></i> Создать временный комплект на витрину';
|
||||
if (confirmBtn) {
|
||||
confirmBtn.innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
|
||||
}
|
||||
if (disassembleBtn) disassembleBtn.style.display = 'none';
|
||||
if (writeOffBtn) writeOffBtn.style.display = 'none';
|
||||
if (addProductBlock) addProductBlock.style.display = 'none';
|
||||
if (qtyBlock) qtyBlock.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает список витрин
|
||||
*/
|
||||
async loadShowcases() {
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.GET_SHOWCASES);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showcases = data.showcases || [];
|
||||
this.renderShowcaseSelect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки витрин:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит select с витринами
|
||||
*/
|
||||
renderShowcaseSelect() {
|
||||
const select = document.getElementById('showcaseSelect');
|
||||
if (!select) return;
|
||||
|
||||
let html = '<option value="">Выберите витрину...</option>';
|
||||
let defaultShowcaseId = null;
|
||||
|
||||
this.showcases.forEach(showcase => {
|
||||
const displayName = showcase.warehouse_name
|
||||
? `${showcase.name} (${showcase.warehouse_name})`
|
||||
: showcase.name;
|
||||
html += `<option value="${showcase.id}">${escapeHtml(displayName)}</option>`;
|
||||
|
||||
// Запоминаем витрину по умолчанию
|
||||
if (showcase.is_default) {
|
||||
defaultShowcaseId = showcase.id;
|
||||
}
|
||||
});
|
||||
|
||||
select.innerHTML = html;
|
||||
|
||||
// Автовыбор витрины по умолчанию (только в режиме создания)
|
||||
if (!this.isEditMode && defaultShowcaseId) {
|
||||
select.value = defaultShowcaseId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит список товаров во временной корзине
|
||||
*/
|
||||
renderTempKitItems() {
|
||||
const container = document.getElementById('tempKitItemsList');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (this.tempCart.size === 0) {
|
||||
container.innerHTML = '<p class="text-muted text-center mb-0">Нет товаров</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let totalBasePrice = 0;
|
||||
|
||||
this.tempCart.forEach((item, key) => {
|
||||
const itemTotal = item.price * item.qty;
|
||||
totalBasePrice += itemTotal;
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'd-flex justify-content-between align-items-center py-1 border-bottom';
|
||||
|
||||
// Левая часть: название и цена
|
||||
const leftDiv = document.createElement('div');
|
||||
leftDiv.className = 'flex-grow-1';
|
||||
|
||||
// Название товара
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.className = 'small';
|
||||
nameDiv.textContent = item.name;
|
||||
leftDiv.appendChild(nameDiv);
|
||||
|
||||
// Контейнер цены с полем ввода
|
||||
const priceContainer = document.createElement('div');
|
||||
priceContainer.className = 'text-muted d-flex align-items-center';
|
||||
priceContainer.style.fontSize = '0.75rem';
|
||||
priceContainer.style.gap = '4px';
|
||||
|
||||
// Поле ввода цены (всегда видимое, без стрелочек)
|
||||
const priceInput = document.createElement('input');
|
||||
priceInput.type = 'text';
|
||||
priceInput.inputMode = 'decimal';
|
||||
priceInput.className = 'form-control form-control-sm';
|
||||
priceInput.style.width = '70px';
|
||||
priceInput.style.padding = '2px 4px';
|
||||
priceInput.style.textAlign = 'right';
|
||||
priceInput.value = formatMoney(item.price);
|
||||
|
||||
// Сохранение цены при изменении
|
||||
const savePrice = () => {
|
||||
const newPrice = parseFloat(priceInput.value.replace(',', '.')) || 0;
|
||||
if (item.price !== newPrice) {
|
||||
item.price = newPrice;
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
}
|
||||
};
|
||||
|
||||
priceInput.onblur = savePrice;
|
||||
priceInput.onkeydown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
priceInput.blur();
|
||||
}
|
||||
};
|
||||
priceInput.onfocus = () => priceInput.select();
|
||||
|
||||
priceContainer.appendChild(priceInput);
|
||||
priceContainer.appendChild(document.createTextNode(`× ${roundQuantity(item.qty)}`));
|
||||
if (item.unit_name) {
|
||||
priceContainer.appendChild(document.createTextNode(` ${item.unit_name}`));
|
||||
}
|
||||
leftDiv.appendChild(priceContainer);
|
||||
|
||||
// Правая часть: итоговая сумма
|
||||
const rightDiv = document.createElement('div');
|
||||
rightDiv.className = 'fw-semibold small';
|
||||
rightDiv.textContent = formatMoney(itemTotal);
|
||||
|
||||
itemDiv.appendChild(leftDiv);
|
||||
itemDiv.appendChild(rightDiv);
|
||||
container.appendChild(itemDiv);
|
||||
});
|
||||
|
||||
// Обновляем базовую цену
|
||||
document.getElementById('tempKitBasePrice').textContent = formatMoney(totalBasePrice) + ' руб.';
|
||||
|
||||
// Проверяем наличие неактуальных цен и обновляем предупреждение
|
||||
let hasOutdatedPrices = false;
|
||||
this.tempCart.forEach((item) => {
|
||||
if (item.price_outdated || (item.actual_catalog_price !== null && item.actual_catalog_price !== undefined && Math.abs(item.price - item.actual_catalog_price) > 0.01)) {
|
||||
hasOutdatedPrices = true;
|
||||
}
|
||||
});
|
||||
this.updatePriceOutdatedWarning(hasOutdatedPrices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет расчёт цен
|
||||
*/
|
||||
updatePriceCalculation() {
|
||||
const basePriceEl = document.getElementById('tempKitBasePrice');
|
||||
const basePrice = parseFloat(basePriceEl?.textContent) || 0;
|
||||
|
||||
const adjustmentType = document.getElementById('priceAdjustmentType')?.value || 'none';
|
||||
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue')?.value) || 0;
|
||||
const useSalePrice = document.getElementById('useSalePrice')?.checked || false;
|
||||
const salePrice = parseFloat(document.getElementById('salePrice')?.value) || 0;
|
||||
|
||||
// Показываем/скрываем блок значения корректировки
|
||||
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
|
||||
if (adjustmentBlock) {
|
||||
adjustmentBlock.style.display = adjustmentType === 'none' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// Показываем/скрываем блок цены продажи
|
||||
const salePriceBlock = document.getElementById('salePriceBlock');
|
||||
if (salePriceBlock) {
|
||||
salePriceBlock.style.display = useSalePrice ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Рассчитываем цену с корректировкой
|
||||
let calculatedPrice = basePrice;
|
||||
|
||||
if (adjustmentType !== 'none' && adjustmentValue > 0) {
|
||||
switch (adjustmentType) {
|
||||
case 'increase_percent':
|
||||
calculatedPrice = basePrice * (1 + adjustmentValue / 100);
|
||||
break;
|
||||
case 'increase_amount':
|
||||
calculatedPrice = basePrice + adjustmentValue;
|
||||
break;
|
||||
case 'decrease_percent':
|
||||
calculatedPrice = basePrice * (1 - adjustmentValue / 100);
|
||||
break;
|
||||
case 'decrease_amount':
|
||||
calculatedPrice = basePrice - adjustmentValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('tempKitCalculatedPrice').textContent = formatMoney(calculatedPrice) + ' руб.';
|
||||
|
||||
// Итоговая цена
|
||||
const finalPrice = useSalePrice && salePrice > 0 ? salePrice : calculatedPrice;
|
||||
document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет отображение предупреждения о неактуальных ценах
|
||||
* @param {boolean} hasOutdated - Есть ли неактуальные цены
|
||||
*/
|
||||
updatePriceOutdatedWarning(hasOutdated) {
|
||||
const warningBlock = document.getElementById('priceOutdatedWarning');
|
||||
if (warningBlock) {
|
||||
warningBlock.style.display = hasOutdated ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитывает цены товаров на актуальные из каталога
|
||||
*/
|
||||
recalculatePrices() {
|
||||
let updated = false;
|
||||
|
||||
this.tempCart.forEach((item, key) => {
|
||||
if (item.actual_catalog_price !== null && item.actual_catalog_price !== undefined) {
|
||||
const oldPrice = item.price;
|
||||
const newPrice = item.actual_catalog_price;
|
||||
|
||||
if (Math.abs(oldPrice - newPrice) > 0.01) {
|
||||
item.price = newPrice;
|
||||
item.price_outdated = false;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
// Перерисовываем список товаров и пересчитываем цены
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
|
||||
// Скрываем предупреждение
|
||||
this.updatePriceOutdatedWarning(false);
|
||||
|
||||
showToast('success', 'Цены обновлены на актуальные из каталога');
|
||||
} else {
|
||||
showToast('info', 'Все цены уже актуальны');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает загрузку фото
|
||||
*/
|
||||
handlePhotoUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const preview = document.getElementById('photoPreview');
|
||||
const img = document.getElementById('photoPreviewImg');
|
||||
if (preview && img) {
|
||||
img.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет загруженное фото
|
||||
*/
|
||||
removePhoto() {
|
||||
const input = document.getElementById('tempKitPhoto');
|
||||
const preview = document.getElementById('photoPreview');
|
||||
if (input) input.value = '';
|
||||
if (preview) preview.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает форму
|
||||
*/
|
||||
resetForm() {
|
||||
// Генерируем название по умолчанию
|
||||
const randomSuffix = Math.floor(Math.random() * 900) + 100;
|
||||
const defaultName = `Витринный букет ${randomSuffix}`;
|
||||
document.getElementById('tempKitName').value = defaultName;
|
||||
// Не сбрасываем showcaseSelect - витрина по умолчанию выбирается в renderShowcaseSelect()
|
||||
document.getElementById('showcaseKitQuantity').value = '1';
|
||||
document.getElementById('tempKitDescription').value = '';
|
||||
document.getElementById('showcaseCreatedAt').value = '';
|
||||
document.getElementById('priceAdjustmentType').value = 'none';
|
||||
document.getElementById('priceAdjustmentValue').value = '0';
|
||||
document.getElementById('useSalePrice').checked = false;
|
||||
document.getElementById('salePrice').value = '';
|
||||
this.removePhoto();
|
||||
// Скрываем предупреждение о неактуальных ценах
|
||||
this.updatePriceOutdatedWarning(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает данные формы
|
||||
*/
|
||||
getFormData() {
|
||||
const name = document.getElementById('tempKitName')?.value.trim();
|
||||
const showcaseId = parseInt(document.getElementById('showcaseSelect')?.value);
|
||||
const quantity = parseInt(document.getElementById('showcaseKitQuantity')?.value) || 1;
|
||||
const description = document.getElementById('tempKitDescription')?.value.trim();
|
||||
const createdAt = document.getElementById('showcaseCreatedAt')?.value;
|
||||
|
||||
const adjustmentType = document.getElementById('priceAdjustmentType')?.value || 'none';
|
||||
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue')?.value) || 0;
|
||||
const useSalePrice = document.getElementById('useSalePrice')?.checked || false;
|
||||
const salePrice = parseFloat(document.getElementById('salePrice')?.value) || 0;
|
||||
|
||||
// Получаем фото
|
||||
const photoInput = document.getElementById('tempKitPhoto');
|
||||
const photoFile = photoInput?.files[0];
|
||||
|
||||
return {
|
||||
name,
|
||||
showcaseId,
|
||||
quantity,
|
||||
description,
|
||||
createdAt,
|
||||
adjustmentType,
|
||||
adjustmentValue,
|
||||
useSalePrice,
|
||||
salePrice,
|
||||
photoFile
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует данные формы
|
||||
*/
|
||||
validateForm(data) {
|
||||
if (!data.name) {
|
||||
showToast('error', 'Укажите название комплекта');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.showcaseId) {
|
||||
showToast('error', 'Выберите витрину');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.tempCart.size === 0) {
|
||||
showToast('error', 'Добавьте товары в комплект');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждает создание/обновление комплекта
|
||||
*/
|
||||
async confirmCreateKit() {
|
||||
const data = this.getFormData();
|
||||
|
||||
if (!this.validateForm(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем данные для отправки
|
||||
const formData = new FormData();
|
||||
formData.append('kit_name', data.name); // Сервер ожидает kit_name
|
||||
formData.append('showcase_id', data.showcaseId);
|
||||
formData.append('description', data.description || '');
|
||||
formData.append('showcase_created_at', data.createdAt || '');
|
||||
formData.append('price_adjustment_type', data.adjustmentType);
|
||||
formData.append('price_adjustment_value', data.adjustmentValue);
|
||||
formData.append('use_sale_price', data.useSalePrice ? '1' : '0');
|
||||
// Если useSalePrice выключен, отправляем пустую строку для явной очистки sale_price на сервере
|
||||
formData.append('sale_price', data.useSalePrice ? (data.salePrice || '') : '');
|
||||
|
||||
if (!this.isEditMode) {
|
||||
formData.append('quantity', data.quantity);
|
||||
}
|
||||
|
||||
// Добавляем товары
|
||||
const items = [];
|
||||
this.tempCart.forEach((item, key) => {
|
||||
items.push({
|
||||
product_id: item.id,
|
||||
quantity: item.qty,
|
||||
unit_price: item.price, // Используем unit_price для сохранения измененной цены товара
|
||||
sales_unit_id: item.sales_unit_id || null
|
||||
});
|
||||
});
|
||||
formData.append('items', JSON.stringify(items));
|
||||
|
||||
// Добавляем фото
|
||||
if (data.photoFile) {
|
||||
formData.append('photo', data.photoFile);
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (this.isEditMode) {
|
||||
// Обновление существующего комплекта
|
||||
response = await fetch(CONFIG.API.PRODUCT_KIT_UPDATE(this.editingKitId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
} else {
|
||||
// Создание нового комплекта
|
||||
response = await fetch(CONFIG.API.CREATE_TEMP_KIT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('success', this.isEditMode ? 'Комплект обновлён' : 'Комплект создан и размещён на витрине');
|
||||
|
||||
// Очищаем корзину
|
||||
this.cart.clear();
|
||||
|
||||
// Закрываем модалку
|
||||
this.close();
|
||||
|
||||
// Вызываем callback
|
||||
if (this.isEditMode && this.onKitUpdated) {
|
||||
this.onKitUpdated(result);
|
||||
} else if (!this.isEditMode && this.onKitCreated) {
|
||||
this.onKitCreated(result);
|
||||
}
|
||||
} else {
|
||||
showToast('error', result.error || 'Ошибка при сохранении комплекта');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении комплекта:', error);
|
||||
showToast('error', 'Ошибка сети при сохранении комплекта');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает комплект
|
||||
*/
|
||||
async disassembleKit() {
|
||||
if (!this.isEditMode || !this.editingKitId) return;
|
||||
|
||||
if (!confirm('Разобрать букет и вернуть товары на склад?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.PRODUCT_KIT_DISASSEMBLE(this.editingKitId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('success', 'Букет разобран, товары возвращены на склад');
|
||||
this.close();
|
||||
|
||||
// Обновляем отображение
|
||||
if (this.onKitUpdated) {
|
||||
this.onKitUpdated(result);
|
||||
}
|
||||
} else {
|
||||
showToast('error', result.error || 'Ошибка при разборке букета');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при разборке комплекта:', error);
|
||||
showToast('error', 'Ошибка сети при разборке букета');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Списывает комплект
|
||||
*/
|
||||
async writeOffKit() {
|
||||
if (!this.isEditMode || !this.editingKitId) return;
|
||||
|
||||
if (!confirm('Списать букет? Товары будут удалены из учёта.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.PRODUCT_KIT_WRITE_OFF(this.editingKitId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('success', 'Букет списан');
|
||||
this.close();
|
||||
|
||||
// Обновляем отображение
|
||||
if (this.onKitUpdated) {
|
||||
this.onKitUpdated(result);
|
||||
}
|
||||
} else {
|
||||
showToast('error', result.error || 'Ошибка при списании букета');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при списании комплекта:', error);
|
||||
showToast('error', 'Ошибка сети при списании букета');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрывает модальное окно
|
||||
*/
|
||||
close() {
|
||||
if (this.modalInstance) {
|
||||
this.modalInstance.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет товар во временную корзину (для режима редактирования)
|
||||
* @param {Object} product - Данные товара
|
||||
* @param {number} quantity - Количество
|
||||
*/
|
||||
addProductToTempCart(product, quantity = 1) {
|
||||
const key = `product-${product.id}-${product.sales_unit_id || 'base'}`;
|
||||
|
||||
if (this.tempCart.has(key)) {
|
||||
const existing = this.tempCart.get(key);
|
||||
existing.qty = roundQuantity(existing.qty + quantity);
|
||||
} else {
|
||||
this.tempCart.set(key, {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
qty: quantity,
|
||||
type: 'product',
|
||||
sales_unit_id: product.sales_unit_id,
|
||||
unit_name: product.unit_name
|
||||
});
|
||||
}
|
||||
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет товар из временной корзины
|
||||
* @param {string} key - Ключ товара
|
||||
*/
|
||||
removeProductFromTempCart(key) {
|
||||
this.tempCart.delete(key);
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
286
myproject/pos/static/pos/js/utils.js
Normal file
286
myproject/pos/static/pos/js/utils.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Утилиты POS Terminal
|
||||
* Общие функции для работы с числами, датами, DOM и т.д.
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
|
||||
/**
|
||||
* Безопасный парсинг JSON с обработкой ошибок
|
||||
* @param {string} jsonString - JSON строка для парсинга
|
||||
* @param {*} defaultValue - Значение по умолчанию при ошибке
|
||||
* @returns {*} Распарсенный объект или defaultValue
|
||||
*/
|
||||
export function safeJSONParse(jsonString, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
console.error('Ошибка парсинга JSON:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Округляет число до N знаков после запятой
|
||||
* Решает проблему погрешности float arithmetic в JavaScript
|
||||
* @param {number} value - Число для округления
|
||||
* @param {number} decimals - Количество знаков после запятой
|
||||
* @returns {number} Округлённое число
|
||||
*/
|
||||
export function roundQuantity(value, decimals = CONFIG.NUMBERS.DECIMALS_QUANTITY) {
|
||||
if (value === null || value === undefined || isNaN(value)) return 0;
|
||||
if (!Number.isInteger(decimals) || decimals < 0) decimals = CONFIG.NUMBERS.DECIMALS_QUANTITY;
|
||||
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует число как денежную сумму
|
||||
* @param {number} value - Сумма
|
||||
* @returns {string} Отформатированная сумма
|
||||
*/
|
||||
export function formatMoney(value) {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return '0.00';
|
||||
return num.toFixed(CONFIG.NUMBERS.DECIMALS_MONEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату как относительное время в русском языке
|
||||
* @param {string|null} isoDate - ISO дата или null
|
||||
* @returns {string} - "0 дней", "1 день", "2 дня", "5 дней", и т.д.
|
||||
*/
|
||||
export function formatDaysAgo(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
|
||||
const created = new Date(isoDate);
|
||||
if (isNaN(created.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - created;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Русские формы множественного числа
|
||||
const lastTwo = diffDays % 100;
|
||||
const lastOne = diffDays % 10;
|
||||
|
||||
let suffix;
|
||||
if (lastTwo >= 11 && lastTwo <= 19) {
|
||||
suffix = 'дней';
|
||||
} else if (lastOne === 1) {
|
||||
suffix = 'день';
|
||||
} else if (lastOne >= 2 && lastOne <= 4) {
|
||||
suffix = 'дня';
|
||||
} else {
|
||||
suffix = 'дней';
|
||||
}
|
||||
|
||||
return `${diffDays} ${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Склонение слов в зависимости от числа
|
||||
* @param {number} number - число
|
||||
* @param {string} one - форма для 1 (товар)
|
||||
* @param {string} two - форма для 2-4 (товара)
|
||||
* @param {string} five - форма для 5+ (товаров)
|
||||
* @returns {string} Правильная форма слова
|
||||
*/
|
||||
export function getNoun(number, one, two, five) {
|
||||
const n = Math.abs(number);
|
||||
const n10 = n % 10;
|
||||
const n100 = n % 100;
|
||||
|
||||
if (n100 >= 11 && n100 <= 19) {
|
||||
return five;
|
||||
}
|
||||
if (n10 === 1) {
|
||||
return one;
|
||||
}
|
||||
if (n10 >= 2 && n10 <= 4) {
|
||||
return two;
|
||||
}
|
||||
return five;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce функция
|
||||
* @param {Function} func - Функция для debounce
|
||||
* @param {number} wait - Задержка в миллисекундах
|
||||
* @returns {Function} Функция с debounce
|
||||
*/
|
||||
export function debounce(func, wait = CONFIG.TIMEOUTS.DEBOUNCE_SEARCH) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает CSRF токен из DOM или cookie
|
||||
* @returns {string|null} CSRF токен
|
||||
*/
|
||||
export function getCsrfToken() {
|
||||
// Пытаемся найти токен в DOM (из {% csrf_token %})
|
||||
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfInput) {
|
||||
return csrfInput.value;
|
||||
}
|
||||
|
||||
// Fallback: пытаемся прочитать из cookie
|
||||
return getCookie('csrftoken');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает значение cookie по имени
|
||||
* @param {string} name - Имя cookie
|
||||
* @returns {string|null} Значение cookie
|
||||
*/
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли устройство мобильным
|
||||
* @returns {boolean} true если мобильное устройство
|
||||
*/
|
||||
export function isMobileDevice() {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
|
||||
// Проверка по юзер-агенту
|
||||
const isMobileUA = CONFIG.MOBILE.USER_AGENTS.some(keyword =>
|
||||
userAgent.indexOf(keyword) > -1
|
||||
);
|
||||
|
||||
// Проверка по размеру экрана
|
||||
const isSmallScreen = window.innerWidth < CONFIG.MOBILE.SCREEN_WIDTH_THRESHOLD;
|
||||
|
||||
return isMobileUA || isSmallScreen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает toast уведомление
|
||||
* @param {'success'|'error'} type - Тип уведомления
|
||||
* @param {string} message - Текст сообщения
|
||||
*/
|
||||
export function showToast(type, message) {
|
||||
const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast';
|
||||
const messageId = type === 'success' ? 'toastMessage' : 'errorMessage';
|
||||
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
|
||||
|
||||
const toastElement = document.getElementById(toastId);
|
||||
const messageElement = document.getElementById(messageId);
|
||||
|
||||
if (!toastElement || !messageElement) {
|
||||
console.warn('Toast элементы не найдены в DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Устанавливаем сообщение
|
||||
messageElement.textContent = message;
|
||||
|
||||
// Добавляем цвет фона
|
||||
toastElement.classList.add(bgClass, 'text-white');
|
||||
|
||||
// Создаём и показываем toast
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
delay: CONFIG.TIMEOUTS.TOAST_DELAY,
|
||||
autohide: true
|
||||
});
|
||||
toast.show();
|
||||
|
||||
// Убираем класс цвета после скрытия
|
||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||
toastElement.classList.remove(bgClass, 'text-white');
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное экранирование HTML
|
||||
* @param {string} text - Текст для экранирования
|
||||
* @returns {string} Экранированный текст
|
||||
*/
|
||||
export function escapeHtml(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает иконку для режима объединения скидок
|
||||
* @param {string} mode - Режим объединения
|
||||
* @returns {string} HTML иконки
|
||||
*/
|
||||
export function getCombineModeIcon(mode) {
|
||||
const icons = {
|
||||
'stack': '<i class="bi bi-layers" title="Складывать (суммировать)"></i>',
|
||||
'max_only': '<i class="bi bi-trophy" title="Только максимум"></i>',
|
||||
'exclusive': '<i class="bi bi-x-circle" title="Исключающая (отменяет остальные)"></i>'
|
||||
};
|
||||
return icons[mode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает описание режима объединения скидок
|
||||
* @param {string} mode - Режим объединения
|
||||
* @returns {string} Описание
|
||||
*/
|
||||
export function getCombineModeTitle(mode) {
|
||||
const titles = {
|
||||
'stack': 'Складывается с другими скидками',
|
||||
'max_only': 'Применяется только максимальная из этого типа',
|
||||
'exclusive': 'Отменяет все другие скидки'
|
||||
};
|
||||
return titles[mode] || mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает AbortController с таймаутом
|
||||
* @param {number} timeoutMs - Таймаут в миллисекундах
|
||||
* @returns {AbortController} AbortController с таймаутом
|
||||
*/
|
||||
export function createAbortController(timeoutMs = 10000) {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), timeoutMs);
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасный fetch с обработкой ошибок и таймаутом
|
||||
* @param {string} url - URL для запроса
|
||||
* @param {Object} options - Опции fetch
|
||||
* @param {number} timeoutMs - Таймаут в миллисекундах
|
||||
* @returns {Promise<Response>} Ответ от сервера
|
||||
*/
|
||||
export async function safeFetch(url, options = {}, timeoutMs = 10000) {
|
||||
const controller = createAbortController(timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timeout');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% load static %}
|
||||
<div class="modal fade" id="editCartItemModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-pencil-square"></i> Редактирование товара
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Название товара -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small">Товар</label>
|
||||
<div id="editModalProductName" class="fw-semibold">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Базовая цена (оригинальная) -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small">Базовая цена</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="editModalBasePrice" class="text-muted">0.00 руб.</span>
|
||||
<span id="editModalUnitBadge" class="badge bg-secondary" style="display: none;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Новая цена -->
|
||||
<div class="mb-3">
|
||||
<label for="editModalPrice" class="form-label fw-semibold">Цена за единицу</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="editModalPrice"
|
||||
min="0" step="0.01" placeholder="0.00">
|
||||
<span class="input-group-text">руб.</span>
|
||||
</div>
|
||||
<div id="editModalPriceWarning" class="text-warning small mt-1" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle"></i> Цена изменена
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Количество -->
|
||||
<div class="mb-3">
|
||||
<label for="editModalQuantity" class="form-label fw-semibold">Количество</label>
|
||||
<input type="number" class="form-control" id="editModalQuantity"
|
||||
min="0.001" step="0.001" value="1">
|
||||
<div id="editModalQtyHint" class="text-muted small mt-1" style="display: none;">
|
||||
<i class="bi bi-info-circle"></i> Количество нельзя изменить для витринного комплекта (собранный товар с резервами)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Итого -->
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>Сумма:</strong>
|
||||
<span class="fs-5" id="editModalTotal">0.00 руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmEditCartItem">
|
||||
<i class="bi bi-check-lg"></i> Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,6 +142,26 @@
|
||||
<button class="btn btn-outline-secondary btn-sm" id="mobileClearCartBtn" title="Очистить корзину">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown "Ещё" -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button"
|
||||
id="mobileMoreBtn" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" id="mobileScheduleLaterBtn" type="button">
|
||||
<i class="bi bi-calendar2 me-2"></i>Отложенный заказ
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" id="mobileAddToShowcaseBtn" type="button">
|
||||
<i class="bi bi-flower1 me-2"></i>На витрину
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,6 +219,14 @@
|
||||
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Дата размещения на витрине -->
|
||||
<div class="mb-3">
|
||||
<label for="showcaseCreatedAt" class="form-label">Дата размещения на витрине</label>
|
||||
<input type="datetime-local" class="form-control" id="showcaseCreatedAt"
|
||||
placeholder="Выберите дату и время">
|
||||
<small class="text-muted">Оставьте пустым для текущего времени</small>
|
||||
</div>
|
||||
|
||||
<!-- Загрузка фото -->
|
||||
<div class="mb-3">
|
||||
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
|
||||
@@ -242,6 +270,20 @@
|
||||
<strong>Ценообразование</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Предупреждение о неактуальных ценах -->
|
||||
<div class="alert alert-warning mb-2" id="priceOutdatedWarning" style="display: none;">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Цены товаров неактуальны</strong>
|
||||
<div class="small mt-1">Некоторые товары имеют устаревшие цены. Рекомендуется пересчитать.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-warning" id="recalculatePricesBtn">
|
||||
<i class="bi bi-arrow-clockwise"></i> Пересчитать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Базовая цена -->
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Базовая цена (сумма компонентов):</small>
|
||||
@@ -302,6 +344,11 @@
|
||||
<i class="bi bi-scissors"></i> Разобрать букет
|
||||
</button>
|
||||
|
||||
<!-- Кнопка "Списать" (отображается только в режиме редактирования) -->
|
||||
<button type="button" class="btn btn-warning me-auto" id="writeOffKitBtn" style="display: none;">
|
||||
<i class="bi bi-file-earmark-x"></i> Списать букет
|
||||
</button>
|
||||
|
||||
<!-- Правая группа кнопок -->
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
|
||||
@@ -614,20 +661,20 @@
|
||||
|
||||
<!-- Модалка: Выбор единицы продажи товара -->
|
||||
<div class="modal fade" id="selectProductUnitModal" tabindex="-1" aria-labelledby="selectProductUnitModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content" style="max-height: 90vh; display: flex; flex-direction: column;">
|
||||
<div class="modal-header" style="flex-shrink: 0;">
|
||||
<h5 class="modal-title" id="selectProductUnitModalLabel">
|
||||
<i class="bi bi-box-seam"></i> <span id="unitModalProductName"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body" style="flex: 1; overflow-y: auto; min-height: 0;">
|
||||
<div class="row">
|
||||
<!-- Левая колонка: список единиц продажи -->
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Выберите единицу продажи</label>
|
||||
<div id="unitSelectionList" class="d-flex flex-column gap-2" style="max-height: 400px; overflow-y: auto;">
|
||||
<div id="unitSelectionList" class="d-flex flex-column gap-2">
|
||||
<!-- Заполняется через JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -684,7 +731,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer" style="flex-shrink: 0; position: sticky; bottom: 0; background: white; border-top: 1px solid #dee2e6;">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-success" id="confirmAddUnitToCart" disabled>
|
||||
<i class="bi bi-cart-plus"></i> Добавить в корзину
|
||||
@@ -693,6 +740,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка редактирования товара в корзине -->
|
||||
{% include 'pos/components/edit_cart_item_modal.html' %}
|
||||
|
||||
<!-- Toast Container для уведомлений -->
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1060;">
|
||||
<div id="orderSuccessToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-check-circle-fill text-success me-2 fs-5"></i>
|
||||
<span id="toastMessage"></span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" style="display: none;"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="orderErrorToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-exclamation-circle-fill text-danger me-2 fs-5"></i>
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -711,5 +783,6 @@
|
||||
</script>
|
||||
|
||||
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
|
||||
<script src="{% static 'pos/js/terminal.js' %}"></script>
|
||||
<script type="module" src="{% static 'pos/js/terminal.js' %}"></script>
|
||||
<script src="{% static 'pos/js/cart-item-editor.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,6 +33,8 @@ urlpatterns = [
|
||||
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
|
||||
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
|
||||
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
||||
# Списать витринный комплект (создать документ списания с компонентами) [POST]
|
||||
path('api/product-kits/<int:kit_id>/write-off/', views.write_off_showcase_kit, name='write-off-showcase-kit'),
|
||||
# Создать временный комплект и зарезервировать на витрину [POST]
|
||||
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
|
||||
# Создать заказ и провести оплату в POS [POST]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch, OuterRef, Subquery, DecimalField
|
||||
from django.db.models import Prefetch, OuterRef, Subquery, DecimalField, F, Case, When, CharField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -13,8 +14,9 @@ import json
|
||||
import logging
|
||||
|
||||
from products.models import Product, ProductCategory, ProductKit, KitItem
|
||||
from inventory.models import Showcase, Reservation, Warehouse, Stock
|
||||
from inventory.models import Showcase, Reservation, Warehouse, Stock, ShowcaseItem
|
||||
from inventory.services import ShowcaseManager
|
||||
from inventory.signals import skip_sale_creation, reset_sale_creation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,6 +83,8 @@ def get_showcase_kits_for_pos():
|
||||
'product_kit__sku',
|
||||
'product_kit__price',
|
||||
'product_kit__sale_price',
|
||||
'product_kit__base_price',
|
||||
'product_kit__showcase_created_at',
|
||||
'showcase_id',
|
||||
'showcase__name'
|
||||
).annotate(
|
||||
@@ -109,6 +113,19 @@ def get_showcase_kits_for_pos():
|
||||
thumbnail_url = None
|
||||
kit_photos[photo.kit_id] = thumbnail_url
|
||||
|
||||
# Загружаем состав комплектов для проверки актуальности цен
|
||||
kit_items_data = {}
|
||||
for ki in KitItem.objects.filter(kit_id__in=kit_ids).select_related('product'):
|
||||
if ki.kit_id not in kit_items_data:
|
||||
kit_items_data[ki.kit_id] = []
|
||||
kit_items_data[ki.kit_id].append(ki)
|
||||
|
||||
# Считаем актуальные цены для каждого комплекта
|
||||
kit_actual_prices = {}
|
||||
for kit_id, items in kit_items_data.items():
|
||||
actual_price = sum((ki.product.actual_price or 0) * (ki.quantity or 0) for ki in items)
|
||||
kit_actual_prices[kit_id] = actual_price
|
||||
|
||||
# Формируем результат
|
||||
showcase_kits = []
|
||||
for item in all_items:
|
||||
@@ -122,9 +139,15 @@ def get_showcase_kits_for_pos():
|
||||
status='available'
|
||||
).values_list('id', flat=True))
|
||||
|
||||
# Определяем актуальную цену
|
||||
# Определяем актуальную цену продажи (sale_price имеет приоритет)
|
||||
price = item['product_kit__sale_price'] or item['product_kit__price']
|
||||
|
||||
# Проверяем актуальность цены (сравниваем сохранённую цену продажи с актуальной ценой компонентов)
|
||||
actual_price = kit_actual_prices.get(kit_id, Decimal('0'))
|
||||
# Сравниваем цену продажи с актуальной ценой компонентов
|
||||
# Если разница больше 0.01, значит цена неактуальна
|
||||
price_outdated = actual_price > 0 and abs(float(price) - float(actual_price)) > 0.01
|
||||
|
||||
showcase_kits.append({
|
||||
'id': kit_id,
|
||||
'name': item['product_kit__name'],
|
||||
@@ -139,7 +162,11 @@ def get_showcase_kits_for_pos():
|
||||
# Количества
|
||||
'available_count': item['available_count'], # Сколько можно добавить
|
||||
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
|
||||
'showcase_item_ids': available_item_ids # IDs только доступных
|
||||
'showcase_item_ids': available_item_ids, # IDs только доступных
|
||||
# Флаг неактуальной цены
|
||||
'price_outdated': price_outdated,
|
||||
# Дата размещения на витрине
|
||||
'showcase_created_at': item.get('product_kit__showcase_created_at')
|
||||
})
|
||||
|
||||
return showcase_kits
|
||||
@@ -241,13 +268,25 @@ def pos_terminal(request):
|
||||
|
||||
if showcase_item_ids:
|
||||
# Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя
|
||||
locked_items = ShowcaseItem.objects.filter(
|
||||
id__in=showcase_item_ids,
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
locked_by_user=request.user,
|
||||
cart_lock_expires_at__gt=timezone.now()
|
||||
)
|
||||
from accounts.models import CustomUser
|
||||
if isinstance(request.user, CustomUser):
|
||||
locked_items = ShowcaseItem.objects.filter(
|
||||
id__in=showcase_item_ids,
|
||||
product_kit=kit,
|
||||
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()
|
||||
|
||||
@@ -454,10 +493,18 @@ def get_showcase_kits_api(request):
|
||||
product_kit_id__in=kit_ids,
|
||||
cart_lock_expires_at__gt=timezone.now(),
|
||||
status='reserved'
|
||||
).select_related('locked_by_user').values(
|
||||
).select_related('locked_by_user').annotate(
|
||||
# На уровне БД выбираем: если name есть - берем name, иначе email
|
||||
locked_by_user_display=Case(
|
||||
When(locked_by_user__name__isnull=False, then=F('locked_by_user__name')),
|
||||
When(locked_by_user__name='', then=F('locked_by_user__email')),
|
||||
default=F('locked_by_user__email'),
|
||||
output_field=CharField()
|
||||
)
|
||||
).values(
|
||||
'product_kit_id',
|
||||
'locked_by_user_id',
|
||||
'locked_by_user__username',
|
||||
'locked_by_user_display',
|
||||
'cart_lock_expires_at'
|
||||
)
|
||||
|
||||
@@ -476,7 +523,7 @@ def get_showcase_kits_api(request):
|
||||
is_locked_by_me = lock_info['locked_by_user_id'] == request.user.id
|
||||
kit['is_locked'] = True
|
||||
kit['locked_by_me'] = is_locked_by_me
|
||||
kit['locked_by_user'] = lock_info['locked_by_user__username']
|
||||
kit['locked_by_user'] = lock_info['locked_by_user_display']
|
||||
kit['lock_expires_at'] = lock_info['cart_lock_expires_at'].isoformat()
|
||||
else:
|
||||
kit['is_locked'] = False
|
||||
@@ -625,11 +672,22 @@ def remove_showcase_kit_from_cart(request, kit_id):
|
||||
showcase_item_ids = []
|
||||
|
||||
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
|
||||
qs = ShowcaseItem.objects.filter(
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
locked_by_user=request.user
|
||||
)
|
||||
from accounts.models import CustomUser
|
||||
|
||||
if isinstance(request.user, CustomUser):
|
||||
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 - фильтруем только их
|
||||
if showcase_item_ids:
|
||||
@@ -680,10 +738,22 @@ def release_all_my_showcase_locks(request):
|
||||
|
||||
try:
|
||||
# Снимаем ВСЕ блокировки текущего пользователя
|
||||
updated_count = ShowcaseItem.objects.filter(
|
||||
status='in_cart',
|
||||
locked_by_user=request.user
|
||||
).update(
|
||||
from accounts.models import CustomUser
|
||||
|
||||
if isinstance(request.user, CustomUser):
|
||||
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',
|
||||
locked_by_user=None,
|
||||
cart_lock_expires_at=None,
|
||||
@@ -770,8 +840,9 @@ def get_items_api(request):
|
||||
'sales_units' # Загружаем единицы продажи для POS
|
||||
)
|
||||
|
||||
# Фильтруем по категории, если указана
|
||||
if category_id:
|
||||
# Фильтруем по категории, если указана И нет поискового запроса
|
||||
# При поиске игнорируем категорию - ищем по всем товарам
|
||||
if category_id and not search_query:
|
||||
products_qs = products_qs.filter(categories__id=category_id)
|
||||
|
||||
# Фильтруем по поисковому запросу (name или sku) - разбиваем на токены
|
||||
@@ -862,8 +933,9 @@ def get_items_api(request):
|
||||
first_kit_photo
|
||||
)
|
||||
|
||||
# Фильтруем комплекты по категории, если указана
|
||||
if category_id:
|
||||
# Фильтруем комплекты по категории, если указана И нет поискового запроса
|
||||
# При поиске игнорируем категорию - ищем по всем комплектам
|
||||
if category_id and not search_query:
|
||||
kits_qs = kits_qs.filter(categories__id=category_id)
|
||||
|
||||
# Фильтруем комплекты по поисковому запросу (name или sku) - разбиваем на токены
|
||||
@@ -948,12 +1020,27 @@ def get_product_kit_details(request, kit_id):
|
||||
showcase_id = showcase_reservation.showcase.id if showcase_reservation else None
|
||||
|
||||
# Собираем данные о составе
|
||||
items = [{
|
||||
'product_id': ki.product.id,
|
||||
'name': ki.product.name,
|
||||
'qty': str(ki.quantity),
|
||||
'price': str(ki.product.actual_price)
|
||||
} for ki in kit.kit_items.all()]
|
||||
# Используем unit_price если есть (зафиксированная цена), иначе актуальную цену товара
|
||||
items = []
|
||||
for ki in kit.kit_items.all():
|
||||
# Зафиксированная цена или актуальная цена товара
|
||||
item_price = ki.unit_price if ki.unit_price is not None else ki.product.actual_price
|
||||
item_data = {
|
||||
'product_id': ki.product.id,
|
||||
'name': ki.product.name,
|
||||
'qty': str(ki.quantity),
|
||||
'price': str(item_price)
|
||||
}
|
||||
# Для временных комплектов всегда добавляем актуальную цену из каталога для сравнения
|
||||
if kit.is_temporary:
|
||||
item_data['actual_catalog_price'] = str(ki.product.actual_price)
|
||||
# Проверяем, неактуальна ли цена (если unit_price установлен и отличается от actual_price)
|
||||
if ki.unit_price is not None:
|
||||
price_diff = abs(float(ki.unit_price) - float(ki.product.actual_price))
|
||||
item_data['price_outdated'] = price_diff > 0.01
|
||||
else:
|
||||
item_data['price_outdated'] = False
|
||||
items.append(item_data)
|
||||
|
||||
# Фото (используем миниатюру для быстрой загрузки)
|
||||
photo_url = None
|
||||
@@ -978,7 +1065,8 @@ def get_product_kit_details(request, kit_id):
|
||||
'final_price': str(kit.actual_price),
|
||||
'showcase_id': showcase_id,
|
||||
'items': items,
|
||||
'photo_url': photo_url
|
||||
'photo_url': photo_url,
|
||||
'showcase_created_at': kit.showcase_created_at.isoformat() if kit.showcase_created_at else None
|
||||
}
|
||||
})
|
||||
except ProductKit.DoesNotExist:
|
||||
@@ -1013,19 +1101,45 @@ def create_temp_kit_to_showcase(request):
|
||||
sale_price_str = request.POST.get('sale_price', '')
|
||||
photo_file = request.FILES.get('photo')
|
||||
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
|
||||
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||
|
||||
# Парсим items из JSON
|
||||
items = json.loads(items_json)
|
||||
|
||||
# Получаем флаг use_sale_price для явной очистки sale_price
|
||||
use_sale_price = request.POST.get('use_sale_price', '0') == '1'
|
||||
|
||||
# Sale price (опционально)
|
||||
sale_price = None
|
||||
if sale_price_str:
|
||||
# Если use_sale_price = True, обрабатываем sale_price_str
|
||||
# Если use_sale_price = False, явно устанавливаем sale_price = None
|
||||
if use_sale_price and sale_price_str:
|
||||
try:
|
||||
sale_price = Decimal(str(sale_price_str))
|
||||
if sale_price <= 0:
|
||||
sale_price = None
|
||||
except (ValueError, InvalidOperation):
|
||||
sale_price = None
|
||||
else:
|
||||
# Явно очищаем sale_price, если чекбокс выключен
|
||||
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:
|
||||
@@ -1066,15 +1180,23 @@ def create_temp_kit_to_showcase(request):
|
||||
}, status=400)
|
||||
|
||||
# Агрегируем дубликаты (если один товар добавлен несколько раз)
|
||||
# Сохраняем также цену из корзины (unit_price)
|
||||
aggregated_items = {}
|
||||
for item in items:
|
||||
product_id = item['product_id']
|
||||
quantity = Decimal(str(item['quantity']))
|
||||
unit_price = item.get('unit_price') # Цена из корзины (может быть изменена пользователем)
|
||||
|
||||
if product_id in aggregated_items:
|
||||
aggregated_items[product_id] += quantity
|
||||
aggregated_items[product_id]['quantity'] += quantity
|
||||
# Если unit_price не был установлен ранее, но есть в текущем элементе, устанавливаем его
|
||||
if aggregated_items[product_id]['unit_price'] is None and unit_price is not None:
|
||||
aggregated_items[product_id]['unit_price'] = Decimal(str(unit_price))
|
||||
else:
|
||||
aggregated_items[product_id] = quantity
|
||||
aggregated_items[product_id] = {
|
||||
'quantity': quantity,
|
||||
'unit_price': Decimal(str(unit_price)) if unit_price is not None else None
|
||||
}
|
||||
|
||||
# Создаём временный комплект и резервируем на витрину
|
||||
with transaction.atomic():
|
||||
@@ -1087,15 +1209,20 @@ def create_temp_kit_to_showcase(request):
|
||||
price_adjustment_type=price_adjustment_type,
|
||||
price_adjustment_value=price_adjustment_value,
|
||||
sale_price=sale_price,
|
||||
showcase=showcase
|
||||
showcase=showcase,
|
||||
showcase_created_at=showcase_created_at
|
||||
)
|
||||
|
||||
# 2. Создаём KitItem для каждого товара из корзины
|
||||
for product_id, quantity in aggregated_items.items():
|
||||
for product_id, item_data in aggregated_items.items():
|
||||
product = products[product_id]
|
||||
# Используем цену из корзины, если передана, иначе из каталога
|
||||
final_price = item_data['unit_price'] if item_data['unit_price'] is not None else product.actual_price
|
||||
KitItem.objects.create(
|
||||
kit=kit,
|
||||
product=products[product_id],
|
||||
quantity=quantity
|
||||
product=product,
|
||||
quantity=item_data['quantity'],
|
||||
unit_price=final_price # Фиксируем цену из корзины (с учётом изменений пользователя)
|
||||
)
|
||||
|
||||
# 3. Пересчитываем цену комплекта
|
||||
@@ -1164,7 +1291,7 @@ def create_temp_kit_to_showcase(request):
|
||||
f' Название: {request.POST.get("kit_name")}\n'
|
||||
f' Витрина ID: {request.POST.get("showcase_id")}\n'
|
||||
f' Товары: {request.POST.get("items")}\n'
|
||||
f' Пользователь: {request.user.username}\n'
|
||||
f' Пользователь: {str(request.user)}\n'
|
||||
f' Ошибка: {str(e)}',
|
||||
exc_info=True
|
||||
)
|
||||
@@ -1220,17 +1347,43 @@ def update_product_kit(request, kit_id):
|
||||
sale_price_str = request.POST.get('sale_price', '')
|
||||
photo_file = request.FILES.get('photo')
|
||||
remove_photo = request.POST.get('remove_photo', '') == '1'
|
||||
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||
|
||||
items = json.loads(items_json)
|
||||
|
||||
# Получаем флаг use_sale_price для явной очистки sale_price
|
||||
use_sale_price = request.POST.get('use_sale_price', '0') == '1'
|
||||
|
||||
sale_price = None
|
||||
if sale_price_str:
|
||||
# Если use_sale_price = True, обрабатываем sale_price_str
|
||||
# Если use_sale_price = False, явно устанавливаем sale_price = None
|
||||
if use_sale_price and sale_price_str:
|
||||
try:
|
||||
sale_price = Decimal(str(sale_price_str))
|
||||
if sale_price <= 0:
|
||||
sale_price = None
|
||||
except (ValueError, InvalidOperation):
|
||||
sale_price = None
|
||||
else:
|
||||
# Явно очищаем sale_price, если чекбокс выключен
|
||||
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:
|
||||
@@ -1246,12 +1399,22 @@ def update_product_kit(request, kit_id):
|
||||
if len(products) != len(product_ids):
|
||||
return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400)
|
||||
|
||||
# Агрегируем количества
|
||||
# Агрегируем количества и цены
|
||||
aggregated_items = {}
|
||||
for item in items:
|
||||
product_id = item['product_id']
|
||||
quantity = Decimal(str(item['quantity']))
|
||||
aggregated_items[product_id] = aggregated_items.get(product_id, Decimal('0')) + quantity
|
||||
unit_price = item.get('unit_price')
|
||||
if product_id in aggregated_items:
|
||||
aggregated_items[product_id]['quantity'] += quantity
|
||||
# Если unit_price не был установлен ранее, но есть в текущем элементе, устанавливаем его
|
||||
if aggregated_items[product_id]['unit_price'] is None and unit_price is not None:
|
||||
aggregated_items[product_id]['unit_price'] = Decimal(str(unit_price))
|
||||
else:
|
||||
aggregated_items[product_id] = {
|
||||
'quantity': quantity,
|
||||
'unit_price': Decimal(str(unit_price)) if unit_price is not None else None
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
# Получаем старый состав для сравнения
|
||||
@@ -1272,7 +1435,7 @@ def update_product_kit(request, kit_id):
|
||||
|
||||
for product_id in all_product_ids:
|
||||
old_qty = old_items.get(product_id, Decimal('0'))
|
||||
new_qty = aggregated_items.get(product_id, Decimal('0'))
|
||||
new_qty = aggregated_items.get(product_id, {}).get('quantity', Decimal('0'))
|
||||
diff = new_qty - old_qty
|
||||
|
||||
if diff > 0 and showcase:
|
||||
@@ -1305,15 +1468,21 @@ def update_product_kit(request, kit_id):
|
||||
kit.price_adjustment_type = price_adjustment_type
|
||||
kit.price_adjustment_value = price_adjustment_value
|
||||
kit.sale_price = sale_price
|
||||
if showcase_created_at is not None: # Обновляем только если передана
|
||||
kit.showcase_created_at = showcase_created_at
|
||||
kit.save()
|
||||
|
||||
# Обновляем состав
|
||||
kit.kit_items.all().delete()
|
||||
for product_id, quantity in aggregated_items.items():
|
||||
for product_id, item_data in aggregated_items.items():
|
||||
product = products[product_id]
|
||||
# Используем переданную цену, если есть, иначе актуальную из каталога
|
||||
final_price = item_data['unit_price'] if item_data['unit_price'] is not None else product.actual_price
|
||||
KitItem.objects.create(
|
||||
kit=kit,
|
||||
product=products[product_id],
|
||||
quantity=quantity
|
||||
product=product,
|
||||
quantity=item_data['quantity'],
|
||||
unit_price=final_price
|
||||
)
|
||||
|
||||
kit.recalculate_base_price()
|
||||
@@ -1415,6 +1584,88 @@ def disassemble_product_kit(request, kit_id):
|
||||
}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def write_off_showcase_kit(request, kit_id):
|
||||
"""
|
||||
Списывает витринный комплект с созданием документа списания.
|
||||
|
||||
Args:
|
||||
request: HTTP запрос
|
||||
kit_id: ID комплекта для списания
|
||||
|
||||
Returns:
|
||||
JSON: {
|
||||
'success': bool,
|
||||
'document_id': int,
|
||||
'document_number': str,
|
||||
'redirect_url': str,
|
||||
'message': str,
|
||||
'error': str (если failed)
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Получаем комплект с витриной (только временные комплекты)
|
||||
kit = ProductKit.objects.select_related('showcase').get(id=kit_id, is_temporary=True)
|
||||
|
||||
# Проверяем, что комплект ещё не разобран
|
||||
if kit.status == 'discontinued':
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Комплект уже разобран (статус: Снят)'
|
||||
}, status=400)
|
||||
|
||||
# Проверяем, что у комплекта есть привязанная витрина
|
||||
if not kit.showcase:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Комплект не привязан к витрине'
|
||||
}, status=400)
|
||||
|
||||
# Находим экземпляр на витрине
|
||||
showcase_item = ShowcaseItem.objects.filter(
|
||||
showcase=kit.showcase,
|
||||
product_kit=kit,
|
||||
status='available'
|
||||
).first()
|
||||
|
||||
if not showcase_item:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Экземпляр комплекта не найден на витрине'
|
||||
}, status=404)
|
||||
|
||||
# Создаём документ списания
|
||||
result = ShowcaseManager.write_off_from_showcase(
|
||||
showcase_item=showcase_item,
|
||||
reason='spoilage',
|
||||
notes=f'Витринный букет: {kit.name}',
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
if not result['success']:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': result['message']
|
||||
}, status=400)
|
||||
|
||||
# Формируем URL для перенаправления
|
||||
redirect_url = reverse('inventory:writeoff-document-detail', kwargs={'pk': result['document_id']})
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'document_id': result['document_id'],
|
||||
'document_number': result['document_number'],
|
||||
'redirect_url': redirect_url,
|
||||
'message': result['message']
|
||||
})
|
||||
|
||||
except ProductKit.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def pos_checkout(request):
|
||||
@@ -1484,6 +1735,10 @@ def pos_checkout(request):
|
||||
|
||||
# Атомарная операция
|
||||
with db_transaction.atomic():
|
||||
# ВАЖНО: Устанавливаем флаг для пропуска автоматического создания Sale в сигнале.
|
||||
# Sale будет создан ЯВНО после применения всех скидок.
|
||||
skip_sale_creation()
|
||||
|
||||
# 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk)
|
||||
from django.utils import timezone as tz
|
||||
from orders.models import Delivery
|
||||
@@ -1672,6 +1927,11 @@ def pos_checkout(request):
|
||||
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
|
||||
cache.delete(cart_key)
|
||||
|
||||
# 7. Явно создаём Sale после применения всех скидок
|
||||
# Сбрасываем флаг пропуска и вызываем save() для активации сигнала
|
||||
reset_sale_creation()
|
||||
order.save() # Триггерит сигнал create_sale_on_order_completion
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'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 ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
|
||||
from .models import UnitOfMeasure, ProductSalesUnit
|
||||
from .models import BouquetName
|
||||
from .admin_displays import (
|
||||
format_quality_badge,
|
||||
format_quality_display,
|
||||
@@ -500,8 +501,8 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
cost_price_details_display.short_description = 'Себестоимость товара'
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Переопределяем queryset для доступа ко всем товарам (включая удаленные)"""
|
||||
qs = Product.all_objects.all()
|
||||
"""Переопределяем queryset для доступа ко всем товарам"""
|
||||
qs = super().get_queryset(request)
|
||||
ordering = self.get_ordering(request)
|
||||
if ordering:
|
||||
qs = qs.order_by(*ordering)
|
||||
@@ -1086,3 +1087,42 @@ class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
count
|
||||
)
|
||||
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:
|
||||
model = KitItem
|
||||
fields = ['product', 'variant_group', 'quantity']
|
||||
fields = ['product', 'variant_group', 'sales_unit', 'quantity']
|
||||
labels = {
|
||||
'product': 'Конкретный товар',
|
||||
'variant_group': 'Группа вариантов',
|
||||
'sales_unit': 'Единица продажи',
|
||||
'quantity': 'Количество'
|
||||
}
|
||||
widgets = {
|
||||
'product': 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'}),
|
||||
}
|
||||
|
||||
@@ -335,24 +337,35 @@ class KitItemForm(forms.ModelForm):
|
||||
cleaned_data = super().clean()
|
||||
product = cleaned_data.get('product')
|
||||
variant_group = cleaned_data.get('variant_group')
|
||||
sales_unit = cleaned_data.get('sales_unit')
|
||||
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
|
||||
return cleaned_data
|
||||
|
||||
# Валидация: должен быть указан либо product, либо variant_group (но не оба)
|
||||
if product and variant_group:
|
||||
# Валидация несовместимых полей
|
||||
if variant_group and (product or sales_unit):
|
||||
raise forms.ValidationError(
|
||||
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
|
||||
"Нельзя указывать группу вариантов одновременно с товаром или единицей продажи."
|
||||
)
|
||||
|
||||
# Валидация: если выбран товар/группа, количество обязательно и должно быть > 0
|
||||
if (product or variant_group):
|
||||
if not quantity or quantity <= 0:
|
||||
raise forms.ValidationError('Необходимо указать количество больше 0')
|
||||
# Если выбрана единица продажи, товар обязателен
|
||||
if sales_unit and not product:
|
||||
raise forms.ValidationError("Для единицы продажи должен быть выбран товар.")
|
||||
|
||||
# Валидация: если выбран товар/группа/единица продажи, количество обязательно и должно быть > 0
|
||||
if not quantity or quantity <= 0:
|
||||
raise forms.ValidationError('Необходимо указать количество больше 0')
|
||||
|
||||
# Валидация: если выбрана единица продажи, проверяем, что она принадлежит выбранному продукту
|
||||
if sales_unit and product and sales_unit.product != product:
|
||||
raise forms.ValidationError('Выбранная единица продажи не принадлежит указанному товару.')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
@@ -367,6 +380,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
|
||||
|
||||
products = []
|
||||
variant_groups = []
|
||||
sales_units = []
|
||||
|
||||
for form in self.forms:
|
||||
if self.can_delete and self._should_delete_form(form):
|
||||
@@ -374,6 +388,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
|
||||
|
||||
product = form.cleaned_data.get('product')
|
||||
variant_group = form.cleaned_data.get('variant_group')
|
||||
sales_unit = form.cleaned_data.get('sales_unit')
|
||||
|
||||
# Проверка дубликатов товаров
|
||||
if product:
|
||||
@@ -393,13 +408,22 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
|
||||
)
|
||||
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(
|
||||
ProductKit,
|
||||
KitItem,
|
||||
form=KitItemForm,
|
||||
formset=BaseKitItemFormSet,
|
||||
fields=['product', 'variant_group', 'quantity'],
|
||||
fields=['product', 'variant_group', 'sales_unit', 'quantity'],
|
||||
extra=1, # Показать 1 пустую форму для первого компонента
|
||||
can_delete=True, # Разрешить удаление компонентов
|
||||
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
|
||||
@@ -413,7 +437,7 @@ KitItemFormSetUpdate = inlineformset_factory(
|
||||
KitItem,
|
||||
form=KitItemForm,
|
||||
formset=BaseKitItemFormSet,
|
||||
fields=['product', 'variant_group', 'quantity'],
|
||||
fields=['product', 'variant_group', 'sales_unit', 'quantity'],
|
||||
extra=0, # НЕ показывать пустые формы при редактировании
|
||||
can_delete=True, # Разрешить удаление компонентов
|
||||
min_num=0, # Минимум 0 компонентов
|
||||
@@ -1297,12 +1321,49 @@ class ProductSalesUnitInlineForm(forms.ModelForm):
|
||||
return super().has_changed()
|
||||
|
||||
|
||||
class BaseProductSalesUnitFormSet(forms.BaseInlineFormSet):
|
||||
"""
|
||||
Базовый формсет для единиц продажи с валидацией.
|
||||
Обеспечивает, что только одна единица продажи может быть по умолчанию.
|
||||
"""
|
||||
def clean(self):
|
||||
if any(self.errors):
|
||||
return
|
||||
|
||||
default_count = 0
|
||||
default_forms = []
|
||||
|
||||
for form in self.forms:
|
||||
if self.can_delete and self._should_delete_form(form):
|
||||
continue
|
||||
|
||||
if not form.cleaned_data:
|
||||
continue
|
||||
|
||||
if form.cleaned_data.get('is_default'):
|
||||
default_count += 1
|
||||
default_forms.append(form)
|
||||
|
||||
if default_count > 1:
|
||||
# Находим названия единиц с is_default для более информативного сообщения
|
||||
unit_names = []
|
||||
for form in default_forms:
|
||||
name = form.cleaned_data.get('name', 'Без названия')
|
||||
unit_names.append(f'"{name}"')
|
||||
|
||||
raise forms.ValidationError(
|
||||
f'Можно установить только одну единицу продажи как "по умолчанию". '
|
||||
f'Найдено {default_count} единиц с флагом "по умолчанию": {", ".join(unit_names)}.'
|
||||
)
|
||||
|
||||
|
||||
# Inline formset для единиц продажи
|
||||
ProductSalesUnitFormSet = inlineformset_factory(
|
||||
Product,
|
||||
ProductSalesUnit,
|
||||
form=ProductSalesUnitInlineForm,
|
||||
extra=1,
|
||||
formset=BaseProductSalesUnitFormSet,
|
||||
extra=0,
|
||||
can_delete=True,
|
||||
min_num=0,
|
||||
validate_min=False,
|
||||
|
||||
144
myproject/products/management/commands/check_default_units.py
Normal file
144
myproject/products/management/commands/check_default_units.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Команда для проверки дубликатов is_default в ProductSalesUnit.
|
||||
|
||||
Проверяет, есть ли товары с несколькими единицами продажи, у которых is_default=True.
|
||||
Если дубликаты найдены, выводит детальную информацию и предлагает варианты исправления.
|
||||
|
||||
Использование:
|
||||
python manage.py check_default_units
|
||||
python manage.py check_default_units --fix # автоматически исправить дубликаты
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction, models
|
||||
from django.db.models import Count
|
||||
from products.models.units import ProductSalesUnit
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Проверить и исправить дубликаты is_default в ProductSalesUnit'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--fix',
|
||||
action='store_true',
|
||||
dest='fix',
|
||||
help='Автоматически исправить найденные дубликаты',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
dest='dry_run',
|
||||
help='Показать, что будет изменено, но не применять изменения',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
fix = options.get('fix')
|
||||
dry_run = options.get('dry_run')
|
||||
|
||||
self.stdout.write(self.style.WARNING('Проверка дубликатов is_default в ProductSalesUnit...'))
|
||||
self.stdout.write('')
|
||||
|
||||
# Находим все товары с несколькими is_default=True
|
||||
products_with_duplicates = (
|
||||
ProductSalesUnit.objects
|
||||
.filter(is_default=True)
|
||||
.values('product_id')
|
||||
.annotate(default_count=models.Count('id'))
|
||||
.filter(default_count__gt=1)
|
||||
)
|
||||
|
||||
if not products_with_duplicates.exists():
|
||||
self.stdout.write(self.style.SUCCESS('✓ Дубликаты не найдены. Все в порядке!'))
|
||||
return
|
||||
|
||||
# Собираем детальную информацию о дубликатах
|
||||
duplicates_info = []
|
||||
for item in products_with_duplicates:
|
||||
product_id = item['product_id']
|
||||
default_units = list(
|
||||
ProductSalesUnit.objects.filter(
|
||||
product_id=product_id,
|
||||
is_default=True
|
||||
).order_by('id')
|
||||
)
|
||||
duplicates_info.append({
|
||||
'product_id': product_id,
|
||||
'product_name': default_units[0].product.name if default_units else f'ID: {product_id}',
|
||||
'units': default_units
|
||||
})
|
||||
|
||||
# Выводим информацию о найденных дубликатах
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Найдено {len(duplicates_info)} товаров с дубликатами is_default:')
|
||||
)
|
||||
self.stdout.write('')
|
||||
|
||||
for info in duplicates_info:
|
||||
self.stdout.write(f' Товар: {info["product_name"]} (ID: {info["product_id"]})')
|
||||
self.stdout.write(f' Единиц с is_default=True: {len(info["units"])}')
|
||||
for unit in info['units']:
|
||||
self.stdout.write(f' - ID: {unit.id}, Название: "{unit.name}", Цена: {unit.price}₽')
|
||||
self.stdout.write('')
|
||||
|
||||
if not fix:
|
||||
self.stdout.write('Для исправления дубликатов запустите:')
|
||||
self.stdout.write(' python manage.py check_default_units --fix')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('Для проверки что будет изменено (без применения):')
|
||||
self.stdout.write(' python manage.py check_default_units --fix --dry-run')
|
||||
return
|
||||
|
||||
# Исправляем дубликаты
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('РЕЖИМ DRY-RUN - изменения не будут применены'))
|
||||
self.stdout.write('')
|
||||
|
||||
self.stdout.write(self.style.WARNING('Исправление дубликатов...'))
|
||||
self.stdout.write('')
|
||||
|
||||
with transaction.atomic():
|
||||
if dry_run:
|
||||
# В режиме dry-run просто показываем что будет сделано
|
||||
for info in duplicates_info:
|
||||
units = info['units']
|
||||
if len(units) > 1:
|
||||
# Оставляем первую, остальные снимаем флаг
|
||||
keep_unit = units[0]
|
||||
remove_units = units[1:]
|
||||
self.stdout.write(
|
||||
f' Товар: {info["product_name"]} (ID: {info["product_id"]})'
|
||||
)
|
||||
self.stdout.write(f' Оставить: ID {keep_unit.id}, "{keep_unit.name}"')
|
||||
for unit in remove_units:
|
||||
self.stdout.write(
|
||||
f' Снять is_default: ID {unit.id}, "{unit.name}"'
|
||||
)
|
||||
else:
|
||||
# Реальное исправление
|
||||
for info in duplicates_info:
|
||||
units = info['units']
|
||||
if len(units) > 1:
|
||||
keep_unit = units[0]
|
||||
remove_units = units[1:]
|
||||
|
||||
# Снимаем флаг is_default со всех кроме первой
|
||||
removed_ids = [unit.id for unit in remove_units]
|
||||
ProductSalesUnit.objects.filter(id__in=removed_ids).update(is_default=False)
|
||||
|
||||
self.stdout.write(
|
||||
f' ✓ Товар: {info["product_name"]} (ID: {info["product_id"]})'
|
||||
)
|
||||
self.stdout.write(
|
||||
f' Оставлен: ID {keep_unit.id}, "{keep_unit.name}"'
|
||||
)
|
||||
self.stdout.write(
|
||||
f' Снято is_default с {len(remove_units)} записей: {removed_ids}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
self.stdout.write('')
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'✓ Исправлено {len(duplicates_info)} товаров с дубликатами!'
|
||||
)
|
||||
)
|
||||
@@ -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='Дата размещения на витрине'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-27 18:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0003_productkit_showcase_created_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='productsalesunit',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('product',), name='unique_default_sales_unit_per_product', violation_error_message='У товара может быть только одна единица продажи по умолчанию'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-19 12:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0003_remove_unit_from_sales_unit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='kititem',
|
||||
name='unit_price',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, используется эта цена вместо актуальной цены товара. Применяется для временных витринных комплектов.', max_digits=10, 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 .bouquet_names import BouquetName
|
||||
|
||||
# Явно указываем, что экспортируется при импорте *
|
||||
__all__ = [
|
||||
# Managers
|
||||
@@ -98,4 +101,7 @@ __all__ = [
|
||||
|
||||
# Import Jobs
|
||||
'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="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
|
||||
)
|
||||
|
||||
# Showcase creation date - editable date for when the bouquet was put on display
|
||||
showcase_created_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата размещения на витрине",
|
||||
help_text="Дата создания букета для витрины (редактируемая)"
|
||||
)
|
||||
|
||||
order = models.ForeignKey(
|
||||
'orders.Order',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -162,13 +170,21 @@ class ProductKit(BaseProductEntity):
|
||||
|
||||
total = Decimal('0')
|
||||
for item in self.kit_items.all():
|
||||
if item.product:
|
||||
actual_price = item.product.actual_price or Decimal('0')
|
||||
qty = item.quantity or Decimal('1')
|
||||
total += actual_price * qty
|
||||
qty = item.quantity or Decimal('1')
|
||||
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:
|
||||
unit_price = item.unit_price
|
||||
else:
|
||||
unit_price = item.product.actual_price or Decimal('0')
|
||||
total += unit_price * qty
|
||||
elif item.variant_group:
|
||||
# Для variant_group unit_price не используется (только для продуктов)
|
||||
actual_price = item.variant_group.price or Decimal('0')
|
||||
qty = item.quantity or Decimal('1')
|
||||
total += actual_price * qty
|
||||
|
||||
self.base_price = total
|
||||
@@ -209,8 +225,16 @@ class ProductKit(BaseProductEntity):
|
||||
# Пересчитаем базовую цену из компонентов
|
||||
total = Decimal('0')
|
||||
for item in self.kit_items.all():
|
||||
if item.product:
|
||||
actual_price = item.product.actual_price or Decimal('0')
|
||||
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:
|
||||
# Используем зафиксированную цену (unit_price) если задана, иначе актуальную цену товара
|
||||
if item.unit_price is not None:
|
||||
actual_price = item.unit_price
|
||||
else:
|
||||
actual_price = item.product.actual_price or Decimal('0')
|
||||
qty = item.quantity or Decimal('1')
|
||||
total += actual_price * qty
|
||||
elif item.variant_group:
|
||||
@@ -297,7 +321,12 @@ class ProductKit(BaseProductEntity):
|
||||
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):
|
||||
"""
|
||||
@@ -315,17 +344,6 @@ class ProductKit(BaseProductEntity):
|
||||
self.save(update_fields=['is_temporary', 'order'])
|
||||
return True
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Soft delete вместо hard delete - марк как удаленный"""
|
||||
self.is_deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(update_fields=['is_deleted', 'deleted_at'])
|
||||
return 1, {self.__class__._meta.label: 1}
|
||||
|
||||
def hard_delete(self):
|
||||
"""Полное удаление из БД (необратимо!)"""
|
||||
super().delete()
|
||||
|
||||
def create_snapshot(self):
|
||||
"""
|
||||
Создает снимок текущего состояния комплекта.
|
||||
@@ -365,6 +383,8 @@ class ProductKit(BaseProductEntity):
|
||||
product_sku=item.product.sku if item.product else '',
|
||||
product_price=product_price,
|
||||
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'),
|
||||
)
|
||||
|
||||
@@ -373,8 +393,8 @@ class ProductKit(BaseProductEntity):
|
||||
|
||||
class KitItem(models.Model):
|
||||
"""
|
||||
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
|
||||
Позиция может быть либо конкретным товаром, либо группой вариантов.
|
||||
Состав комплекта: связь между ProductKit и Product, ProductVariantGroup или ProductSalesUnit.
|
||||
Позиция может быть либо конкретным товаром, либо группой вариантов, либо конкретной единицей продажи.
|
||||
"""
|
||||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
|
||||
verbose_name="Комплект")
|
||||
@@ -394,7 +414,23 @@ class KitItem(models.Model):
|
||||
related_name='kit_items',
|
||||
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="Количество")
|
||||
unit_price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Цена за единицу (зафиксированная)",
|
||||
help_text="Если задана, используется эта цена вместо актуальной цены товара. Применяется для временных витринных комплектов."
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Компонент комплекта"
|
||||
@@ -411,21 +447,46 @@ class KitItem(models.Model):
|
||||
return f"{self.kit.name} - {self.get_display_name()}"
|
||||
|
||||
def clean(self):
|
||||
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
|
||||
if self.product and self.variant_group:
|
||||
raise ValidationError(
|
||||
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
|
||||
)
|
||||
if not self.product and not self.variant_group:
|
||||
"""Валидация: должна быть указана группа вариантов ИЛИ (товар [плюс опционально единица продажи])"""
|
||||
|
||||
has_variant = bool(self.variant_group)
|
||||
has_product = bool(self.product)
|
||||
has_sales_unit = bool(self.sales_unit)
|
||||
|
||||
# 1. Проверка на пустоту
|
||||
if not (has_variant or has_product or has_sales_unit):
|
||||
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):
|
||||
"""Возвращает строку для отображения названия компонента"""
|
||||
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 self.product.name if self.product else "Не указан"
|
||||
return "Не указан"
|
||||
|
||||
def has_priorities_set(self):
|
||||
"""Проверяет, настроены ли приоритеты замены для данного компонента"""
|
||||
@@ -435,10 +496,16 @@ class KitItem(models.Model):
|
||||
"""
|
||||
Возвращает список доступных товаров для этого компонента.
|
||||
|
||||
Если указана единица продажи - возвращает товар, к которому она относится.
|
||||
Если указан конкретный товар - возвращает его.
|
||||
Если указаны приоритеты - возвращает товары в порядке приоритета.
|
||||
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
|
||||
"""
|
||||
# Приоритет: сначала единица продажи, затем товар, затем группа вариантов
|
||||
if self.sales_unit:
|
||||
# Если указана единица продажи, возвращаем товар, к которому она относится
|
||||
return [self.sales_unit.product]
|
||||
|
||||
if self.product:
|
||||
# Если указан конкретный товар, возвращаем только его
|
||||
return [self.product]
|
||||
|
||||
@@ -51,6 +51,8 @@ class Product(BaseProductEntity):
|
||||
on_delete=models.PROTECT,
|
||||
related_name='products',
|
||||
verbose_name="Базовая единица",
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах."
|
||||
)
|
||||
|
||||
@@ -139,6 +141,14 @@ class Product(BaseProductEntity):
|
||||
from ..services.cost_calculator import ProductCostCalculator
|
||||
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):
|
||||
# Используем сервис для подготовки к сохранению
|
||||
ProductSaveService.prepare_product_for_save(self)
|
||||
|
||||
@@ -128,6 +128,14 @@ class ProductSalesUnit(models.Model):
|
||||
verbose_name_plural = "Единицы продажи товаров"
|
||||
ordering = ['position', 'id']
|
||||
unique_together = [['product', 'name']]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['product'],
|
||||
condition=models.Q(is_default=True),
|
||||
name='unique_default_sales_unit_per_product',
|
||||
violation_error_message='У товара может быть только одна единица продажи по умолчанию'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.name}"
|
||||
@@ -146,10 +154,15 @@ class ProductSalesUnit(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
# Если это единица по умолчанию, снимаем флаг с других
|
||||
if self.is_default:
|
||||
ProductSalesUnit.objects.filter(
|
||||
# Используем exclude только если pk уже существует
|
||||
# Для новых записей (pk=None) exclude не нужен, т.к. запись еще не в БД
|
||||
queryset = ProductSalesUnit.objects.filter(
|
||||
product=self.product,
|
||||
is_default=True
|
||||
).exclude(pk=self.pk).update(is_default=False)
|
||||
)
|
||||
if self.pk:
|
||||
queryset = queryset.exclude(pk=self.pk)
|
||||
queryset.update(is_default=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Сервисы для бизнес-логики products приложения.
|
||||
Следует принципу "Skinny Models, Fat Services".
|
||||
Следует принципу "Тонкие модели, толстые сервисы".
|
||||
"""
|
||||
from .unit_service import UnitOfMeasureService
|
||||
from .ai.bouquet_names import BouquetNameGenerator
|
||||
|
||||
__all__ = [
|
||||
'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
|
||||
)
|
||||
@@ -111,6 +111,10 @@ def make_kit_permanent(kit: ProductKit) -> bool:
|
||||
kit.is_temporary = False
|
||||
kit.order = None # Отвязываем от заказа
|
||||
kit.save()
|
||||
|
||||
# Очищаем зафиксированные цены - теперь будет использоваться актуальная цена товаров
|
||||
kit.kit_items.update(unit_price=None)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class UnitOfMeasureService:
|
||||
{'code': 'банч', 'name': 'Банч', 'short_name': 'банч', 'position': 10},
|
||||
{'code': 'ветка', 'name': 'Ветка', 'short_name': 'вет.', 'position': 11},
|
||||
{'code': 'пучок', 'name': 'Пучок', 'short_name': 'пуч.', 'position': 12},
|
||||
{'code': 'коробка', 'name': 'Коробка', 'short_name': 'кор.', 'position': 13},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -207,6 +207,18 @@
|
||||
self._toggleProduct(productId);
|
||||
}
|
||||
});
|
||||
|
||||
// Двойной клик по товару - сразу добавляет в документ
|
||||
this.elements.grid.addEventListener('dblclick', function(e) {
|
||||
var productCard = e.target.closest('.product-picker-item');
|
||||
if (productCard && self.options.onAddSelected) {
|
||||
var productId = productCard.dataset.productId;
|
||||
var product = self._findProductById(productId);
|
||||
if (product) {
|
||||
self.options.onAddSelected(product, self);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Добавить выбранный
|
||||
@@ -435,17 +447,7 @@
|
||||
*/
|
||||
ProductSearchPicker.prototype._toggleProduct = function(productId) {
|
||||
var self = this;
|
||||
var product = null;
|
||||
|
||||
// Находим товар в списке
|
||||
for (var i = 0; i < this.state.products.length; i++) {
|
||||
var p = this.state.products[i];
|
||||
if (String(p.id).replace('product_', '') === productId) {
|
||||
product = p;
|
||||
product.id = productId; // Сохраняем очищенный ID
|
||||
break;
|
||||
}
|
||||
}
|
||||
var product = this._findProductById(productId);
|
||||
|
||||
if (!product) return;
|
||||
|
||||
@@ -473,6 +475,21 @@
|
||||
this._updateSelectionUI();
|
||||
};
|
||||
|
||||
/**
|
||||
* Поиск товара по ID в загруженном списке
|
||||
*/
|
||||
ProductSearchPicker.prototype._findProductById = function(productId) {
|
||||
for (var i = 0; i < this.state.products.length; i++) {
|
||||
var p = this.state.products[i];
|
||||
if (String(p.id).replace('product_', '') === productId) {
|
||||
var product = Object.assign({}, p);
|
||||
product.id = productId; // Сохраняем очищенный ID
|
||||
return product;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Принудительно снять выделение со всех товаров
|
||||
*/
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
{% elif item.item_type == 'kit' %}
|
||||
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
||||
<a href="{% url 'products:kit-detail' item.pk %}"
|
||||
<a href="{% url 'products:productkit-detail' item.pk %}"
|
||||
style="color: #6c757d;">{{ item.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -100,7 +100,7 @@
|
||||
{% elif item.item_type == 'product' %}
|
||||
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
{% elif item.item_type == 'kit' %}
|
||||
<a href="{% url 'products:kit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
<a href="{% url 'products:productkit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -48,27 +48,27 @@ ProductSearchPicker.init('#writeoff-products', {
|
||||
{% if skip_stock_filter %}data-skip-stock-filter="true"{% endif %}>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<!-- Строка поиска -->
|
||||
<div class="card-header bg-white py-3">
|
||||
<!-- Строка поиска - компактный размер -->
|
||||
<div class="card-header bg-white py-1">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="bi bi-search text-primary"></i>
|
||||
</span>
|
||||
<input type="text"
|
||||
class="form-control form-control-lg border-start-0 product-picker-search"
|
||||
class="form-control form-control-sm border-start-0 product-picker-search"
|
||||
placeholder="{{ title|default:'Поиск товара по названию, артикулу...' }}"
|
||||
style="box-shadow: none;">
|
||||
<button class="btn btn-outline-secondary product-picker-search-clear"
|
||||
<button class="btn btn-outline-secondary btn-sm product-picker-search-clear"
|
||||
type="button" style="display: none;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_filters|default:True %}
|
||||
<!-- Фильтры -->
|
||||
<div class="card-body border-bottom py-2">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<!-- Фильтры - компактный вид -->
|
||||
<div class="card-body border-bottom py-1">
|
||||
<div class="d-flex gap-1 align-items-center flex-wrap">
|
||||
{% if categories %}
|
||||
<!-- Фильтр по категории -->
|
||||
<select class="form-select form-select-sm product-picker-category" style="width: auto;">
|
||||
@@ -113,29 +113,29 @@ ProductSearchPicker.init('#writeoff-products', {
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Контент: сетка/список товаров -->
|
||||
<div class="card-body product-picker-content" style="max-height: {{ content_height|default:'400px' }}; overflow-y: auto;">
|
||||
<!-- Контент: сетка/список товаров - компактный -->
|
||||
<div class="card-body product-picker-content p-1" style="max-height: {{ content_height|default:'400px' }}; overflow-y: auto;">
|
||||
<!-- Индикатор загрузки -->
|
||||
<div class="product-picker-loading text-center py-4" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<div class="product-picker-loading text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сетка товаров -->
|
||||
<div class="row g-2 product-picker-grid" data-view="{{ initial_view|default:'list' }}">
|
||||
<div class="row g-1 product-picker-grid" data-view="{{ initial_view|default:'list' }}">
|
||||
<!-- Товары загружаются через AJAX -->
|
||||
</div>
|
||||
|
||||
<!-- Пустой результат -->
|
||||
<div class="product-picker-empty text-center py-4 text-muted" style="display: none;">
|
||||
<i class="bi bi-search fs-1 opacity-25"></i>
|
||||
<p class="mb-0 mt-2">Товары не найдены</p>
|
||||
<div class="product-picker-empty text-center py-2 text-muted" style="display: none;">
|
||||
<i class="bi bi-search fs-5 opacity-25"></i>
|
||||
<p class="mb-0 mt-1 small">Товары не найдены</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Футер с кнопкой действия -->
|
||||
<div class="card-footer bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<!-- Футер с кнопкой действия - компактный -->
|
||||
<div class="card-footer bg-white py-1 d-flex justify-content-between align-items-center flex-wrap gap-1">
|
||||
<div></div>
|
||||
|
||||
<button class="btn btn-primary btn-sm product-picker-add-selected" disabled>
|
||||
|
||||
@@ -8,25 +8,33 @@
|
||||
|
||||
<div id="kititem-forms">
|
||||
{% for kititem_form in kititem_formset %}
|
||||
<div class="card mb-2 kititem-form border"
|
||||
data-form-index="{{ forloop.counter0 }}"
|
||||
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 %}">
|
||||
<div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}"
|
||||
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 %}">
|
||||
{{ kititem_form.id }}
|
||||
<div class="card-body p-2">
|
||||
{% if kititem_form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-sm mb-2">
|
||||
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
<div class="alert alert-danger alert-sm mb-2">
|
||||
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
{{ kititem_form.product }}
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
@@ -34,19 +42,18 @@
|
||||
<div class="col-md-1 d-flex justify-content-center align-items-center">
|
||||
<div class="kit-item-separator">
|
||||
<span class="separator-text">ИЛИ</span>
|
||||
<i class="bi bi-info-circle separator-help"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
|
||||
<i class="bi bi-info-circle separator-help" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ГРУППА ВАРИАНТОВ -->
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Группа вариантов</label>
|
||||
{{ kititem_form.variant_group }}
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
@@ -55,17 +62,19 @@
|
||||
<label class="form-label small text-muted mb-1">Кол-во</label>
|
||||
{{ kititem_form.quantity|smart_quantity }}
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
<!-- УДАЛЕНИЕ -->
|
||||
<div class="col-md-1 text-end">
|
||||
{% 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="Удалить">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
{{ 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="Удалить">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
{{ kititem_form.DELETE }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
/**
|
||||
* Инициализирует Select2 для элемента с AJAX поиском товаров
|
||||
* @param {Element} element - DOM элемент select
|
||||
* @param {string} type - Тип поиска ('product' или 'variant')
|
||||
* @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
|
||||
* @param {string} apiUrl - URL API для поиска
|
||||
* @returns {boolean} - true если инициализация прошла успешно, false иначе
|
||||
*/
|
||||
@@ -70,60 +70,92 @@
|
||||
|
||||
var placeholders = {
|
||||
'product': 'Начните вводить название товара...',
|
||||
'variant': 'Начните вводить название группы...'
|
||||
'variant': 'Начните вводить название группы...',
|
||||
'sales_unit': 'Выберите единицу продажи...'
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
// Для единиц продажи используем другой подход - не AJAX, а загрузка при выборе товара
|
||||
if (type === 'sales_unit') {
|
||||
try {
|
||||
$element.select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: placeholders[type] || 'Выберите...',
|
||||
allowClear: true,
|
||||
width: '100%',
|
||||
language: 'ru',
|
||||
minimumInputLength: 0,
|
||||
dropdownAutoWidth: false,
|
||||
// Для единиц продажи не используем AJAX, т.к. они загружаются при выборе товара
|
||||
disabled: true, // Изначально отключен до выбора товара
|
||||
templateResult: formatSelectResult,
|
||||
templateSelection: formatSelectSelection
|
||||
});
|
||||
console.log('initProductSelect2: successfully initialized sales_unit for', element.name);
|
||||
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) {
|
||||
return {
|
||||
results: data.results,
|
||||
pagination: {
|
||||
more: data.pagination.more
|
||||
}
|
||||
};
|
||||
},
|
||||
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;
|
||||
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 для всех селектов, совпадающих с паттерном
|
||||
* @param {string} fieldPattern - Паттерн name атрибута (например: 'items-', 'kititem-')
|
||||
* @param {string} type - Тип поиска ('product' или 'variant')
|
||||
* @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
|
||||
* @param {string} apiUrl - URL API для поиска
|
||||
*/
|
||||
window.initAllProductSelect2 = function(fieldPattern, type, apiUrl) {
|
||||
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-product"]').forEach(function(element) {
|
||||
window.initProductSelect2(element, type, apiUrl);
|
||||
});
|
||||
if (type === 'sales_unit') {
|
||||
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>
|
||||
|
||||
@@ -362,6 +362,113 @@
|
||||
</table>
|
||||
</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 class="col-md-4">
|
||||
|
||||
@@ -119,6 +119,365 @@
|
||||
.photo-card:hover .card {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* === Стили для маркетинговых флагов === */
|
||||
.marketing-flag-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.marketing-flag-card:hover {
|
||||
border-color: #28a745;
|
||||
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.marketing-flag-card:has(input:checked) {
|
||||
background: linear-gradient(135deg, #f8fff9 0%, #ffffff 100%);
|
||||
border-color: #28a745;
|
||||
border-width: 2px;
|
||||
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.15);
|
||||
}
|
||||
|
||||
.marketing-flag-card:has(input:checked) .marketing-flag-title {
|
||||
color: #28a745;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.marketing-flag-card:has(input:checked) .marketing-flag-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.marketing-flag-icon {
|
||||
font-size: 1.75rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.marketing-flag-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #212529;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.marketing-flag-card small {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Крупные выключатели для маркетинговых флагов */
|
||||
.marketing-flag-switch {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input {
|
||||
width: 3.5rem !important;
|
||||
height: 1.875rem !important;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
background-color: #ced4da;
|
||||
border: 2px solid #ced4da;
|
||||
border-radius: 2rem !important;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input::after {
|
||||
width: 1.5rem !important;
|
||||
height: 1.5rem !important;
|
||||
border-radius: 50% !important;
|
||||
background-color: #fff;
|
||||
transition: transform 0.3s ease;
|
||||
transform: translateX(0.125rem);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input:checked {
|
||||
background-color: #28a745 !important;
|
||||
border-color: #28a745 !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input:checked::after {
|
||||
transform: translateX(1.625rem);
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(40, 167, 69, 0.25);
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-label {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Адаптивность для маркетинговых флагов */
|
||||
@media (max-width: 768px) {
|
||||
.marketing-flag-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.marketing-flag-icon {
|
||||
font-size: 1.5rem;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.marketing-flag-title {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input {
|
||||
width: 3rem !important;
|
||||
height: 1.625rem !important;
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input::after {
|
||||
width: 1.375rem !important;
|
||||
height: 1.375rem !important;
|
||||
}
|
||||
|
||||
.marketing-flag-switch .form-check-input:checked::after {
|
||||
transform: translateX(1.375rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Стили для таблицы единиц продажи === */
|
||||
#sales-units-container {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sales-units-container .table-responsive {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#sales-units-container .table {
|
||||
margin-bottom: 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#sales-units-container .table thead th {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0.75rem 0.5rem;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#sales-units-container .table tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
height: 60px; /* Фиксированная высота строки */
|
||||
}
|
||||
|
||||
#sales-units-container .table tbody tr:hover {
|
||||
background-color: #f8f9fa !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
#sales-units-container .table tbody tr.bg-light {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
#sales-units-container .table tbody td {
|
||||
padding: 0.5rem;
|
||||
vertical-align: middle;
|
||||
border-top: 1px solid #e9ecef;
|
||||
height: 60px; /* Фиксированная высота ячейки */
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
#sales-units-container .table tbody td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
#sales-units-container .table thead th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Стили для полей ввода в таблице */
|
||||
#sales-units-container .table input[type="text"],
|
||||
#sales-units-container .table input[type="number"] {
|
||||
width: 100%;
|
||||
min-width: 60px;
|
||||
max-width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#sales-units-container .table input[type="text"]:focus,
|
||||
#sales-units-container .table input[type="number"]:focus {
|
||||
border-color: #28a745;
|
||||
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.15);
|
||||
outline: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#sales-units-container .table input[type="text"]:hover,
|
||||
#sales-units-container .table input[type="number"]:hover {
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
/* Стили для оберток полей */
|
||||
#sales-units-container .table td > div {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
/* Стили для чекбоксов */
|
||||
#sales-units-container .table input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
accent-color: #28a745;
|
||||
}
|
||||
|
||||
#sales-units-container .table td.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Стилизация чекбоксов "Активна" и "Удалить" */
|
||||
#sales-units-container .table .form-check {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: auto;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#sales-units-container .table .form-check:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
#sales-units-container .table .form-check input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 0.25rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#sales-units-container .table .form-check-label {
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Стили для чекбокса "Удалить" */
|
||||
#sales-units-container .table .form-check:has(input[name*="-DELETE"]) .form-check-label {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
#sales-units-container .table .form-check:has(input[name*="-DELETE"]:checked) {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
/* Выделение строки с единицей по умолчанию */
|
||||
#sales-units-container .table tbody tr:has(input[name*="-is_default"]:checked) {
|
||||
background-color: #d4edda !important;
|
||||
border-left: 3px solid #28a745;
|
||||
}
|
||||
|
||||
#sales-units-container .table tbody tr:has(input[name*="-is_default"]:checked) td:first-child {
|
||||
padding-left: calc(0.5rem - 3px);
|
||||
}
|
||||
|
||||
/* Стили для ошибок валидации */
|
||||
#sales-units-container .table tbody tr + tr[style*="display"] td {
|
||||
padding: 0.75rem;
|
||||
background-color: #f8d7da;
|
||||
border-top: 2px solid #dc3545;
|
||||
}
|
||||
|
||||
/* Улучшение для кнопки добавления */
|
||||
#add-sales-unit {
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#add-sales-unit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
/* Стили для пустых полей */
|
||||
#sales-units-container .table input[type="text"]:placeholder-shown,
|
||||
#sales-units-container .table input[type="number"]:placeholder-shown {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Улучшение для числовых полей */
|
||||
#sales-units-container .table input[type="number"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#sales-units-container .table td:has(input[type="number"]) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Стили для первой колонки (название) */
|
||||
#sales-units-container .table tbody td:first-child input {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Адаптивность для маленьких экранов */
|
||||
@media (max-width: 768px) {
|
||||
#sales-units-container .table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#sales-units-container .table thead th,
|
||||
#sales-units-container .table tbody td {
|
||||
padding: 0.375rem 0.25rem;
|
||||
}
|
||||
|
||||
#sales-units-container .table tbody tr {
|
||||
height: auto;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
#sales-units-container .table input[type="text"],
|
||||
#sales-units-container .table input[type="number"] {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -546,35 +905,61 @@
|
||||
|
||||
<!-- Блок: Маркетинговые флаги -->
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3"><i class="bi bi-tag"></i> Маркетинговые флаги</h5>
|
||||
<p class="text-muted small mb-3">Отображаются на внешних площадках (Recommerce и др.)</p>
|
||||
<div class="row">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-tag"></i> Маркетинговые флаги</h5>
|
||||
<small class="text-muted">Отображаются на внешних площадках</small>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
{{ form.is_new }}
|
||||
<label class="form-check-label" for="id_is_new">
|
||||
<i class="bi bi-stars text-warning"></i> Новинка
|
||||
</label>
|
||||
<div class="marketing-flag-card">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-stars marketing-flag-icon text-warning"></i>
|
||||
<div>
|
||||
<div class="marketing-flag-title">Новинка</div>
|
||||
<small class="text-muted">Товар как новый</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch marketing-flag-switch">
|
||||
{{ form.is_new }}
|
||||
<label class="form-check-label" for="id_is_new"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block ms-4">Товар отображается как новый</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
{{ form.is_popular }}
|
||||
<label class="form-check-label" for="id_is_popular">
|
||||
<i class="bi bi-fire text-danger"></i> Популярный
|
||||
</label>
|
||||
<div class="marketing-flag-card">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-fire marketing-flag-icon text-danger"></i>
|
||||
<div>
|
||||
<div class="marketing-flag-title">Популярный</div>
|
||||
<small class="text-muted">Популярный товар</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch marketing-flag-switch">
|
||||
{{ form.is_popular }}
|
||||
<label class="form-check-label" for="id_is_popular"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block ms-4">Товар отображается как популярный</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
{{ form.is_special }}
|
||||
<label class="form-check-label" for="id_is_special">
|
||||
<i class="bi bi-percent text-success"></i> Спецпредложение
|
||||
</label>
|
||||
<div class="marketing-flag-card">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-percent marketing-flag-icon text-success"></i>
|
||||
<div>
|
||||
<div class="marketing-flag-title">Спецпредложение</div>
|
||||
<small class="text-muted">Акционный товар</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch marketing-flag-switch">
|
||||
{{ form.is_special }}
|
||||
<label class="form-check-label" for="id_is_special"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block ms-4">Акционный товар (+ автоматически при скидке)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -605,122 +990,159 @@
|
||||
<!-- Шаблон для новых форм (скрыт) -->
|
||||
<template id="empty-sales-unit-template">
|
||||
{% with form=sales_unit_formset.empty_form %}
|
||||
<div class="sales-unit-row border rounded p-3 mb-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Ед. измерения</label>
|
||||
{{ form.unit }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Название</label>
|
||||
<tr class="sales-unit-row">
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.name }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Коэфф.</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.conversion_factor }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Цена</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.price }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Скидка</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.sale_price }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Мин.кол</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.min_quantity }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Шаг</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.quantity_step }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Поз.</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.position }}
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small d-block">По умолч.</label>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-items-center justify-content-center h-100">
|
||||
{{ form.is_default }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<div class="d-flex gap-1">
|
||||
<div class="form-check" title="Активна">
|
||||
{{ form.is_active }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-2 align-items-center justify-content-center h-100 flex-wrap">
|
||||
<div class="form-check mb-0" title="Активна">
|
||||
{{ form.is_active }}
|
||||
<label class="form-check-label small" for="{{ form.is_active.id_for_label }}">
|
||||
Активна
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
</template>
|
||||
|
||||
<div id="sales-units-container">
|
||||
{% for form in sales_unit_formset %}
|
||||
<div class="sales-unit-row border rounded p-3 mb-2 {% if form.instance.pk %}bg-light{% endif %}">
|
||||
<div class="row g-2 align-items-end">
|
||||
{% if form.instance.pk %}
|
||||
<input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Ед. измерения</label>
|
||||
{{ form.unit }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Название</label>
|
||||
{{ form.name }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Коэфф.</label>
|
||||
{{ form.conversion_factor }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Цена</label>
|
||||
{{ form.price }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Скидка</label>
|
||||
{{ form.sale_price }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Мин.кол</label>
|
||||
{{ form.min_quantity }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Шаг</label>
|
||||
{{ form.quantity_step }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Поз.</label>
|
||||
{{ form.position }}
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small d-block">По умолч.</label>
|
||||
{{ form.is_default }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<div class="d-flex gap-1">
|
||||
<div class="form-check" title="Активна">
|
||||
{{ form.is_active }}
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 20%">Название</th>
|
||||
<th style="width: 10%">Коэфф.</th>
|
||||
<th style="width: 10%">Цена</th>
|
||||
<th style="width: 10%">Скидка</th>
|
||||
<th style="width: 10%">Мин.кол</th>
|
||||
<th style="width: 10%">Шаг</th>
|
||||
<th style="width: 10%">Поз.</th>
|
||||
<th style="width: 10%" class="text-center">По умолч.</th>
|
||||
<th style="width: 10%">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for form in sales_unit_formset %}
|
||||
<tr class="{% if form.instance.pk %}bg-light{% endif %} sales-unit-row">
|
||||
{% if form.instance.pk %}
|
||||
<div class="form-check" title="Удалить">
|
||||
{{ form.DELETE }}
|
||||
</div>
|
||||
<input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if form.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for field, errors in form.errors.items %}
|
||||
{{ field }}: {{ errors|join:", " }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.name }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.conversion_factor }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.price }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.sale_price }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.min_quantity }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.quantity_step }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center h-100">
|
||||
{{ form.position }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-items-center justify-content-center h-100">
|
||||
{{ form.is_default }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-2 align-items-center justify-content-center h-100 flex-wrap">
|
||||
<div class="form-check mb-0" title="Активна">
|
||||
{{ form.is_active }}
|
||||
<label class="form-check-label small" for="{{ form.is_active.id_for_label }}">
|
||||
Активна
|
||||
</label>
|
||||
</div>
|
||||
{% if form.instance.pk %}
|
||||
<div class="form-check mb-0" title="Удалить">
|
||||
{{ form.DELETE }}
|
||||
<label class="form-check-label small text-danger" for="{{ form.DELETE.id_for_label }}">
|
||||
Удалить
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% if form.errors %}
|
||||
<tr class="table-danger">
|
||||
<td colspan="9" class="py-2">
|
||||
<div class="text-danger small">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
{% for field, errors in form.errors.items %}
|
||||
<strong>{{ field }}:</strong> {{ errors|join:", " }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-outline-success btn-sm mt-2" id="add-sales-unit">
|
||||
@@ -840,7 +1262,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// === Динамическое добавление единиц продажи ===
|
||||
const addButton = document.getElementById('add-sales-unit');
|
||||
const container = document.getElementById('sales-units-container');
|
||||
const container = document.querySelector('#sales-units-container tbody');
|
||||
const totalFormsInput = document.querySelector('[name="sales_units-TOTAL_FORMS"]');
|
||||
|
||||
if (addButton && container && totalFormsInput) {
|
||||
@@ -851,10 +1273,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (template) {
|
||||
// Клонируем содержимое шаблона
|
||||
const newRow = template.content.cloneNode(true);
|
||||
const rowDiv = newRow.querySelector('.sales-unit-row');
|
||||
const row = newRow.querySelector('tr');
|
||||
|
||||
// Обновляем имена и id полей
|
||||
rowDiv.querySelectorAll('input, select').forEach(input => {
|
||||
row.querySelectorAll('input, select').forEach(input => {
|
||||
const name = input.getAttribute('name');
|
||||
const id = input.getAttribute('id');
|
||||
|
||||
@@ -884,11 +1306,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(rowDiv);
|
||||
container.appendChild(row);
|
||||
totalFormsInput.value = formCount + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Логика для чекбоксов "По умолчанию" ===
|
||||
// Используем делегирование событий для обработки всех чекбоксов is_default
|
||||
// (включая динамически добавляемые)
|
||||
const salesUnitsContainer = document.getElementById('sales-units-container');
|
||||
if (salesUnitsContainer) {
|
||||
salesUnitsContainer.addEventListener('change', function(e) {
|
||||
// Проверяем, что это чекбокс is_default
|
||||
if (e.target && e.target.name && e.target.name.includes('-is_default')) {
|
||||
if (e.target.checked) {
|
||||
// Если этот чекбокс отмечен, снимаем галочки с остальных
|
||||
const allIsDefaultCheckboxes = document.querySelectorAll('input[name*="-is_default"]');
|
||||
allIsDefaultCheckboxes.forEach(cb => {
|
||||
if (cb !== e.target) {
|
||||
cb.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,13 @@
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</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 %}">
|
||||
{{ item.product.name }}
|
||||
</a>
|
||||
@@ -149,7 +155,9 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.product %}
|
||||
{% if item.sales_unit %}
|
||||
<span class="badge bg-info">Единица продажи</span>
|
||||
{% elif item.product %}
|
||||
<span class="badge bg-success">Товар</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">Варианты</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,13 @@
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_name" class="form-label">Название *</label>
|
||||
{{ form.name }}
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">Название *</label>
|
||||
<input type="text"
|
||||
name="{{ form.name.html_name }}"
|
||||
class="form-control{% if form.name.errors %} is-invalid{% endif %}"
|
||||
id="{{ form.name.id_for_label }}"
|
||||
value="{{ form.name.value|default:'' }}"
|
||||
required>
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.name.errors }}
|
||||
@@ -36,8 +41,11 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_description" class="form-label">Описание</label>
|
||||
{{ form.description }}
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">Описание</label>
|
||||
<textarea name="{{ form.description.html_name }}"
|
||||
class="form-control{% if form.description.errors %} is-invalid{% endif %}"
|
||||
id="{{ form.description.id_for_label }}"
|
||||
rows="3">{{ form.description.value|default:'' }}</textarea>
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.description.errors }}
|
||||
@@ -46,8 +54,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_categories" class="form-label">Категории</label>
|
||||
{{ form.categories }}
|
||||
<label for="{{ form.categories.id_for_label }}" class="form-label">Категории</label>
|
||||
<select name="{{ form.categories.html_name }}"
|
||||
class="form-select{% if form.categories.errors %} is-invalid{% endif %}"
|
||||
id="{{ form.categories.id_for_label }}"
|
||||
multiple>
|
||||
{% for value, label in form.categories.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if value in form.categories.value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.categories.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.categories.errors }}
|
||||
@@ -59,8 +77,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_tags" class="form-label">Теги</label>
|
||||
{{ form.tags }}
|
||||
<label for="{{ form.tags.id_for_label }}" class="form-label">Теги</label>
|
||||
<select name="{{ form.tags.html_name }}"
|
||||
class="form-select{% if form.tags.errors %} is-invalid{% endif %}"
|
||||
id="{{ form.tags.id_for_label }}"
|
||||
multiple>
|
||||
{% for value, label in form.tags.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if value in form.tags.value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.tags.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.tags.errors }}
|
||||
@@ -69,8 +97,14 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_sale_price" class="form-label">Цена со скидкой</label>
|
||||
{{ form.sale_price }}
|
||||
<label for="{{ form.sale_price.id_for_label }}" class="form-label">Цена со скидкой</label>
|
||||
<input type="number"
|
||||
name="{{ form.sale_price.html_name }}"
|
||||
class="form-control{% if form.sale_price.errors %} is-invalid{% endif %}"
|
||||
id="{{ form.sale_price.id_for_label }}"
|
||||
value="{{ form.sale_price.value|default:'' }}"
|
||||
step="0.01"
|
||||
min="0">
|
||||
{% if form.sale_price.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.sale_price.errors }}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% csrf_token %}
|
||||
<div class="container-fluid mt-4">
|
||||
<h2 class="mb-4">
|
||||
<i class="bi bi-box-seam"></i> Товары и они комплекты
|
||||
<i class="bi bi-box-seam"></i> Товары и комплекты
|
||||
</h2>
|
||||
|
||||
<!-- Панель фильтрации и действий -->
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-secondary">
|
||||
Отмена
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" name="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> {{ submit_text }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
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>/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/photos/delete-bulk/', views.productkit_photos_delete_bulk, name='productkit-photos-delete-bulk'),
|
||||
|
||||
# API endpoints
|
||||
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/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/bouquet-names/random/', api_views.RandomBouquetNamesView.as_view(), name='api-random-bouquet-names'),
|
||||
path('api/bouquet-names/generate/', api_views.GenerateBouquetNamesView.as_view(), name='api-generate-bouquet-names'),
|
||||
path('api/bouquet-names/<int:pk>/delete/', api_views.DeleteBouquetNameView.as_view(), name='api-delete-bouquet-name'),
|
||||
path('api/bouquet-names/count/', api_views.GetBouquetNamesCountView.as_view(), name='api-get-bouquet-names-count'),
|
||||
|
||||
# Photo processing status API (for AJAX polling)
|
||||
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||
|
||||
@@ -158,49 +158,41 @@ class ImageProcessor:
|
||||
@staticmethod
|
||||
def _resize_image(img, size):
|
||||
"""
|
||||
Изменяет размер изображения с сохранением пропорций.
|
||||
Изменяет размер изображения с center-crop до точного квадратного размера.
|
||||
НЕ увеличивает маленькие изображения (сохраняет качество).
|
||||
Создает адаптивный квадрат по размеру реального изображения.
|
||||
Создает квадратное изображение без белых полей.
|
||||
|
||||
Args:
|
||||
img: PIL Image object
|
||||
size: Кортеж (width, height) - максимальный целевой размер
|
||||
size: Кортеж (width, height) - целевой размер (обычно квадратный)
|
||||
|
||||
Returns:
|
||||
PIL Image object - квадратное изображение с минимальным белым фоном
|
||||
PIL Image object - квадратное изображение без белых полей
|
||||
"""
|
||||
# Копируем изображение, чтобы не модифицировать оригинал
|
||||
img_copy = img.copy()
|
||||
target_width, target_height = size
|
||||
|
||||
# Вычисляем пропорции исходного изображения и целевого размера
|
||||
img_aspect = img_copy.width / img_copy.height
|
||||
target_aspect = size[0] / size[1]
|
||||
# Шаг 1: Center crop для получения квадрата
|
||||
# Определяем минимальную сторону (будет размер квадрата)
|
||||
min_side = min(img_copy.width, img_copy.height)
|
||||
|
||||
# Определяем, какой размер будет ограничивающим при масштабировании
|
||||
if img_aspect > target_aspect:
|
||||
# Изображение шире - ограничиваемый размер это ширина
|
||||
new_width = min(img_copy.width, size[0])
|
||||
new_height = int(new_width / img_aspect)
|
||||
# Вычисляем координаты для обрезки из центра
|
||||
left = (img_copy.width - min_side) // 2
|
||||
top = (img_copy.height - min_side) // 2
|
||||
right = left + min_side
|
||||
bottom = top + min_side
|
||||
|
||||
# Обрезаем до квадрата
|
||||
img_cropped = img_copy.crop((left, top, right, bottom))
|
||||
|
||||
# Шаг 2: Масштабируем до целевого размера (если исходный квадрат больше цели)
|
||||
# Не увеличиваем маленькие изображения
|
||||
if min_side > target_width:
|
||||
img_resized = img_cropped.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||
else:
|
||||
# Изображение выше - ограничиваемый размер это высота
|
||||
new_height = min(img_copy.height, size[1])
|
||||
new_width = int(new_height * img_aspect)
|
||||
img_resized = img_cropped
|
||||
|
||||
# Масштабируем только если необходимо (не увеличиваем маленькие изображения)
|
||||
if img_copy.width > new_width or img_copy.height > new_height:
|
||||
img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Создаем адаптивный квадрат по размеру реального изображения (а не по конфигурации)
|
||||
# Это позволяет избежать огромных белых полей для маленьких фото
|
||||
square_size = max(img_copy.width, img_copy.height)
|
||||
new_img = Image.new('RGB', (square_size, square_size), (255, 255, 255))
|
||||
|
||||
# Центрируем исходное изображение на белом фоне
|
||||
offset_x = (square_size - img_copy.width) // 2
|
||||
offset_y = (square_size - img_copy.height) // 2
|
||||
new_img.paste(img_copy, (offset_x, offset_y))
|
||||
|
||||
return new_img
|
||||
return img_resized
|
||||
|
||||
@staticmethod
|
||||
def _make_square_image(img, max_size):
|
||||
|
||||
@@ -21,6 +21,7 @@ from .photo_management import (
|
||||
productkit_photo_set_main,
|
||||
productkit_photo_move_up,
|
||||
productkit_photo_move_down,
|
||||
productkit_photos_delete_bulk,
|
||||
)
|
||||
|
||||
# Управление фотографиями (Category)
|
||||
@@ -114,7 +115,14 @@ from .attribute_views import (
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -149,6 +157,7 @@ __all__ = [
|
||||
'productkit_photo_set_main',
|
||||
'productkit_photo_move_up',
|
||||
'productkit_photo_move_down',
|
||||
'productkit_photos_delete_bulk',
|
||||
|
||||
# Управление фотографиями Category
|
||||
'category_photo_delete',
|
||||
@@ -225,6 +234,8 @@ __all__ = [
|
||||
'validate_kit_cost',
|
||||
'create_temporary_kit_api',
|
||||
'create_tag_api',
|
||||
'RandomBouquetNamesView',
|
||||
'GenerateBouquetNamesView',
|
||||
|
||||
# Каталог
|
||||
'CatalogView',
|
||||
|
||||
@@ -1800,3 +1800,72 @@ def bulk_update_categories(request):
|
||||
'success': False,
|
||||
'message': f'Произошла ошибка: {str(e)}'
|
||||
}, 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':
|
||||
self.price = obj.sale_price
|
||||
elif item_type == 'kit':
|
||||
self.price = obj.get_sale_price()
|
||||
self.price = obj.actual_price
|
||||
else:
|
||||
self.price = None
|
||||
|
||||
|
||||
@@ -380,3 +380,67 @@ def product_photos_delete_bulk(request):
|
||||
'success': False,
|
||||
'error': f'Ошибка сервера: {str(e)}'
|
||||
}, 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)
|
||||
|
||||
@@ -122,34 +122,35 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
from django.db import IntegrityError
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
context = self.get_context_data()
|
||||
sales_unit_formset = context['sales_unit_formset']
|
||||
|
||||
try:
|
||||
# Сначала сохраняем товар
|
||||
self.object = form.save()
|
||||
with transaction.atomic():
|
||||
# Сначала сохраняем товар
|
||||
self.object = form.save()
|
||||
|
||||
# Затем сохраняем единицы продажи
|
||||
if sales_unit_formset.is_valid():
|
||||
sales_unit_formset.instance = self.object
|
||||
sales_unit_formset.save()
|
||||
else:
|
||||
# Если formset невалиден, показываем ошибки
|
||||
for error in sales_unit_formset.errors:
|
||||
if error:
|
||||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||
# Затем сохраняем единицы продажи
|
||||
if sales_unit_formset.is_valid():
|
||||
sales_unit_formset.instance = self.object
|
||||
sales_unit_formset.save()
|
||||
else:
|
||||
# Если formset невалиден, показываем ошибки
|
||||
for error in sales_unit_formset.errors:
|
||||
if error:
|
||||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||
|
||||
# Обработка загрузки фотографий
|
||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||
if photo_errors:
|
||||
for error in photo_errors:
|
||||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||||
if 'Загружено' in error and 'обработано только' in error:
|
||||
messages.warning(self.request, error)
|
||||
else:
|
||||
messages.error(self.request, error)
|
||||
# Обработка загрузки фотографий
|
||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||
if photo_errors:
|
||||
for error in photo_errors:
|
||||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||||
if 'Загружено' in error and 'обработано только' in error:
|
||||
messages.warning(self.request, error)
|
||||
else:
|
||||
messages.error(self.request, error)
|
||||
|
||||
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
||||
return super().form_valid(form)
|
||||
@@ -157,7 +158,13 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
|
||||
except IntegrityError as e:
|
||||
# Обработка ошибки дублирования slug'а или других unique constraints
|
||||
error_msg = str(e).lower()
|
||||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
||||
if 'unique_default_sales_unit_per_product' in error_msg or 'is_default' in error_msg:
|
||||
messages.error(
|
||||
self.request,
|
||||
'Ошибка: у товара может быть только одна единица продажи по умолчанию. '
|
||||
'Пожалуйста, выберите только одну единицу как "по умолчанию".'
|
||||
)
|
||||
elif 'slug' in error_msg or 'duplicate key' in error_msg:
|
||||
messages.error(
|
||||
self.request,
|
||||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||||
@@ -208,6 +215,15 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
|
||||
# Единицы продажи (активные, отсортированные)
|
||||
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
|
||||
|
||||
|
||||
@@ -240,34 +256,35 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
from django.db import IntegrityError
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
context = self.get_context_data()
|
||||
sales_unit_formset = context['sales_unit_formset']
|
||||
|
||||
try:
|
||||
# Сначала сохраняем товар
|
||||
self.object = form.save()
|
||||
with transaction.atomic():
|
||||
# Сначала сохраняем товар
|
||||
self.object = form.save()
|
||||
|
||||
# Затем сохраняем единицы продажи
|
||||
if sales_unit_formset.is_valid():
|
||||
sales_unit_formset.instance = self.object
|
||||
sales_unit_formset.save()
|
||||
else:
|
||||
# Если formset невалиден, показываем ошибки
|
||||
for error in sales_unit_formset.errors:
|
||||
if error:
|
||||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||
# Затем сохраняем единицы продажи
|
||||
if sales_unit_formset.is_valid():
|
||||
sales_unit_formset.instance = self.object
|
||||
sales_unit_formset.save()
|
||||
else:
|
||||
# Если formset невалиден, показываем ошибки
|
||||
for error in sales_unit_formset.errors:
|
||||
if error:
|
||||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||
|
||||
# Обработка загрузки фотографий
|
||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||
if photo_errors:
|
||||
for error in photo_errors:
|
||||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||||
if 'Загружено' in error and 'обработано только' in error:
|
||||
messages.warning(self.request, error)
|
||||
else:
|
||||
messages.error(self.request, error)
|
||||
# Обработка загрузки фотографий
|
||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||
if photo_errors:
|
||||
for error in photo_errors:
|
||||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||||
if 'Загружено' in error and 'обработано только' in error:
|
||||
messages.warning(self.request, error)
|
||||
else:
|
||||
messages.error(self.request, error)
|
||||
|
||||
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
||||
return super().form_valid(form)
|
||||
@@ -275,7 +292,13 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
|
||||
except IntegrityError as e:
|
||||
# Обработка ошибки дублирования slug'а или других unique constraints
|
||||
error_msg = str(e).lower()
|
||||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
||||
if 'unique_default_sales_unit_per_product' in error_msg or 'is_default' in error_msg:
|
||||
messages.error(
|
||||
self.request,
|
||||
'Ошибка: у товара может быть только одна единица продажи по умолчанию. '
|
||||
'Пожалуйста, выберите только одну единицу как "по умолчанию".'
|
||||
)
|
||||
elif 'slug' in error_msg or 'duplicate key' in error_msg:
|
||||
messages.error(
|
||||
self.request,
|
||||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||||
|
||||
@@ -9,9 +9,10 @@ from django.shortcuts import redirect
|
||||
from django.db import transaction, IntegrityError
|
||||
|
||||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||||
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
|
||||
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName, ProductSalesUnit
|
||||
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
||||
from .utils import handle_photos
|
||||
import os
|
||||
|
||||
|
||||
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||||
@@ -97,6 +98,37 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
form_class = ProductKitForm
|
||||
template_name = 'products/productkit_create.html'
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
copy_id = self.request.GET.get('copy_from')
|
||||
if copy_id:
|
||||
try:
|
||||
kit = ProductKit.objects.get(pk=copy_id)
|
||||
|
||||
# Generate unique name
|
||||
base_name = f"{kit.name} (Копия)"
|
||||
new_name = base_name
|
||||
counter = 1
|
||||
while ProductKit.objects.filter(name=new_name).exists():
|
||||
counter += 1
|
||||
new_name = f"{base_name} {counter}"
|
||||
|
||||
initial.update({
|
||||
'name': new_name,
|
||||
'description': kit.description,
|
||||
'short_description': kit.short_description,
|
||||
'categories': list(kit.categories.values_list('pk', flat=True)),
|
||||
'tags': list(kit.tags.values_list('pk', flat=True)),
|
||||
'sale_price': kit.sale_price,
|
||||
'price_adjustment_type': kit.price_adjustment_type,
|
||||
'price_adjustment_value': kit.price_adjustment_value,
|
||||
'external_category': kit.external_category,
|
||||
'status': 'active', # Default to active for new kits
|
||||
})
|
||||
except ProductKit.DoesNotExist:
|
||||
pass
|
||||
return initial
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
||||
@@ -113,6 +145,12 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
# Извлекаем числовой ID из "product_123"
|
||||
numeric_id = value.split('_')[1]
|
||||
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 = post_data
|
||||
@@ -126,9 +164,9 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
||||
|
||||
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
|
||||
from ..models import Product, ProductVariantGroup
|
||||
selected_products = {}
|
||||
selected_variants = {}
|
||||
selected_sales_units = {}
|
||||
|
||||
for key, value in self.request.POST.items():
|
||||
if '-product' in key and value:
|
||||
@@ -168,10 +206,120 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
except ProductVariantGroup.DoesNotExist:
|
||||
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_variants'] = selected_variants
|
||||
context['selected_sales_units'] = selected_sales_units
|
||||
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
|
||||
|
||||
@@ -208,6 +356,48 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
# Обработка фотографий
|
||||
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||||
|
||||
# Handle copied photos
|
||||
copied_photo_ids = self.request.POST.getlist('copied_photos')
|
||||
print(f"DEBUG: copied_photo_ids in POST: {copied_photo_ids}")
|
||||
|
||||
if copied_photo_ids:
|
||||
from django.core.files.base import ContentFile
|
||||
original_photos = ProductKitPhoto.objects.filter(id__in=copied_photo_ids)
|
||||
print(f"DEBUG: Found {original_photos.count()} original photos to copy")
|
||||
|
||||
# Get max order from existing photos (uploaded via handle_photos)
|
||||
from django.db.models import Max
|
||||
max_order = self.object.photos.aggregate(Max('order'))['order__max']
|
||||
next_order = 0 if max_order is None else max_order + 1
|
||||
print(f"DEBUG: Starting order for copies: {next_order}")
|
||||
|
||||
for photo in original_photos:
|
||||
try:
|
||||
# Open the original image file
|
||||
if photo.image:
|
||||
print(f"DEBUG: Processing photo {photo.id}: {photo.image.name}")
|
||||
with photo.image.open('rb') as f:
|
||||
image_content = f.read()
|
||||
|
||||
# Create a new ContentFile
|
||||
new_image_name = f"copy_{self.object.id}_{os.path.basename(photo.image.name)}"
|
||||
print(f"DEBUG: New image name: {new_image_name}")
|
||||
|
||||
# Create new photo instance
|
||||
new_photo = ProductKitPhoto(kit=self.object, order=next_order)
|
||||
# Save the image file (this also saves the model instance)
|
||||
new_photo.image.save(new_image_name, ContentFile(image_content))
|
||||
print(f"DEBUG: Successfully saved copy for photo {photo.id}")
|
||||
|
||||
next_order += 1
|
||||
else:
|
||||
print(f"DEBUG: Photo {photo.id} has no image file")
|
||||
except Exception as e:
|
||||
print(f"Error copying photo {photo.id}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f'Комплект "{self.object.name}" успешно создан!'
|
||||
@@ -271,6 +461,12 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
||||
# Извлекаем числовой ID из "product_123"
|
||||
numeric_id = value.split('_')[1]
|
||||
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 = post_data
|
||||
@@ -284,8 +480,10 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
||||
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
|
||||
|
||||
# При ошибке валидации - подготавливаем данные для Select2
|
||||
from ..models import Product, ProductVariantGroup, ProductSalesUnit
|
||||
selected_products = {}
|
||||
selected_variants = {}
|
||||
selected_sales_units = {}
|
||||
|
||||
for key, value in self.request.POST.items():
|
||||
if '-product' in key and value:
|
||||
@@ -328,14 +526,35 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
||||
except ProductVariantGroup.DoesNotExist:
|
||||
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_variants'] = selected_variants
|
||||
context['selected_sales_units'] = selected_sales_units
|
||||
else:
|
||||
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
|
||||
|
||||
# Подготавливаем данные для предзагрузки в Select2
|
||||
from ..models import Product, ProductVariantGroup, ProductSalesUnit
|
||||
selected_products = {}
|
||||
selected_variants = {}
|
||||
selected_sales_units = {}
|
||||
|
||||
for item in self.object.kit_items.all():
|
||||
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'
|
||||
}
|
||||
|
||||
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:
|
||||
variant_group = ProductVariantGroup.objects.prefetch_related(
|
||||
'items__product'
|
||||
@@ -373,6 +603,7 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
|
||||
|
||||
context['selected_products'] = selected_products
|
||||
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['photos_count'] = self.object.photos.count()
|
||||
|
||||
@@ -63,6 +63,11 @@ def unit_of_measure_create(request):
|
||||
"""
|
||||
Создание новой единицы измерения
|
||||
"""
|
||||
# Проверка: PlatformAdmin не имеет доступа к бизнес-данным тенантов
|
||||
if request.user.__class__.__name__ == 'PlatformAdmin':
|
||||
messages.error(request, 'У вас недостаточно прав для выполнения этого действия')
|
||||
return redirect('products:unit-list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = UnitOfMeasureForm(request.POST)
|
||||
if form.is_valid():
|
||||
@@ -85,6 +90,11 @@ def unit_of_measure_update(request, pk):
|
||||
"""
|
||||
Редактирование единицы измерения
|
||||
"""
|
||||
# Проверка: PlatformAdmin не имеет доступа к бизнес-данным тенантов
|
||||
if request.user.__class__.__name__ == 'PlatformAdmin':
|
||||
messages.error(request, 'У вас недостаточно прав для выполнения этого действия')
|
||||
return redirect('products:unit-list')
|
||||
|
||||
unit = get_object_or_404(UnitOfMeasure, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -110,11 +120,16 @@ def unit_of_measure_delete(request, pk):
|
||||
"""
|
||||
Удаление единицы измерения
|
||||
"""
|
||||
# Проверка: PlatformAdmin не имеет доступа к бизнес-данным тенантов
|
||||
if request.user.__class__.__name__ == 'PlatformAdmin':
|
||||
messages.error(request, 'У вас недостаточно прав для выполнения этого действия')
|
||||
return redirect('products:unit-list')
|
||||
|
||||
unit = get_object_or_404(UnitOfMeasure, pk=pk)
|
||||
|
||||
# Проверяем использование
|
||||
products_using = unit.products.count()
|
||||
sales_units_using = unit.productsalesunit_set.count()
|
||||
sales_units_using = ProductSalesUnit.objects.filter(product__base_unit=unit).count()
|
||||
|
||||
can_delete = products_using == 0 and sales_units_using == 0
|
||||
|
||||
|
||||
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';
|
||||
|
||||
// Построить форму
|
||||
buildForm(data.fields, data.data || {});
|
||||
// Построить форму (теперь асинхронно)
|
||||
await buildForm(data.fields, data.data || {});
|
||||
|
||||
// Показать/скрыть кнопку тестирования
|
||||
const testBtn = document.getElementById('test-connection-btn');
|
||||
@@ -173,11 +173,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// Построение формы из метаданных полей
|
||||
function buildForm(fields, data) {
|
||||
async function buildForm(fields, data) {
|
||||
const container = document.getElementById('settings-fields');
|
||||
container.innerHTML = '';
|
||||
|
||||
fields.forEach(field => {
|
||||
for (const field of fields) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mb-3';
|
||||
|
||||
@@ -189,27 +189,82 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
|
||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||
`;
|
||||
|
||||
} else if (field.type === 'select') {
|
||||
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' : ''}>
|
||||
${field.choices.map(choice => `
|
||||
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
|
||||
${choice[1]}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||
`;
|
||||
let optionsHtml = '';
|
||||
|
||||
if (field.dynamic_choices) {
|
||||
// Динамическая загрузка options
|
||||
optionsHtml = '<option value="">Загрузка моделей...</option>';
|
||||
|
||||
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>` : ''}
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
// Асинхронная загрузка
|
||||
const select = div.querySelector('select');
|
||||
try {
|
||||
const response = await fetch(field.choices_url);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
|
||||
console.error(result.error);
|
||||
} else {
|
||||
select.innerHTML = result.models.map(m =>
|
||||
`<option value="${m.id}">${m.name}</option>`
|
||||
).join('');
|
||||
|
||||
if (data[field.name]) {
|
||||
select.value = data[field.name];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
|
||||
console.error('Error loading models:', error);
|
||||
}
|
||||
|
||||
} else {
|
||||
// Статический select (для temperature)
|
||||
optionsHtml = field.choices.map(choice => `
|
||||
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
|
||||
${choice[1]}
|
||||
</option>
|
||||
`).join('');
|
||||
|
||||
div.innerHTML = `
|
||||
<label class="form-label" for="field-${field.name}">
|
||||
${field.label}
|
||||
${field.required ? '<span class="text-danger">*</span>' : ''}
|
||||
</label>
|
||||
<select class="form-select" id="field-${field.name}"
|
||||
name="${field.name}"
|
||||
${field.required ? 'required' : ''}>
|
||||
${optionsHtml}
|
||||
</select>
|
||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
} else {
|
||||
// text, password, url
|
||||
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
|
||||
const value = data[field.name] || '';
|
||||
const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : '';
|
||||
let value = data[field.name] || '';
|
||||
const isMasked = value === '••••••••';
|
||||
const placeholder = isMasked ? 'Ключ сохранён. Оставьте пустым, чтобы не менять' : '';
|
||||
|
||||
// Для password полей показываем звёздочки (8 штук как индикатор сохранённого ключа)
|
||||
const inputValue = (field.type === 'password' && isMasked) ? '********' : value;
|
||||
|
||||
div.innerHTML = `
|
||||
<label class="form-label" for="field-${field.name}">
|
||||
@@ -217,15 +272,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
${field.required ? '<span class="text-danger">*</span>' : ''}
|
||||
</label>
|
||||
<input type="${inputType}" class="form-control" id="field-${field.name}"
|
||||
name="${field.name}" value="${value !== '........' ? value : ''}"
|
||||
name="${field.name}" value="${inputValue}"
|
||||
placeholder="${placeholder}"
|
||||
${field.required ? 'required' : ''}>
|
||||
${field.required && !isMasked ? 'required' : ''}>
|
||||
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
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()) {
|
||||
// Пропустить пустые password поля (не менять если не введено)
|
||||
// Пропустить пустые password поля или звёздочки (не менять если не введено новое значение)
|
||||
const input = document.getElementById(`field-${key}`);
|
||||
if (input && input.type === 'password' && !value) continue;
|
||||
if (input && input.type === 'password' && (!value || value === '********')) continue;
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
{% if user.is_authenticated %}
|
||||
{% comment %}Показываем меню tenant приложений только если мы не на странице setup-password (public схема){% endcomment %}
|
||||
{% if 'setup-password' not in request.path %}
|
||||
<!-- 📦 Товары -->
|
||||
<!-- Товары -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'products' %}active{% endif %}" href="#" id="productsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
📦 Товары
|
||||
Товары
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="productsDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'products:products-list' %}">Все товары</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:catalog' %}"><i class="bi bi-grid-3x3-gap"></i> Каталог</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:catalog' %}">Каталог</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:configurableproduct-list' %}">Вариативные товары</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li>
|
||||
@@ -35,15 +35,15 @@
|
||||
<li><a class="dropdown-item" href="{% url 'products:variantgroup-list' %}">Варианты (группы)</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:attribute-list' %}">Атрибуты</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:unit-list' %}"><i class="bi bi-rulers"></i> Единицы измерения</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:sales-unit-list' %}"><i class="bi bi-box-seam"></i> Единицы продажи</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:unit-list' %}">Единицы измерения</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:sales-unit-list' %}">Единицы продажи</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- 📋 Заказы -->
|
||||
<!-- Заказы -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'orders' %}active{% endif %}" href="{% url 'orders:order-list' %}" id="ordersDropdown">
|
||||
📋 Заказы
|
||||
Заказы
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="ordersDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'orders:order-list' %}">Список заказов</a></li>
|
||||
@@ -52,17 +52,17 @@
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- 👥 Клиенты -->
|
||||
<!-- Клиенты -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">
|
||||
👥 Клиенты
|
||||
Клиенты
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- 📦 Склад -->
|
||||
<!-- Склад -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'inventory' %}active{% endif %}" href="#" id="inventoryDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
🏭 Склад
|
||||
Склад
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="inventoryDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:inventory-home' %}">Управление складом</a></li>
|
||||
@@ -70,37 +70,37 @@
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- 💰 Касса -->
|
||||
<!-- Касса -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'pos' %}active{% endif %}" href="{% url 'pos:terminal' %}">
|
||||
💰 Касса
|
||||
Касса
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- ⚙️ Настройки (только для owner/superuser) -->
|
||||
<!-- Настройки (только для owner/superuser) -->
|
||||
{% if request.user.is_owner or request.user.is_superuser %}
|
||||
<li class="nav-item">
|
||||
{% if request.tenant %}
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'system_settings' or 'user_roles' in request.resolver_match.app_names %}active{% endif %}"
|
||||
href="{% url 'system_settings:settings' %}">
|
||||
⚙️ Настройки
|
||||
Настройки
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="nav-link" href="/platform/dashboard">
|
||||
⚙️ Настройки
|
||||
Настройки
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- 🔧 Debug (только для superuser) -->
|
||||
{% if user.is_superuser %}
|
||||
<!-- Debug (для owner или manager) -->
|
||||
{% if user.is_owner or user.is_manager %}
|
||||
{% url 'inventory:debug_page' as debug_url %}
|
||||
{% if debug_url %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ debug_url }}" style="color: #dc3545; font-weight: bold;">
|
||||
🔧 Debug
|
||||
Debug
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
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()
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Скрипт деплоя для octopus (FIXED v4 - Auto-Update Compose)
|
||||
LOG_FILE="/tmp/deploy-octopus.log"
|
||||
HASH_FILE="/tmp/requirements-hash.txt"
|
||||
# Скрипт деплоя для octopus (FIXED v7 - Correct paths and docker-compose)
|
||||
LOG_FILE="/Volume1/DockerAppsData/mixapp/deploy-octopus.log"
|
||||
HASH_FILE="/Volume1/DockerAppsData/mixapp/requirements-hash.txt"
|
||||
DOCKER_COMPOSE_DIR="/Volume1/DockerYAML/mix"
|
||||
APP_ROOT="/Volume1/DockerAppsData/mixapp/app"
|
||||
|
||||
@@ -11,7 +11,7 @@ echo "=== Deploy started at $(date) ===" >> "$LOG_FILE"
|
||||
echo "Step 1: Git pull..." >> "$LOG_FILE"
|
||||
docker exec git-cli sh -c "cd /git/octopus && git pull" >> "$LOG_FILE" 2>&1
|
||||
|
||||
# 2. Вычисляем общий хеш (requirements + docker config + docker-compose)
|
||||
# 2. Вычисляем общий хеш (requirements + docker config + docker-compose.yml)
|
||||
echo "Step 2: Checking for structural changes..." >> "$LOG_FILE"
|
||||
NEW_HASH=$(docker exec git-cli sh -c "cd /git/octopus && cat myproject/requirements.txt docker/* docker/docker-compose.yml | md5sum" | awk '{print $1}')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user