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)