Консолидация миграций и добавление unit_service
- Обновлены начальные миграции для всех приложений - Удалены устаревшие миграции для единиц измерения и SKU - Добавлен новый сервис unit_service.py для управления единицами - Обновлены команды инициализации данных тенанта
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
# Generated by Django 5.0.10 on 2025-12-29 22:19
|
||||
# Generated by Django 5.0.10 on 2026-01-03 08:35
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import products.models.photos
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -76,7 +78,7 @@ class Migration(migrations.Migration):
|
||||
name='SKUCounter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('counter_type', models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter'), ('category', 'Category Counter')], max_length=20, unique=True, verbose_name='Тип счетчика')),
|
||||
('counter_type', models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter'), ('category', 'Category Counter'), ('configurable', 'Configurable Product Counter')], max_length=20, unique=True, verbose_name='Тип счетчика')),
|
||||
('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')),
|
||||
],
|
||||
options={
|
||||
@@ -84,6 +86,22 @@ class Migration(migrations.Migration):
|
||||
'verbose_name_plural': 'Счетчики артикулов',
|
||||
},
|
||||
),
|
||||
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.CreateModel(
|
||||
name='ConfigurableProduct',
|
||||
fields=[
|
||||
@@ -126,6 +144,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attributes', models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта')),
|
||||
('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')),
|
||||
('variant_sku', models.CharField(blank=True, help_text='Дополнительный артикул для внешних площадок. Генерируется автоматически.', max_length=50, verbose_name='Артикул варианта')),
|
||||
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurableproduct', verbose_name='Родитель (вариативный товар)')),
|
||||
],
|
||||
options={
|
||||
@@ -181,7 +200,7 @@ class Migration(migrations.Migration):
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
|
||||
('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
|
||||
('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
|
||||
('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения (deprecated)')),
|
||||
('cost_price', models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, null=True, verbose_name='Себестоимость')),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')),
|
||||
('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')),
|
||||
@@ -379,6 +398,27 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['order', '-created_at'],
|
||||
},
|
||||
),
|
||||
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='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Единица продажи товара',
|
||||
'verbose_name_plural': 'Единицы продажи товаров',
|
||||
'ordering': ['position', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductTag',
|
||||
fields=[
|
||||
@@ -429,6 +469,16 @@ class Migration(migrations.Migration):
|
||||
name='variant_group',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productsalesunit',
|
||||
name='unit',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='products.unitofmeasure', verbose_name='Единица измерения'),
|
||||
),
|
||||
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.AddIndex(
|
||||
model_name='configurableproductoptionattribute',
|
||||
index=models.Index(fields=['option'], name='products_co_option__bd950a_idx'),
|
||||
@@ -505,6 +555,10 @@ class Migration(migrations.Migration):
|
||||
model_name='configurableproductoption',
|
||||
index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__be8830_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurableproductoption',
|
||||
index=models.Index(fields=['variant_sku'], name='products_co_variant_2da938_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='configurableproductoption',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('kit__isnull', False), ('product__isnull', True)), models.Q(('kit__isnull', True), ('product__isnull', False)), _connector='OR'), name='configurable_option_kit_xor_product'),
|
||||
@@ -565,14 +619,6 @@ class Migration(migrations.Migration):
|
||||
model_name='productkit',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_temporary', False), ('status', 'active')), fields=('name',), name='unique_active_kit_name'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['in_stock'], name='products_pr_in_stoc_4fee1a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['sku'], name='products_pr_sku_ca0cdc_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kititem',
|
||||
index=models.Index(fields=['kit'], name='products_ki_kit_id_d28dc9_idx'),
|
||||
@@ -605,4 +651,16 @@ class Migration(migrations.Migration):
|
||||
name='productvariantgroupitem',
|
||||
unique_together={('variant_group', 'product')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='productsalesunit',
|
||||
unique_together={('product', 'name')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['in_stock'], name='products_pr_in_stoc_4fee1a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['sku'], name='products_pr_sku_ca0cdc_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-12-30 07:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='skucounter',
|
||||
name='counter_type',
|
||||
field=models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter'), ('category', 'Category Counter'), ('configurable', 'Configurable Product Counter')], max_length=20, unique=True, verbose_name='Тип счетчика'),
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-12-30 08:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0002_add_configurable_sku_counter'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='configurableproductoption',
|
||||
name='variant_sku',
|
||||
field=models.CharField(blank=True, help_text='Дополнительный артикул для внешних площадок. Генерируется автоматически.', max_length=50, verbose_name='Артикул варианта'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurableproductoption',
|
||||
index=models.Index(fields=['variant_sku'], name='products_co_variant_2da938_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2025-12-30 08:17
|
||||
|
||||
import re
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def populate_variant_sku(apps, schema_editor):
|
||||
"""
|
||||
Генерируем variant_sku для существующих вариантов.
|
||||
Формат: {parent.sku}-V{counter}
|
||||
"""
|
||||
ConfigurableProductOption = apps.get_model('products', 'ConfigurableProductOption')
|
||||
ConfigurableProduct = apps.get_model('products', 'ConfigurableProduct')
|
||||
|
||||
# Получаем все родительские товары
|
||||
for parent in ConfigurableProduct.objects.all():
|
||||
# Получаем все варианты этого родителя
|
||||
options = ConfigurableProductOption.objects.filter(parent=parent).order_by('id')
|
||||
|
||||
for idx, option in enumerate(options, start=1):
|
||||
# Генерируем variant_sku только если он пустой
|
||||
if not option.variant_sku:
|
||||
option.variant_sku = f"{parent.sku}-V{idx}"
|
||||
option.save(update_fields=['variant_sku'])
|
||||
|
||||
|
||||
def reverse_populate(apps, schema_editor):
|
||||
"""Очистка variant_sku при откате миграции"""
|
||||
ConfigurableProductOption = apps.get_model('products', 'ConfigurableProductOption')
|
||||
ConfigurableProductOption.objects.all().update(variant_sku='')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0003_add_variant_sku'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_variant_sku, reverse_populate),
|
||||
]
|
||||
@@ -1,65 +0,0 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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