Консолидация миграций и добавление 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,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.contrib.auth.validators
import django.utils.timezone import django.utils.timezone

View File

@@ -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 django.db.models.deletion
import phonenumber_field.modelfields import phonenumber_field.modelfields

View File

@@ -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 django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@@ -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 django.db.models.deletion
import phonenumber_field.modelfields import phonenumber_field.modelfields
@@ -71,6 +71,7 @@ class Migration(migrations.Migration):
('converted_at', models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования')), ('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_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 сессии корзины')), ('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={ options={
'verbose_name': 'Резервирование', 'verbose_name': 'Резервирование',
@@ -87,6 +88,8 @@ class Migration(migrations.Migration):
('document_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Номер документа')), ('document_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Номер документа')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')), ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')),
('processed', models.BooleanField(default=False, verbose_name='Обработана (FIFO применена)')), ('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={ options={
'verbose_name': 'Продажа', 'verbose_name': 'Продажа',

View File

@@ -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 django.db.models.deletion
from django.conf import settings from django.conf import settings
@@ -63,6 +63,11 @@ class Migration(migrations.Migration):
name='product_kit', 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='Комплект'), 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( migrations.AddField(
model_name='sale', model_name='sale',
name='order', name='order',
@@ -73,6 +78,11 @@ class Migration(migrations.Migration):
name='product', name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sales', to='products.product', verbose_name='Товар'), 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( migrations.AddField(
model_name='salebatchallocation', model_name='salebatchallocation',
name='sale', name='sale',

View File

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

View File

@@ -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 django.db.models.deletion
import phonenumber_field.modelfields 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')), ('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_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
('item_sku_snapshot', models.CharField(blank=True, max_length=100, 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='Цена за единицу')), ('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_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
('is_from_showcase', 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='Дата добавления')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
], ],
options={ options={
@@ -250,10 +253,13 @@ class Migration(migrations.Migration):
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), ('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_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')),
('item_sku_snapshot', models.CharField(blank=True, max_length=100, 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='Цена за единицу')), ('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_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')),
('is_from_showcase', 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='Дата добавления')), ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата добавления')),
('history_id', models.AutoField(primary_key=True, serialize=False)), ('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)), ('history_date', models.DateTimeField(db_index=True)),

View File

@@ -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 django.db.models.deletion
from django.conf import settings from django.conf import settings
@@ -28,6 +28,11 @@ class Migration(migrations.Migration):
name='product_kit', 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='Комплект товаров'), 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( migrations.AddField(
model_name='historicalorderitem', model_name='historicalorderitem',
name='showcase', name='showcase',
@@ -93,6 +98,11 @@ class Migration(migrations.Migration):
name='product_kit', 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='Комплект товаров'), 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( migrations.AddField(
model_name='orderitem', model_name='orderitem',
name='showcase', name='showcase',

View File

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

View File

@@ -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='Количество'),
),
]

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 django.db.models.deletion
import products.models.photos import products.models.photos
from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -76,7 +78,7 @@ class Migration(migrations.Migration):
name='SKUCounter', name='SKUCounter',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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='Текущее значение')), ('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')),
], ],
options={ options={
@@ -84,6 +86,22 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Счетчики артикулов', '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( migrations.CreateModel(
name='ConfigurableProduct', name='ConfigurableProduct',
fields=[ fields=[
@@ -126,6 +144,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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='Атрибуты варианта')), ('attributes', models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта')),
('is_default', models.BooleanField(default=False, 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='Родитель (вариативный товар)')), ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurableproduct', verbose_name='Родитель (вариативный товар)')),
], ],
options={ options={
@@ -181,7 +200,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=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='Суффикс варианта')), ('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='Себестоимость')), ('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='Основная цена')), ('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='Цена со скидкой')), ('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'], '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( migrations.CreateModel(
name='ProductTag', name='ProductTag',
fields=[ fields=[
@@ -429,6 +469,16 @@ class Migration(migrations.Migration):
name='variant_group', name='variant_group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'), 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( migrations.AddIndex(
model_name='configurableproductoptionattribute', model_name='configurableproductoptionattribute',
index=models.Index(fields=['option'], name='products_co_option__bd950a_idx'), index=models.Index(fields=['option'], name='products_co_option__bd950a_idx'),
@@ -505,6 +555,10 @@ class Migration(migrations.Migration):
model_name='configurableproductoption', model_name='configurableproductoption',
index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__be8830_idx'), 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( migrations.AddConstraint(
model_name='configurableproductoption', 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'), 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', model_name='productkit',
constraint=models.UniqueConstraint(condition=models.Q(('is_temporary', False), ('status', 'active')), fields=('name',), name='unique_active_kit_name'), 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( migrations.AddIndex(
model_name='kititem', model_name='kititem',
index=models.Index(fields=['kit'], name='products_ki_kit_id_d28dc9_idx'), index=models.Index(fields=['kit'], name='products_ki_kit_id_d28dc9_idx'),
@@ -605,4 +651,16 @@ class Migration(migrations.Migration):
name='productvariantgroupitem', name='productvariantgroupitem',
unique_together={('variant_group', 'product')}, 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 приложения. Сервисы для бизнес-логики products приложения.
Следует принципу "Skinny Models, Fat Services". Следует принципу "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'))

View File

@@ -8,6 +8,7 @@ Management команда для инициализации всех систе
- Системные способы оплаты - Системные способы оплаты
- Склад по умолчанию - Склад по умолчанию
- Витрину по умолчанию - Витрину по умолчанию
- Единицы измерения
Использование: Использование:
# Инициализация для конкретного тенанта # Инициализация для конкретного тенанта
@@ -21,7 +22,7 @@ from django_tenants.utils import get_tenant_model, schema_context
class Command(BaseCommand): class Command(BaseCommand):
help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты, склад, витрина)' help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты, склад, витрина, единицы измерения)'
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(

View File

@@ -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.core.validators
import django.db.models.deletion import django.db.models.deletion

View File

@@ -93,6 +93,7 @@ class TenantOnboardingService:
from customers.models import Customer from customers.models import Customer
from orders.services import OrderStatusService, PaymentMethodService from orders.services import OrderStatusService, PaymentMethodService
from inventory.services import WarehouseService, ShowcaseService from inventory.services import WarehouseService, ShowcaseService
from products.services import UnitOfMeasureService
# 1. Системный клиент # 1. Системный клиент
logger.info("Создание системного клиента...") logger.info("Создание системного клиента...")
@@ -133,6 +134,14 @@ class TenantOnboardingService:
showcase, created = ShowcaseService.get_or_create_default(warehouse) showcase, created = ShowcaseService.get_or_create_default(warehouse)
logger.info(f"Витрина по умолчанию: {showcase.name}") logger.info(f"Витрина по умолчанию: {showcase.name}")
# 6. Единицы измерения
logger.info("Создание единиц измерения...")
if reset:
UnitOfMeasureService.reset_default_units()
else:
UnitOfMeasureService.create_default_units()
logger.info("Единицы измерения созданы")
# ==================== Приватные методы ==================== # ==================== Приватные методы ====================
@classmethod @classmethod

View File

@@ -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 django.db.models.deletion
from django.conf import settings from django.conf import settings