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}'
+ )