Implement functionality to allow sales even when stock is insufficient, tracking pending quantities and resolving them when new stock arrives via incoming documents. This includes new fields in Sale model (is_pending_cost, pending_quantity), updates to batch manager for negative write-offs, and signal handlers for automatic processing. - Add is_pending_cost and pending_quantity fields to Sale model - Modify write_off_by_fifo to support allow_negative flag and return pending quantity - Update incoming document service to allocate pending sales to new batches - Enhance sale processor and signals to handle pending sales - Remove outdated tests.py file - Add migration for new Sale fields
269 lines
10 KiB
Python
269 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. Списываем товар по FIFO из партий
|
||
3. Фиксируем распределение в SaleBatchAllocation для аудита
|
||
|
||
Args:
|
||
product: объект Product
|
||
warehouse: объект Warehouse
|
||
quantity: Decimal - количество товара В БАЗОВЫХ ЕДИНИЦАХ.
|
||
Для списания со склада всегда используются базовые единицы.
|
||
sale_price: Decimal - цена продажи
|
||
order: (опционально) объект Order
|
||
document_number: (опционально) номер документа
|
||
sales_unit: (опционально) объект ProductSalesUnit - единица продажи.
|
||
Используется ТОЛЬКО для сохранения снимка (не для конверсии).
|
||
|
||
Returns:
|
||
Объект Sale
|
||
|
||
Raises:
|
||
ValueError: если недостаточно товара или некорректные данные
|
||
"""
|
||
if quantity <= 0:
|
||
raise ValueError("Количество должно быть больше нуля")
|
||
|
||
if sale_price < 0:
|
||
raise ValueError("Цена продажи не может быть отрицательной")
|
||
|
||
# quantity УЖЕ в базовых единицах, конверсия не нужна
|
||
quantity_base = quantity
|
||
unit_name_snapshot = sales_unit.name if sales_unit else ''
|
||
|
||
# Создаем запись Sale
|
||
# ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
||
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
||
sale = Sale.objects.create(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
quantity=quantity_base, # В базовых единицах (для истории/отчётов)
|
||
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,
|
||
is_pending_cost=False,
|
||
pending_quantity=Decimal('0')
|
||
)
|
||
|
||
# Списываем товар по FIFO в БАЗОВЫХ единицах
|
||
# exclude_order позволяет не считать резервы этого заказа как занятые
|
||
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
||
# allow_negative=True разрешает продажи "в минус"
|
||
allocations, pending = StockBatchManager.write_off_by_fifo(
|
||
product, warehouse, quantity_base, exclude_order=order, allow_negative=True
|
||
)
|
||
|
||
# Фиксируем распределение для аудита
|
||
for batch, qty_allocated in allocations:
|
||
SaleBatchAllocation.objects.create(
|
||
sale=sale,
|
||
batch=batch,
|
||
quantity=qty_allocated,
|
||
cost_price=batch.cost_price
|
||
)
|
||
|
||
# Если есть pending - это продажа "в минус"
|
||
if pending > 0:
|
||
sale.is_pending_cost = True
|
||
sale.pending_quantity = pending
|
||
sale.save(update_fields=['is_pending_cost', 'pending_quantity'])
|
||
|
||
# Обновляем Stock (теперь учитывает pending_sales)
|
||
StockBatchManager.refresh_stock_cache(product, warehouse)
|
||
|
||
return sale
|
||
|
||
@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
|
||
}
|