feat(products): обеспечить уникальность единицы продажи по умолчанию и улучшить UI формы

Добавлено ограничение на уровне базы данных и валидация форм для обеспечения,
что у товара может быть только одна единица продажи с флагом "по умолчанию".
Переработан интерфейс маркетинговых флагов и единиц продажи для улучшения UX.

Основные изменения:
- Добавлен UniqueConstraint в модель ProductSalesUnit для валидации на уровне БД
- Создан BaseProductSalesUnitFormSet с кастомной валидацией формы
- Обновлен метод save() для корректной обработки новых и существующих записей
- Добавлена транзакционная обертка в представлениях ProductCreateView и ProductUpdateView
- Переработан блок маркетинговых флагов с карточным дизайном и интерактивными переключателями
- Переработан блок единиц продажи в табличный вид с улучшенным UX
- Добавлена клиентская логика для взаимного исключения чекбоксов "По умолчанию
This commit is contained in:
2026-01-27 22:37:00 +03:00
parent 0ee6391810
commit eb7569f983
6 changed files with 831 additions and 163 deletions

View File

@@ -1321,12 +1321,49 @@ class ProductSalesUnitInlineForm(forms.ModelForm):
return super().has_changed() 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 для единиц продажи # Inline formset для единиц продажи
ProductSalesUnitFormSet = inlineformset_factory( ProductSalesUnitFormSet = inlineformset_factory(
Product, Product,
ProductSalesUnit, ProductSalesUnit,
form=ProductSalesUnitInlineForm, form=ProductSalesUnitInlineForm,
extra=1, formset=BaseProductSalesUnitFormSet,
extra=0,
can_delete=True, can_delete=True,
min_num=0, min_num=0,
validate_min=False, validate_min=False,

View 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)} товаров с дубликатами!'
)
)

View File

@@ -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='У товара может быть только одна единица продажи по умолчанию'),
),
]

View File

