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:
13
myproject/inventory/services/__init__.py
Normal file
13
myproject/inventory/services/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Сервисы для работы со складским учетом.
|
||||
"""
|
||||
|
||||
from .batch_manager import StockBatchManager
|
||||
from .sale_processor import SaleProcessor
|
||||
from .inventory_processor import InventoryProcessor
|
||||
|
||||
__all__ = [
|
||||
'StockBatchManager',
|
||||
'SaleProcessor',
|
||||
'InventoryProcessor',
|
||||
]
|
||||
246
myproject/inventory/services/batch_manager.py
Normal file
246
myproject/inventory/services/batch_manager.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Менеджер для работы с партиями товаров (StockBatch).
|
||||
Основной функционал:
|
||||
- Получение партий для FIFO списания
|
||||
- Создание новых партий при поступлении
|
||||
- Списание товара по FIFO при продажах и инвентаризации
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum, Q
|
||||
|
||||
from inventory.models import StockBatch, Stock, SaleBatchAllocation
|
||||
|
||||
|
||||
class StockBatchManager:
|
||||
"""
|
||||
Менеджер для работы с партиями товаров.
|
||||
Реализует логику FIFO для списания товаров.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_batches_for_fifo(product, warehouse):
|
||||
"""
|
||||
Получить все активные партии товара на складе,
|
||||
отсортированные по created_at (старые первыми для FIFO).
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
|
||||
Returns:
|
||||
QuerySet отсортированных партий
|
||||
"""
|
||||
return StockBatch.objects.filter(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
is_active=True,
|
||||
quantity__gt=0 # Только партии с остатком
|
||||
).order_by('created_at') # FIFO: старые первыми
|
||||
|
||||
@staticmethod
|
||||
def create_batch(product, warehouse, quantity, cost_price):
|
||||
"""
|
||||
Создать новую партию товара при поступлении.
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
quantity: Decimal - количество товара
|
||||
cost_price: Decimal - закупочная цена
|
||||
|
||||
Returns:
|
||||
Созданный объект StockBatch
|
||||
"""
|
||||
if quantity <= 0:
|
||||
raise ValueError("Количество должно быть больше нуля")
|
||||
|
||||
batch = StockBatch.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=quantity,
|
||||
cost_price=cost_price
|
||||
)
|
||||
|
||||
# Обновляем кеш остатков
|
||||
StockBatchManager.refresh_stock_cache(product, warehouse)
|
||||
|
||||
return batch
|
||||
|
||||
@staticmethod
|
||||
def write_off_by_fifo(product, warehouse, quantity_to_write_off):
|
||||
"""
|
||||
Списать товар по FIFO (старые партии первыми).
|
||||
Возвращает список (batch, written_off_quantity) кортежей.
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
quantity_to_write_off: Decimal - сколько списать
|
||||
|
||||
Returns:
|
||||
list: [(batch, qty_written), ...] - какие партии и сколько списано
|
||||
|
||||
Raises:
|
||||
ValueError: если недостаточно товара на складе
|
||||
"""
|
||||
remaining = quantity_to_write_off
|
||||
allocations = []
|
||||
|
||||
# Получаем партии по FIFO
|
||||
batches = StockBatchManager.get_batches_for_fifo(product, warehouse)
|
||||
|
||||
for batch in batches:
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
# Сколько можем списать из этой партии
|
||||
qty_from_this_batch = min(batch.quantity, remaining)
|
||||
|
||||
# Списываем
|
||||
batch.quantity -= qty_from_this_batch
|
||||
batch.save(update_fields=['quantity', 'updated_at'])
|
||||
|
||||
remaining -= qty_from_this_batch
|
||||
|
||||
# Фиксируем распределение
|
||||
allocations.append((batch, qty_from_this_batch))
|
||||
|
||||
# Если партия опустошена, деактивируем её
|
||||
if batch.quantity <= 0:
|
||||
batch.is_active = False
|
||||
batch.save(update_fields=['is_active'])
|
||||
|
||||
if remaining > 0:
|
||||
raise ValueError(
|
||||
f"Недостаточно товара на складе. "
|
||||
f"Требуется {quantity_to_write_off}, доступно {quantity_to_write_off - remaining}"
|
||||
)
|
||||
|
||||
# Обновляем кеш остатков
|
||||
StockBatchManager.refresh_stock_cache(product, warehouse)
|
||||
|
||||
return allocations
|
||||
|
||||
@staticmethod
|
||||
def transfer_batch(batch, to_warehouse, quantity):
|
||||
"""
|
||||
Перенести товар из одной партии на другой склад.
|
||||
Сохраняет cost_price партии.
|
||||
|
||||
Args:
|
||||
batch: объект StockBatch (источник)
|
||||
to_warehouse: объект Warehouse (пункт назначения)
|
||||
quantity: Decimal - сколько перенести
|
||||
|
||||
Returns:
|
||||
Новый объект StockBatch на целевом складе
|
||||
"""
|
||||
if quantity <= 0:
|
||||
raise ValueError("Количество должно быть больше нуля")
|
||||
|
||||
if quantity > batch.quantity:
|
||||
raise ValueError(
|
||||
f"Недостаточно товара в партии. "
|
||||
f"Требуется {quantity}, доступно {batch.quantity}"
|
||||
)
|
||||
|
||||
# Уменьшаем исходную партию
|
||||
batch.quantity -= quantity
|
||||
batch.save(update_fields=['quantity', 'updated_at'])
|
||||
|
||||
# Если исходная партия опустошена, деактивируем
|
||||
if batch.quantity <= 0:
|
||||
batch.is_active = False
|
||||
batch.save(update_fields=['is_active'])
|
||||
|
||||
# Создаем новую партию на целевом складе с той же ценой
|
||||
new_batch = StockBatch.objects.create(
|
||||
product=batch.product,
|
||||
warehouse=to_warehouse,
|
||||
quantity=quantity,
|
||||
cost_price=batch.cost_price # Сохраняем цену!
|
||||
)
|
||||
|
||||
# Обновляем кеш остатков на обоих складах
|
||||
StockBatchManager.refresh_stock_cache(batch.product, batch.warehouse)
|
||||
StockBatchManager.refresh_stock_cache(batch.product, to_warehouse)
|
||||
|
||||
return new_batch
|
||||
|
||||
@staticmethod
|
||||
def refresh_stock_cache(product, warehouse):
|
||||
"""
|
||||
Пересчитать кеш остатков для товара на складе.
|
||||
Обновляет модель Stock с агрегированными данными.
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
"""
|
||||
# Получаем или создаем запись Stock
|
||||
stock, created = Stock.objects.get_or_create(
|
||||
product=product,
|
||||
warehouse=warehouse
|
||||
)
|
||||
|
||||
# Обновляем её из батчей
|
||||
# refresh_from_batches() уже вызывает save() внутри
|
||||
stock.refresh_from_batches()
|
||||
|
||||
@staticmethod
|
||||
def get_total_stock(product, warehouse):
|
||||
"""
|
||||
Получить общее доступное количество товара на складе.
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
|
||||
Returns:
|
||||
Decimal - количество товара
|
||||
"""
|
||||
total = StockBatch.objects.filter(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
is_active=True
|
||||
).aggregate(total=Sum('quantity'))['total'] or Decimal('0')
|
||||
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def get_batch_details(warehouse, product=None):
|
||||
"""
|
||||
Получить подробную информацию о партиях на складе.
|
||||
Полезно для отчетов.
|
||||
|
||||
Args:
|
||||
warehouse: объект Warehouse
|
||||
product: (опционально) объект Product для фильтрации
|
||||
|
||||
Returns:
|
||||
list: QuerySet партий с деталями
|
||||
"""
|
||||
qs = StockBatch.objects.filter(warehouse=warehouse, is_active=True)
|
||||
|
||||
if product:
|
||||
qs = qs.filter(product=product)
|
||||
|
||||
return qs.select_related('product', 'warehouse').order_by('product', 'created_at')
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def close_batch(batch):
|
||||
"""
|
||||
Закрыть партию (например, при окончании срока годности).
|
||||
Невозможно списывать из закрытой партии.
|
||||
|
||||
Args:
|
||||
batch: объект StockBatch
|
||||
"""
|
||||
if batch.quantity > 0:
|
||||
raise ValueError(f"Невозможно закрыть партию с остатком {batch.quantity}")
|
||||
|
||||
batch.is_active = False
|
||||
batch.save(update_fields=['is_active'])
|
||||
286
myproject/inventory/services/inventory_processor.py
Normal file
286
myproject/inventory/services/inventory_processor.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Процессор для обработки инвентаризации.
|
||||
Основной функционал:
|
||||
- Обработка расхождений между фактом и системой
|
||||
- Автоматическое создание WriteOff для недостач (по FIFO)
|
||||
- Автоматическое создание Incoming для излишков
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from inventory.models import (
|
||||
Inventory, InventoryLine, WriteOff, Incoming,
|
||||
StockBatch, Stock
|
||||
)
|
||||
from inventory.services.batch_manager import StockBatchManager
|
||||
|
||||
|
||||
class InventoryProcessor:
|
||||
"""
|
||||
Обработчик инвентаризации с автоматической коррекцией остатков.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def process_inventory(inventory_id):
|
||||
"""
|
||||
Обработать инвентаризацию:
|
||||
- Для недостач (разница < 0): создать WriteOff по FIFO
|
||||
- Для излишков (разница > 0): создать Incoming с новой партией
|
||||
- Обновить статус inventory и lines
|
||||
|
||||
Args:
|
||||
inventory_id: ID объекта Inventory
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'inventory': Inventory,
|
||||
'processed_lines': int,
|
||||
'writeoffs_created': int,
|
||||
'incomings_created': int,
|
||||
'errors': [...]
|
||||
}
|
||||
"""
|
||||
inventory = Inventory.objects.get(id=inventory_id)
|
||||
lines = InventoryLine.objects.filter(inventory=inventory, processed=False)
|
||||
|
||||
writeoffs_created = 0
|
||||
incomings_created = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
for line in lines:
|
||||
try:
|
||||
if line.difference < 0:
|
||||
# Недостача: списать по FIFO
|
||||
InventoryProcessor._create_writeoff_for_deficit(
|
||||
inventory, line
|
||||
)
|
||||
writeoffs_created += 1
|
||||
|
||||
elif line.difference > 0:
|
||||
# Излишек: создать новую партию
|
||||
InventoryProcessor._create_incoming_for_surplus(
|
||||
inventory, line
|
||||
)
|
||||
incomings_created += 1
|
||||
|
||||
# Отмечаем строку как обработанную
|
||||
line.processed = True
|
||||
line.save(update_fields=['processed'])
|
||||
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
'line': line,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
# Обновляем статус инвентаризации
|
||||
inventory.status = 'completed'
|
||||
inventory.save(update_fields=['status'])
|
||||
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
'inventory': inventory,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
'inventory': inventory,
|
||||
'processed_lines': lines.count(),
|
||||
'writeoffs_created': writeoffs_created,
|
||||
'incomings_created': incomings_created,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _create_writeoff_for_deficit(inventory, line):
|
||||
"""
|
||||
Создать операцию WriteOff для недостачи при инвентаризации.
|
||||
Списывается по FIFO из старейших партий.
|
||||
|
||||
Args:
|
||||
inventory: объект Inventory
|
||||
line: объект InventoryLine с negative difference
|
||||
"""
|
||||
quantity_to_writeoff = abs(line.difference)
|
||||
|
||||
# Списываем по FIFO
|
||||
allocations = StockBatchManager.write_off_by_fifo(
|
||||
line.product,
|
||||
inventory.warehouse,
|
||||
quantity_to_writeoff
|
||||
)
|
||||
|
||||
# Создаем WriteOff для каждой партии
|
||||
for batch, qty_allocated in allocations:
|
||||
WriteOff.objects.create(
|
||||
batch=batch,
|
||||
quantity=qty_allocated,
|
||||
reason='inventory',
|
||||
cost_price=batch.cost_price,
|
||||
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_incoming_for_surplus(inventory, line):
|
||||
"""
|
||||
Создать операцию Incoming для излишка при инвентаризации.
|
||||
Новая партия создается с последней известной cost_price товара.
|
||||
|
||||
Args:
|
||||
inventory: объект Inventory
|
||||
line: объект InventoryLine с positive difference
|
||||
"""
|
||||
quantity_surplus = line.difference
|
||||
|
||||
# Получаем последнюю known cost_price
|
||||
cost_price = InventoryProcessor._get_last_cost_price(
|
||||
line.product,
|
||||
inventory.warehouse
|
||||
)
|
||||
|
||||
# Создаем новую партию
|
||||
batch = StockBatchManager.create_batch(
|
||||
line.product,
|
||||
inventory.warehouse,
|
||||
quantity_surplus,
|
||||
cost_price
|
||||
)
|
||||
|
||||
# Создаем документ Incoming
|
||||
Incoming.objects.create(
|
||||
product=line.product,
|
||||
warehouse=inventory.warehouse,
|
||||
quantity=quantity_surplus,
|
||||
cost_price=cost_price,
|
||||
batch=batch,
|
||||
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_last_cost_price(product, warehouse):
|
||||
"""
|
||||
Получить последнюю известную закупочную цену товара на складе.
|
||||
Используется для создания новой партии при излишке.
|
||||
|
||||
Порядок поиска:
|
||||
1. Последняя активная партия на этом складе
|
||||
2. Последняя активная партия на любом складе
|
||||
3. cost_price из карточки Product (если есть)
|
||||
4. Дефолт 0 (если ничего не найдено)
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
|
||||
Returns:
|
||||
Decimal - закупочная цена
|
||||
"""
|
||||
from inventory.models import StockBatch
|
||||
|
||||
# Вариант 1: последняя партия на этом складе
|
||||
last_batch = StockBatch.objects.filter(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
is_active=True
|
||||
).order_by('-created_at').first()
|
||||
|
||||
if last_batch:
|
||||
return last_batch.cost_price
|
||||
|
||||
# Вариант 2: последняя партия на любом складе
|
||||
last_batch_any = StockBatch.objects.filter(
|
||||
product=product,
|
||||
is_active=True
|
||||
).order_by('-created_at').first()
|
||||
|
||||
if last_batch_any:
|
||||
return last_batch_any.cost_price
|
||||
|
||||
# Вариант 3: cost_price из карточки товара
|
||||
if product.cost_price:
|
||||
return product.cost_price
|
||||
|
||||
# Вариант 4: ноль (не должно быть)
|
||||
return Decimal('0')
|
||||
|
||||
@staticmethod
|
||||
def create_inventory_lines_from_current_stock(inventory):
|
||||
"""
|
||||
Автоматически создать InventoryLine для всех товаров на складе.
|
||||
Используется для удобства: оператор может сразу начать вводить фактические
|
||||
количества, имея под рукой системные остатки.
|
||||
|
||||
Args:
|
||||
inventory: объект Inventory
|
||||
"""
|
||||
from inventory.models import StockBatch
|
||||
|
||||
# Получаем все товары, которые есть на этом складе
|
||||
batches = StockBatch.objects.filter(
|
||||
warehouse=inventory.warehouse,
|
||||
is_active=True
|
||||
).values('product').distinct()
|
||||
|
||||
for batch_dict in batches:
|
||||
product = batch_dict['product']
|
||||
|
||||
# Рассчитываем системный остаток
|
||||
quantity_system = StockBatchManager.get_total_stock(product, inventory.warehouse)
|
||||
|
||||
# Создаем строку инвентаризации (факт будет заполнен оператором)
|
||||
InventoryLine.objects.get_or_create(
|
||||
inventory=inventory,
|
||||
product_id=product,
|
||||
defaults={
|
||||
'quantity_system': quantity_system,
|
||||
'quantity_fact': 0, # Оператор должен заполнить
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_inventory_report(inventory):
|
||||
"""
|
||||
Получить отчет по инвентаризации.
|
||||
|
||||
Args:
|
||||
inventory: объект Inventory
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'inventory': Inventory,
|
||||
'total_lines': int,
|
||||
'total_deficit': Decimal,
|
||||
'total_surplus': Decimal,
|
||||
'lines': [...]
|
||||
}
|
||||
"""
|
||||
lines = InventoryLine.objects.filter(inventory=inventory).select_related('product')
|
||||
|
||||
total_deficit = Decimal('0')
|
||||
total_surplus = Decimal('0')
|
||||
lines_data = []
|
||||
|
||||
for line in lines:
|
||||
if line.difference < 0:
|
||||
total_deficit += abs(line.difference)
|
||||
elif line.difference > 0:
|
||||
total_surplus += line.difference
|
||||
|
||||
lines_data.append({
|
||||
'line': line,
|
||||
'system_value': line.quantity_system * line.product.cost_price,
|
||||
'fact_value': line.quantity_fact * line.product.cost_price,
|
||||
'value_difference': (line.quantity_fact - line.quantity_system) * line.product.cost_price,
|
||||
})
|
||||
|
||||
return {
|
||||
'inventory': inventory,
|
||||
'total_lines': lines.count(),
|
||||
'total_deficit': total_deficit,
|
||||
'total_surplus': total_surplus,
|
||||
'lines': lines_data
|
||||
}
|
||||
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