feat(products): add support for product sales units

Add new models UnitOfMeasure and ProductSalesUnit to enable selling products in different units (e.g., bunches, kg). Update Product model with base_unit field and methods for unit conversions and availability. Extend Sale, Reservation, and OrderItem models with sales_unit fields and snapshots. Modify SaleProcessor to handle quantity conversions. Include admin interfaces for managing units. Add corresponding database migrations.
This commit is contained in:
2026-01-02 02:09:44 +03:00
parent ca308ae2a2
commit 5b68f14bb4
11 changed files with 764 additions and 15 deletions

View File

@@ -12,6 +12,7 @@ from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
from .models import UnitOfMeasure, ProductSalesUnit
from .admin_displays import (
format_quality_badge,
format_quality_display,
@@ -395,7 +396,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups')
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display')
autocomplete_fields = []
autocomplete_fields = ['base_unit']
actions = [
restore_items,
delete_selected,
@@ -407,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
fieldsets = (
('Основная информация', {
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'unit', 'price', 'sale_price')
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'base_unit', 'unit', 'price', 'sale_price')
}),
('Себестоимость', {
'fields': ('cost_price_details_display',),
@@ -832,7 +833,7 @@ class ProductKitAdminWithItems(ProductKitAdmin):
# Update admin classes to include photo inlines
class ProductAdminWithPhotos(ProductAdmin):
inlines = [ProductPhotoInline]
inlines = [ProductPhotoInline, ProductSalesUnitInline]
class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin):
inlines = [KitItemInline, ProductKitPhotoInline]
@@ -943,6 +944,88 @@ class CostPriceHistoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
return False
# === Админка для единиц измерения ===
@admin.register(UnitOfMeasure)
class UnitOfMeasureAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для справочника единиц измерения"""
list_display = ('code', 'name', 'short_name', 'position', 'is_active')
list_filter = ('is_active',)
search_fields = ('code', 'name', 'short_name')
list_editable = ('position', 'is_active')
ordering = ('position', 'code')
fieldsets = (
('Основная информация', {
'fields': ('code', 'name', 'short_name')
}),
('Настройки', {
'fields': ('position', 'is_active')
}),
)
class ProductSalesUnitInline(admin.TabularInline):
"""Инлайн для единиц продажи товара"""
model = ProductSalesUnit
extra = 0
fields = (
'unit', 'name', 'conversion_factor', 'price', 'sale_price',
'min_quantity', 'quantity_step', 'is_default', 'is_active'
)
autocomplete_fields = ['unit']
verbose_name = "Единица продажи"
verbose_name_plural = "Единицы продажи"
@admin.register(ProductSalesUnit)
class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для единиц продажи товаров"""
list_display = (
'product', 'name', 'unit', 'conversion_factor',
'get_price_display', 'min_quantity', 'is_default', 'is_active'
)
list_filter = ('is_active', 'is_default', 'unit')
search_fields = ('product__name', 'product__sku', 'name')
autocomplete_fields = ['product', 'unit']
list_editable = ('is_default', 'is_active')
ordering = ('product', 'position')
fieldsets = (
('Товар и единица', {
'fields': ('product', 'unit', 'name')
}),
('Конверсия', {
'fields': ('conversion_factor',),
'description': 'Сколько единиц продажи получается из 1 базовой единицы товара. '
'Например: 1 банч = 15 веток → conversion_factor = 15'
}),
('Ценообразование', {
'fields': ('price', 'sale_price'),
'description': 'Цена за единицу продажи. sale_price - цена со скидкой (опционально).'
}),
('Ограничения', {
'fields': ('min_quantity', 'quantity_step'),
'description': 'min_quantity - минимальное количество для заказа. '
'quantity_step - шаг изменения количества.'
}),
('Настройки', {
'fields': ('position', 'is_default', 'is_active')
}),
)
def get_price_display(self, obj):
"""Отображение цены с учетом скидки"""
if obj.sale_price:
return format_html(
'<span style="text-decoration: line-through; color: #999;">{}</span> '
'<span style="color: #dc3545; font-weight: bold;">{}</span>',
obj.price, obj.sale_price
)
return obj.price
get_price_display.short_description = 'Цена'
admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos)
admin.site.register(ProductTag, ProductTagAdmin)
admin.site.register(Product, ProductAdminWithPhotos)