""" Сервис для работы с документами поступления (IncomingDocument). Обеспечивает: - Создание документов с автонумерацией - Добавление позиций в черновик - Проведение документов (создание IncomingBatch и Incoming) - Отмену документов """ from decimal import Decimal from django.db import transaction from django.utils import timezone from django.core.exceptions import ValidationError from inventory.models import ( IncomingDocument, IncomingDocumentItem, StockBatch, Stock ) from inventory.utils.document_generator import generate_incoming_document_number class IncomingDocumentService: """ Сервис для работы с документами поступления. """ @classmethod @transaction.atomic def create_document(cls, warehouse, date, receipt_type='supplier', supplier_name=None, notes=None, created_by=None): """ Создать новый документ поступления (черновик). Args: warehouse: объект Warehouse date: дата документа (date) receipt_type: тип поступления ('supplier', 'inventory', 'adjustment') supplier_name: наименование поставщика (str, опционально, для типа 'supplier') notes: примечания (str, опционально) created_by: пользователь (User, опционально) Returns: IncomingDocument """ document = IncomingDocument.objects.create( document_number=generate_incoming_document_number(), warehouse=warehouse, status='draft', date=date, receipt_type=receipt_type, supplier_name=supplier_name if receipt_type == 'supplier' else None, notes=notes, created_by=created_by ) return document @classmethod @transaction.atomic def add_item(cls, document, product, quantity, cost_price, notes=None): """ Добавить позицию в документ поступления. Args: document: IncomingDocument product: Product quantity: Decimal - количество товара cost_price: Decimal - закупочная цена notes: str - примечания Returns: IncomingDocumentItem Raises: ValidationError: если документ не черновик """ if document.status != 'draft': raise ValidationError( "Нельзя добавлять позиции в проведённый или отменённый документ" ) quantity = Decimal(str(quantity)) if quantity <= 0: raise ValidationError("Количество должно быть больше нуля") cost_price = Decimal(str(cost_price)) if cost_price < 0: raise ValidationError("Закупочная цена не может быть отрицательной") # Создаем позицию документа item = IncomingDocumentItem.objects.create( document=document, product=product, quantity=quantity, cost_price=cost_price, notes=notes ) return item @classmethod @transaction.atomic def update_item(cls, item, quantity=None, cost_price=None, notes=None): """ Обновить позицию документа. Args: item: IncomingDocumentItem quantity: новое количество (опционально) cost_price: новая закупочная цена (опционально) notes: новые примечания (опционально) Returns: IncomingDocumentItem Raises: ValidationError: если документ не черновик """ if item.document.status != 'draft': raise ValidationError( "Нельзя редактировать позиции проведённого или отменённого документа" ) if quantity is not None: quantity = Decimal(str(quantity)) if quantity <= 0: raise ValidationError("Количество должно быть больше нуля") item.quantity = quantity if cost_price is not None: cost_price = Decimal(str(cost_price)) if cost_price < 0: raise ValidationError("Закупочная цена не может быть отрицательной") item.cost_price = cost_price if notes is not None: item.notes = notes item.save() return item @classmethod @transaction.atomic def remove_item(cls, item): """ Удалить позицию из документа. Args: item: IncomingDocumentItem Raises: ValidationError: если документ не черновик """ if item.document.status != 'draft': raise ValidationError( "Нельзя удалять позиции из проведённого или отменённого документа" ) # Удаляем позицию item.delete() @classmethod @transaction.atomic def confirm_document(cls, document, confirmed_by=None): """ Провести документ поступления. Процесс: 1. Проверяем что документ - черновик и имеет позиции 2. Для каждой позиции создаем StockBatch напрямую 3. Обновляем Stock 4. Меняем статус документа на 'confirmed' Args: document: IncomingDocument confirmed_by: User - кто проводит документ Returns: dict: результат проведения Raises: ValidationError: если документ нельзя провести """ if document.status != 'draft': raise ValidationError( f"Документ уже проведён или отменён (статус: {document.get_status_display()})" ) if not document.items.exists(): raise ValidationError("Нельзя провести пустой документ") # Создаем StockBatch напрямую для каждого товара batches_created = [] total_cost = Decimal('0') for item in document.items.select_related('product'): # Создаем партию товара на складе stock_batch = StockBatch.objects.create( product=item.product, warehouse=document.warehouse, quantity=item.quantity, cost_price=item.cost_price, is_active=True ) batches_created.append(stock_batch) total_cost += item.total_cost # Обрабатываем ожидающие продажи "в минус" для этого товара cls._process_pending_sales( product=item.product, warehouse=document.warehouse, new_batch=stock_batch ) # Обновляем или создаем запись в Stock stock, _ = Stock.objects.get_or_create( product=item.product, warehouse=document.warehouse ) # Пересчитываем остаток из всех активных партий stock.refresh_from_batches() # Обновляем статус документа document.status = 'confirmed' document.confirmed_by = confirmed_by document.confirmed_at = timezone.now() document.save(update_fields=['status', 'confirmed_by', 'confirmed_at', 'updated_at']) return { 'document': document, 'batches_created': len(batches_created), 'total_quantity': document.total_quantity, 'total_cost': total_cost } @classmethod def _process_pending_sales(cls, product, warehouse, new_batch): """ Привязать ожидающие продажи "в минус" к новой партии по FIFO. Себестоимость берётся из этой партии. Args: product: объект Product warehouse: объект Warehouse new_batch: объект StockBatch (только что созданная партия) """ from inventory.models import Sale, SaleBatchAllocation # Ожидающие продажи по дате (старые первыми - FIFO) pending_sales = Sale.objects.filter( product=product, warehouse=warehouse, is_pending_cost=True, pending_quantity__gt=0 ).order_by('date') available_qty = new_batch.quantity for sale in pending_sales: if available_qty <= 0: break qty_to_allocate = min(sale.pending_quantity, available_qty) # Создаем SaleBatchAllocation с себестоимостью из приёмки SaleBatchAllocation.objects.create( sale=sale, batch=new_batch, quantity=qty_to_allocate, cost_price=new_batch.cost_price ) # Уменьшаем pending в Sale sale.pending_quantity -= qty_to_allocate if sale.pending_quantity <= 0: sale.is_pending_cost = False sale.save(update_fields=['pending_quantity', 'is_pending_cost']) # Уменьшаем партию (товар уже был "продан" ранее) new_batch.quantity -= qty_to_allocate available_qty -= qty_to_allocate # Сохраняем партию с оставшимся количеством if new_batch.quantity <= 0: new_batch.is_active = False new_batch.save(update_fields=['quantity', 'is_active']) @classmethod @transaction.atomic def cancel_document(cls, document): """ Отменить документ поступления (черновик). Args: document: IncomingDocument Returns: IncomingDocument Raises: ValidationError: если документ уже проведён """ if document.status == 'confirmed': raise ValidationError( "Нельзя отменить проведённый документ. " "Создайте новый документ для корректировки." ) if document.status == 'cancelled': raise ValidationError("Документ уже отменён") # Обновляем статус документа document.status = 'cancelled' document.save(update_fields=['status', 'updated_at']) return document @staticmethod def get_draft_documents(warehouse=None): """ Получить все черновики документов поступления. Args: warehouse: фильтр по складу (опционально) Returns: QuerySet[IncomingDocument] """ qs = IncomingDocument.objects.filter(status='draft') if warehouse: qs = qs.filter(warehouse=warehouse) return qs.select_related('warehouse', 'created_by').prefetch_related('items') @staticmethod def get_today_drafts(warehouse): """ Получить черновики за сегодня для склада. Args: warehouse: Warehouse Returns: QuerySet[IncomingDocument] """ today = timezone.now().date() return IncomingDocument.objects.filter( warehouse=warehouse, status='draft', date=today ).select_related('warehouse', 'created_by')