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:
40
myproject/inventory/migrations/0003_add_sales_unit_fields.py
Normal file
40
myproject/inventory/migrations/0003_add_sales_unit_fields.py
Normal 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)'),
|
||||
),
|
||||
]
|
||||
@@ -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 = "Резервирования"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
# Фиксируем распределение для аудита
|
||||
|
||||
55
myproject/orders/migrations/0003_add_sales_unit_fields.py
Normal file
55
myproject/orders/migrations/0003_add_sales_unit_fields.py
Normal 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)'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
65
myproject/products/migrations/0005_add_unit_of_measure.py
Normal file
65
myproject/products/migrations/0005_add_unit_of_measure.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
222
myproject/products/models/units.py
Normal file
222
myproject/products/models/units.py
Normal 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}'
|
||||
)
|
||||
Reference in New Issue
Block a user