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)

View File

@@ -0,0 +1,65 @@
# Generated by Django 5.0.10 on 2026-01-01 21:28
import django.core.validators
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0004_populate_variant_sku'),
]
operations = [
migrations.CreateModel(
name='UnitOfMeasure',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(help_text='Короткий код: шт, кг, банч, ветка', max_length=20, unique=True, verbose_name='Код')),
('name', models.CharField(help_text='Полное название: Штука, Килограмм, Банч', max_length=100, verbose_name='Название')),
('short_name', models.CharField(help_text='Для UI: шт., кг., бч.', max_length=10, verbose_name='Сокращение')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('position', models.PositiveIntegerField(default=0, verbose_name='Порядок сортировки')),
],
options={
'verbose_name': 'Единица измерения',
'verbose_name_plural': 'Единицы измерения',
'ordering': ['position', 'name'],
},
),
migrations.AlterField(
model_name='product',
name='unit',
field=models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения (deprecated)'),
),
migrations.AddField(
model_name='product',
name='base_unit',
field=models.ForeignKey(blank=True, help_text="Единица хранения и закупки (банч, кг, шт). Если указана, используется вместо поля 'unit'.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='products.unitofmeasure', verbose_name='Базовая единица'),
),
migrations.CreateModel(
name='ProductSalesUnit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Например: 'Ветка большая', 'Ветка средняя'", max_length=100, verbose_name='Название')),
('conversion_factor', models.DecimalField(decimal_places=6, help_text='Сколько единиц продажи в 1 базовой единице товара. Например: 15 (из 1 банча получается 15 больших веток)', max_digits=15, validators=[django.core.validators.MinValueValidator(Decimal('0.000001'))], verbose_name='Коэффициент конверсии')),
('price', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='Цена продажи')),
('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='Цена со скидкой')),
('min_quantity', models.DecimalField(decimal_places=3, default=Decimal('1'), help_text='Минимальное количество для продажи', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.001'))], verbose_name='Мин. количество')),
('quantity_step', models.DecimalField(decimal_places=3, default=Decimal('1'), help_text='С каким шагом можно заказывать (0.1, 0.5, 1)', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.001'))], verbose_name='Шаг количества')),
('is_default', models.BooleanField(default=False, help_text='Единица, выбираемая по умолчанию при добавлении в заказ', verbose_name='Единица по умолчанию')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('position', models.PositiveIntegerField(default=0, verbose_name='Порядок сортировки')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sales_units', to='products.product', verbose_name='Товар')),
('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='products.unitofmeasure', verbose_name='Единица измерения')),
],
options={
'verbose_name': 'Единица продажи товара',
'verbose_name_plural': 'Единицы продажи товаров',
'ordering': ['position', 'id'],
'unique_together': {('product', 'name')},
},
),
]

View File

@@ -0,0 +1,68 @@
# Generated manually for populating UnitOfMeasure with initial data
from django.db import migrations
def populate_units(apps, schema_editor):
"""Заполнение справочника единиц измерения начальными данными."""
UnitOfMeasure = apps.get_model('products', 'UnitOfMeasure')
units_data = [
# Базовые единицы (из UNIT_CHOICES)
{'code': 'шт', 'name': 'Штука', 'short_name': 'шт.', 'position': 1},
{'code': 'м', 'name': 'Метр', 'short_name': 'м.', 'position': 2},
{'code': 'г', 'name': 'Грамм', 'short_name': 'г.', 'position': 3},
{'code': 'л', 'name': 'Литр', 'short_name': 'л.', 'position': 4},
{'code': 'кг', 'name': 'Килограмм', 'short_name': 'кг.', 'position': 5},
# Флористические единицы
{'code': 'банч', 'name': 'Банч', 'short_name': 'банч', 'position': 10},
{'code': 'ветка', 'name': 'Ветка', 'short_name': 'вет.', 'position': 11},
{'code': 'пучок', 'name': 'Пучок', 'short_name': 'пуч.', 'position': 12},
{'code': 'голова', 'name': 'Голова', 'short_name': 'гол.', 'position': 13},
{'code': 'стебель', 'name': 'Стебель', 'short_name': 'стеб.', 'position': 14},
]
for data in units_data:
UnitOfMeasure.objects.get_or_create(
code=data['code'],
defaults={
'name': data['name'],
'short_name': data['short_name'],
'position': data['position'],
'is_active': True,
}
)
def migrate_products_to_base_unit(apps, schema_editor):
"""Миграция существующих товаров: связываем поле unit с base_unit."""
Product = apps.get_model('products', 'Product')
UnitOfMeasure = apps.get_model('products', 'UnitOfMeasure')
# Создаём маппинг старых кодов на объекты UnitOfMeasure
unit_mapping = {}
for uom in UnitOfMeasure.objects.all():
unit_mapping[uom.code] = uom
# Обновляем товары
for product in Product.objects.filter(base_unit__isnull=True):
if product.unit and product.unit in unit_mapping:
product.base_unit = unit_mapping[product.unit]
product.save(update_fields=['base_unit'])
def reverse_migration(apps, schema_editor):
"""Откат миграции - ничего не делаем, данные останутся."""
pass
class Migration(migrations.Migration):
dependencies = [
('products', '0005_add_unit_of_measure'),
]
operations = [
migrations.RunPython(populate_units, reverse_migration),
migrations.RunPython(migrate_products_to_base_unit, reverse_migration),
]

View File

@@ -40,6 +40,9 @@ from .kits import (
# Атрибуты
from .attributes import ProductAttribute, ProductAttributeValue
# Единицы измерения
from .units import UnitOfMeasure, ProductSalesUnit
# Фотографии
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
@@ -79,6 +82,10 @@ __all__ = [
'ProductAttribute',
'ProductAttributeValue',
# Units
'UnitOfMeasure',
'ProductSalesUnit',
# Photos
'BasePhoto',
'ProductPhoto',

View File

@@ -1,8 +1,9 @@
"""
Модель Product - базовый товар (цветок, упаковка, аксессуар).
"""
from decimal import Decimal
from django.db import models
from django.db.models import Q
from django.db.models import Q, Sum
from .base import BaseProductEntity
from .categories import ProductCategory, ProductTag
@@ -56,7 +57,19 @@ class Product(BaseProductEntity):
max_length=10,
choices=UNIT_CHOICES,
default='шт',
verbose_name="Единица измерения"
verbose_name="Единица измерения (deprecated)"
)
# Новое поле: ссылка на справочник единиц измерения
base_unit = models.ForeignKey(
'UnitOfMeasure',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='products',
verbose_name="Базовая единица",
help_text="Единица хранения и закупки (банч, кг, шт). "
"Если указана, используется вместо поля 'unit'."
)
# ЦЕНООБРАЗОВАНИЕ - переименованные поля
@@ -153,6 +166,99 @@ class Product(BaseProductEntity):
variant_groups__in=self.variant_groups.all()
).exclude(id=self.id).distinct()
# === МЕТОДЫ ДЛЯ РАБОТЫ С ЕДИНИЦАМИ ПРОДАЖИ ===
@property
def unit_display(self):
"""
Отображаемое название единицы измерения.
Приоритет: base_unit.code > unit
"""
if self.base_unit:
return self.base_unit.code
return self.unit
@property
def has_sales_units(self):
"""Есть ли у товара настроенные единицы продажи"""
return self.sales_units.filter(is_active=True).exists()
def get_default_sales_unit(self):
"""
Получить единицу продажи по умолчанию.
Возвращает первую единицу с is_default=True или первую активную.
"""
default = self.sales_units.filter(is_active=True, is_default=True).first()
if default:
return default
return self.sales_units.filter(is_active=True).first()
def get_total_available(self, warehouse=None):
"""
Получить общий доступный остаток в базовых единицах.
Args:
warehouse: (опционально) конкретный склад
Returns:
Decimal: количество в базовых единицах
"""
from inventory.models import Stock
qs = Stock.objects.filter(product=self)
if warehouse:
qs = qs.filter(warehouse=warehouse)
result = qs.aggregate(
total=Sum('quantity_available')
)['total']
return result or Decimal('0')
def get_available_in_unit(self, sales_unit, warehouse=None):
"""
Получить остаток в указанной единице продажи.
Args:
sales_unit: объект ProductSalesUnit
warehouse: (опционально) конкретный склад
Returns:
Decimal: количество в единицах продажи
"""
base_qty = self.get_total_available(warehouse)
return sales_unit.convert_from_base(base_qty)
def get_all_units_availability(self, warehouse=None):
"""
Получить остатки во всех единицах продажи.
Args:
warehouse: (опционально) конкретный склад
Returns:
list[dict]: список с информацией по каждой единице продажи
[
{
'sales_unit': ProductSalesUnit,
'available': Decimal,
'price': Decimal
},
...
]
"""
base_qty = self.get_total_available(warehouse)
result = []
for su in self.sales_units.filter(is_active=True).select_related('unit'):
result.append({
'sales_unit': su,
'available': su.convert_from_base(base_qty),
'price': su.actual_price
})
return result
class CostPriceHistory(models.Model):
"""

View File

@@ -0,0 +1,222 @@
"""
Модели единиц измерения.
- UnitOfMeasure: справочник единиц измерения
- ProductSalesUnit: единицы продажи товара с коэффициентами конверсии
"""
from decimal import Decimal
from django.db import models
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
class UnitOfMeasure(models.Model):
"""
Справочник единиц измерения.
Расширяемый справочник вместо жестко заданных UNIT_CHOICES.
"""
code = models.CharField(
max_length=20,
unique=True,
verbose_name="Код",
help_text="Короткий код: шт, кг, банч, ветка"
)
name = models.CharField(
max_length=100,
verbose_name="Название",
help_text="Полное название: Штука, Килограмм, Банч"
)
short_name = models.CharField(
max_length=10,
verbose_name="Сокращение",
help_text="Для UI: шт., кг., бч."
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок сортировки"
)
class Meta:
verbose_name = "Единица измерения"
verbose_name_plural = "Единицы измерения"
ordering = ['position', 'name']
def __str__(self):
return f"{self.name} ({self.code})"
class ProductSalesUnit(models.Model):
"""
Единица продажи товара с коэффициентом конверсии.
Один товар может иметь несколько единиц продажи.
Пример:
Товар: Пихта Нобилис (базовая единица: банч)
Единицы продажи:
- Ветка большая: 1 банч = 15 веток, цена 300₽
- Ветка средняя: 1 банч = 30 веток, цена 150₽
- Ветка маленькая: 1 банч = 50 веток, цена 100₽
"""
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='sales_units',
verbose_name="Товар"
)
unit = models.ForeignKey(
UnitOfMeasure,
on_delete=models.PROTECT,
verbose_name="Единица измерения"
)
name = models.CharField(
max_length=100,
verbose_name="Название",
help_text="Например: 'Ветка большая', 'Ветка средняя'"
)
conversion_factor = models.DecimalField(
max_digits=15,
decimal_places=6,
validators=[MinValueValidator(Decimal('0.000001'))],
verbose_name="Коэффициент конверсии",
help_text="Сколько единиц продажи в 1 базовой единице товара. "
"Например: 15 (из 1 банча получается 15 больших веток)"
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(Decimal('0'))],
verbose_name="Цена продажи"
)
sale_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0'))],
verbose_name="Цена со скидкой"
)
min_quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
default=Decimal('1'),
validators=[MinValueValidator(Decimal('0.001'))],
verbose_name="Мин. количество",
help_text="Минимальное количество для продажи"
)
quantity_step = models.DecimalField(
max_digits=10,
decimal_places=3,
default=Decimal('1'),
validators=[MinValueValidator(Decimal('0.001'))],
verbose_name="Шаг количества",
help_text="С каким шагом можно заказывать (0.1, 0.5, 1)"
)
is_default = models.BooleanField(
default=False,
verbose_name="Единица по умолчанию",
help_text="Единица, выбираемая по умолчанию при добавлении в заказ"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок сортировки"
)
class Meta:
verbose_name = "Единица продажи товара"
verbose_name_plural = "Единицы продажи товаров"
ordering = ['position', 'id']
unique_together = [['product', 'name']]
def __str__(self):
return f"{self.product.name} - {self.name} ({self.unit.code})"
def clean(self):
super().clean()
if self.conversion_factor and self.conversion_factor <= 0:
raise ValidationError({
'conversion_factor': 'Коэффициент конверсии должен быть больше 0'
})
if self.sale_price and self.price and self.sale_price >= self.price:
raise ValidationError({
'sale_price': 'Цена со скидкой должна быть меньше основной цены'
})
def save(self, *args, **kwargs):
# Если это единица по умолчанию, снимаем флаг с других
if self.is_default:
ProductSalesUnit.objects.filter(
product=self.product,
is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
@property
def actual_price(self):
"""Финальная цена (со скидкой или без)"""
return self.sale_price if self.sale_price else self.price
def convert_to_base(self, quantity):
"""
Конвертировать количество в базовые единицы товара.
Args:
quantity: количество в единицах продажи
Returns:
Decimal: количество в базовых единицах
Пример:
10 больших веток → 10 / 15 = 0.667 банча
"""
if not self.conversion_factor or self.conversion_factor == 0:
return Decimal(quantity)
return Decimal(quantity) / self.conversion_factor
def convert_from_base(self, base_quantity):
"""
Конвертировать из базовых единиц в единицы продажи.
Args:
base_quantity: количество в базовых единицах товара
Returns:
Decimal: количество в единицах продажи
Пример:
2.5 банча → 2.5 * 15 = 37.5 больших веток
"""
if not self.conversion_factor:
return Decimal(base_quantity)
return Decimal(base_quantity) * self.conversion_factor
def validate_quantity(self, quantity):
"""
Проверить, что количество соответствует ограничениям.
Args:
quantity: количество для проверки
Raises:
ValidationError: если количество некорректно
"""
quantity = Decimal(quantity)
if quantity < self.min_quantity:
raise ValidationError(
f'Минимальное количество для "{self.name}": {self.min_quantity}'
)
# Проверяем шаг количества (с учётом погрешности float)
if self.quantity_step and self.quantity_step > 0:
remainder = quantity % self.quantity_step
if remainder > Decimal('0.0001') and (self.quantity_step - remainder) > Decimal('0.0001'):
raise ValidationError(
f'Количество должно быть кратно {self.quantity_step}'
)