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
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'})
|
||||||
|
|||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='Себестоимость'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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()})"
|
||||||
|
|||||||
50
myproject/products/signals.py
Normal file
50
myproject/products/signals.py
Normal 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='Себестоимость пересчитана на основе партий товара'
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user