Консолидация миграций и добавление unit_service

- Обновлены начальные миграции для всех приложений
- Удалены устаревшие миграции для единиц измерения и SKU
- Добавлен новый сервис unit_service.py для управления единицами
- Обновлены команды инициализации данных тенанта
This commit is contained in:
2026-01-03 12:09:31 +03:00
parent 030d5ad198
commit 208c6b55de
22 changed files with 272 additions and 355 deletions

View File

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

View File

@@ -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='Тип счетчика'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,3 +2,8 @@
Сервисы для бизнес-логики products приложения.
Следует принципу "Skinny Models, Fat Services".
"""
from .unit_service import UnitOfMeasureService
__all__ = [
'UnitOfMeasureService',
]

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
"""
Сервис для управления единицами измерения.
Отвечает за создание и управление справочником единиц измерения (UnitOfMeasure).
"""
import logging
from typing import List, Dict, Any
logger = logging.getLogger(__name__)
class UnitOfMeasureService:
"""
Сервис для управления единицами измерения.
Предоставляет методы для создания и управления базовыми единицами измерения,
которые используются в системе для товаров и продаж.
"""
# Базовый набор единиц измерения для новых тенантов
DEFAULT_UNITS = [
# Базовые единицы (из старых 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},
]
@classmethod
def create_default_units(cls) -> List:
"""
Создает базовый набор единиц измерения для тенанта.
Использует get_or_create, поэтому безопасно вызывать повторно.
Returns:
List[UnitOfMeasure]: Список созданных/существующих единиц измерения
"""
from products.models import UnitOfMeasure
created_units = []
for unit_data in cls.DEFAULT_UNITS:
unit, created = UnitOfMeasure.objects.get_or_create(
code=unit_data['code'],
defaults={
'name': unit_data['name'],
'short_name': unit_data['short_name'],
'position': unit_data['position'],
'is_active': True,
}
)
created_units.append(unit)
if created:
logger.debug(f"Создана единица измерения: {unit.code} - {unit.name}")
logger.info(f"Инициализация единиц измерения завершена: {len(created_units)} единиц")
return created_units
@classmethod
def reset_default_units(cls) -> List:
"""
Удаляет все единицы измерения и создаёт их заново.
ВНИМАНИЕ: Используйте только при инициализации тенанта или в тестах!
Удаление единиц может нарушить связи с существующими товарами.
Returns:
List[UnitOfMeasure]: Список созданных единиц измерения
"""
from products.models import UnitOfMeasure
logger.warning("Удаление всех единиц измерения...")
UnitOfMeasure.objects.all().delete()
return cls.create_default_units()
@classmethod
def get_or_create_unit(cls, code: str, name: str, short_name: str,
position: int = 0) -> tuple:
"""
Получает или создаёт единицу измерения.
Args:
code: Уникальный код единицы
name: Полное название
short_name: Короткое название для UI
position: Позиция для сортировки
Returns:
tuple: (UnitOfMeasure, created) - единица и флаг создания
"""
from products.models import UnitOfMeasure
unit, created = UnitOfMeasure.objects.get_or_create(
code=code,
defaults={
'name': name,
'short_name': short_name,
'position': position,
'is_active': True,
}
)
if created:
logger.info(f"Создана единица измерения: {code} - {name}")
return unit, created
@classmethod
def get_unit_by_code(cls, code: str):
"""
Получает единицу измерения по коду.
Args:
code: Код единицы измерения
Returns:
UnitOfMeasure или None, если не найдена
"""
from products.models import UnitOfMeasure
try:
return UnitOfMeasure.objects.get(code=code, is_active=True)
except UnitOfMeasure.DoesNotExist:
logger.warning(f"Единица измерения с кодом '{code}' не найдена")
return None
@classmethod
def get_active_units(cls) -> List:
"""
Возвращает все активные единицы измерения.
Returns:
List[UnitOfMeasure]: Список активных единиц, отсортированных по position
"""
from products.models import UnitOfMeasure
return list(UnitOfMeasure.objects.filter(is_active=True).order_by('position', 'code'))