""" Менеджер для работы с партиями товаров (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'])