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="Дата операции")
|
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
|
||||||
processed = models.BooleanField(default=False, verbose_name="Обработана (FIFO применена)")
|
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:
|
class Meta:
|
||||||
verbose_name = "Продажа"
|
verbose_name = "Продажа"
|
||||||
verbose_name_plural = "Продажи"
|
verbose_name_plural = "Продажи"
|
||||||
@@ -133,7 +158,8 @@ class Sale(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
class SaleBatchAllocation(models.Model):
|
||||||
@@ -469,6 +495,24 @@ class Reservation(models.Model):
|
|||||||
help_text="Резерв для входного товара трансформации (черновик)"
|
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:
|
class Meta:
|
||||||
verbose_name = "Резервирование"
|
verbose_name = "Резервирование"
|
||||||
verbose_name_plural = "Резервирования"
|
verbose_name_plural = "Резервирования"
|
||||||
|
|||||||
@@ -55,22 +55,25 @@ class SaleProcessor:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@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-списание.
|
Создать операцию продажи и произвести FIFO-списание.
|
||||||
|
|
||||||
Процесс:
|
Процесс:
|
||||||
1. Создаем запись Sale
|
1. Создаем запись Sale
|
||||||
2. Списываем товар по FIFO из партий
|
2. Конвертируем количество в базовые единицы (если указана sales_unit)
|
||||||
3. Фиксируем распределение в SaleBatchAllocation для аудита
|
3. Списываем товар по FIFO из партий
|
||||||
|
4. Фиксируем распределение в SaleBatchAllocation для аудита
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
product: объект Product
|
product: объект Product
|
||||||
warehouse: объект Warehouse
|
warehouse: объект Warehouse
|
||||||
quantity: Decimal - количество товара
|
quantity: Decimal - количество товара (в единицах продажи, если указана sales_unit)
|
||||||
sale_price: Decimal - цена продажи
|
sale_price: Decimal - цена продажи
|
||||||
order: (опционально) объект Order
|
order: (опционально) объект Order
|
||||||
document_number: (опционально) номер документа
|
document_number: (опционально) номер документа
|
||||||
|
sales_unit: (опционально) объект ProductSalesUnit - единица продажи.
|
||||||
|
Если указана, quantity конвертируется в базовые единицы товара.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Объект Sale
|
Объект Sale
|
||||||
@@ -84,25 +87,36 @@ class SaleProcessor:
|
|||||||
if sale_price < 0:
|
if sale_price < 0:
|
||||||
raise ValueError("Цена продажи не может быть отрицательной")
|
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
|
# Создаем запись Sale
|
||||||
# ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
# ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
||||||
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
||||||
sale = Sale.objects.create(
|
sale = Sale.objects.create(
|
||||||
product=product,
|
product=product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
quantity=quantity,
|
quantity=quantity, # В единицах продажи (для истории/отчётов)
|
||||||
|
quantity_base=quantity_base, # В базовых единицах (для списания)
|
||||||
sale_price=sale_price,
|
sale_price=sale_price,
|
||||||
order=order,
|
order=order,
|
||||||
document_number=document_number,
|
document_number=document_number,
|
||||||
processed=True # Сразу отмечаем как обработанную
|
processed=True, # Сразу отмечаем как обработанную
|
||||||
|
sales_unit=sales_unit,
|
||||||
|
unit_name_snapshot=unit_name_snapshot
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Списываем товар по FIFO
|
# Списываем товар по FIFO в БАЗОВЫХ единицах
|
||||||
# exclude_order позволяет не считать резервы этого заказа как занятые
|
# exclude_order позволяет не считать резервы этого заказа как занятые
|
||||||
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
||||||
allocations = StockBatchManager.write_off_by_fifo(
|
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.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from products.models import Product, ProductKit
|
from products.models import Product, ProductKit
|
||||||
@@ -96,6 +97,39 @@ class OrderItem(models.Model):
|
|||||||
help_text="Витрина, с которой был продан товар"
|
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(
|
created_at = models.DateTimeField(
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
@@ -159,11 +193,22 @@ class OrderItem(models.Model):
|
|||||||
|
|
||||||
# Автоматически фиксируем цену при создании, если она не указана
|
# Автоматически фиксируем цену при создании, если она не указана
|
||||||
if not self.price:
|
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
|
self.price = self.product.actual_price
|
||||||
elif self.kit_snapshot:
|
elif self.kit_snapshot:
|
||||||
self.price = self.kit_snapshot.actual_price
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_total_price(self):
|
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 ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||||
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
|
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
|
||||||
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
|
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
|
||||||
|
from .models import UnitOfMeasure, ProductSalesUnit
|
||||||
from .admin_displays import (
|
from .admin_displays import (
|
||||||
format_quality_badge,
|
format_quality_badge,
|
||||||
format_quality_display,
|
format_quality_display,
|
||||||
@@ -395,7 +396,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
||||||
filter_horizontal = ('categories', 'tags', 'variant_groups')
|
filter_horizontal = ('categories', 'tags', 'variant_groups')
|
||||||
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display')
|
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display')
|
||||||
autocomplete_fields = []
|
autocomplete_fields = ['base_unit']
|
||||||
actions = [
|
actions = [
|
||||||
restore_items,
|
restore_items,
|
||||||
delete_selected,
|
delete_selected,
|
||||||
@@ -407,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
fieldsets = (
|
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',),
|
'fields': ('cost_price_details_display',),
|
||||||
@@ -832,7 +833,7 @@ class ProductKitAdminWithItems(ProductKitAdmin):
|
|||||||
|
|
||||||
# Update admin classes to include photo inlines
|
# Update admin classes to include photo inlines
|
||||||
class ProductAdminWithPhotos(ProductAdmin):
|
class ProductAdminWithPhotos(ProductAdmin):
|
||||||
inlines = [ProductPhotoInline]
|
inlines = [ProductPhotoInline, ProductSalesUnitInline]
|
||||||
|
|
||||||
class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin):
|
class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin):
|
||||||
inlines = [KitItemInline, ProductKitPhotoInline]
|
inlines = [KitItemInline, ProductKitPhotoInline]
|
||||||
@@ -943,6 +944,88 @@ class CostPriceHistoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
return False
|
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(ProductCategory, ProductCategoryAdminWithPhotos)
|
||||||
admin.site.register(ProductTag, ProductTagAdmin)
|
admin.site.register(ProductTag, ProductTagAdmin)
|
||||||
admin.site.register(Product, ProductAdminWithPhotos)
|
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 .attributes import ProductAttribute, ProductAttributeValue
|
||||||
|
|
||||||
|
# Единицы измерения
|
||||||
|
from .units import UnitOfMeasure, ProductSalesUnit
|
||||||
|
|
||||||
# Фотографии
|
# Фотографии
|
||||||
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
|
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
|
||||||
|
|
||||||
@@ -79,6 +82,10 @@ __all__ = [
|
|||||||
'ProductAttribute',
|
'ProductAttribute',
|
||||||
'ProductAttributeValue',
|
'ProductAttributeValue',
|
||||||
|
|
||||||
|
# Units
|
||||||
|
'UnitOfMeasure',
|
||||||
|
'ProductSalesUnit',
|
||||||
|
|
||||||
# Photos
|
# Photos
|
||||||
'BasePhoto',
|
'BasePhoto',
|
||||||
'ProductPhoto',
|
'ProductPhoto',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Модель Product - базовый товар (цветок, упаковка, аксессуар).
|
Модель Product - базовый товар (цветок, упаковка, аксессуар).
|
||||||
"""
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q, Sum
|
||||||
|
|
||||||
from .base import BaseProductEntity
|
from .base import BaseProductEntity
|
||||||
from .categories import ProductCategory, ProductTag
|
from .categories import ProductCategory, ProductTag
|
||||||
@@ -56,7 +57,19 @@ class Product(BaseProductEntity):
|
|||||||
max_length=10,
|
max_length=10,
|
||||||
choices=UNIT_CHOICES,
|
choices=UNIT_CHOICES,
|
||||||
default='шт',
|
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()
|
variant_groups__in=self.variant_groups.all()
|
||||||
).exclude(id=self.id).distinct()
|
).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):
|
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