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.
268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""
|
||
Процессор для обработки продаж.
|
||
Основной функционал:
|
||
- Создание операции Sale
|
||
- FIFO-списание товара из партий
|
||
- Фиксирование распределения партий для аудита
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from inventory.models import Sale, SaleBatchAllocation
|
||
from inventory.services.batch_manager import StockBatchManager
|
||
|
||
|
||
class SaleProcessor:
|
||
"""
|
||
Обработчик продаж с автоматическим FIFO-списанием.
|
||
"""
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def create_sale_from_reservation(reservation, order=None):
|
||
"""
|
||
Создать продажу на основе резерва.
|
||
Используется для продажи с витрины.
|
||
|
||
Args:
|
||
reservation: объект Reservation
|
||
order: (опционально) объект Order
|
||
|
||
Returns:
|
||
Объект Sale
|
||
"""
|
||
# Определяем цену продажи из заказа или из товара
|
||
if order and reservation.order_item:
|
||
# Цена из OrderItem
|
||
sale_price = reservation.order_item.price
|
||
else:
|
||
# Цена из товара
|
||
sale_price = reservation.product.actual_price or Decimal('0')
|
||
|
||
# Создаём продажу с FIFO-списанием
|
||
sale = SaleProcessor.create_sale(
|
||
product=reservation.product,
|
||
warehouse=reservation.warehouse,
|
||
quantity=reservation.quantity,
|
||
sale_price=sale_price,
|
||
order=order,
|
||
document_number=None
|
||
)
|
||
|
||
return sale
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None, sales_unit=None):
|
||
"""
|
||
Создать операцию продажи и произвести FIFO-списание.
|
||
|
||
Процесс:
|
||
1. Создаем запись Sale
|
||
2. Конвертируем количество в базовые единицы (если указана sales_unit)
|
||
3. Списываем товар по FIFO из партий
|
||
4. Фиксируем распределение в SaleBatchAllocation для аудита
|
||
|
||
Args:
|
||
product: объект Product
|
||
warehouse: объект Warehouse
|
||
quantity: Decimal - количество товара (в единицах продажи, если указана sales_unit)
|
||
sale_price: Decimal - цена продажи
|
||
order: (опционально) объект Order
|
||
document_number: (опционально) номер документа
|
||
sales_unit: (опционально) объект ProductSalesUnit - единица продажи.
|
||
Если указана, quantity конвертируется в базовые единицы товара.
|
||
|
||
Returns:
|
||
Объект Sale
|
||
|
||
Raises:
|
||
ValueError: если недостаточно товара или некорректные данные
|
||
"""
|
||
if quantity <= 0:
|
||
raise ValueError("Количество должно быть больше нуля")
|
||
|
||
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_base=quantity_base, # В базовых единицах (для списания)
|
||
sale_price=sale_price,
|
||
order=order,
|
||
document_number=document_number,
|
||
processed=True, # Сразу отмечаем как обработанную
|
||
sales_unit=sales_unit,
|
||
unit_name_snapshot=unit_name_snapshot
|
||
)
|
||
|
||
try:
|
||
# Списываем товар по FIFO в БАЗОВЫХ единицах
|
||
# exclude_order позволяет не считать резервы этого заказа как занятые
|
||
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
||
allocations = StockBatchManager.write_off_by_fifo(
|
||
product, warehouse, quantity_base, exclude_order=order
|
||
)
|
||
|
||
# Фиксируем распределение для аудита
|
||
for batch, qty_allocated in allocations:
|
||
SaleBatchAllocation.objects.create(
|
||
sale=sale,
|
||
batch=batch,
|
||
quantity=qty_allocated,
|
||
cost_price=batch.cost_price
|
||
)
|
||
|
||
# processed уже установлен в True при создании Sale
|
||
return sale
|
||
|
||
except ValueError as e:
|
||
# Если ошибка при списании - удаляем Sale и пробрасываем исключение
|
||
sale.delete()
|
||
raise
|
||
|
||
@staticmethod
|
||
def get_sale_cost_analysis(sale):
|
||
"""
|
||
Получить анализ себестоимости продажи.
|
||
Возвращает список партий, использованных при продаже, с расчетом прибыли.
|
||
|
||
Args:
|
||
sale: объект Sale
|
||
|
||
Returns:
|
||
dict: {
|
||
'total_quantity': Decimal,
|
||
'total_cost': Decimal, # сумма себестоимости
|
||
'total_revenue': Decimal, # сумма выручки
|
||
'profit': Decimal,
|
||
'profit_margin': Decimal, # процент прибыли
|
||
'allocations': [ # распределение по партиям
|
||
{
|
||
'batch': StockBatch,
|
||
'quantity': Decimal,
|
||
'cost_price': Decimal,
|
||
'batch_cost': Decimal,
|
||
'revenue': Decimal,
|
||
'batch_profit': Decimal
|
||
},
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch')
|
||
|
||
allocation_details = []
|
||
total_cost = Decimal('0')
|
||
total_revenue = sale.quantity * sale.sale_price
|
||
|
||
for alloc in allocations:
|
||
batch_cost = alloc.quantity * alloc.cost_price
|
||
batch_revenue = alloc.quantity * sale.sale_price
|
||
batch_profit = batch_revenue - batch_cost
|
||
|
||
total_cost += batch_cost
|
||
|
||
allocation_details.append({
|
||
'batch': alloc.batch,
|
||
'quantity': alloc.quantity,
|
||
'cost_price': alloc.cost_price,
|
||
'batch_cost': batch_cost,
|
||
'revenue': batch_revenue,
|
||
'batch_profit': batch_profit
|
||
})
|
||
|
||
total_profit = total_revenue - total_cost
|
||
profit_margin = (total_profit / total_revenue * 100) if total_revenue > 0 else Decimal('0')
|
||
|
||
return {
|
||
'total_quantity': sale.quantity,
|
||
'total_cost': total_cost,
|
||
'total_revenue': total_revenue,
|
||
'profit': total_profit,
|
||
'profit_margin': round(profit_margin, 2),
|
||
'allocations': allocation_details
|
||
}
|
||
|
||
@staticmethod
|
||
def get_sales_report(warehouse, product=None, date_from=None, date_to=None):
|
||
"""
|
||
Получить отчет по продажам с расчетом прибыли.
|
||
|
||
Args:
|
||
warehouse: объект Warehouse
|
||
product: (опционально) объект Product для фильтрации
|
||
date_from: (опционально) начальная дата
|
||
date_to: (опционально) конечная дата
|
||
|
||
Returns:
|
||
dict: {
|
||
'total_sales': int, # количество операций
|
||
'total_quantity': Decimal,
|
||
'total_revenue': Decimal,
|
||
'total_cost': Decimal,
|
||
'total_profit': Decimal,
|
||
'avg_profit_margin': Decimal,
|
||
'sales': [...] # подробная информация по каждой продаже
|
||
}
|
||
"""
|
||
from inventory.models import Sale
|
||
|
||
qs = Sale.objects.filter(warehouse=warehouse, processed=True)
|
||
|
||
if product:
|
||
qs = qs.filter(product=product)
|
||
|
||
if date_from:
|
||
qs = qs.filter(date__gte=date_from)
|
||
|
||
if date_to:
|
||
qs = qs.filter(date__lte=date_to)
|
||
|
||
sales_list = []
|
||
total_revenue = Decimal('0')
|
||
total_cost = Decimal('0')
|
||
total_quantity = Decimal('0')
|
||
|
||
for sale in qs.select_related('product', 'order'):
|
||
analysis = SaleProcessor.get_sale_cost_analysis(sale)
|
||
|
||
total_revenue += analysis['total_revenue']
|
||
total_cost += analysis['total_cost']
|
||
total_quantity += analysis['total_quantity']
|
||
|
||
sales_list.append({
|
||
'sale': sale,
|
||
'analysis': analysis
|
||
})
|
||
|
||
total_profit = total_revenue - total_cost
|
||
avg_profit_margin = (
|
||
(total_profit / total_revenue * 100) if total_revenue > 0 else Decimal('0')
|
||
)
|
||
|
||
return {
|
||
'total_sales': len(sales_list),
|
||
'total_quantity': total_quantity,
|
||
'total_revenue': total_revenue,
|
||
'total_cost': total_cost,
|
||
'total_profit': total_profit,
|
||
'avg_profit_margin': round(avg_profit_margin, 2),
|
||
'sales': sales_list
|
||
}
|