feat(products): обеспечить уникальность единицы продажи по умолчанию и улучшить UI формы
Добавлено ограничение на уровне базы данных и валидация форм для обеспечения, что у товара может быть только одна единица продажи с флагом "по умолчанию". Переработан интерфейс маркетинговых флагов и единиц продажи для улучшения UX. Основные изменения: - Добавлен UniqueConstraint в модель ProductSalesUnit для валидации на уровне БД - Создан BaseProductSalesUnitFormSet с кастомной валидацией формы - Обновлен метод save() для корректной обработки новых и существующих записей - Добавлена транзакционная обертка в представлениях ProductCreateView и ProductUpdateView - Переработан блок маркетинговых флагов с карточным дизайном и интерактивными переключателями - Переработан блок единиц продажи в табличный вид с улучшенным UX - Добавлена клиентская логика для взаимного исключения чекбоксов "По умолчанию
This commit is contained in:
@@ -1321,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,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='У товара может быть только одна единица продажи по умолчанию'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<i class="bi bi-stars text-warning"></i> Новинка
|
||||
</label>
|
||||
<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">
|
||||
<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">
|
||||
<i class="bi bi-fire text-danger"></i> Популярный
|
||||
</label>
|
||||
<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="Активна">
|
||||
</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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
</template>
|
||||
|
||||
<div id="sales-units-container">
|
||||
<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 %}
|
||||
<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">
|
||||
<tr class="{% if form.instance.pk %}bg-light{% endif %} sales-unit-row">
|
||||
{% 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>
|
||||
<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="Активна">
|
||||
</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" title="Удалить">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% if form.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
<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 %}
|
||||
{{ field }}: {{ errors|join:", " }}
|
||||
<strong>{{ field }}:</strong> {{ errors|join:", " }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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 %}
|
||||
|
||||
@@ -122,12 +122,13 @@ 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:
|
||||
with transaction.atomic():
|
||||
# Сначала сохраняем товар
|
||||
self.object = form.save()
|
||||
|
||||
@@ -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}" уже существует. '
|
||||
@@ -249,12 +256,13 @@ 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:
|
||||
with transaction.atomic():
|
||||
# Сначала сохраняем товар
|
||||
self.object = form.save()
|
||||
|
||||
@@ -284,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}" уже существует. '
|
||||
|
||||
Reference in New Issue
Block a user