""" Менеджер для работы с партиями товаров (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): """ Получить все активные партии товара на складе для FIFO списания. Возвращает ВСЕ партии с quantity > 0, отсортированные по created_at. ВАЖНО: Логика учета резервов реализована в write_off_by_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: если недостаточно свободного товара на складе """ from inventory.models import Reservation remaining = quantity_to_write_off allocations = [] # Получаем общее количество зарезервированного товара (все резервы со статусом 'reserved') total_reserved = Reservation.objects.filter( product=product, warehouse=warehouse, status='reserved' ).aggregate(total=Sum('quantity'))['total'] or Decimal('0') # Получаем партии по FIFO batches = StockBatchManager.get_batches_for_fifo(product, warehouse) # Проходим партии, списывая только СВОБОДНОЕ количество reserved_remaining = total_reserved # Сколько резерва еще не распределено по партиям for batch in batches: if remaining <= 0: break # Определяем сколько в этой партии зарезервировано (пропорционально) # Логика: старые партии "съедают" резерв первыми (как и при списании) batch_reserved = min(batch.quantity, reserved_remaining) reserved_remaining -= batch_reserved # Свободное количество в партии batch_free = batch.quantity - batch_reserved if batch_free <= 0: # Партия полностью зарезервирована → пропускаем continue # Сколько можем списать из этой партии (только свободное) qty_from_this_batch = min(batch_free, 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}. " f"(Общий резерв: {total_reserved})" ) # Обновляем кеш остатков 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']) @staticmethod @transaction.atomic def transfer_product_by_fifo(product, from_warehouse, to_warehouse, quantity): """ Переместить товар с одного склада на другой по FIFO логике. Старые партии перемещаются первыми. Args: product: объект Product from_warehouse: объект Warehouse (источник) to_warehouse: объект Warehouse (назначение) quantity: Decimal - количество товара для перемещения Returns: list: [(source_batch, qty_transferred, new_batch), ...] список кортежей с исходной партией, количеством и созданной партией Raises: ValueError: если недостаточно товара на складе-источнике """ # Получаем партии по FIFO (старые первыми) allocations = StockBatchManager.get_batches_for_fifo(product, from_warehouse) result = [] remaining = quantity for batch in allocations: if remaining <= 0: break # Определяем сколько перемещаем из этой партии qty_to_transfer = min(batch.quantity, remaining) # Уменьшаем исходную партию batch.quantity -= qty_to_transfer if batch.quantity <= 0: batch.is_active = False batch.save(update_fields=['quantity', 'is_active', 'updated_at']) # Создаем новую партию на целевом складе с СОХРАНЕНИЕМ cost_price new_batch = StockBatch.objects.create( product=product, warehouse=to_warehouse, quantity=qty_to_transfer, cost_price=batch.cost_price # ВАЖНО: сохраняем цену! ) result.append((batch, qty_to_transfer, new_batch)) remaining -= qty_to_transfer # Проверяем что было достаточно товара if remaining > 0: raise ValueError( f"Недостаточно товара '{product.name}' на складе '{from_warehouse.name}'. " f"Не хватает {remaining} шт из запрашиваемых {quantity} шт" ) # Обновляем кеш остатков на обоих складах StockBatchManager.refresh_stock_cache(product, from_warehouse) StockBatchManager.refresh_stock_cache(product, to_warehouse) return result