Files
octopus/myproject/inventory/services/sale_processor.py
Andrey Smakotin 9cd3796527 feat(woocommerce): реализовать проверку соединения с WooCommerce API
- Добавлена реализация метода 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
2026-01-20 23:05:18 +03:00

273 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Процессор для обработки продаж.
Основной функционал:
- Создание операции 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
}