From 4c74ae5c733f1d13bed016f9d7220092608e8ad3 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 10 Dec 2025 23:34:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан WriteOffDocumentService с методами работы с документами списания - create_document() - создание документа с автогенерацией номера (WO-XXXXXX) - add_item() - добавление позиции с автоматическим созданием резерва - update_item() - обновление позиции с пересчетом резерва - remove_item() - удаление позиции с освобождением резерва - confirm_document() - проведение документа (создание WriteOff записей по FIFO) - cancel_document() - отмена с освобождением всех резервов - Добавлена валидация доступного количества товара при создании/обновлении позиций - Добавлена функция generate_writeoff_document_number() для генерации номеров документов --- .../services/writeoff_document_service.py | 374 ++++++++++++++++++ .../inventory/utils/document_generator.py | 14 + 2 files changed, 388 insertions(+) create mode 100644 myproject/inventory/services/writeoff_document_service.py diff --git a/myproject/inventory/services/writeoff_document_service.py b/myproject/inventory/services/writeoff_document_service.py new file mode 100644 index 0000000..35555f1 --- /dev/null +++ b/myproject/inventory/services/writeoff_document_service.py @@ -0,0 +1,374 @@ +""" +Сервис для работы с документами списания (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_sale' + 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') diff --git a/myproject/inventory/utils/document_generator.py b/myproject/inventory/utils/document_generator.py index cb8d333..261e7f7 100644 --- a/myproject/inventory/utils/document_generator.py +++ b/myproject/inventory/utils/document_generator.py @@ -5,6 +5,20 @@ from inventory.models import DocumentCounter +def generate_writeoff_document_number(): + """ + Генерирует уникальный номер документа списания. + + Формат: WO-XXXXXX (6 цифр) + Thread-safe через DocumentCounter. + + Returns: + str: Сгенерированный номер документа (например, WO-000001) + """ + next_number = DocumentCounter.get_next_value('writeoff') + return f"WO-{next_number:06d}" + + def generate_transfer_document_number(): """ Генерирует уникальный номер документа перемещения.