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:
2025-10-29 03:26:06 +03:00
parent 097d4ea304
commit 6735be9b08
73 changed files with 6536 additions and 122 deletions

View 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
}