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.
This commit is contained in:
2026-01-02 02:09:44 +03:00
parent ca308ae2a2
commit 5b68f14bb4
11 changed files with 764 additions and 15 deletions

View File

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

View File

@@ -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 = "Резервирования"

View File

@@ -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
)
# Фиксируем распределение для аудита

View File

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

View File

@@ -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):

View File

@@ -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(
'<span style="text-decoration: line-through; color: #999;">{}</span> '
'<span style="color: #dc3545; font-weight: bold;">{}</span>',
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)

View File

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

View File

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

View File

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

View File

@@ -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):
"""

View File

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