@@ -128,6 +128,14 @@ class ProductSalesUnit(models.Model):
verbose_name_plural = "Единицы продажи товаров" verbose_name_plural = "Единицы продажи товаров"
ordering = ['position', 'id'] ordering = ['position', 'id']
unique_together = [['product', 'name']] 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): def __str__(self):
return f"{self.product.name} - {self.name}" return f"{self.product.name} - {self.name}"
@@ -146,10 +154,15 @@ class ProductSalesUnit(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Если это единица по умолчанию, снимаем флаг с других # Если это единица по умолчанию, снимаем флаг с других
if self.is_default: if self.is_default:
ProductSalesUnit.objects.filter( # Используем exclude только если pk уже существует
# Для новых записей (pk=None) exclude не нужен, т.к. запись еще не в БД
queryset = ProductSalesUnit.objects.filter(
product=self.product, product=self.product,
is_default=True 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) super().save(*args, **kwargs)
@property @property

View File

@@ -119,6 +119,365 @@
.photo-card:hover .card { .photo-card:hover .card {
border-color: #667eea; 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> </style>
{% endblock %} {% endblock %}
@@ -546,35 +905,61 @@
<!-- Блок: Маркетинговые флаги --> <!-- Блок: Маркетинговые флаги -->
<div class="mb-4"> <div class="mb-4">
<h5 class="mb-3"><i class="bi bi-tag"></i> Маркетинговые флаги</h5> <div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted small mb-3">Отображаются на внешних площадках (Recommerce и др.)</p> <h5 class="mb-0"><i class="bi bi-tag"></i> Маркетинговые флаги</h5>
<div class="row"> <small class="text-muted">Отображаются на внешних площадках</small>
</div>
<div class="row g-3">
<div class="col-md-4"> <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 }} {{ form.is_new }}
<label class="form-check-label" for="id_is_new"> <label class="form-check-label" for="id_is_new"></label>
<i class="bi bi-stars text-warning"></i> Новинка </div>
</label> </div>
</div> </div>
<small class="text-muted d-block ms-4">Товар отображается как новый</small>
</div> </div>
<div class="col-md-4"> <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 }} {{ form.is_popular }}
<label class="form-check-label" for="id_is_popular"> <label class="form-check-label" for="id_is_popular"></label>
<i class="bi bi-fire text-danger"></i> Популярный </div>
</label> </div>
</div> </div>
<small class="text-muted d-block ms-4">Товар отображается как популярный</small>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="form-check form-switch"> <div class="marketing-flag-card">
{{ form.is_special }} <div class="d-flex align-items-center justify-content-between">
<label class="form-check-label" for="id_is_special"> <div class="d-flex align-items-center gap-2">
<i class="bi bi-percent text-success"></i> Спецпредложение <i class="bi bi-percent marketing-flag-icon text-success"></i>
</label> <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> </div>
<small class="text-muted d-block ms-4">Акционный товар (+ автоматически при скидке)</small>
</div> </div>
</div> </div>
</div> </div>
@@ -605,122 +990,159 @@
<!-- Шаблон для новых форм (скрыт) --> <!-- Шаблон для новых форм (скрыт) -->
<template id="empty-sales-unit-template"> <template id="empty-sales-unit-template">
{% with form=sales_unit_formset.empty_form %} {% with form=sales_unit_formset.empty_form %}
<div class="sales-unit-row border rounded p-3 mb-2"> <tr class="sales-unit-row">
<div class="row g-2 align-items-end"> <td>
<div class="col-md-2"> <div class="d-flex align-items-center h-100">
<label class="form-label small">Ед. измерения</label>
{{ form.unit }}
</div>
<div class="col-md-2">
<label class="form-label small">Название</label>
{{ form.name }} {{ form.name }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Коэфф.</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.conversion_factor }} {{ form.conversion_factor }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Цена</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.price }} {{ form.price }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Скидка</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.sale_price }} {{ form.sale_price }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Мин.кол</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.min_quantity }} {{ form.min_quantity }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Шаг</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.quantity_step }} {{ form.quantity_step }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Поз.</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.position }} {{ form.position }}
</div> </div>
<div class="col-md-1 text-center"> </td>
<label class="form-label small d-block">По умолч.</label> <td class="text-center">
<div class="d-flex align-items-center justify-content-center h-100">
{{ form.is_default }} {{ form.is_default }}
</div> </div>
<div class="col-md-1"> </td>
<div class="d-flex gap-1"> <td>
<div class="form-check" title="Активна"> <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 }} {{ form.is_active }}
<label class="form-check-label small" for="{{ form.is_active.id_for_label }}">
Активна
</label>
</div> </div>
</div> </div>
</div> </td>
</div> </tr>
</div>
{% endwith %} {% endwith %}
</template> </template>
<div id="sales-units-container"> <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 %} {% for form in sales_unit_formset %}
<div class="sales-unit-row border rounded p-3 mb-2 {% if form.instance.pk %}bg-light{% endif %}"> <tr class="{% if form.instance.pk %}bg-light{% endif %} sales-unit-row">
<div class="row g-2 align-items-end">
{% if form.instance.pk %} {% if form.instance.pk %}
<input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}"> <input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}">
{% endif %} {% endif %}
<div class="col-md-2"> <td>
<label class="form-label small">Ед. измерения</label> <div class="d-flex align-items-center h-100">
{{ form.unit }}
</div>
<div class="col-md-2">
<label class="form-label small">Название</label>
{{ form.name }} {{ form.name }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Коэфф.</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.conversion_factor }} {{ form.conversion_factor }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Цена</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.price }} {{ form.price }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Скидка</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.sale_price }} {{ form.sale_price }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Мин.кол</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.min_quantity }} {{ form.min_quantity }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Шаг</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.quantity_step }} {{ form.quantity_step }}
</div> </div>
<div class="col-md-1"> </td>
<label class="form-label small">Поз.</label> <td>
<div class="d-flex align-items-center h-100">
{{ form.position }} {{ form.position }}
</div> </div>
<div class="col-md-1 text-center"> </td>
<label class="form-label small d-block">По умолч.</label> <td class="text-center">
<div class="d-flex align-items-center justify-content-center h-100">
{{ form.is_default }} {{ form.is_default }}
</div> </div>
<div class="col-md-1"> </td>
<div class="d-flex gap-1"> <td>
<div class="form-check" title="Активна"> <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 }} {{ form.is_active }}
<label class="form-check-label small" for="{{ form.is_active.id_for_label }}">
Активна
</label>
</div> </div>
{% if form.instance.pk %} {% if form.instance.pk %}
<div class="form-check" title="Удалить"> <div class="form-check mb-0" title="Удалить">
{{ form.DELETE }} {{ form.DELETE }}
<label class="form-check-label small text-danger" for="{{ form.DELETE.id_for_label }}">
Удалить
</label>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </td>
</div> </tr>
{% if form.errors %} {% 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 %} {% for field, errors in form.errors.items %}
{{ field }}: {{ errors|join:", " }} <strong>{{ field }}:</strong> {{ errors|join:", " }}
{% endfor %} {% endfor %}
</div> </div>
</td>
</tr>
{% endif %} {% endif %}
</div>
{% endfor %} {% endfor %}
</tbody>
</table>
</div>
</div> </div>
<button type="button" class="btn btn-outline-success btn-sm mt-2" id="add-sales-unit"> <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 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"]'); const totalFormsInput = document.querySelector('[name="sales_units-TOTAL_FORMS"]');
if (addButton && container && totalFormsInput) { if (addButton && container && totalFormsInput) {
@@ -851,10 +1273,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (template) { if (template) {
// Клонируем содержимое шаблона // Клонируем содержимое шаблона
const newRow = template.content.cloneNode(true); const newRow = template.content.cloneNode(true);
const rowDiv = newRow.querySelector('.sales-unit-row'); const row = newRow.querySelector('tr');
// Обновляем имена и id полей // Обновляем имена и id полей
rowDiv.querySelectorAll('input, select').forEach(input => { row.querySelectorAll('input, select').forEach(input => {
const name = input.getAttribute('name'); const name = input.getAttribute('name');
const id = input.getAttribute('id'); const id = input.getAttribute('id');
@@ -884,11 +1306,32 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
container.appendChild(rowDiv); container.appendChild(row);
totalFormsInput.value = formCount + 1; 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> </script>
{% endblock %} {% endblock %}

View File

@@ -122,12 +122,13 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
return context return context
def form_valid(self, form): def form_valid(self, form):
from django.db import IntegrityError from django.db import IntegrityError, transaction
context = self.get_context_data() context = self.get_context_data()
sales_unit_formset = context['sales_unit_formset'] sales_unit_formset = context['sales_unit_formset']
try: try:
with transaction.atomic():
# Сначала сохраняем товар # Сначала сохраняем товар
self.object = form.save() self.object = form.save()
@@ -157,7 +158,13 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
except IntegrityError as e: except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints # Обработка ошибки дублирования slug'а или других unique constraints
error_msg = str(e).lower() 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( messages.error(
self.request, self.request,
f'Ошибка: товар с названием "{form.instance.name}" уже существует. ' f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
@@ -249,12 +256,13 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
return context return context
def form_valid(self, form): def form_valid(self, form):
from django.db import IntegrityError from django.db import IntegrityError, transaction
context = self.get_context_data() context = self.get_context_data()
sales_unit_formset = context['sales_unit_formset'] sales_unit_formset = context['sales_unit_formset']
try: try:
with transaction.atomic():
# Сначала сохраняем товар # Сначала сохраняем товар
self.object = form.save() self.object = form.save()
@@ -284,7 +292,13 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
except IntegrityError as e: except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints # Обработка ошибки дублирования slug'а или других unique constraints
error_msg = str(e).lower() 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( messages.error(
self.request, self.request,
f'Ошибка: товар с названием "{form.instance.name}" уже существует. ' f'Ошибка: товар с названием "{form.instance.name}" уже существует. '