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