Feat: Автоматическая себестоимость товара (read-only)

- Удалено ручное редактирование себестоимости из формы товара
- Себестоимость теперь рассчитывается автоматически из партий (FIFO)
- Добавлена модель CostPriceHistory для логирования изменений
- Добавлен signal для автоматического логирования изменений cost_price
- Админ-панель: себестоимость read-only с детальной информацией о партиях
- Фронтенд: цены перемещены под название, теги под категории
- Поле cost_price сделано опциональным (default=0) для создания товаров

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-23 23:22:45 +03:00
parent 493b6c212d
commit addc5e0962
9 changed files with 374 additions and 71 deletions

View File

@@ -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(
'<div style="padding: 12px; background-color: #f9f9f9; border-radius: 4px; border-left: 4px solid #ffc107;">'
'<strong>Нет партий</strong><br/>'
'<span style="color: #666;">Себестоимость установится при поступлении первой партии товара</span>'
'</div>'
)
# Текущая себестоимость
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 = '<div style="margin-top: 10px;"><strong style="font-size: 13px;">Партии:</strong><ul style="margin: 5px 0; padding-left: 20px;">'
for batch in batches:
batches_html += (
f'<li style="font-size: 12px; margin: 3px 0;">'
f'{batch.get("warehouse_name", "")} | '
f'Кол-во: {batch.get("quantity", 0)} | '
f'Цена: {batch.get("cost_price", 0)} руб. | '
f'Дата: {batch.get("created_at", "")}'
f'</li>'
)
batches_html += '</ul></div>'
return format_html(
'<div style="padding: 12px; background-color: #f9f9f9; border-radius: 4px; border-left: 4px solid #28a745;">'
'<div><strong style="font-size: 14px;">Текущая себестоимость:</strong> <span style="font-size: 16px; color: #28a745; font-weight: bold;">{} руб.</span></div>'
'<div style="margin-top: 8px; font-size: 12px; color: #666;">'
'Статус: <span style="color: {}; font-weight: bold;">{}</span><br/>'
'Всего в партиях: {} шт.<br/>'
'Рассчитанная: {} руб.'
'</div>'
'{}'
'<div style="margin-top: 8px; font-size: 11px; color: #999;">'
'Себестоимость рассчитывается автоматически при поступлении товара (FIFO метод).<br/>'
'Редактировать вручную невозможно.'
'</div>'
'</div>',
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(
'<span style="color: {}; font-weight: bold;">{} {} {:.2f} руб. ({:+.2f}%)</span>',
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)

View File

@@ -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

View File

@@ -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'})

View File

@@ -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')],
},
),
]

View File

@@ -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='Себестоимость'),
),
]

View File

@@ -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',

View File

@@ -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()})"

View File

@@ -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='Себестоимость пересчитана на основе партий товара'
)

View File

@@ -33,6 +33,26 @@
{% endif %}
</div>
<!-- Основная цена и Цена со скидкой -->
<div class="row mb-3">
<div class="col-md-6">
<label for="id_price" class="form-label fw-bold">Основная цена <span class="text-danger">*</span></label>
{{ form.price }}
<small class="form-text text-muted">Цена продажи товара</small>
{% if form.price.errors %}
<div class="text-danger">{{ form.price.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_sale_price" class="form-label">Цена со скидкой</label>
{{ form.sale_price }}
<small class="form-text text-muted">Необязательно. Если задана, товар будет продаваться по этой цене</small>
{% if form.sale_price.errors %}
<div class="text-danger">{{ form.sale_price.errors }}</div>
{% endif %}
</div>
</div>
<!-- Артикул -->
<div class="mb-3">
{{ form.sku.label_tag }}
@@ -67,6 +87,20 @@
{% endif %}
</div>
<!-- Теги -->
<div class="mb-3">
{{ form.tags.label_tag }}
<div class="p-3 bg-light rounded">
{{ form.tags }}
</div>
{% if form.tags.help_text %}
<small class="form-text text-muted">{{ form.tags.help_text }}</small>
{% endif %}
{% if form.tags.errors %}
<div class="text-danger">{{ form.tags.errors }}</div>
{% endif %}
</div>
<!-- Описание -->
<div class="mb-3">
{{ form.description.label_tag }}
@@ -89,9 +123,9 @@
{% endif %}
</div>
<!-- Единица измерения и Статус в один ряд -->
<!-- Единица измерения, Основная цена, Цена со скидкой -->
<div class="row mb-3">
<div class="col-md-8">
<div class="col-md-6">
{{ form.unit.label_tag }}
{{ form.unit }}
{% if form.unit.help_text %}
@@ -101,7 +135,7 @@
<div class="text-danger">{{ form.unit.errors }}</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="col-md-6">
{{ form.status.label_tag }}
{{ form.status }}
{% if form.status.help_text %}
@@ -116,45 +150,6 @@
<hr class="my-4">
<!-- Блок 2: Ценообразование -->
<div class="mb-4">
<h5 class="mb-3">Ценообразование</h5>
<div class="row mb-3">
<div class="col-md-6">
<label for="id_cost_price" class="form-label">Себестоимость</label>
{{ form.cost_price }}
{% if form.cost_price.help_text %}
<small class="form-text text-muted">{{ form.cost_price.help_text }}</small>
{% endif %}
{% if form.cost_price.errors %}
<div class="text-danger">{{ form.cost_price.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_price" class="form-label fw-bold">Основная цена <span class="text-danger">*</span></label>
{{ form.price }}
<small class="form-text text-muted">Цена продажи товара</small>
{% if form.price.errors %}
<div class="text-danger">{{ form.price.errors }}</div>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="id_sale_price" class="form-label">Цена со скидкой</label>
{{ form.sale_price }}
<small class="form-text text-muted">Необязательно. Если задана, товар будет продаваться по этой цене (дешевле основной)</small>
{% if form.sale_price.errors %}
<div class="text-danger">{{ form.sale_price.errors }}</div>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- Блок 2.5: Информация о наличии (только при редактировании) -->
{% if object %}
<div class="mb-4 p-3 bg-info-light rounded border border-info">
@@ -387,25 +382,6 @@
<hr class="my-4">
<!-- Блок 4: Классификация -->
<div class="mb-4">
<h5 class="mb-3">Классификация</h5>
<!-- Теги -->
<div class="mb-3">
{{ form.tags.label_tag }}
<div class="p-3 bg-light rounded">
{{ form.tags }}
</div>
{% if form.tags.help_text %}
<small class="form-text text-muted">{{ form.tags.help_text }}</small>
{% endif %}
{% if form.tags.errors %}
<div class="text-danger">{{ form.tags.errors }}</div>
{% endif %}
</div>
</div>
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
<div>
<a href="{% url 'products:products-list' %}" class="btn btn-secondary">Отмена</a>