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:
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'])
|
||||
Reference in New Issue
Block a user