""" Процессор для обработки продаж. Основной функционал: - Создание операции 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 allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity) # Фиксируем распределение для аудита 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 }