Files
octopus/myproject/inventory/services/batch_manager.py
Andrey Smakotin 6735be9b08 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>
2025-10-29 03:26:06 +03:00

247 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Менеджер для работы с партиями товаров (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'])