From addc5e096298fc06b9c07ce6db5b0bbfe549aa50 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 23 Nov 2025 23:22:45 +0300 Subject: [PATCH] =?UTF-8?q?Feat:=20=D0=90=D0=B2=D1=82=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20=D1=81=D0=B5?= =?UTF-8?q?=D0=B1=D0=B5=D1=81=D1=82=D0=BE=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=B0=20(read-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалено ручное редактирование себестоимости из формы товара - Себестоимость теперь рассчитывается автоматически из партий (FIFO) - Добавлена модель CostPriceHistory для логирования изменений - Добавлен signal для автоматического логирования изменений cost_price - Админ-панель: себестоимость read-only с детальной информацией о партиях - Фронтенд: цены перемещены под название, теги под категории - Поле cost_price сделано опциональным (default=0) для создания товаров 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- myproject/products/admin.py | 137 +++++++++++++++++- myproject/products/apps.py | 6 + myproject/products/forms.py | 4 +- ...ter_productcategoryphoto_image_and_more.py | 50 +++++++ .../0010_alter_product_cost_price.py | 18 +++ myproject/products/models/__init__.py | 3 +- myproject/products/models/products.py | 79 ++++++++++ myproject/products/signals.py | 50 +++++++ .../templates/products/product_form.html | 98 +++++-------- 9 files changed, 374 insertions(+), 71 deletions(-) create mode 100644 myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py create mode 100644 myproject/products/migrations/0010_alter_product_cost_price.py create mode 100644 myproject/products/signals.py diff --git a/myproject/products/admin.py b/myproject/products/admin.py index 387c0ba..db9a96d 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -5,7 +5,7 @@ from django.db.models import Q import nested_admin from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto -from .models import ProductVariantGroup, KitItemPriority, SKUCounter +from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory from .admin_displays import ( format_quality_badge, format_quality_display, @@ -387,7 +387,7 @@ class ProductAdmin(admin.ModelAdmin): 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', 'archived_at', 'archived_by') + readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display') autocomplete_fields = [] actions = [ restore_items, @@ -400,11 +400,11 @@ class ProductAdmin(admin.ModelAdmin): fieldsets = ( ('Основная информация', { - 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'unit') + 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'unit', 'price', 'sale_price') }), - ('Цены', { - 'fields': ('cost_price', 'price', 'sale_price'), - 'description': 'price - основная цена, sale_price - цена со скидкой (опционально)' + ('Себестоимость', { + 'fields': ('cost_price_details_display',), + 'description': 'Себестоимость рассчитывается автоматически на основе партий товара (FIFO метод). Редактировать вручную невозможно.' }), ('Дополнительно', { 'fields': ('tags', 'variant_groups', 'status') @@ -425,6 +425,72 @@ class ProductAdmin(admin.ModelAdmin): }), ) + def cost_price_details_display(self, obj): + """ + Отображает детали расчета себестоимости товара в админ-панели. + Показывает: текущая себестоимость, количество партий, их цены и даты. + """ + from django.utils.html import format_html + from decimal import Decimal + + # Получаем детали стоимости + details = obj.cost_price_details + + if not details or not details.get('batches'): + return format_html( + '
' + 'Нет партий
' + 'Себестоимость установится при поступлении первой партии товара' + '
' + ) + + # Текущая себестоимость + current_cost = details.get('cached_cost', Decimal('0')) + calculated_cost = details.get('calculated_cost', Decimal('0')) + total_qty = details.get('total_quantity', Decimal('0')) + is_synced = details.get('is_synced', True) + batches = details.get('batches', []) + + # Статус синхронизации + sync_status = '✓ Синхронизирована' if is_synced else '⚠️ Несинхронизирована' + sync_color = '#28a745' if is_synced else '#dc3545' + + # HTML для партий + batches_html = '
Партии:
    ' + for batch in batches: + batches_html += ( + f'
  • ' + f'{batch.get("warehouse_name", "—")} | ' + f'Кол-во: {batch.get("quantity", 0)} | ' + f'Цена: {batch.get("cost_price", 0)} руб. | ' + f'Дата: {batch.get("created_at", "—")}' + f'
  • ' + ) + batches_html += '
' + + return format_html( + '
' + '
Текущая себестоимость: {} руб.
' + '
' + 'Статус: {}
' + 'Всего в партиях: {} шт.
' + 'Рассчитанная: {} руб.' + '
' + '{}' + '
' + 'Себестоимость рассчитывается автоматически при поступлении товара (FIFO метод).
' + 'Редактировать вручную невозможно.' + '
' + '
', + current_cost, + sync_color, + sync_status, + total_qty, + calculated_cost, + batches_html + ) + cost_price_details_display.short_description = 'Себестоимость товара' + def get_queryset(self, request): """Переопределяем queryset для доступа ко всем товарам (включая удаленные)""" qs = Product.all_objects.all() @@ -811,6 +877,65 @@ class SKUCounterAdmin(admin.ModelAdmin): return False +@admin.register(CostPriceHistory) +class CostPriceHistoryAdmin(admin.ModelAdmin): + list_display = ['product', 'get_price_change', 'reason', 'created_at'] + list_filter = ['reason', 'created_at', 'product'] + search_fields = ['product__name', 'product__sku', 'notes'] + readonly_fields = ['product', 'old_cost_price', 'new_cost_price', 'reason', 'related_object_id', 'related_object_type', 'notes', 'created_at'] + date_hierarchy = 'created_at' + + fieldsets = ( + ('Информация о изменении', { + 'fields': ('product', 'reason', 'created_at') + }), + ('Себестоимость', { + 'fields': ('old_cost_price', 'new_cost_price', 'get_price_change') + }), + ('Связанные объекты', { + 'fields': ('related_object_type', 'related_object_id'), + 'classes': ('collapse',), + }), + ('Примечания', { + 'fields': ('notes',), + 'classes': ('collapse',), + }), + ) + + def get_price_change(self, obj): + """Показывает изменение цены в красивом формате""" + change = obj.new_cost_price - obj.old_cost_price + change_percent = (change / obj.old_cost_price * 100) if obj.old_cost_price != 0 else 0 + + if change > 0: + color = '#dc3545' # red + symbol = '↑' + elif change < 0: + color = '#28a745' # green + symbol = '↓' + else: + color = '#6c757d' # gray + symbol = '=' + + return format_html( + '{} {} {:.2f} руб. ({:+.2f}%)', + color, + symbol, + obj.old_cost_price, + abs(change), + change_percent + ) + get_price_change.short_description = 'Изменение' + + def has_delete_permission(self, request, obj=None): + # История не должна удаляться вручную + return False + + def has_add_permission(self, request): + # История создается только автоматически через сигналы + return False + + admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos) admin.site.register(ProductTag, ProductTagAdmin) admin.site.register(Product, ProductAdminWithPhotos) diff --git a/myproject/products/apps.py b/myproject/products/apps.py index 145a2ac..b19b826 100644 --- a/myproject/products/apps.py +++ b/myproject/products/apps.py @@ -4,3 +4,9 @@ from django.apps import AppConfig class ProductsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'products' + + def ready(self): + """ + Подключаем сигналы при готовности приложения. + """ + import products.signals # noqa diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 9f842e5..9989445 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -30,7 +30,7 @@ class ProductForm(forms.ModelForm): model = Product fields = [ 'name', 'sku', 'description', 'short_description', 'categories', - 'tags', 'unit', 'cost_price', 'price', 'sale_price', 'status' + 'tags', 'unit', 'price', 'sale_price', 'status' ] labels = { 'name': 'Название', @@ -40,7 +40,6 @@ class ProductForm(forms.ModelForm): 'categories': 'Категории', 'tags': 'Теги', 'unit': 'Единица измерения', - 'cost_price': 'Себестоимость', 'price': 'Основная цена', 'sale_price': 'Цена со скидкой', 'status': 'Статус' @@ -66,7 +65,6 @@ class ProductForm(forms.ModelForm): 'rows': 2, 'placeholder': 'Краткое описание для превью и площадок' }) - self.fields['cost_price'].widget.attrs.update({'class': 'form-control'}) 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'}) diff --git a/myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py b/myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py new file mode 100644 index 0000000..24f5e6a --- /dev/null +++ b/myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 5.0.10 on 2025-11-23 19:15 + +import django.db.models.deletion +import products.models.photos +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0008_productkit_showcase_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='productcategoryphoto', + name='image', + field=models.ImageField(upload_to=products.models.photos.get_category_photo_upload_path, verbose_name='Оригинальное фото'), + ), + migrations.AlterField( + model_name='productkitphoto', + name='image', + field=models.ImageField(upload_to=products.models.photos.get_kit_photo_upload_path, verbose_name='Оригинальное фото'), + ), + migrations.AlterField( + model_name='productphoto', + name='image', + field=models.ImageField(upload_to=products.models.photos.get_product_photo_upload_path, verbose_name='Оригинальное фото'), + ), + migrations.CreateModel( + name='CostPriceHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('old_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Старая себестоимость')), + ('new_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Новая себестоимость')), + ('reason', models.CharField(choices=[('incoming', 'Поступление товара'), ('batch_edit', 'Редактирование партии'), ('batch_delete', 'Удаление партии'), ('recalculation', 'Пересчет себестоимости'), ('system', 'Системная корректировка')], max_length=20, verbose_name='Причина изменения')), + ('related_object_id', models.IntegerField(blank=True, help_text='Например, ID партии (StockBatch) для поступлений', null=True, verbose_name='ID связанного объекта')), + ('related_object_type', models.CharField(blank=True, help_text="Например, 'StockBatch' для партий", max_length=50, verbose_name='Тип связанного объекта')), + ('notes', models.TextField(blank=True, help_text='Дополнительная информация об изменении', verbose_name='Примечания')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время изменения')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cost_price_history', to='products.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'История себестоимости', + 'verbose_name_plural': 'Истории себестоимости', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['product', '-created_at'], name='products_co_product_3320c9_idx'), models.Index(fields=['reason'], name='products_co_reason_959ee1_idx')], + }, + ), + ] diff --git a/myproject/products/migrations/0010_alter_product_cost_price.py b/myproject/products/migrations/0010_alter_product_cost_price.py new file mode 100644 index 0000000..19bb8b3 --- /dev/null +++ b/myproject/products/migrations/0010_alter_product_cost_price.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.10 on 2025-11-23 19:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0009_alter_productcategoryphoto_image_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='cost_price', + field=models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, null=True, verbose_name='Себестоимость'), + ), + ] diff --git a/myproject/products/models/__init__.py b/myproject/products/models/__init__.py index ad6da9e..35d84bb 100644 --- a/myproject/products/models/__init__.py +++ b/myproject/products/models/__init__.py @@ -29,7 +29,7 @@ from .categories import ProductCategory, ProductTag from .variants import ProductVariantGroup, ProductVariantGroupItem # Продукты -from .products import Product +from .products import Product, CostPriceHistory # Комплекты from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute @@ -58,6 +58,7 @@ __all__ = [ # Products 'Product', + 'CostPriceHistory', # Kits 'ProductKit', diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py index 9baddf3..2d7dd86 100644 --- a/myproject/products/models/products.py +++ b/myproject/products/models/products.py @@ -63,6 +63,9 @@ class Product(BaseProductEntity): cost_price = models.DecimalField( max_digits=10, decimal_places=2, + default=0, + null=True, + blank=True, verbose_name="Себестоимость", help_text="Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)" ) @@ -149,3 +152,79 @@ class Product(BaseProductEntity): return Product.objects.filter( variant_groups__in=self.variant_groups.all() ).exclude(id=self.id).distinct() + + +class CostPriceHistory(models.Model): + """ + История изменений себестоимости товара. + Логирует все изменения себестоимости, их причины и дата/время. + """ + REASON_CHOICES = [ + ('incoming', 'Поступление товара'), + ('batch_edit', 'Редактирование партии'), + ('batch_delete', 'Удаление партии'), + ('recalculation', 'Пересчет себестоимости'), + ('system', 'Системная корректировка'), + ] + + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='cost_price_history', + verbose_name="Товар" + ) + + old_cost_price = models.DecimalField( + max_digits=10, + decimal_places=2, + verbose_name="Старая себестоимость" + ) + + new_cost_price = models.DecimalField( + max_digits=10, + decimal_places=2, + verbose_name="Новая себестоимость" + ) + + reason = models.CharField( + max_length=20, + choices=REASON_CHOICES, + verbose_name="Причина изменения" + ) + + related_object_id = models.IntegerField( + null=True, + blank=True, + verbose_name="ID связанного объекта", + help_text="Например, ID партии (StockBatch) для поступлений" + ) + + related_object_type = models.CharField( + max_length=50, + blank=True, + verbose_name="Тип связанного объекта", + help_text="Например, 'StockBatch' для партий" + ) + + notes = models.TextField( + blank=True, + verbose_name="Примечания", + help_text="Дополнительная информация об изменении" + ) + + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата и время изменения" + ) + + class Meta: + verbose_name = "История себестоимости" + verbose_name_plural = "Истории себестоимости" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['product', '-created_at']), + models.Index(fields=['reason']), + ] + + def __str__(self): + return f"{self.product.name}: {self.old_cost_price} → {self.new_cost_price} ({self.get_reason_display()})" diff --git a/myproject/products/signals.py b/myproject/products/signals.py new file mode 100644 index 0000000..dfa33cf --- /dev/null +++ b/myproject/products/signals.py @@ -0,0 +1,50 @@ +""" +Signals для приложения products. + +Логирует изменения себестоимости товара через CostPriceHistory. +""" + +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.db import models + +from .models import Product, CostPriceHistory + + +@receiver(post_save, sender=Product) +def log_cost_price_changes(sender, instance, created, **kwargs): + """ + Логирует изменения себестоимости товара. + + Срабатывает при создании или обновлении товара. + Создает запись в CostPriceHistory если себестоимость изменилась. + """ + if created: + # При создании товара себестоимость обычно 0, логируем только если не 0 + if instance.cost_price != 0: + CostPriceHistory.objects.create( + product=instance, + old_cost_price=0, + new_cost_price=instance.cost_price, + reason='system', + notes='Начальная себестоимость при создании товара' + ) + return + + # Получаем предыдущее значение себестоимости + try: + previous = Product.objects.get(pk=instance.pk) + old_cost_price = previous.cost_price + except Product.DoesNotExist: + # Товар был удален, не логируем + return + + # Если себестоимость изменилась, логируем + if old_cost_price != instance.cost_price: + CostPriceHistory.objects.create( + product=instance, + old_cost_price=old_cost_price, + new_cost_price=instance.cost_price, + reason='recalculation', + notes='Себестоимость пересчитана на основе партий товара' + ) diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html index 126040e..919e533 100644 --- a/myproject/products/templates/products/product_form.html +++ b/myproject/products/templates/products/product_form.html @@ -33,6 +33,26 @@ {% endif %} + +
+
+ + {{ form.price }} + Цена продажи товара + {% if form.price.errors %} +
{{ form.price.errors }}
+ {% endif %} +
+
+ + {{ form.sale_price }} + Необязательно. Если задана, товар будет продаваться по этой цене + {% if form.sale_price.errors %} +
{{ form.sale_price.errors }}
+ {% endif %} +
+
+
{{ form.sku.label_tag }} @@ -67,6 +87,20 @@ {% endif %}
+ +
+ {{ form.tags.label_tag }} +
+ {{ form.tags }} +
+ {% if form.tags.help_text %} + {{ form.tags.help_text }} + {% endif %} + {% if form.tags.errors %} +
{{ form.tags.errors }}
+ {% endif %} +
+
{{ form.description.label_tag }} @@ -89,9 +123,9 @@ {% endif %}
- +
-
+
{{ form.unit.label_tag }} {{ form.unit }} {% if form.unit.help_text %} @@ -101,7 +135,7 @@
{{ form.unit.errors }}
{% endif %}
-
+
{{ form.status.label_tag }} {{ form.status }} {% if form.status.help_text %} @@ -116,45 +150,6 @@
- -
-
Ценообразование
- -
-
- - {{ form.cost_price }} - {% if form.cost_price.help_text %} - {{ form.cost_price.help_text }} - {% endif %} - {% if form.cost_price.errors %} -
{{ form.cost_price.errors }}
- {% endif %} -
-
- - {{ form.price }} - Цена продажи товара - {% if form.price.errors %} -
{{ form.price.errors }}
- {% endif %} -
-
- -
-
- - {{ form.sale_price }} - Необязательно. Если задана, товар будет продаваться по этой цене (дешевле основной) - {% if form.sale_price.errors %} -
{{ form.sale_price.errors }}
- {% endif %} -
-
-
- -
- {% if object %}
@@ -387,25 +382,6 @@
- -
-
Классификация
- - -
- {{ form.tags.label_tag }} -
- {{ form.tags }} -
- {% if form.tags.help_text %} - {{ form.tags.help_text }} - {% endif %} - {% if form.tags.errors %} -
{{ form.tags.errors }}
- {% endif %} -
-
-