diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index 2cabdeb..b2185e4 100644 --- a/myproject/accounts/migrations/0001_initial.py +++ b/myproject/accounts/migrations/0001_initial.py @@ -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 diff --git a/myproject/customers/migrations/0001_initial.py b/myproject/customers/migrations/0001_initial.py index 2f4655f..efbe34f 100644 --- a/myproject/customers/migrations/0001_initial.py +++ b/myproject/customers/migrations/0001_initial.py @@ -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 diff --git a/myproject/customers/migrations/0002_initial.py b/myproject/customers/migrations/0002_initial.py index def0af7..cad2828 100644 --- a/myproject/customers/migrations/0002_initial.py +++ b/myproject/customers/migrations/0002_initial.py @@ -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 diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py index 11688ef..aab5025 100644 --- a/myproject/inventory/migrations/0001_initial.py +++ b/myproject/inventory/migrations/0001_initial.py @@ -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': 'Продажа', diff --git a/myproject/inventory/migrations/0002_initial.py b/myproject/inventory/migrations/0002_initial.py index 29e2d2b..d8815b9 100644 --- a/myproject/inventory/migrations/0002_initial.py +++ b/myproject/inventory/migrations/0002_initial.py @@ -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', diff --git a/myproject/inventory/migrations/0003_add_sales_unit_fields.py b/myproject/inventory/migrations/0003_add_sales_unit_fields.py deleted file mode 100644 index 9aa194e..0000000 --- a/myproject/inventory/migrations/0003_add_sales_unit_fields.py +++ /dev/null @@ -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)'), - ), - ] diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py index f9d11ba..3d646d0 100644 --- a/myproject/orders/migrations/0001_initial.py +++ b/myproject/orders/migrations/0001_initial.py @@ -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)), diff --git a/myproject/orders/migrations/0002_initial.py b/myproject/orders/migrations/0002_initial.py index 1b109c6..87e465d 100644 --- a/myproject/orders/migrations/0002_initial.py +++ b/myproject/orders/migrations/0002_initial.py @@ -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', diff --git a/myproject/orders/migrations/0003_add_sales_unit_fields.py b/myproject/orders/migrations/0003_add_sales_unit_fields.py deleted file mode 100644 index d72197d..0000000 --- a/myproject/orders/migrations/0003_add_sales_unit_fields.py +++ /dev/null @@ -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)'), - ), - ] diff --git a/myproject/orders/migrations/0004_change_orderitem_quantity_to_decimal.py b/myproject/orders/migrations/0004_change_orderitem_quantity_to_decimal.py deleted file mode 100644 index 8cd8664..0000000 --- a/myproject/orders/migrations/0004_change_orderitem_quantity_to_decimal.py +++ /dev/null @@ -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='Количество'), - ), - ] diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py index 020c34d..bf96190 100644 --- a/myproject/products/migrations/0001_initial.py +++ b/myproject/products/migrations/0001_initial.py @@ -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'), + ), ] diff --git a/myproject/products/migrations/0002_add_configurable_sku_counter.py b/myproject/products/migrations/0002_add_configurable_sku_counter.py deleted file mode 100644 index 1653c71..0000000 --- a/myproject/products/migrations/0002_add_configurable_sku_counter.py +++ /dev/null @@ -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='Тип счетчика'), - ), - ] diff --git a/myproject/products/migrations/0003_add_variant_sku.py b/myproject/products/migrations/0003_add_variant_sku.py deleted file mode 100644 index fb0682f..0000000 --- a/myproject/products/migrations/0003_add_variant_sku.py +++ /dev/null @@ -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'), - ), - ] diff --git a/myproject/products/migrations/0004_populate_variant_sku.py b/myproject/products/migrations/0004_populate_variant_sku.py deleted file mode 100644 index 0932277..0000000 --- a/myproject/products/migrations/0004_populate_variant_sku.py +++ /dev/null @@ -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), - ] diff --git a/myproject/products/migrations/0005_add_unit_of_measure.py b/myproject/products/migrations/0005_add_unit_of_measure.py deleted file mode 100644 index 48ec6b4..0000000 --- a/myproject/products/migrations/0005_add_unit_of_measure.py +++ /dev/null @@ -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')}, - }, - ), - ] diff --git a/myproject/products/migrations/0006_populate_unit_of_measure.py b/myproject/products/migrations/0006_populate_unit_of_measure.py deleted file mode 100644 index 4923276..0000000 --- a/myproject/products/migrations/0006_populate_unit_of_measure.py +++ /dev/null @@ -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), - ] diff --git a/myproject/products/services/__init__.py b/myproject/products/services/__init__.py index 2b46668..b2ca251 100644 --- a/myproject/products/services/__init__.py +++ b/myproject/products/services/__init__.py @@ -2,3 +2,8 @@ Сервисы для бизнес-логики products приложения. Следует принципу "Skinny Models, Fat Services". """ +from .unit_service import UnitOfMeasureService + +__all__ = [ + 'UnitOfMeasureService', +] diff --git a/myproject/products/services/unit_service.py b/myproject/products/services/unit_service.py new file mode 100644 index 0000000..8af4693 --- /dev/null +++ b/myproject/products/services/unit_service.py @@ -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')) diff --git a/myproject/tenants/management/commands/init_tenant_data.py b/myproject/tenants/management/commands/init_tenant_data.py index 35e0ae1..970abc1 100644 --- a/myproject/tenants/management/commands/init_tenant_data.py +++ b/myproject/tenants/management/commands/init_tenant_data.py @@ -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( diff --git a/myproject/tenants/migrations/0001_initial.py b/myproject/tenants/migrations/0001_initial.py index 5321fb9..2e4993d 100644 --- a/myproject/tenants/migrations/0001_initial.py +++ b/myproject/tenants/migrations/0001_initial.py @@ -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 diff --git a/myproject/tenants/services/onboarding.py b/myproject/tenants/services/onboarding.py index 3147e62..99bd841 100644 --- a/myproject/tenants/services/onboarding.py +++ b/myproject/tenants/services/onboarding.py @@ -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 diff --git a/myproject/user_roles/migrations/0001_initial.py b/myproject/user_roles/migrations/0001_initial.py index 3ecaa5e..cfe633e 100644 --- a/myproject/user_roles/migrations/0001_initial.py +++ b/myproject/user_roles/migrations/0001_initial.py @@ -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