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

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