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

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