Проблема: При переводе заказа в статус 'completed' возникала ошибка "Не удалось создать Sale для заказа", т.к. резервы этого же заказа блокировали списание товара. Причина: write_off_by_fifo() считал все резервы со статусом 'reserved' как занятые, включая резервы текущего заказа, которые ещё не были переведены в 'converted_to_sale'. Решение: - Добавлен параметр exclude_order в write_off_by_fifo() для исключения резервов конкретного заказа из расчёта занятого товара - SaleProcessor.create_sale() теперь передаёт order в write_off_by_fifo() - Добавлены транзакции в views для атомарности операций с заказами: при ошибке в сигналах статус заказа откатывается вместе со всеми связанными изменениями 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
254 lines
9.4 KiB
Python
254 lines
9.4 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):
|
||
"""
|
||
Создать операцию продажи и произвести FIFO-списание.
|
||
|
||
Процесс:
|
||
1. Создаем запись Sale
|
||
2. Списываем товар по FIFO из партий
|
||
3. Фиксируем распределение в SaleBatchAllocation для аудита
|
||
|
||
Args:
|
||
product: объект Product
|
||
warehouse: объект Warehouse
|
||
quantity: Decimal - количество товара
|
||
sale_price: Decimal - цена продажи
|
||
order: (опционально) объект Order
|
||
document_number: (опционально) номер документа
|
||
|
||
Returns:
|
||
Объект Sale
|
||
|
||
Raises:
|
||
ValueError: если недостаточно товара или некорректные данные
|
||
"""
|
||
if quantity <= 0:
|
||
raise ValueError("Количество должно быть больше нуля")
|
||
|
||
if sale_price < 0:
|
||
raise ValueError("Цена продажи не может быть отрицательной")
|
||
|
||
# Создаем запись Sale
|
||
# ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
||
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
||
sale = Sale.objects.create(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
quantity=quantity,
|
||
sale_price=sale_price,
|
||
order=order,
|
||
document_number=document_number,
|
||
processed=True # Сразу отмечаем как обработанную
|
||
)
|
||
|
||
try:
|
||
# Списываем товар по FIFO
|
||
# exclude_order позволяет не считать резервы этого заказа как занятые
|
||
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
||
allocations = StockBatchManager.write_off_by_fifo(
|
||
product, warehouse, quantity, 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
|
||
}
|