From 5b68f14bb4bfdb85fbffe426ae7f4993f667d2ac Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Fri, 2 Jan 2026 02:09:44 +0300 Subject: [PATCH] feat(products): add support for product sales units Add new models UnitOfMeasure and ProductSalesUnit to enable selling products in different units (e.g., bunches, kg). Update Product model with base_unit field and methods for unit conversions and availability. Extend Sale, Reservation, and OrderItem models with sales_unit fields and snapshots. Modify SaleProcessor to handle quantity conversions. Include admin interfaces for managing units. Add corresponding database migrations. --- .../migrations/0003_add_sales_unit_fields.py | 40 ++++ myproject/inventory/models.py | 46 +++- .../inventory/services/sale_processor.py | 30 ++- .../migrations/0003_add_sales_unit_fields.py | 55 +++++ myproject/orders/models/order_item.py | 47 +++- myproject/products/admin.py | 89 ++++++- .../migrations/0005_add_unit_of_measure.py | 65 +++++ .../0006_populate_unit_of_measure.py | 68 ++++++ myproject/products/models/__init__.py | 7 + myproject/products/models/products.py | 110 ++++++++- myproject/products/models/units.py | 222 ++++++++++++++++++ 11 files changed, 764 insertions(+), 15 deletions(-) create mode 100644 myproject/inventory/migrations/0003_add_sales_unit_fields.py create mode 100644 myproject/orders/migrations/0003_add_sales_unit_fields.py create mode 100644 myproject/products/migrations/0005_add_unit_of_measure.py create mode 100644 myproject/products/migrations/0006_populate_unit_of_measure.py create mode 100644 myproject/products/models/units.py diff --git a/myproject/inventory/migrations/0003_add_sales_unit_fields.py b/myproject/inventory/migrations/0003_add_sales_unit_fields.py new file mode 100644 index 0000000..9aa194e --- /dev/null +++ b/myproject/inventory/migrations/0003_add_sales_unit_fields.py @@ -0,0 +1,40 @@ +# 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/inventory/models.py b/myproject/inventory/models.py index ce66eb7..799f25b 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -122,6 +122,31 @@ class Sale(models.Model): date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции") processed = models.BooleanField(default=False, verbose_name="Обработана (FIFO применена)") + # === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ === + sales_unit = models.ForeignKey( + 'products.ProductSalesUnit', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='sales', + verbose_name="Единица продажи" + ) + quantity_base = models.DecimalField( + max_digits=10, + decimal_places=6, + null=True, + blank=True, + verbose_name="Количество в базовых единицах", + help_text="Количество в единицах хранения товара (для списания со склада)" + ) + unit_name_snapshot = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name="Название единицы (snapshot)", + help_text="Название единицы продажи на момент продажи" + ) + class Meta: verbose_name = "Продажа" verbose_name_plural = "Продажи" @@ -133,7 +158,8 @@ class Sale(models.Model): ] def __str__(self): - return f"Продажа {self.product.name}: {self.quantity} шт @ {self.sale_price}" + unit_info = f" ({self.unit_name_snapshot})" if self.unit_name_snapshot else "" + return f"Продажа {self.product.name}: {self.quantity}{unit_info} @ {self.sale_price}" class SaleBatchAllocation(models.Model): @@ -469,6 +495,24 @@ class Reservation(models.Model): help_text="Резерв для входного товара трансформации (черновик)" ) + # === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ === + sales_unit = models.ForeignKey( + 'products.ProductSalesUnit', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reservations', + verbose_name="Единица продажи" + ) + quantity_base = models.DecimalField( + max_digits=10, + decimal_places=6, + null=True, + blank=True, + verbose_name="Количество в базовых единицах", + help_text="Количество в единицах хранения товара" + ) + class Meta: verbose_name = "Резервирование" verbose_name_plural = "Резервирования" diff --git a/myproject/inventory/services/sale_processor.py b/myproject/inventory/services/sale_processor.py index 197f4c7..968c51d 100644 --- a/myproject/inventory/services/sale_processor.py +++ b/myproject/inventory/services/sale_processor.py @@ -55,22 +55,25 @@ class SaleProcessor: @staticmethod @transaction.atomic - def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None): + def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None, sales_unit=None): """ Создать операцию продажи и произвести FIFO-списание. Процесс: 1. Создаем запись Sale - 2. Списываем товар по FIFO из партий - 3. Фиксируем распределение в SaleBatchAllocation для аудита + 2. Конвертируем количество в базовые единицы (если указана sales_unit) + 3. Списываем товар по FIFO из партий + 4. Фиксируем распределение в SaleBatchAllocation для аудита Args: product: объект Product warehouse: объект Warehouse - quantity: Decimal - количество товара + quantity: Decimal - количество товара (в единицах продажи, если указана sales_unit) sale_price: Decimal - цена продажи order: (опционально) объект Order document_number: (опционально) номер документа + sales_unit: (опционально) объект ProductSalesUnit - единица продажи. + Если указана, quantity конвертируется в базовые единицы товара. Returns: Объект Sale @@ -84,25 +87,36 @@ class SaleProcessor: if sale_price < 0: raise ValueError("Цена продажи не может быть отрицательной") + # Конвертируем количество в базовые единицы, если указана единица продажи + if sales_unit: + quantity_base = sales_unit.convert_to_base(quantity) + unit_name_snapshot = sales_unit.name + else: + quantity_base = quantity + unit_name_snapshot = '' + # Создаем запись Sale # ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал # (списание делаем вручную ниже, чтобы избежать двойного списания) sale = Sale.objects.create( product=product, warehouse=warehouse, - quantity=quantity, + quantity=quantity, # В единицах продажи (для истории/отчётов) + quantity_base=quantity_base, # В базовых единицах (для списания) sale_price=sale_price, order=order, document_number=document_number, - processed=True # Сразу отмечаем как обработанную + processed=True, # Сразу отмечаем как обработанную + sales_unit=sales_unit, + unit_name_snapshot=unit_name_snapshot ) try: - # Списываем товар по FIFO + # Списываем товар по FIFO в БАЗОВЫХ единицах # exclude_order позволяет не считать резервы этого заказа как занятые # (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale) allocations = StockBatchManager.write_off_by_fifo( - product, warehouse, quantity, exclude_order=order + product, warehouse, quantity_base, exclude_order=order ) # Фиксируем распределение для аудита diff --git a/myproject/orders/migrations/0003_add_sales_unit_fields.py b/myproject/orders/migrations/0003_add_sales_unit_fields.py new file mode 100644 index 0000000..d72197d --- /dev/null +++ b/myproject/orders/migrations/0003_add_sales_unit_fields.py @@ -0,0 +1,55 @@ +# 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/models/order_item.py b/myproject/orders/models/order_item.py index 7b46d29..ef74f7e 100644 --- a/myproject/orders/models/order_item.py +++ b/myproject/orders/models/order_item.py @@ -1,3 +1,4 @@ +from decimal import Decimal from django.db import models from django.core.exceptions import ValidationError from products.models import Product, ProductKit @@ -96,6 +97,39 @@ class OrderItem(models.Model): help_text="Витрина, с которой был продан товар" ) + # === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ === + sales_unit = models.ForeignKey( + 'products.ProductSalesUnit', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='order_items', + verbose_name="Единица продажи" + ) + unit_name_snapshot = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name="Название единицы (snapshot)", + help_text="Название единицы продажи на момент заказа" + ) + conversion_factor_snapshot = models.DecimalField( + max_digits=15, + decimal_places=6, + null=True, + blank=True, + verbose_name="Коэффициент конверсии (snapshot)", + help_text="Коэффициент конверсии на момент заказа" + ) + quantity_in_base_units = models.DecimalField( + max_digits=10, + decimal_places=6, + null=True, + blank=True, + verbose_name="Количество в базовых единицах", + help_text="Количество в единицах хранения товара (для списания со склада)" + ) + # Временные метки created_at = models.DateTimeField( auto_now_add=True, @@ -159,11 +193,22 @@ class OrderItem(models.Model): # Автоматически фиксируем цену при создании, если она не указана if not self.price: - if self.product: + # Сначала проверяем единицу продажи + if self.sales_unit: + self.price = self.sales_unit.actual_price + elif self.product: self.price = self.product.actual_price elif self.kit_snapshot: self.price = self.kit_snapshot.actual_price + # Сохраняем snapshot единицы продажи + if self.sales_unit: + self.unit_name_snapshot = self.sales_unit.name + self.conversion_factor_snapshot = self.sales_unit.conversion_factor + self.quantity_in_base_units = self.sales_unit.convert_to_base( + Decimal(self.quantity) + ) + super().save(*args, **kwargs) def get_total_price(self): diff --git a/myproject/products/admin.py b/myproject/products/admin.py index e3abfbc..e40a3ba 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -12,6 +12,7 @@ from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute +from .models import UnitOfMeasure, ProductSalesUnit from .admin_displays import ( format_quality_badge, format_quality_display, @@ -395,7 +396,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): search_fields = ('name', 'sku', 'description', 'search_keywords') filter_horizontal = ('categories', 'tags', 'variant_groups') readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display') - autocomplete_fields = [] + autocomplete_fields = ['base_unit'] actions = [ restore_items, delete_selected, @@ -407,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): fieldsets = ( ('Основная информация', { - 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'unit', 'price', 'sale_price') + 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'base_unit', 'unit', 'price', 'sale_price') }), ('Себестоимость', { 'fields': ('cost_price_details_display',), @@ -832,7 +833,7 @@ class ProductKitAdminWithItems(ProductKitAdmin): # Update admin classes to include photo inlines class ProductAdminWithPhotos(ProductAdmin): - inlines = [ProductPhotoInline] + inlines = [ProductPhotoInline, ProductSalesUnitInline] class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin): inlines = [KitItemInline, ProductKitPhotoInline] @@ -943,6 +944,88 @@ class CostPriceHistoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): return False +# === Админка для единиц измерения === + +@admin.register(UnitOfMeasure) +class UnitOfMeasureAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): + """Админка для справочника единиц измерения""" + list_display = ('code', 'name', 'short_name', 'position', 'is_active') + list_filter = ('is_active',) + search_fields = ('code', 'name', 'short_name') + list_editable = ('position', 'is_active') + ordering = ('position', 'code') + + fieldsets = ( + ('Основная информация', { + 'fields': ('code', 'name', 'short_name') + }), + ('Настройки', { + 'fields': ('position', 'is_active') + }), + ) + + +class ProductSalesUnitInline(admin.TabularInline): + """Инлайн для единиц продажи товара""" + model = ProductSalesUnit + extra = 0 + fields = ( + 'unit', 'name', 'conversion_factor', 'price', 'sale_price', + 'min_quantity', 'quantity_step', 'is_default', 'is_active' + ) + autocomplete_fields = ['unit'] + verbose_name = "Единица продажи" + verbose_name_plural = "Единицы продажи" + + +@admin.register(ProductSalesUnit) +class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): + """Админка для единиц продажи товаров""" + list_display = ( + 'product', 'name', 'unit', 'conversion_factor', + 'get_price_display', 'min_quantity', 'is_default', 'is_active' + ) + list_filter = ('is_active', 'is_default', 'unit') + search_fields = ('product__name', 'product__sku', 'name') + autocomplete_fields = ['product', 'unit'] + list_editable = ('is_default', 'is_active') + ordering = ('product', 'position') + + fieldsets = ( + ('Товар и единица', { + 'fields': ('product', 'unit', 'name') + }), + ('Конверсия', { + 'fields': ('conversion_factor',), + 'description': 'Сколько единиц продажи получается из 1 базовой единицы товара. ' + 'Например: 1 банч = 15 веток → conversion_factor = 15' + }), + ('Ценообразование', { + 'fields': ('price', 'sale_price'), + 'description': 'Цена за единицу продажи. sale_price - цена со скидкой (опционально).' + }), + ('Ограничения', { + 'fields': ('min_quantity', 'quantity_step'), + 'description': 'min_quantity - минимальное количество для заказа. ' + 'quantity_step - шаг изменения количества.' + }), + ('Настройки', { + 'fields': ('position', 'is_default', 'is_active') + }), + ) + + def get_price_display(self, obj): + """Отображение цены с учетом скидки""" + if obj.sale_price: + return format_html( + '{} ' + '{}', + obj.price, obj.sale_price + ) + return obj.price + get_price_display.short_description = 'Цена' + + admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos) admin.site.register(ProductTag, ProductTagAdmin) admin.site.register(Product, ProductAdminWithPhotos) diff --git a/myproject/products/migrations/0005_add_unit_of_measure.py b/myproject/products/migrations/0005_add_unit_of_measure.py new file mode 100644 index 0000000..48ec6b4 --- /dev/null +++ b/myproject/products/migrations/0005_add_unit_of_measure.py @@ -0,0 +1,65 @@ +# 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 new file mode 100644 index 0000000..4923276 --- /dev/null +++ b/myproject/products/migrations/0006_populate_unit_of_measure.py @@ -0,0 +1,68 @@ +# 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/models/__init__.py b/myproject/products/models/__init__.py index 58595d8..f7fb840 100644 --- a/myproject/products/models/__init__.py +++ b/myproject/products/models/__init__.py @@ -40,6 +40,9 @@ from .kits import ( # Атрибуты from .attributes import ProductAttribute, ProductAttributeValue +# Единицы измерения +from .units import UnitOfMeasure, ProductSalesUnit + # Фотографии from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus @@ -79,6 +82,10 @@ __all__ = [ 'ProductAttribute', 'ProductAttributeValue', + # Units + 'UnitOfMeasure', + 'ProductSalesUnit', + # Photos 'BasePhoto', 'ProductPhoto', diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py index 2d7dd86..fd7a5e8 100644 --- a/myproject/products/models/products.py +++ b/myproject/products/models/products.py @@ -1,8 +1,9 @@ """ Модель Product - базовый товар (цветок, упаковка, аксессуар). """ +from decimal import Decimal from django.db import models -from django.db.models import Q +from django.db.models import Q, Sum from .base import BaseProductEntity from .categories import ProductCategory, ProductTag @@ -56,7 +57,19 @@ class Product(BaseProductEntity): max_length=10, choices=UNIT_CHOICES, default='шт', - verbose_name="Единица измерения" + verbose_name="Единица измерения (deprecated)" + ) + + # Новое поле: ссылка на справочник единиц измерения + base_unit = models.ForeignKey( + 'UnitOfMeasure', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='products', + verbose_name="Базовая единица", + help_text="Единица хранения и закупки (банч, кг, шт). " + "Если указана, используется вместо поля 'unit'." ) # ЦЕНООБРАЗОВАНИЕ - переименованные поля @@ -153,6 +166,99 @@ class Product(BaseProductEntity): variant_groups__in=self.variant_groups.all() ).exclude(id=self.id).distinct() + # === МЕТОДЫ ДЛЯ РАБОТЫ С ЕДИНИЦАМИ ПРОДАЖИ === + + @property + def unit_display(self): + """ + Отображаемое название единицы измерения. + Приоритет: base_unit.code > unit + """ + if self.base_unit: + return self.base_unit.code + return self.unit + + @property + def has_sales_units(self): + """Есть ли у товара настроенные единицы продажи""" + return self.sales_units.filter(is_active=True).exists() + + def get_default_sales_unit(self): + """ + Получить единицу продажи по умолчанию. + Возвращает первую единицу с is_default=True или первую активную. + """ + default = self.sales_units.filter(is_active=True, is_default=True).first() + if default: + return default + return self.sales_units.filter(is_active=True).first() + + def get_total_available(self, warehouse=None): + """ + Получить общий доступный остаток в базовых единицах. + + Args: + warehouse: (опционально) конкретный склад + + Returns: + Decimal: количество в базовых единицах + """ + from inventory.models import Stock + + qs = Stock.objects.filter(product=self) + if warehouse: + qs = qs.filter(warehouse=warehouse) + + result = qs.aggregate( + total=Sum('quantity_available') + )['total'] + + return result or Decimal('0') + + def get_available_in_unit(self, sales_unit, warehouse=None): + """ + Получить остаток в указанной единице продажи. + + Args: + sales_unit: объект ProductSalesUnit + warehouse: (опционально) конкретный склад + + Returns: + Decimal: количество в единицах продажи + """ + base_qty = self.get_total_available(warehouse) + return sales_unit.convert_from_base(base_qty) + + def get_all_units_availability(self, warehouse=None): + """ + Получить остатки во всех единицах продажи. + + Args: + warehouse: (опционально) конкретный склад + + Returns: + list[dict]: список с информацией по каждой единице продажи + [ + { + 'sales_unit': ProductSalesUnit, + 'available': Decimal, + 'price': Decimal + }, + ... + ] + """ + base_qty = self.get_total_available(warehouse) + result = [] + + for su in self.sales_units.filter(is_active=True).select_related('unit'): + result.append({ + 'sales_unit': su, + 'available': su.convert_from_base(base_qty), + 'price': su.actual_price + }) + + return result + class CostPriceHistory(models.Model): """ diff --git a/myproject/products/models/units.py b/myproject/products/models/units.py new file mode 100644 index 0000000..819cf08 --- /dev/null +++ b/myproject/products/models/units.py @@ -0,0 +1,222 @@ +""" +Модели единиц измерения. +- UnitOfMeasure: справочник единиц измерения +- ProductSalesUnit: единицы продажи товара с коэффициентами конверсии +""" +from decimal import Decimal +from django.db import models +from django.core.validators import MinValueValidator +from django.core.exceptions import ValidationError + + +class UnitOfMeasure(models.Model): + """ + Справочник единиц измерения. + Расширяемый справочник вместо жестко заданных UNIT_CHOICES. + """ + code = models.CharField( + max_length=20, + unique=True, + verbose_name="Код", + help_text="Короткий код: шт, кг, банч, ветка" + ) + name = models.CharField( + max_length=100, + verbose_name="Название", + help_text="Полное название: Штука, Килограмм, Банч" + ) + short_name = models.CharField( + max_length=10, + verbose_name="Сокращение", + help_text="Для UI: шт., кг., бч." + ) + is_active = models.BooleanField( + default=True, + verbose_name="Активна" + ) + position = models.PositiveIntegerField( + default=0, + verbose_name="Порядок сортировки" + ) + + class Meta: + verbose_name = "Единица измерения" + verbose_name_plural = "Единицы измерения" + ordering = ['position', 'name'] + + def __str__(self): + return f"{self.name} ({self.code})" + + +class ProductSalesUnit(models.Model): + """ + Единица продажи товара с коэффициентом конверсии. + Один товар может иметь несколько единиц продажи. + + Пример: + Товар: Пихта Нобилис (базовая единица: банч) + Единицы продажи: + - Ветка большая: 1 банч = 15 веток, цена 300₽ + - Ветка средняя: 1 банч = 30 веток, цена 150₽ + - Ветка маленькая: 1 банч = 50 веток, цена 100₽ + """ + product = models.ForeignKey( + 'Product', + on_delete=models.CASCADE, + related_name='sales_units', + verbose_name="Товар" + ) + unit = models.ForeignKey( + UnitOfMeasure, + on_delete=models.PROTECT, + verbose_name="Единица измерения" + ) + name = models.CharField( + max_length=100, + verbose_name="Название", + help_text="Например: 'Ветка большая', 'Ветка средняя'" + ) + conversion_factor = models.DecimalField( + max_digits=15, + decimal_places=6, + validators=[MinValueValidator(Decimal('0.000001'))], + verbose_name="Коэффициент конверсии", + help_text="Сколько единиц продажи в 1 базовой единице товара. " + "Например: 15 (из 1 банча получается 15 больших веток)" + ) + price = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(Decimal('0'))], + verbose_name="Цена продажи" + ) + sale_price = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(Decimal('0'))], + verbose_name="Цена со скидкой" + ) + min_quantity = models.DecimalField( + max_digits=10, + decimal_places=3, + default=Decimal('1'), + validators=[MinValueValidator(Decimal('0.001'))], + verbose_name="Мин. количество", + help_text="Минимальное количество для продажи" + ) + quantity_step = models.DecimalField( + max_digits=10, + decimal_places=3, + default=Decimal('1'), + validators=[MinValueValidator(Decimal('0.001'))], + verbose_name="Шаг количества", + help_text="С каким шагом можно заказывать (0.1, 0.5, 1)" + ) + is_default = models.BooleanField( + default=False, + verbose_name="Единица по умолчанию", + help_text="Единица, выбираемая по умолчанию при добавлении в заказ" + ) + is_active = models.BooleanField( + default=True, + verbose_name="Активна" + ) + position = models.PositiveIntegerField( + default=0, + verbose_name="Порядок сортировки" + ) + + class Meta: + verbose_name = "Единица продажи товара" + verbose_name_plural = "Единицы продажи товаров" + ordering = ['position', 'id'] + unique_together = [['product', 'name']] + + def __str__(self): + return f"{self.product.name} - {self.name} ({self.unit.code})" + + def clean(self): + super().clean() + if self.conversion_factor and self.conversion_factor <= 0: + raise ValidationError({ + 'conversion_factor': 'Коэффициент конверсии должен быть больше 0' + }) + if self.sale_price and self.price and self.sale_price >= self.price: + raise ValidationError({ + 'sale_price': 'Цена со скидкой должна быть меньше основной цены' + }) + + def save(self, *args, **kwargs): + # Если это единица по умолчанию, снимаем флаг с других + if self.is_default: + ProductSalesUnit.objects.filter( + product=self.product, + is_default=True + ).exclude(pk=self.pk).update(is_default=False) + super().save(*args, **kwargs) + + @property + def actual_price(self): + """Финальная цена (со скидкой или без)""" + return self.sale_price if self.sale_price else self.price + + def convert_to_base(self, quantity): + """ + Конвертировать количество в базовые единицы товара. + + Args: + quantity: количество в единицах продажи + + Returns: + Decimal: количество в базовых единицах + + Пример: + 10 больших веток → 10 / 15 = 0.667 банча + """ + if not self.conversion_factor or self.conversion_factor == 0: + return Decimal(quantity) + return Decimal(quantity) / self.conversion_factor + + def convert_from_base(self, base_quantity): + """ + Конвертировать из базовых единиц в единицы продажи. + + Args: + base_quantity: количество в базовых единицах товара + + Returns: + Decimal: количество в единицах продажи + + Пример: + 2.5 банча → 2.5 * 15 = 37.5 больших веток + """ + if not self.conversion_factor: + return Decimal(base_quantity) + return Decimal(base_quantity) * self.conversion_factor + + def validate_quantity(self, quantity): + """ + Проверить, что количество соответствует ограничениям. + + Args: + quantity: количество для проверки + + Raises: + ValidationError: если количество некорректно + """ + quantity = Decimal(quantity) + + if quantity < self.min_quantity: + raise ValidationError( + f'Минимальное количество для "{self.name}": {self.min_quantity}' + ) + + # Проверяем шаг количества (с учётом погрешности float) + if self.quantity_step and self.quantity_step > 0: + remainder = quantity % self.quantity_step + if remainder > Decimal('0.0001') and (self.quantity_step - remainder) > Decimal('0.0001'): + raise ValidationError( + f'Количество должно быть кратно {self.quantity_step}' + )