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

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