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:
65
myproject/products/migrations/0005_add_unit_of_measure.py
Normal file
65
myproject/products/migrations/0005_add_unit_of_measure.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
Reference in New Issue
Block a user