- Добавлена реализация метода test_connection() с обработкой различных HTTP статусов - Реализованы вспомогательные методы _get_api_url() и _get_auth() для работы с API - Добавлена интеграция WooCommerceService в get_integration_service() - Настроены поля формы для WooCommerceIntegration в get_form_fields_meta() fix(inventory): исправить расчет цены продажи в базовых единицах - Исправлен расчет sale_price в SaleProcessor с учетом conversion_factor_snapshot - Обновлен расчет цены в сигнале create_sale_on_order_completion для корректной работы с sales_unit
273 lines
11 KiB
Python
273 lines
11 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:
|
||
item = reservation.order_item
|
||
# Пересчитываем цену в базовые единицы
|
||
if item.sales_unit and item.conversion_factor_snapshot:
|
||
sale_price = Decimal(str(item.price)) * item.conversion_factor_snapshot
|
||
else:
|
||
sale_price = 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
|
||
}
|