""" Процессор для обработки инвентаризации. Основной функционал: - Обработка расхождений между фактом и системой - Автоматическое создание WriteOffDocument для недостач (черновик) - Автоматическое создание IncomingDocument для излишков (черновик) """ from decimal import Decimal from django.db import transaction from django.utils import timezone from inventory.models import ( Inventory, InventoryLine, StockBatch, Stock ) from inventory.services.batch_manager import StockBatchManager from inventory.services.writeoff_document_service import WriteOffDocumentService from inventory.services.incoming_document_service import IncomingDocumentService class InventoryProcessor: """ Обработчик инвентаризации с автоматической коррекцией остатков. """ @staticmethod @transaction.atomic def process_inventory(inventory_id): """ Обработать инвентаризацию: - Для недостач (разница < 0): создать WriteOffDocument (черновик) с позициями - Для излишков (разница > 0): создать IncomingDocument (черновик) с позициями - Обновить статус inventory и lines - НЕ проводить документы сразу - они остаются в статусе 'draft' Args: inventory_id: ID объекта Inventory Returns: dict: { 'inventory': Inventory, 'processed_lines': int, 'writeoff_document': WriteOffDocument или None, 'incoming_document': IncomingDocument или None, 'errors': [...] } """ inventory = Inventory.objects.get(id=inventory_id) lines = InventoryLine.objects.filter(inventory=inventory, processed=False) errors = [] writeoff_document = None incoming_document = None # Собираем недостачи и излишки deficit_lines = [] surplus_lines = [] try: for line in lines: try: if line.difference < 0: # Недостача deficit_lines.append(line) elif line.difference > 0: # Излишек surplus_lines.append(line) # Отмечаем строку как обработанную line.processed = True line.save(update_fields=['processed']) except Exception as e: errors.append({ 'line': line, 'error': str(e) }) # Создаем WriteOffDocument для недостач (если есть) if deficit_lines: writeoff_document = InventoryProcessor._create_writeoff_document( inventory, deficit_lines ) # Создаем IncomingDocument для излишков (если есть) if surplus_lines: incoming_document = InventoryProcessor._create_incoming_document( inventory, surplus_lines ) # Обновляем статус инвентаризации inventory.status = 'completed' inventory.save(update_fields=['status']) except Exception as e: errors.append({ 'inventory': inventory, 'error': str(e) }) return { 'inventory': inventory, 'processed_lines': lines.count(), 'writeoff_document': writeoff_document, 'incoming_document': incoming_document, 'errors': errors } @staticmethod def _create_writeoff_document(inventory, deficit_lines): """ Создать документ списания (WriteOffDocument) для недостач при инвентаризации. Документ создается в статусе 'draft' и не проводится сразу. Args: inventory: объект Inventory deficit_lines: список InventoryLine с negative difference Returns: WriteOffDocument """ if not deficit_lines: return None # Создаем документ списания (черновик) writeoff_document = WriteOffDocumentService.create_document( warehouse=inventory.warehouse, date=inventory.date.date(), notes=f'Списание по результатам инвентаризации #{inventory.id}', created_by=None # Можно добавить пользователя если передавать в process_inventory ) # Связываем документ с инвентаризацией writeoff_document.inventory = inventory writeoff_document.save(update_fields=['inventory']) # Добавляем позиции в документ for line in deficit_lines: quantity_to_writeoff = abs(line.difference) WriteOffDocumentService.add_item( document=writeoff_document, product=line.product, quantity=quantity_to_writeoff, reason='inventory', notes=f'Инвентаризация #{inventory.id}, строка #{line.id}' ) return writeoff_document @staticmethod def _create_incoming_document(inventory, surplus_lines): """ Создать документ поступления (IncomingDocument) для излишков при инвентаризации. Документ создается в статусе 'draft' и не проводится сразу. Args: inventory: объект Inventory surplus_lines: список InventoryLine с positive difference Returns: IncomingDocument """ if not surplus_lines: return None # Создаем документ поступления (черновик) с типом 'inventory' incoming_document = IncomingDocumentService.create_document( warehouse=inventory.warehouse, date=inventory.date.date(), receipt_type='inventory', supplier_name=None, notes=f'Оприходование по результатам инвентаризации #{inventory.id}', created_by=None # Можно добавить пользователя если передавать в process_inventory ) # Связываем документ с инвентаризацией incoming_document.inventory = inventory incoming_document.save(update_fields=['inventory']) # Добавляем позиции в документ for line in surplus_lines: quantity_surplus = line.difference # Получаем последнюю known cost_price cost_price = InventoryProcessor._get_last_cost_price( line.product, inventory.warehouse ) IncomingDocumentService.add_item( document=incoming_document, product=line.product, quantity=quantity_surplus, cost_price=cost_price, notes=f'Инвентаризация #{inventory.id}, строка #{line.id}' ) return incoming_document @staticmethod def _get_last_cost_price(product, warehouse): """ Получить последнюю известную закупочную цену товара на складе. Используется для создания новой партии при излишке. Порядок поиска: 1. Последняя активная партия на этом складе 2. Последняя активная партия на любом складе 3. cost_price из карточки Product (если есть) 4. Дефолт 0 (если ничего не найдено) Args: product: объект Product warehouse: объект Warehouse Returns: Decimal - закупочная цена """ from inventory.models import StockBatch # Вариант 1: последняя партия на этом складе last_batch = StockBatch.objects.filter( product=product, warehouse=warehouse, is_active=True ).order_by('-created_at').first() if last_batch: return last_batch.cost_price # Вариант 2: последняя партия на любом складе last_batch_any = StockBatch.objects.filter( product=product, is_active=True ).order_by('-created_at').first() if last_batch_any: return last_batch_any.cost_price # Вариант 3: cost_price из карточки товара if product.cost_price: return product.cost_price # Вариант 4: ноль (не должно быть) return Decimal('0') @staticmethod def create_inventory_lines_from_current_stock(inventory): """ Автоматически создать InventoryLine для всех товаров на складе. Используется для удобства: оператор может сразу начать вводить фактические количества, имея под рукой системные остатки. Args: inventory: объект Inventory """ from inventory.models import StockBatch # Получаем все товары, которые есть на этом складе batches = StockBatch.objects.filter( warehouse=inventory.warehouse, is_active=True ).values('product').distinct() for batch_dict in batches: product = batch_dict['product'] # Рассчитываем системный остаток quantity_system = StockBatchManager.get_total_stock(product, inventory.warehouse) # Создаем строку инвентаризации (факт будет заполнен оператором) InventoryLine.objects.get_or_create( inventory=inventory, product_id=product, defaults={ 'quantity_system': quantity_system, 'quantity_fact': 0, # Оператор должен заполнить } ) @staticmethod def get_inventory_report(inventory): """ Получить отчет по инвентаризации. Args: inventory: объект Inventory Returns: dict: { 'inventory': Inventory, 'total_lines': int, 'total_deficit': Decimal, 'total_surplus': Decimal, 'lines': [...] } """ lines = InventoryLine.objects.filter(inventory=inventory).select_related('product') total_deficit = Decimal('0') total_surplus = Decimal('0') lines_data = [] for line in lines: if line.difference < 0: total_deficit += abs(line.difference) elif line.difference > 0: total_surplus += line.difference lines_data.append({ 'line': line, 'system_value': line.quantity_system * line.product.cost_price, 'fact_value': line.quantity_fact * line.product.cost_price, 'value_difference': (line.quantity_fact - line.quantity_system) * line.product.cost_price, }) return { 'inventory': inventory, 'total_lines': lines.count(), 'total_deficit': total_deficit, 'total_surplus': total_surplus, 'lines': lines_data }