diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index f1a6275..f25da8e 100644 --- a/myproject/accounts/migrations/0001_initial.py +++ b/myproject/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import django.contrib.auth.validators import django.utils.timezone diff --git a/myproject/customers/migrations/0001_initial.py b/myproject/customers/migrations/0001_initial.py index 2ceea6a..689bb69 100644 --- a/myproject/customers/migrations/0001_initial.py +++ b/myproject/customers/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import phonenumber_field.modelfields from django.db import migrations, models diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py index 948fbef..ff98a96 100644 --- a/myproject/inventory/forms.py +++ b/myproject/inventory/forms.py @@ -404,7 +404,7 @@ class TransferLineForm(forms.Form): Используется в динамической таблице для ввода нескольких товаров. """ product = forms.ModelChoiceField( - queryset=Product.objects.filter(is_active=True).order_by('name'), + queryset=Product.objects.filter(status='active').order_by('name'), widget=forms.Select(attrs={'class': 'form-control'}), label="Товар", required=True diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py index 882b8d3..56a4715 100644 --- a/myproject/inventory/migrations/0001_initial.py +++ b/myproject/inventory/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import phonenumber_field.modelfields from django.db import migrations, models diff --git a/myproject/inventory/migrations/0002_initial.py b/myproject/inventory/migrations/0002_initial.py index ccace40..055328b 100644 --- a/myproject/inventory/migrations/0002_initial.py +++ b/myproject/inventory/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import django.db.models.deletion from django.db import migrations, models diff --git a/myproject/inventory/views/incoming.py b/myproject/inventory/views/incoming.py index a05bebb..9e7f3b1 100644 --- a/myproject/inventory/views/incoming.py +++ b/myproject/inventory/views/incoming.py @@ -88,7 +88,7 @@ class IncomingCreateView(LoginRequiredMixin, View): """Отображение формы ввода товаров.""" form = IncomingForm() # Django-tenants автоматически фильтрует по текущей схеме - products = Product.objects.filter(is_active=True).order_by('name') + products = Product.objects.filter(status='active').order_by('name') # Генерируем номер документа автоматически generated_document_number = generate_incoming_document_number() @@ -106,7 +106,7 @@ class IncomingCreateView(LoginRequiredMixin, View): if not form.is_valid(): # Django-tenants автоматически фильтрует по текущей схеме - products = Product.objects.filter(is_active=True).order_by('name') + products = Product.objects.filter(status='active').order_by('name') context = { 'form': form, 'products': products, @@ -128,7 +128,7 @@ class IncomingCreateView(LoginRequiredMixin, View): if not products_data: messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.') # Django-tenants автоматически фильтрует по текущей схеме - products = Product.objects.filter(is_active=True).order_by('name') + products = Product.objects.filter(status='active').order_by('name') context = { 'form': form, 'products': products, @@ -186,7 +186,7 @@ class IncomingCreateView(LoginRequiredMixin, View): messages.error(request, f'Ошибка при создании партии: {str(e)}') # Восстанавливаем данные на форме - products = Product.objects.filter(is_active=True).order_by('name') + products = Product.objects.filter(status='active').order_by('name') context = { 'form': form, 'products': products, @@ -200,7 +200,7 @@ class IncomingCreateView(LoginRequiredMixin, View): f'❌ Ошибка при создании приходов: {str(e)}' ) # Django-tenants автоматически фильтрует по текущей схеме - products = Product.objects.filter(is_active=True).order_by('name') + products = Product.objects.filter(status='active').order_by('name') context = { 'form': form, 'products': products, diff --git a/myproject/inventory/views/transfer.py b/myproject/inventory/views/transfer.py index 8caec94..5ea4c16 100644 --- a/myproject/inventory/views/transfer.py +++ b/myproject/inventory/views/transfer.py @@ -49,7 +49,7 @@ class TransferBulkCreateView(LoginRequiredMixin, View): def get(self, request): from products.models import Product form = TransferBulkForm() - products = Product.objects.filter(is_active=True).values('id', 'name', 'sku').order_by('name') + products = Product.objects.filter(status='active').values('id', 'name', 'sku').order_by('name') return render(request, self.template_name, { 'form': form, 'products': products diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py index 1147fef..ddbb427 100644 --- a/myproject/orders/migrations/0001_initial.py +++ b/myproject/orders/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import django.db.models.deletion import simple_history.models @@ -17,6 +17,53 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_number', models.PositiveIntegerField(editable=False, help_text='Уникальный номер заказа', unique=True, verbose_name='Номер заказа')), + ('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')), + ('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')), + ('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')), + ('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')), + ('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')), + ('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')), + ('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')), + ('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')), + ('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')), + ('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')), + ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')), + ('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')), + ('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')), + ('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')), + ('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')), + ('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')), + ('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')), + ('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')), + ('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ], + options={ + 'verbose_name': 'Заказ', + 'verbose_name_plural': 'Заказы', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='OrderItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')), + ('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')), + ('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')), + ], + options={ + 'verbose_name': 'Позиция заказа', + 'verbose_name_plural': 'Позиции заказа', + }, + ), migrations.CreateModel( name='OrderStatus', fields=[ @@ -123,55 +170,25 @@ class Migration(migrations.Migration): bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='Order', + name='HistoricalOrderItem', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('order_number', models.PositiveIntegerField(editable=False, help_text='Уникальный номер заказа', unique=True, verbose_name='Номер заказа')), - ('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')), - ('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')), - ('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')), - ('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')), - ('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')), - ('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')), - ('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')), - ('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')), - ('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')), - ('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')), - ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')), - ('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')), - ('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')), - ('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')), - ('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')), - ('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')), - ('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')), - ('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')), - ('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент')), - ('delivery_address', models.OneToOneField(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='orders.address', verbose_name='Адрес доставки')), - ('modified_by', models.ForeignKey(blank=True, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_orders', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем')), - ('pickup_warehouse', models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='inventory.warehouse', verbose_name='Склад для самовывоза')), - ], - options={ - 'verbose_name': 'Заказ', - 'verbose_name_plural': 'Заказы', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='OrderItem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), ('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')), ('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')), ('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')), - ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')), + ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата добавления')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ], options={ - 'verbose_name': 'Позиция заказа', - 'verbose_name_plural': 'Позиции заказа', + 'verbose_name': 'historical Позиция заказа', + 'verbose_name_plural': 'historical Позиции заказа', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), }, + bases=(simple_history.models.HistoricalChanges, models.Model), ), ] diff --git a/myproject/orders/migrations/0002_initial.py b/myproject/orders/migrations/0002_initial.py index cd4564d..18f9660 100644 --- a/myproject/orders/migrations/0002_initial.py +++ b/myproject/orders/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import django.db.models.deletion from django.conf import settings @@ -10,12 +10,54 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('customers', '0001_initial'), + ('inventory', '0002_initial'), ('orders', '0001_initial'), ('products', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.AddField( + model_name='historicalorderitem', + name='product', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.product', verbose_name='Товар'), + ), + migrations.AddField( + model_name='historicalorderitem', + name='product_kit', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.productkit', verbose_name='Комплект товаров'), + ), + migrations.AddField( + model_name='order', + name='customer', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент'), + ), + migrations.AddField( + model_name='order', + name='delivery_address', + field=models.OneToOneField(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='orders.address', verbose_name='Адрес доставки'), + ), + migrations.AddField( + model_name='order', + name='modified_by', + field=models.ForeignKey(blank=True, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_orders', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем'), + ), + migrations.AddField( + model_name='order', + name='pickup_warehouse', + field=models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='inventory.warehouse', verbose_name='Склад для самовывоза'), + ), + migrations.AddField( + model_name='historicalorderitem', + name='order', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.order', verbose_name='Заказ'), + ), + migrations.AddField( + model_name='orderitem', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ'), + ), migrations.AddField( model_name='orderitem', name='product', diff --git a/myproject/orders/models.py b/myproject/orders/models.py index 459de59..54e71a4 100644 --- a/myproject/orders/models.py +++ b/myproject/orders/models.py @@ -685,6 +685,9 @@ class OrderItem(models.Model): verbose_name="Дата добавления" ) + # История изменений + history = HistoricalRecords() + class Meta: verbose_name = "Позиция заказа" verbose_name_plural = "Позиции заказа" diff --git a/myproject/products/admin.py b/myproject/products/admin.py index d2a5bb0..f99bbbc 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -17,21 +17,32 @@ from .admin_displays import ( class DeletedFilter(admin.SimpleListFilter): """Фильтр для отображения удаленных/активных элементов""" - title = 'Статус удаления' - parameter_name = 'is_deleted' + title = 'Статус' + parameter_name = 'status' def lookups(self, request, model_admin): return ( - ('0', 'Активные'), - ('1', 'Удаленные'), + ('active', 'Активные'), + ('archived', 'Архивные'), + ('discontinued', 'Снятые'), ) def queryset(self, request, queryset): - # queryset уже содержит всё (включая удаленные) благодаря get_queryset() - if self.value() == '0': - return queryset.filter(is_deleted=False) - elif self.value() == '1': - return queryset.filter(is_deleted=True) + # queryset уже содержит всё благодаря get_queryset() + # Проверяем есть ли поле status или is_deleted на модели + if hasattr(queryset.model, 'status'): + if self.value() == 'active': + return queryset.filter(status='active') + elif self.value() == 'archived': + return queryset.filter(status='archived') + elif self.value() == 'discontinued': + return queryset.filter(status='discontinued') + elif hasattr(queryset.model, 'is_deleted'): + # Для старой системы (Category, Tag) + if self.value() == '0': + return queryset.filter(is_deleted=False) + elif self.value() == '1': + return queryset.filter(is_deleted=True) return queryset @@ -68,7 +79,12 @@ class QualityLevelFilter(admin.SimpleListFilter): def restore_items(modeladmin, request, queryset): """Action для восстановления удаленных элементов""" - updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None) + if hasattr(queryset.model, 'status'): + # Новая система со статусом + updated = queryset.update(status='active', archived_at=None, archived_by=None) + else: + # Старая система с is_deleted + updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None) modeladmin.message_user(request, f'✓ Восстановлено {updated} элемент(ов).') restore_items.short_description = '✓ Восстановить выбранные элементы' @@ -367,11 +383,11 @@ class ProductTagAdmin(admin.ModelAdmin): class ProductAdmin(admin.ModelAdmin): - list_display = ('photo_with_quality', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status') - list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'categories', 'tags', 'variant_groups') + list_display = ('photo_with_quality', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'get_status_display') + list_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups') search_fields = ('name', 'sku', 'description', 'search_keywords') filter_horizontal = ('categories', 'tags', 'variant_groups') - readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') + readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by') autocomplete_fields = [] actions = [ restore_items, @@ -391,12 +407,12 @@ class ProductAdmin(admin.ModelAdmin): 'description': 'price - основная цена, sale_price - цена со скидкой (опционально)' }), ('Дополнительно', { - 'fields': ('tags', 'variant_groups', 'is_active') + 'fields': ('tags', 'variant_groups', 'status') }), - ('Удаление', { - 'fields': ('deleted_at', 'deleted_by'), + ('Архивирование', { + 'fields': ('archived_at', 'archived_by'), 'classes': ('collapse',), - 'description': 'Информация о мягком удалении товара.' + 'description': 'Информация об архивировании товара (статус "Архивный" или "Снят").' }), ('Поиск', { 'fields': ('search_keywords',), @@ -417,14 +433,19 @@ class ProductAdmin(admin.ModelAdmin): qs = qs.order_by(*ordering) return qs - def get_deleted_status(self, obj): - """Показывает статус удаления""" - if obj.is_deleted: - return format_html( - '🗑️ Удален' - ) - return format_html('✓ Активен') - get_deleted_status.short_description = 'Статус' + def get_status_display(self, obj): + """Показывает статус товара""" + status_colors = { + 'active': ('green', '✓ Активный'), + 'archived': ('orange', '📦 Архивный'), + 'discontinued': ('red', '🗑️ Снят'), + } + color, label = status_colors.get(obj.status, ('gray', obj.status)) + return format_html( + '{}', + color, label + ) + get_status_display.short_description = 'Статус' def get_categories_display(self, obj): categories = obj.categories.all()[:3] @@ -489,11 +510,11 @@ class ProductAdmin(admin.ModelAdmin): class ProductKitAdmin(admin.ModelAdmin): - list_display = ('photo_with_quality', 'name', 'slug', 'get_categories_display', 'get_price_display', 'is_temporary', 'get_order_link', 'is_active', 'get_deleted_status') - list_filter = (DeletedFilter, 'is_active', 'is_temporary', QualityLevelFilter, 'categories', 'tags') + list_display = ('photo_with_quality', 'name', 'slug', 'get_categories_display', 'get_price_display', 'is_temporary', 'get_order_link', 'get_status_display') + list_filter = (DeletedFilter, 'is_temporary', QualityLevelFilter, 'categories', 'tags') prepopulated_fields = {'slug': ('name',)} filter_horizontal = ('categories', 'tags') - readonly_fields = ('photo_preview_large', 'base_price', 'deleted_at', 'deleted_by', 'order') + readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order') actions = [ restore_items, delete_selected, @@ -516,12 +537,12 @@ class ProductKitAdmin(admin.ModelAdmin): 'description': 'Временные комплекты создаются для конкретных заказов и не показываются в каталоге.' }), ('Дополнительно', { - 'fields': ('tags', 'is_active') + 'fields': ('tags', 'status') }), - ('Удаление', { - 'fields': ('deleted_at', 'deleted_by'), + ('Архивирование', { + 'fields': ('archived_at', 'archived_by'), 'classes': ('collapse',), - 'description': 'Информация о мягком удалении комплекта.' + 'description': 'Информация об архивировании комплекта (статус "Архивный" или "Снят").' }), ('Фото', { 'fields': ('photo_preview_large',), @@ -554,14 +575,19 @@ class ProductKitAdmin(admin.ModelAdmin): qs = qs.order_by(*ordering) return qs - def get_deleted_status(self, obj): - """Показывает статус удаления""" - if obj.is_deleted: - return format_html( - '🗑️ Удален' - ) - return format_html('✓ Активен') - get_deleted_status.short_description = 'Статус' + def get_status_display(self, obj): + """Показывает статус комплекта""" + status_colors = { + 'active': ('green', '✓ Активный'), + 'archived': ('orange', '📦 Архивный'), + 'discontinued': ('red', '🗑️ Снят'), + } + color, label = status_colors.get(obj.status, ('gray', obj.status)) + return format_html( + '{}', + color, label + ) + get_status_display.short_description = 'Статус' def get_categories_display(self, obj): categories = obj.categories.all()[:3] diff --git a/myproject/products/forms.py b/myproject/products/forms.py index e06709a..ff2bedc 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -26,7 +26,7 @@ class ProductForm(forms.ModelForm): model = Product fields = [ 'name', 'sku', 'description', 'short_description', 'categories', - 'tags', 'unit', 'cost_price', 'price', 'sale_price', 'is_active' + 'tags', 'unit', 'cost_price', 'price', 'sale_price', 'status' ] labels = { 'name': 'Название', @@ -39,7 +39,7 @@ class ProductForm(forms.ModelForm): 'cost_price': 'Себестоимость', 'price': 'Основная цена', 'sale_price': 'Цена со скидкой', - 'is_active': 'Активен' + 'status': 'Статус' } def __init__(self, *args, **kwargs): @@ -66,7 +66,7 @@ class ProductForm(forms.ModelForm): self.fields['price'].widget.attrs.update({'class': 'form-control'}) self.fields['sale_price'].widget.attrs.update({'class': 'form-control'}) self.fields['unit'].widget.attrs.update({'class': 'form-control'}) - self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'}) + self.fields['status'].widget.attrs.update({'class': 'form-control'}) def clean(self): """Валидация уникальности имени для активных товаров""" @@ -78,7 +78,7 @@ class ProductForm(forms.ModelForm): # Исключаем текущий товар при редактировании (self.instance.pk) existing = Product.objects.filter( name=name, - is_deleted=False + status='active' ) if self.instance.pk: @@ -116,7 +116,7 @@ class ProductKitForm(forms.ModelForm): model = ProductKit fields = [ 'name', 'sku', 'description', 'short_description', 'categories', - 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'is_active' + 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status' ] labels = { 'name': 'Название', @@ -128,7 +128,7 @@ class ProductKitForm(forms.ModelForm): 'sale_price': 'Цена со скидкой', 'price_adjustment_type': 'Как изменить итоговую цену', 'price_adjustment_value': 'Значение корректировки', - 'is_active': 'Активен' + 'status': 'Статус' } def __init__(self, *args, **kwargs): @@ -158,7 +158,7 @@ class ProductKitForm(forms.ModelForm): 'step': '0.01', 'placeholder': '0' }) - self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'}) + self.fields['status'].widget.attrs.update({'class': 'form-control'}) def clean(self): """ @@ -174,7 +174,7 @@ class ProductKitForm(forms.ModelForm): if name: existing = ProductKit.objects.filter( name=name, - is_deleted=False, + status='active', is_temporary=False ) diff --git a/myproject/products/management/commands/recalculate_product_costs.py b/myproject/products/management/commands/recalculate_product_costs.py index 52a2f55..2ae25b7 100644 --- a/myproject/products/management/commands/recalculate_product_costs.py +++ b/myproject/products/management/commands/recalculate_product_costs.py @@ -61,7 +61,7 @@ class Command(InteractiveTenantOption, BaseCommand): self.stdout.write(self.style.SUCCESS('='*80 + '\n')) # Получаем все активные товары - all_products = Product.objects.filter(is_active=True) + all_products = Product.objects.filter(status='active') total = all_products.count() updated_count = 0 unchanged_count = 0 diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py index 7906fdf..7a50306 100644 --- a/myproject/products/migrations/0001_initial.py +++ b/myproject/products/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import django.db.models.deletion from django.conf import settings @@ -15,6 +15,17 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='KitItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True, verbose_name='Количество')), + ], + options={ + 'verbose_name': 'Компонент комплекта', + 'verbose_name_plural': 'Компоненты комплектов', + }, + ), migrations.CreateModel( name='ProductVariantGroup', fields=[ @@ -30,6 +41,18 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), + migrations.CreateModel( + name='ProductVariantGroupItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (1 - наивысший приоритет в этой группе)')), + ], + options={ + 'verbose_name': 'Товар в группе вариантов', + 'verbose_name_plural': 'Товары в группах вариантов', + 'ordering': ['priority', 'id'], + }, + ), migrations.CreateModel( name='SKUCounter', fields=[ @@ -42,6 +65,74 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Счетчики артикулов', }, ), + migrations.CreateModel( + name='PhotoProcessingStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('photo_id', models.IntegerField(help_text='ID объекта ProductPhoto/ProductKitPhoto/ProductCategoryPhoto', verbose_name='ID фото')), + ('photo_model', models.CharField(help_text='Полный путь модели (e.g., products.ProductPhoto)', max_length=100, verbose_name='Модель фото')), + ('status', models.CharField(choices=[('pending', 'В очереди'), ('processing', 'Обрабатывается'), ('completed', 'Завершено'), ('failed', 'Ошибка')], db_index=True, default='pending', max_length=20, verbose_name='Статус обработки')), + ('task_id', models.CharField(blank=True, db_index=True, help_text='Уникальный ID задачи для отслеживания', max_length=255, verbose_name='ID задачи Celery')), + ('error_message', models.TextField(blank=True, help_text='Детальное описание ошибки при обработке', verbose_name='Сообщение об ошибке')), + ('result_data', models.JSONField(blank=True, default=dict, help_text='JSON с информацией о качестве, путях и метаданных', verbose_name='Результаты обработки')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Время начала обработки')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Время завершения обработки')), + ], + options={ + 'verbose_name': 'Статус обработки фото', + 'verbose_name_plural': 'Статусы обработки фото', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['photo_id', 'photo_model'], name='products_ph_photo_i_e42a67_idx'), models.Index(fields=['task_id'], name='products_ph_task_id_748118_idx'), models.Index(fields=['status'], name='products_ph_status_1182b4_idx'), models.Index(fields=['status', 'created_at'], name='products_ph_status_41d415_idx')], + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')), + ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), + ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), + ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')), + ('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')), + ('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')), + ('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')), + ('cost_price', models.DecimalField(decimal_places=2, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, verbose_name='Себестоимость')), + ('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')), + ('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')), + ('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')), + ('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')), + ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')), + ], + options={ + 'verbose_name': 'Товар', + 'verbose_name_plural': 'Товары', + }, + ), + migrations.CreateModel( + name='KitItemPriority', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (0 - наивысший)')), + ('kit_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='priorities', to='products.kititem', verbose_name='Позиция в букете')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'Приоритет варианта', + 'verbose_name_plural': 'Приоритеты вариантов', + 'ordering': ['priority', 'id'], + }, + ), + migrations.AddField( + model_name='kititem', + name='product', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items_direct', to='products.product', verbose_name='Конкретный товар'), + ), migrations.CreateModel( name='ProductCategory', fields=[ @@ -62,34 +153,10 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Категории товаров', }, ), - migrations.CreateModel( - name='Product', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=200, verbose_name='Название')), - ('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')), - ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), - ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), - ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')), - ('is_active', models.BooleanField(default=True, verbose_name='Активен')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')), - ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')), - ('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')), - ('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')), - ('cost_price', models.DecimalField(decimal_places=2, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, verbose_name='Себестоимость')), - ('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')), - ('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')), - ('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')), - ('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')), - ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')), - ('categories', models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории')), - ], - options={ - 'verbose_name': 'Товар', - 'verbose_name_plural': 'Товары', - }, + migrations.AddField( + model_name='product', + name='categories', + field=models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории'), ), migrations.CreateModel( name='ProductCategoryPhoto', @@ -117,19 +184,18 @@ class Migration(migrations.Migration): ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')), - ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')), - ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')), + ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')), ('base_price', models.DecimalField(decimal_places=2, default=0, help_text='Сумма actual_price всех компонентов. Пересчитывается автоматически.', max_digits=10, verbose_name='Базовая цена')), ('price', models.DecimalField(decimal_places=2, default=0, help_text='Базовая цена с учетом корректировок. Вычисляется автоматически.', max_digits=10, verbose_name='Итоговая цена')), ('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, комплект продается по этой цене', max_digits=10, null=True, verbose_name='Цена со скидкой')), ('price_adjustment_type', models.CharField(choices=[('none', 'Без изменения'), ('increase_percent', 'Увеличить на %'), ('increase_amount', 'Увеличить на сумму'), ('decrease_percent', 'Уменьшить на %'), ('decrease_amount', 'Уменьшить на сумму')], default='none', max_length=20, verbose_name='Тип корректировки цены')), ('price_adjustment_value', models.DecimalField(decimal_places=2, default=0, help_text='Процент (%) или сумма (руб) в зависимости от типа корректировки', max_digits=10, verbose_name='Значение корректировки')), ('is_temporary', models.BooleanField(default=False, help_text='Временные комплекты не показываются в каталоге и создаются для конкретного заказа', verbose_name='Временный комплект')), + ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')), ('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')), - ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')), ('order', models.ForeignKey(blank=True, help_text='Заказ, для которого создан временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='orders.order', verbose_name='Заказ')), ], options={ @@ -137,6 +203,11 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Комплекты', }, ), + migrations.AddField( + model_name='kititem', + name='kit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект'), + ), migrations.CreateModel( name='ProductKitPhoto', fields=[ @@ -175,7 +246,7 @@ class Migration(migrations.Migration): name='ProductTag', 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='Название')), + ('name', models.CharField(max_length=100, verbose_name='Название')), ('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL-идентификатор')), ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активен')), ('created_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания')), @@ -187,6 +258,10 @@ class Migration(migrations.Migration): 'indexes': [models.Index(fields=['is_active'], name='products_pr_is_acti_7f288f_idx')], }, ), + migrations.AddConstraint( + model_name='producttag', + constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('name',), name='unique_active_tag_name'), + ), migrations.AddField( model_name='productkit', name='tags', @@ -202,48 +277,24 @@ class Migration(migrations.Migration): name='variant_groups', field=models.ManyToManyField(blank=True, related_name='products', to='products.productvariantgroup', verbose_name='Группы вариантов'), ), - migrations.CreateModel( - name='KitItem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True, verbose_name='Количество')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items_direct', to='products.product', verbose_name='Конкретный товар')), - ('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект')), - ('variant_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productvariantgroup', verbose_name='Группа вариантов')), - ], - options={ - 'verbose_name': 'Компонент комплекта', - 'verbose_name_plural': 'Компоненты комплектов', - }, + migrations.AddField( + model_name='kititem', + name='variant_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productvariantgroup', verbose_name='Группа вариантов'), ), - migrations.CreateModel( - name='ProductVariantGroupItem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (1 - наивысший приоритет в этой группе)')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variant_group_items', to='products.product', verbose_name='Товар')), - ('variant_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов')), - ], - options={ - 'verbose_name': 'Товар в группе вариантов', - 'verbose_name_plural': 'Товары в группах вариантов', - 'ordering': ['priority', 'id'], - }, + migrations.AddField( + model_name='productvariantgroupitem', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variant_group_items', to='products.product', verbose_name='Товар'), ), - migrations.CreateModel( - name='KitItemPriority', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (0 - наивысший)')), - ('kit_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='priorities', to='products.kititem', verbose_name='Позиция в букете')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар')), - ], - options={ - 'verbose_name': 'Приоритет варианта', - 'verbose_name_plural': 'Приоритеты вариантов', - 'ordering': ['priority', 'id'], - 'unique_together': {('kit_item', 'product')}, - }, + migrations.AddField( + model_name='productvariantgroupitem', + name='variant_group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'), + ), + migrations.AlterUniqueTogether( + name='kititempriority', + unique_together={('kit_item', 'product')}, ), migrations.AddIndex( model_name='productcategory', @@ -257,6 +308,10 @@ class Migration(migrations.Migration): model_name='productcategory', index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_b8cdf3_idx'), ), + migrations.AddConstraint( + model_name='productcategory', + constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('name',), name='unique_active_category_name'), + ), migrations.AddIndex( model_name='productcategoryphoto', index=models.Index(fields=['quality_level'], name='products_pr_quality_ab44c2_idx'), @@ -301,6 +356,10 @@ class Migration(migrations.Migration): model_name='productkit', index=models.Index(fields=['order'], name='products_pr_order_i_2b5675_idx'), ), + migrations.AddConstraint( + model_name='productkit', + constraint=models.UniqueConstraint(condition=models.Q(('is_temporary', False), ('status', 'active')), fields=('name',), name='unique_active_kit_name'), + ), migrations.AddIndex( model_name='product', index=models.Index(fields=['in_stock'], name='products_pr_in_stoc_4fee1a_idx'), diff --git a/myproject/products/migrations/0002_photoprocessingstatus.py b/myproject/products/migrations/0002_photoprocessingstatus.py deleted file mode 100644 index 3e02e13..0000000 --- a/myproject/products/migrations/0002_photoprocessingstatus.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-15 07:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='PhotoProcessingStatus', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('photo_id', models.IntegerField(help_text='ID объекта ProductPhoto/ProductKitPhoto/ProductCategoryPhoto', verbose_name='ID фото')), - ('photo_model', models.CharField(help_text='Полный путь модели (e.g., products.ProductPhoto)', max_length=100, verbose_name='Модель фото')), - ('status', models.CharField(choices=[('pending', 'В очереди'), ('processing', 'Обрабатывается'), ('completed', 'Завершено'), ('failed', 'Ошибка')], db_index=True, default='pending', max_length=20, verbose_name='Статус обработки')), - ('task_id', models.CharField(blank=True, db_index=True, help_text='Уникальный ID задачи для отслеживания', max_length=255, verbose_name='ID задачи Celery')), - ('error_message', models.TextField(blank=True, help_text='Детальное описание ошибки при обработке', verbose_name='Сообщение об ошибке')), - ('result_data', models.JSONField(blank=True, default=dict, help_text='JSON с информацией о качестве, путях и метаданных', verbose_name='Результаты обработки')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Время начала обработки')), - ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Время завершения обработки')), - ], - options={ - 'verbose_name': 'Статус обработки фото', - 'verbose_name_plural': 'Статусы обработки фото', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['photo_id', 'photo_model'], name='products_ph_photo_i_e42a67_idx'), models.Index(fields=['task_id'], name='products_ph_task_id_748118_idx'), models.Index(fields=['status'], name='products_ph_status_1182b4_idx'), models.Index(fields=['status', 'created_at'], name='products_ph_status_41d415_idx')], - }, - ), - ] diff --git a/myproject/products/migrations/0003_alter_producttag_name_and_more.py b/myproject/products/migrations/0003_alter_producttag_name_and_more.py deleted file mode 100644 index 7db5590..0000000 --- a/myproject/products/migrations/0003_alter_producttag_name_and_more.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-15 10:37 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0002_initial'), - ('products', '0002_photoprocessingstatus'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='producttag', - name='name', - field=models.CharField(max_length=100, verbose_name='Название'), - ), - migrations.AddConstraint( - model_name='product', - constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('name',), name='unique_active_product_name'), - ), - migrations.AddConstraint( - model_name='productcategory', - constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('name',), name='unique_active_category_name'), - ), - migrations.AddConstraint( - model_name='productkit', - constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False), ('is_temporary', False)), fields=('name',), name='unique_active_kit_name'), - ), - migrations.AddConstraint( - model_name='producttag', - constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('name',), name='unique_active_tag_name'), - ), - ] diff --git a/myproject/products/models/base.py b/myproject/products/models/base.py index 778be4a..8901cfb 100644 --- a/myproject/products/models/base.py +++ b/myproject/products/models/base.py @@ -3,6 +3,7 @@ Содержит SKUCounter и BaseProductEntity (абстрактный базовый класс). """ from django.db import models, transaction +from django.db.models import Q from django.utils import timezone from django.contrib.auth import get_user_model @@ -98,10 +99,19 @@ class BaseProductEntity(models.Model): help_text="Используется для карточек товаров, превью и площадок" ) - # Статус - is_active = models.BooleanField( - default=True, - verbose_name="Активен" + # Статусы товаров + STATUS_CHOICES = [ + ('active', 'Активный'), # На продажу + ('archived', 'Архивный'), # Скрыт (можно вернуть в сезон) + ('discontinued', 'Снят'), # Морально устарел, на удаление + ] + + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='active', + db_index=True, + verbose_name="Статус" ) # Временные метки @@ -114,54 +124,73 @@ class BaseProductEntity(models.Model): verbose_name="Дата обновления" ) - # Soft delete - is_deleted = models.BooleanField( - default=False, - verbose_name="Удален", - db_index=True - ) - deleted_at = models.DateTimeField( + # История архивирования + archived_at = models.DateTimeField( null=True, blank=True, - verbose_name="Время удаления" + verbose_name="Время архивирования" ) - deleted_by = models.ForeignKey( + archived_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, - related_name='deleted_%(class)s_set', - verbose_name="Удален пользователем" + related_name='archived_%(class)s_set', + verbose_name="Архивировано пользователем" ) # Managers - objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() - all_objects = models.Manager() - active = ActiveManager() + objects = models.Manager() # Все товары + active_objects = models.Manager() # Будет переопределен ниже class Meta: abstract = True indexes = [ - models.Index(fields=['is_active']), - models.Index(fields=['is_deleted']), - models.Index(fields=['is_deleted', 'created_at']), + models.Index(fields=['status']), + models.Index(fields=['status', 'created_at']), + models.Index(fields=['created_at']), + ] + constraints = [ + models.UniqueConstraint( + fields=['name'], + condition=Q(status='active'), + name='unique_active_%(class)s_name' + ), ] def __str__(self): return self.name - def delete(self, *args, **kwargs): - """Мягкое удаление (soft delete)""" - user = kwargs.pop('user', None) - self.is_deleted = True - self.deleted_at = timezone.now() + def archive(self, user=None): + """Архивирование товара (скрыть, но можно восстановить)""" + self.status = 'archived' + self.archived_at = timezone.now() if user: - self.deleted_by = user - self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by']) + self.archived_by = user + self.save(update_fields=['status', 'archived_at', 'archived_by']) + + def restore(self): + """Восстановление архивированного товара""" + self.status = 'active' + self.archived_at = None + self.archived_by = None + self.save(update_fields=['status', 'archived_at', 'archived_by']) + + def discontinue(self, user=None): + """Пометить товар как снятый (устарел, готов к удалению)""" + self.status = 'discontinued' + if user: + self.archived_by = user + self.save(update_fields=['status', 'archived_by']) + + def delete(self, *args, **kwargs): + """Для совместимости: вызывает archive()""" + user = kwargs.pop('user', None) + self.archive(user=user) return 1, {self.__class__._meta.label: 1} def hard_delete(self): - """Физическое удаление из БД (необратимо!)""" + """Физическое удаление из БД (необратимо! только для старых товаров)""" super().delete() def save(self, *args, **kwargs): diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 3257687..efae9df 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -112,11 +112,11 @@ class ProductKit(BaseProductEntity): models.Index(fields=['order']), ] constraints = [ - # Уникальное имя для активных комплектов (исключаем удалённые) + # Уникальное имя для активных комплектов (исключаем архивированные и снятые) # Примечание: временные комплекты могут иметь дубли имён (создаются для заказов) models.UniqueConstraint( fields=['name'], - condition=Q(is_deleted=False, is_temporary=False), + condition=Q(status='active', is_temporary=False), name='unique_active_kit_name' ), ] diff --git a/myproject/products/models/managers.py b/myproject/products/models/managers.py index 2d6aea7..3d56ee0 100644 --- a/myproject/products/models/managers.py +++ b/myproject/products/models/managers.py @@ -9,58 +9,141 @@ from django.utils import timezone class ActiveManager(models.Manager): """Менеджер для фильтрации только активных записей""" def get_queryset(self): - return super().get_queryset().filter(is_active=True) + # Работает с обоими полями: status (Product/ProductKit) и is_active (Category/Tag) + qs = super().get_queryset() + if hasattr(self.model, 'status'): + return qs.filter(status='active') + elif hasattr(self.model, 'is_active'): + return qs.filter(is_active=True) + return qs class SoftDeleteQuerySet(models.QuerySet): """ - QuerySet для мягкого удаления (soft delete). - Позволяет фильтровать удаленные элементы и восстанавливать их. + QuerySet для архивирования товаров. + Позволяет фильтровать архивированные/снятые элементы и восстанавливать их. + Поддерживает обе системы: status (новая) и is_deleted/is_active (старая). """ + def _has_status_field(self): + """Проверяет, использует ли модель поле status""" + return hasattr(self.model, 'status') + + def _has_is_deleted_field(self): + """Проверяет, использует ли модель поле is_deleted""" + return hasattr(self.model, 'is_deleted') + def delete(self): - """Soft delete вместо hard delete""" - return self.update( - is_deleted=True, - deleted_at=timezone.now() - ) + """Архивирование вместо hard delete""" + if self._has_status_field(): + return self.update( + status='archived', + archived_at=timezone.now() + ) + elif self._has_is_deleted_field(): + return self.update( + is_deleted=True, + deleted_at=timezone.now() + ) + else: + # Fallback для моделей без мягкого удаления + return super().delete() def hard_delete(self): """Явный hard delete - удаляет из БД окончательно""" return super().delete() + def archive(self): + """Архивирование товаров""" + if self._has_status_field(): + return self.update(status='archived', archived_at=timezone.now()) + elif self._has_is_deleted_field(): + return self.update(is_deleted=True, deleted_at=timezone.now()) + else: + return self.delete() + def restore(self): - """Восстановление из удаленного состояния""" - return self.update( - is_deleted=False, - deleted_at=None, - deleted_by=None - ) + """Восстановление архивированных товаров""" + if self._has_status_field(): + return self.update( + status='active', + archived_at=None, + archived_by=None + ) + elif self._has_is_deleted_field(): + return self.update( + is_deleted=False, + deleted_at=None, + deleted_by=None + ) - def deleted_only(self): - """Получить только удаленные элементы""" - return self.filter(is_deleted=True) + def discontinue(self): + """Пометить как снятые (устарели)""" + if self._has_status_field(): + return self.update(status='discontinued') + else: + # Для моделей без status просто архивируем + return self.archive() - def not_deleted(self): - """Получить только не удаленные элементы""" - return self.filter(is_deleted=False) + def archived_only(self): + """Получить только архивированные товары""" + if self._has_status_field(): + return self.filter(status='archived') + elif self._has_is_deleted_field(): + return self.filter(is_deleted=True) + return self.none() - def with_deleted(self): - """Получить все элементы включая удаленные""" + def discontinued_only(self): + """Получить только снятые товары""" + if self._has_status_field(): + return self.filter(status='discontinued') + return self.none() + + def active_only(self): + """Получить только активные товары""" + if self._has_status_field(): + return self.filter(status='active') + elif self._has_is_deleted_field(): + return self.filter(is_deleted=False) + return self.all() + + def with_archived(self): + """Получить все элементы включая архивированные""" return self.all() class SoftDeleteManager(models.Manager): """ - Manager для работы с мягким удалением. - По умолчанию исключает удаленные элементы из запросов. + Manager для работы с архивированием товаров. + По умолчанию показывает только активные товары/элементы. + Поддерживает обе системы: status (новая) и is_deleted/is_active (старая). """ def get_queryset(self): - return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False) + qs = SoftDeleteQuerySet(self.model, using=self._db) + # Автоматически фильтруем активные записи в зависимости от полей + if hasattr(self.model, 'status'): + return qs.filter(status='active') + elif hasattr(self.model, 'is_deleted'): + return qs.filter(is_deleted=False) + elif hasattr(self.model, 'is_active'): + return qs.filter(is_active=True) + return qs - def deleted_only(self): - """Получить только удаленные элементы""" - return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True) + def archived_only(self): + """Получить только архивированные товары""" + qs = SoftDeleteQuerySet(self.model, using=self._db) + if hasattr(self.model, 'status'): + return qs.filter(status='archived') + elif hasattr(self.model, 'is_deleted'): + return qs.filter(is_deleted=True) + return qs.none() - def all_with_deleted(self): - """Получить все элементы включая удаленные""" + def discontinued_only(self): + """Получить только снятые товары""" + qs = SoftDeleteQuerySet(self.model, using=self._db) + if hasattr(self.model, 'status'): + return qs.filter(status='discontinued') + return qs.none() + + def all_with_archived(self): + """Получить все товары включая архивированные""" return SoftDeleteQuerySet(self.model, using=self._db).all() diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py index 9880d53..9baddf3 100644 --- a/myproject/products/models/products.py +++ b/myproject/products/models/products.py @@ -102,14 +102,7 @@ class Product(BaseProductEntity): models.Index(fields=['in_stock']), models.Index(fields=['sku']), ] - constraints = [ - # Уникальное имя для активных товаров (исключаем удалённые) - models.UniqueConstraint( - fields=['name'], - condition=Q(is_deleted=False), - name='unique_active_product_name' - ), - ] + # constraints наследуются из BaseProductEntity (unique_active_product_name) @property def actual_price(self): diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html index 3cc1f43..a2cecc7 100644 --- a/myproject/products/templates/products/product_form.html +++ b/myproject/products/templates/products/product_form.html @@ -102,15 +102,13 @@ {% endif %}
-
- {{ form.is_active }} - {{ form.is_active.label_tag }} -
- {% if form.is_active.help_text %} - {{ form.is_active.help_text }} + {{ form.status.label_tag }} + {{ form.status }} + {% if form.status.help_text %} + {{ form.status.help_text }} {% endif %} - {% if form.is_active.errors %} -
{{ form.is_active.errors }}
+ {% if form.status.errors %} +
{{ form.status.errors }}
{% endif %}
diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html index 72ec6fe..8a7afdf 100644 --- a/myproject/products/templates/products/productkit_create.html +++ b/myproject/products/templates/products/productkit_create.html @@ -208,11 +208,12 @@ {% endif %} -
- {{ form.is_active }} - +
+ {{ form.status.label_tag }} + {{ form.status }} + {% if form.status.errors %} +
{{ form.status.errors }}
+ {% endif %}
diff --git a/myproject/products/templates/products/productkit_edit.html b/myproject/products/templates/products/productkit_edit.html index f579276..631653e 100644 --- a/myproject/products/templates/products/productkit_edit.html +++ b/myproject/products/templates/products/productkit_edit.html @@ -209,11 +209,12 @@ {% endif %} -
- {{ form.is_active }} - +
+ {{ form.status.label_tag }} + {{ form.status }} + {% if form.status.errors %} +
{{ form.status.errors }}
+ {% endif %}
diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 4e3baa5..41b8fd7 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -142,7 +142,7 @@ def search_products_and_variants(request): if search_type in ['all', 'product']: # Показываем последние добавленные активные товары - products = Product.objects.filter(is_active=True)\ + products = Product.objects.filter(status='active')\ .order_by('-created_at')[:page_size]\ .values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock') diff --git a/myproject/products/views/product_views.py b/myproject/products/views/product_views.py index 69fd267..ac51109 100644 --- a/myproject/products/views/product_views.py +++ b/myproject/products/views/product_views.py @@ -44,11 +44,9 @@ class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): queryset = queryset.filter(categories__id=category_id) # Фильтр по статусу - is_active = self.request.GET.get('is_active') - if is_active == '1': - queryset = queryset.filter(is_active=True) - elif is_active == '0': - queryset = queryset.filter(is_active=False) + status_filter = self.request.GET.get('status') + if status_filter: + queryset = queryset.filter(status=status_filter) # Фильтр по тегам tags = self.request.GET.getlist('tags') @@ -67,7 +65,7 @@ class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): 'current': { 'search': self.request.GET.get('search', ''), 'category': self.request.GET.get('category', ''), - 'is_active': self.request.GET.get('is_active', ''), + 'status': self.request.GET.get('status', ''), 'tags': self.request.GET.getlist('tags'), } } @@ -251,7 +249,7 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV # Применяем фильтры search_query = self.request.GET.get('search') category_id = self.request.GET.get('category') - is_active = self.request.GET.get('is_active') + status_filter = self.request.GET.get('status') # Фильтрация по поиску if search_query: @@ -276,12 +274,9 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV kits = kits.filter(categories__id=category_id) # Фильтрация по статусу - if is_active == '1': - products = products.filter(is_active=True) - kits = kits.filter(is_active=True) - elif is_active == '0': - products = products.filter(is_active=False) - kits = kits.filter(is_active=False) + if status_filter: + products = products.filter(status=status_filter) + kits = kits.filter(status=status_filter) # Добавляем type для различения в шаблоне products_list = list(products.order_by('-created_at')) @@ -311,7 +306,7 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV 'current': { 'search': self.request.GET.get('search', ''), 'category': self.request.GET.get('category', ''), - 'is_active': self.request.GET.get('is_active', ''), + 'status': self.request.GET.get('status', ''), } } diff --git a/myproject/tenants/migrations/0001_initial.py b/myproject/tenants/migrations/0001_initial.py index a1d88e8..3050450 100644 --- a/myproject/tenants/migrations/0001_initial.py +++ b/myproject/tenants/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-14 20:45 +# Generated by Django 5.0.10 on 2025-11-15 11:57 import django.core.validators import django.db.models.deletion