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>
This commit is contained in:
216
myproject/inventory/services/sale_processor.py
Normal file
216
myproject/inventory/services/sale_processor.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Процессор для обработки продаж.
|
||||
Основной функционал:
|
||||
- Создание операции 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
|
||||
}
|
||||
Reference in New Issue
Block a user