Реализован сервис управления документами списания

- Создан WriteOffDocumentService с методами работы с документами списания
- create_document() - создание документа с автогенерацией номера (WO-XXXXXX)
- add_item() - добавление позиции с автоматическим созданием резерва
- update_item() - обновление позиции с пересчетом резерва
- remove_item() - удаление позиции с освобождением резерва
- confirm_document() - проведение документа (создание WriteOff записей по FIFO)
- cancel_document() - отмена с освобождением всех резервов
- Добавлена валидация доступного количества товара при создании/обновлении позиций
- Добавлена функция generate_writeoff_document_number() для генерации номеров документов
This commit is contained in:
2025-12-10 23:34:56 +03:00
parent 56a04ae4be
commit 4c74ae5c73
2 changed files with 388 additions and 0 deletions

View File

@@ -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')

View File

@@ -5,6 +5,20 @@
from inventory.models import DocumentCounter 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(): def generate_transfer_document_number():
""" """
Генерирует уникальный номер документа перемещения. Генерирует уникальный номер документа перемещения.