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 import nested_admin
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
from .admin_displays import ( from .admin_displays import (
format_quality_badge, format_quality_badge,
format_quality_display, format_quality_display,
@@ -387,7 +387,7 @@ class ProductAdmin(admin.ModelAdmin):
list_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups') list_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups')
search_fields = ('name', 'sku', 'description', 'search_keywords') search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups') 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 = [] autocomplete_fields = []
actions = [ actions = [
restore_items, restore_items,
@@ -400,11 +400,11 @@ class ProductAdmin(admin.ModelAdmin):
fieldsets = ( 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'), 'fields': ('cost_price_details_display',),
'description': 'price - основная цена, sale_price - цена со скидкой (опционально)' 'description': 'Себестоимость рассчитывается автоматически на основе партий товара (FIFO метод). Редактировать вручную невозможно.'
}), }),
('Дополнительно', { ('Дополнительно', {
'fields': ('tags', 'variant_groups', 'status') '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): def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем товарам (включая удаленные)""" """Переопределяем queryset для доступа ко всем товарам (включая удаленные)"""
qs = Product.all_objects.all() qs = Product.all_objects.all()
@@ -811,6 +877,65 @@ class SKUCounterAdmin(admin.ModelAdmin):
return False 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(ProductCategory, ProductCategoryAdminWithPhotos)
admin.site.register(ProductTag, ProductTagAdmin) admin.site.register(ProductTag, ProductTagAdmin)
admin.site.register(Product, ProductAdminWithPhotos) admin.site.register(Product, ProductAdminWithPhotos)

View File

@@ -4,3 +4,9 @@ from django.apps import AppConfig
class ProductsConfig(AppConfig): class ProductsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'products' name = 'products'
def ready(self):
"""
Подключаем сигналы при готовности приложения.
"""
import products.signals # noqa

View File

@@ -30,7 +30,7 @@ class ProductForm(forms.ModelForm):
model = Product model = Product
fields = [ fields = [
'name', 'sku', 'description', 'short_description', 'categories', 'name', 'sku', 'description', 'short_description', 'categories',
'tags', 'unit', 'cost_price', 'price', 'sale_price', 'status' 'tags', 'unit', 'price', 'sale_price', 'status'
] ]
labels = { labels = {
'name': 'Название', 'name': 'Название',
@@ -40,7 +40,6 @@ class ProductForm(forms.ModelForm):
'categories': 'Категории', 'categories': 'Категории',
'tags': 'Теги', 'tags': 'Теги',
'unit': 'Единица измерения', 'unit': 'Единица измерения',
'cost_price': 'Себестоимость',
'price': 'Основная цена', 'price': 'Основная цена',
'sale_price': 'Цена со скидкой', 'sale_price': 'Цена со скидкой',
'status': 'Статус' 'status': 'Статус'
@@ -66,7 +65,6 @@ class ProductForm(forms.ModelForm):
'rows': 2, 'rows': 2,
'placeholder': 'Краткое описание для превью и площадок' 'placeholder': 'Краткое описание для превью и площадок'
}) })
self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
self.fields['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['sale_price'].widget.attrs.update({'class': 'form-control'})
self.fields['unit'].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 .variants import ProductVariantGroup, ProductVariantGroupItem
# Продукты # Продукты
from .products import Product from .products import Product, CostPriceHistory
# Комплекты # Комплекты
from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute
@@ -58,6 +58,7 @@ __all__ = [
# Products # Products
'Product', 'Product',
'CostPriceHistory',
# Kits # Kits
'ProductKit', 'ProductKit',

View File

@@ -63,6 +63,9 @@ class Product(BaseProductEntity):
cost_price = models.DecimalField( cost_price = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=2, decimal_places=2,
default=0,
null=True,
blank=True,
verbose_name="Себестоимость", verbose_name="Себестоимость",
help_text="Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)" help_text="Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)"
) )
@@ -149,3 +152,79 @@ class Product(BaseProductEntity):
return Product.objects.filter( return Product.objects.filter(
variant_groups__in=self.variant_groups.all() variant_groups__in=self.variant_groups.all()
).exclude(id=self.id).distinct() ).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 %} {% endif %}
</div> </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"> <div class="mb-3">
{{ form.sku.label_tag }} {{ form.sku.label_tag }}
@@ -67,6 +87,20 @@
{% endif %} {% endif %}
</div> </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"> <div class="mb-3">
{{ form.description.label_tag }} {{ form.description.label_tag }}
@@ -89,9 +123,9 @@
{% endif %} {% endif %}
</div> </div>
<!-- Единица измерения и Статус в один ряд --> <!-- Единица измерения, Основная цена, Цена со скидкой -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-8"> <div class="col-md-6">
{{ form.unit.label_tag }} {{ form.unit.label_tag }}
{{ form.unit }} {{ form.unit }}
{% if form.unit.help_text %} {% if form.unit.help_text %}
@@ -101,7 +135,7 @@
<div class="text-danger">{{ form.unit.errors }}</div> <div class="text-danger">{{ form.unit.errors }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4"> <div class="col-md-6">
{{ form.status.label_tag }} {{ form.status.label_tag }}
{{ form.status }} {{ form.status }}
{% if form.status.help_text %} {% if form.status.help_text %}
@@ -116,45 +150,6 @@
<hr class="my-4"> <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: Информация о наличии (только при редактировании) --> <!-- Блок 2.5: Информация о наличии (только при редактировании) -->
{% if object %} {% if object %}
<div class="mb-4 p-3 bg-info-light rounded border border-info"> <div class="mb-4 p-3 bg-info-light rounded border border-info">
@@ -387,25 +382,6 @@
<hr class="my-4"> <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 class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
<div> <div>
<a href="{% url 'products:products-list' %}" class="btn btn-secondary">Отмена</a> <a href="{% url 'products:products-list' %}" class="btn btn-secondary">Отмена</a>