Files
octopus/myproject/inventory/services/sale_processor.py
Andrey Smakotin 6735be9b08 feat: Реализовать систему поступления товаров с партиями (IncomingBatch)
Основные изменения:
- Создана модель IncomingBatch для группировки товаров по документам
- Каждое поступление (Incoming) связано с одной батчем поступления
- Автоматическое создание StockBatch для каждого товара в приходе
- Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД
- Обновлены все представления (views) для работы с новой архитектурой
- Добавлены детальные страницы просмотра партий поступлений
- Обновлены шаблоны для отображения информации о партиях и их товарах
- Исправлена логика сигналов для создания StockBatch при приходе товара
- Обновлены формы для работы с новой структурой IncomingBatch

Архитектура FIFO:
- IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX)
- Incoming: товар в партии поступления
- StockBatch: одна партия товара на складе (создается для каждого товара)

Это позволяет системе правильно применять FIFO при продаже товаров.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 03:26:06 +03:00

217 lines
7.7 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(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
sale = Sale.objects.create(
product=product,
warehouse=warehouse,
quantity=quantity,
sale_price=sale_price,
order=order,
document_number=document_number,
processed=False
)
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
)
# Отмечаем продажу как обработанную
sale.processed = True
sale.save(update_fields=['processed'])
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
}