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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user