Консолидация миграций и добавление unit_service
- Обновлены начальные миграции для всех приложений - Удалены устаревшие миграции для единиц измерения и SKU - Добавлен новый сервис unit_service.py для управления единицами - Обновлены команды инициализации данных тенанта
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# 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.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
@@ -71,6 +71,7 @@ class Migration(migrations.Migration):
|
||||
('converted_at', models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования')),
|
||||
('cart_lock_expires_at', models.DateTimeField(blank=True, help_text='Время истечения блокировки в корзине (для витринных комплектов)', null=True, verbose_name='Блокировка корзины истекает')),
|
||||
('cart_session_id', models.CharField(blank=True, help_text='Дополнительная идентификация сессии для надежности', max_length=100, null=True, verbose_name='ID сессии корзины')),
|
||||
('quantity_base', models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара', max_digits=10, null=True, verbose_name='Количество в базовых единицах')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Резервирование',
|
||||
@@ -87,6 +88,8 @@ class Migration(migrations.Migration):
|
||||
('document_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Номер документа')),
|
||||
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')),
|
||||
('processed', models.BooleanField(default=False, verbose_name='Обработана (FIFO применена)')),
|
||||
('quantity_base', models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара (для списания со склада)', max_digits=10, null=True, verbose_name='Количество в базовых единицах')),
|
||||
('unit_name_snapshot', models.CharField(blank=True, default='', help_text='Название единицы продажи на момент продажи', max_length=100, verbose_name='Название единицы (snapshot)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Продажа',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -63,6 +63,11 @@ class Migration(migrations.Migration):
|
||||
name='product_kit',
|
||||
field=models.ForeignKey(blank=True, help_text='Временный комплект, для которого создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.productkit', verbose_name='Комплект'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reservations', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='order',
|
||||
@@ -73,6 +78,11 @@ class Migration(migrations.Migration):
|
||||
name='product',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sales', to='products.product', verbose_name='Товар'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salebatchallocation',
|
||||
name='sale',
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-01 21:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0002_initial'),
|
||||
('products', '0006_populate_unit_of_measure'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='quantity_base',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара', max_digits=10, null=True, verbose_name='Количество в базовых единицах'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reservations', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='quantity_base',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара (для списания со склада)', max_digits=10, null=True, verbose_name='Количество в базовых единицах'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='unit_name_snapshot',
|
||||
field=models.CharField(blank=True, default='', help_text='Название единицы продажи на момент продажи', max_length=100, verbose_name='Название единицы (snapshot)'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
@@ -85,10 +85,13 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
|
||||
('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')),
|
||||
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
|
||||
('quantity', models.DecimalField(decimal_places=3, default=1, help_text='Количество в единицах продажи (может быть дробным)', max_digits=10, verbose_name='Количество')),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
|
||||
('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
|
||||
('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')),
|
||||
('unit_name_snapshot', models.CharField(blank=True, default='', help_text='Название единицы продажи на момент заказа', max_length=100, verbose_name='Название единицы (snapshot)')),
|
||||
('conversion_factor_snapshot', models.DecimalField(blank=True, decimal_places=6, help_text='Коэффициент конверсии на момент заказа', max_digits=15, null=True, verbose_name='Коэффициент конверсии (snapshot)')),
|
||||
('quantity_in_base_units', models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара (для списания со склада)', max_digits=10, null=True, verbose_name='Количество в базовых единицах')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
||||
],
|
||||
options={
|
||||
@@ -250,10 +253,13 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
||||
('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
|
||||
('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')),
|
||||
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
|
||||
('quantity', models.DecimalField(decimal_places=3, default=1, help_text='Количество в единицах продажи (может быть дробным)', max_digits=10, verbose_name='Количество')),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
|
||||
('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
|
||||
('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')),
|
||||
('unit_name_snapshot', models.CharField(blank=True, default='', help_text='Название единицы продажи на момент заказа', max_length=100, verbose_name='Название единицы (snapshot)')),
|
||||
('conversion_factor_snapshot', models.DecimalField(blank=True, decimal_places=6, help_text='Коэффициент конверсии на момент заказа', max_digits=15, null=True, verbose_name='Коэффициент конверсии (snapshot)')),
|
||||
('quantity_in_base_units', models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара (для списания со склада)', max_digits=10, null=True, verbose_name='Количество в базовых единицах')),
|
||||
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата добавления')),
|
||||
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('history_date', models.DateTimeField(db_index=True)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -28,6 +28,11 @@ class Migration(migrations.Migration):
|
||||
name='product_kit',
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.productkit', verbose_name='Комплект товаров'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='showcase',
|
||||
@@ -93,6 +98,11 @@ class Migration(migrations.Migration):
|
||||
name='product_kit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.productkit', verbose_name='Комплект товаров'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='showcase',
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-01 21:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('orders', '0002_initial'),
|
||||
('products', '0006_populate_unit_of_measure'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='conversion_factor_snapshot',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Коэффициент конверсии на момент заказа', max_digits=15, null=True, verbose_name='Коэффициент конверсии (snapshot)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='quantity_in_base_units',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара (для списания со склада)', max_digits=10, null=True, verbose_name='Количество в базовых единицах'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalorderitem',
|
||||
name='unit_name_snapshot',
|
||||
field=models.CharField(blank=True, default='', help_text='Название единицы продажи на момент заказа', max_length=100, verbose_name='Название единицы (snapshot)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='conversion_factor_snapshot',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Коэффициент конверсии на момент заказа', max_digits=15, null=True, verbose_name='Коэффициент конверсии (snapshot)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='quantity_in_base_units',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара (для списания со склада)', max_digits=10, null=True, verbose_name='Количество в базовых единицах'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderitem',
|
||||
name='unit_name_snapshot',
|
||||
field=models.CharField(blank=True, default='', help_text='Название единицы продажи на момент заказа', max_length=100, verbose_name='Название единицы (snapshot)'),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-02 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('orders', '0003_add_sales_unit_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='historicalorderitem',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=3, default=1, help_text='Количество в единицах продажи (может быть дробным)', max_digits=10, verbose_name='Количество'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderitem',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=3, default=1, help_text='Количество в единицах продажи (может быть дробным)', max_digits=10, verbose_name='Количество'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -2,3 +2,8 @@
|
||||
Сервисы для бизнес-логики products приложения.
|
||||
Следует принципу "Skinny Models, Fat Services".
|
||||
"""
|
||||
from .unit_service import UnitOfMeasureService
|
||||
|
||||
__all__ = [
|
||||
'UnitOfMeasureService',
|
||||
]
|
||||
|
||||
147
myproject/products/services/unit_service.py
Normal file
147
myproject/products/services/unit_service.py
Normal 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'))
|
||||
@@ -8,6 +8,7 @@ Management команда для инициализации всех систе
|
||||
- Системные способы оплаты
|
||||
- Склад по умолчанию
|
||||
- Витрину по умолчанию
|
||||
- Единицы измерения
|
||||
|
||||
Использование:
|
||||
# Инициализация для конкретного тенанта
|
||||
@@ -21,7 +22,7 @@ from django_tenants.utils import get_tenant_model, schema_context
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты, склад, витрина)'
|
||||
help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты, склад, витрина, единицы измерения)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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
|
||||
|
||||
@@ -93,6 +93,7 @@ class TenantOnboardingService:
|
||||
from customers.models import Customer
|
||||
from orders.services import OrderStatusService, PaymentMethodService
|
||||
from inventory.services import WarehouseService, ShowcaseService
|
||||
from products.services import UnitOfMeasureService
|
||||
|
||||
# 1. Системный клиент
|
||||
logger.info("Создание системного клиента...")
|
||||
@@ -133,6 +134,14 @@ class TenantOnboardingService:
|
||||
showcase, created = ShowcaseService.get_or_create_default(warehouse)
|
||||
logger.info(f"Витрина по умолчанию: {showcase.name}")
|
||||
|
||||
# 6. Единицы измерения
|
||||
logger.info("Создание единиц измерения...")
|
||||
if reset:
|
||||
UnitOfMeasureService.reset_default_units()
|
||||
else:
|
||||
UnitOfMeasureService.create_default_units()
|
||||
logger.info("Единицы измерения созданы")
|
||||
|
||||
# ==================== Приватные методы ====================
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
Reference in New Issue
Block a user