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 %}
-
-
-