Files
octopus/myproject/inventory/services/writeoff_document_service.py
Andrey Smakotin 8d7869e9e7 Добавлен статус 'converted_to_writeoff' для резервов документов списания
Проблема:
- Резервы документов списания помечались как 'converted_to_sale'
- Это вводило в заблуждение - списание это не продажа
- В админке резервы списания отображались как 'В продажу'

Решение:
- Добавлен новый статус 'converted_to_writeoff' в Reservation.STATUS_CHOICES
- Увеличен max_length поля status с 20 до 25 символов
- Обновлен WriteOffDocumentService.confirm_document() - теперь использует новый статус
- Обновлено описание поля converted_at (теперь для продажи ИЛИ списания)
- Создана миграция 0011_add_writeoff_status_to_reservation

Изменения:
- inventory/models.py: добавлен статус, увеличен max_length, обновлен help_text
- inventory/services/writeoff_document_service.py: используется converted_to_writeoff
- inventory/migrations/0011_*.py: миграция для изменений модели

Влияние:
- Чистая аналитика: можно отличить продажи от списаний
- Корректный учёт Stock: статус влияет на quantity_reserved
- Защита от ошибок при будущих доработках (откат списания)
2025-12-11 21:52:09 +03:00

375 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сервис для работы с документами списания (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')