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,13 @@
"""
Сервисы для работы со складским учетом.
"""
from .batch_manager import StockBatchManager
from .sale_processor import SaleProcessor
from .inventory_processor import InventoryProcessor
__all__ = [
'StockBatchManager',
'SaleProcessor',
'InventoryProcessor',
]

View 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'])

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

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
}