""" Сервис для работы с документами списания (WriteOffDocument). Обеспечивает: - Создание документов с автонумерацией - Добавление позиций с автоматическим резервированием - Проведение документов (создание WriteOff записей) - Отмену документов (освобождение резервов) """ from decimal import Decimal from django.db import transaction from django.utils import timezone from django.core.exceptions import ValidationError from inventory.models import ( WriteOffDocument, WriteOffDocumentItem, WriteOff, Reservation, Stock, StockBatch ) from inventory.utils.document_generator import generate_writeoff_document_number from inventory.services.batch_manager import StockBatchManager class WriteOffDocumentService: """ Сервис для работы с документами списания. """ @classmethod @transaction.atomic def create_document(cls, warehouse, date, notes=None, created_by=None): """ Создать новый документ списания (черновик). Args: warehouse: объект Warehouse date: дата документа (date) notes: примечания (str, опционально) created_by: пользователь (User, опционально) Returns: WriteOffDocument """ document = WriteOffDocument.objects.create( document_number=generate_writeoff_document_number(), warehouse=warehouse, status='draft', date=date, notes=notes, created_by=created_by ) return document @classmethod @transaction.atomic def add_item(cls, document, product, quantity, reason='damage', notes=None): """ Добавить позицию в документ списания. Автоматически создает резерв. Args: document: WriteOffDocument product: Product quantity: Decimal - количество для списания reason: str - причина (damage, spoilage, shortage, inventory, other) notes: str - примечания Returns: WriteOffDocumentItem Raises: ValidationError: если документ не черновик или недостаточно товара """ if document.status != 'draft': raise ValidationError( "Нельзя добавлять позиции в проведённый или отменённый документ" ) quantity = Decimal(str(quantity)) if quantity <= 0: raise ValidationError("Количество должно быть больше нуля") # Проверяем доступное количество stock = Stock.objects.filter( product=product, warehouse=document.warehouse ).first() if not stock: raise ValidationError( f"Товар '{product.name}' отсутствует на складе '{document.warehouse.name}'" ) # quantity_free = quantity_available - quantity_reserved available = stock.quantity_available - stock.quantity_reserved if quantity > available: raise ValidationError( f"Недостаточно свободного товара '{product.name}'. " f"Доступно: {available}, запрашивается: {quantity}" ) # Создаем позицию документа item = WriteOffDocumentItem.objects.create( document=document, product=product, quantity=quantity, reason=reason, notes=notes ) # Создаем резерв reservation = Reservation.objects.create( product=product, warehouse=document.warehouse, quantity=quantity, status='reserved', writeoff_document_item=item ) # Связываем резерв с позицией item.reservation = reservation item.save(update_fields=['reservation']) return item @classmethod @transaction.atomic def update_item(cls, item, quantity=None, reason=None, notes=None): """ Обновить позицию документа. Обновляет резерв при изменении количества. Args: item: WriteOffDocumentItem quantity: новое количество (опционально) reason: новая причина (опционально) notes: новые примечания (опционально) Returns: WriteOffDocumentItem Raises: ValidationError: если документ не черновик или недостаточно товара """ if item.document.status != 'draft': raise ValidationError( "Нельзя редактировать позиции проведённого или отменённого документа" ) if quantity is not None: quantity = Decimal(str(quantity)) if quantity <= 0: raise ValidationError("Количество должно быть больше нуля") if quantity != item.quantity: # Проверяем доступное количество stock = Stock.objects.filter( product=item.product, warehouse=item.document.warehouse ).first() if stock: # Учитываем текущий резерв этой позиции current_reserved = item.reservation.quantity if item.reservation else Decimal('0') available = (stock.quantity_available - stock.quantity_reserved) + current_reserved if quantity > available: raise ValidationError( f"Недостаточно свободного товара. " f"Доступно: {available}, запрашивается: {quantity}" ) # Обновляем резерв if item.reservation: item.reservation.quantity = quantity item.reservation.save(update_fields=['quantity']) item.quantity = quantity if reason is not None: item.reason = reason if notes is not None: item.notes = notes item.save() return item @classmethod @transaction.atomic def remove_item(cls, item): """ Удалить позицию из документа. Освобождает резерв. Args: item: WriteOffDocumentItem Raises: ValidationError: если документ не черновик """ if item.document.status != 'draft': raise ValidationError( "Нельзя удалять позиции из проведённого или отменённого документа" ) # Освобождаем резерв if item.reservation: item.reservation.status = 'released' item.reservation.released_at = timezone.now() item.reservation.save(update_fields=['status', 'released_at']) # Удаляем позицию item.delete() @classmethod @transaction.atomic def confirm_document(cls, document, confirmed_by=None): """ Провести документ списания. Процесс: 1. Проверяем что документ - черновик и имеет позиции 2. Для каждой позиции создаем WriteOff записи (FIFO) 3. Обновляем статусы резервов 4. Меняем статус документа на 'confirmed' Args: document: WriteOffDocument 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("Нельзя провести пустой документ") writeoffs_created = [] total_cost = Decimal('0') for item in document.items.select_related('product', 'reservation'): # Получаем партии по FIFO для списания batches = StockBatchManager.get_batches_for_fifo( item.product, document.warehouse ) remaining = item.quantity for batch in batches: if remaining <= 0: break # Сколько можем списать из этой партии qty_to_writeoff = min(batch.quantity, remaining) # Создаем запись WriteOff (она сама уменьшит batch.quantity в save()) writeoff = WriteOff.objects.create( batch=batch, quantity=qty_to_writeoff, reason=item.reason, document_number=document.document_number, notes=f"[Документ {document.document_number}] {item.notes or ''}" ) writeoffs_created.append(writeoff) total_cost += qty_to_writeoff * batch.cost_price remaining -= qty_to_writeoff if remaining > 0: raise ValidationError( f"Недостаточно товара '{item.product.name}' для списания. " f"Не хватает: {remaining}" ) # Обновляем резерв - помечаем как преобразованный в списание if item.reservation: item.reservation.status = 'converted_to_writeoff' item.reservation.converted_at = timezone.now() item.reservation.save(update_fields=['status', 'converted_at']) # Обновляем статус документа 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, 'writeoffs_created': len(writeoffs_created), 'total_quantity': document.total_quantity, 'total_cost': total_cost } @classmethod @transaction.atomic def cancel_document(cls, document): """ Отменить документ списания (черновик). Освобождает все резервы. Args: document: WriteOffDocument Returns: WriteOffDocument Raises: ValidationError: если документ уже проведён """ if document.status == 'confirmed': raise ValidationError( "Нельзя отменить проведённый документ. " "Создайте новый документ для корректировки." ) if document.status == 'cancelled': raise ValidationError("Документ уже отменён") # Освобождаем все резервы for item in document.items.select_related('reservation'): if item.reservation and item.reservation.status == 'reserved': item.reservation.status = 'released' item.reservation.released_at = timezone.now() item.reservation.save(update_fields=['status', 'released_at']) # Обновляем статус документа document.status = 'cancelled' document.save(update_fields=['status', 'updated_at']) return document @staticmethod def get_draft_documents(warehouse=None): """ Получить все черновики документов списания. Args: warehouse: фильтр по складу (опционально) Returns: QuerySet[WriteOffDocument] """ qs = WriteOffDocument.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[WriteOffDocument] """ today = timezone.now().date() return WriteOffDocument.objects.filter( warehouse=warehouse, status='draft', date=today ).select_related('warehouse', 'created_by')