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

@@ -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),
]