Реализован сервис управления документами списания
- Создан 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:
374
myproject/inventory/services/writeoff_document_service.py
Normal file
374
myproject/inventory/services/writeoff_document_service.py
Normal 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')
|
||||||
@@ -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():
|
||||||
"""
|
"""
|
||||||
Генерирует уникальный номер документа перемещения.
|
Генерирует уникальный номер документа перемещения.
|
||||||
|
|||||||
Reference in New Issue
Block a user