КРИТИЧНО: Все агрегации Reservation.quantity заменены на quantity_base Проблемы и решения: 🔴 КРИТИЧНО - BatchManager.write_off_by_fifo(): - Проблема: суммировал quantity вместо quantity_base - Влияние: FIFO расчет свободного товара был некорректен - Решение: aggregate(Sum('quantity_base')) в строках 118, 125 🟡 СРЕДНЯЯ ВАЖНОСТЬ - ShowcaseManager: - reserve_showcase_item(): обновление quantity и quantity_base (строка 403) - release_showcase_reservation(): обновление обоих полей (строка 481) - Теперь витринные резервы полностью консистентны 🟡 СРЕДНЯЯ ВАЖНОСТЬ - TransformationService: - confirm(): проверка доступности через quantity_base (строка 254) - Корректная валидация при трансформации товаров 🟢 НИЗКАЯ ВАЖНОСТЬ - WriteOffDocumentService: - update_item(): синхронизация quantity и quantity_base (строка 175) - Полнота данных в резервах документов списания 🟢 НИЗКАЯ ВАЖНОСТЬ - Сигналы (signals.py): - update_order_item_reservation(): обновление обоих полей для товаров - Для обычных товаров: quantity_base = quantity_in_base_units (строка 1081) - Для комплектов: quantity_base = quantity (компоненты в базовых) (строка 1107) - Добавлено обновление sales_unit при изменении OrderItem Архитектура: - Принцип: quantity_base ВСЕГДА содержит количество в базовых единицах - Все агрегации резервов используют quantity_base для корректных расчетов - quantity сохраняется для совместимости и отображения - sales_unit хранит ссылку на единицу продажи для аудита
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""
|
||
Сервис для работы с трансформациями товаров (Transformation).
|
||
|
||
Обеспечивает:
|
||
- Создание документов трансформации с автонумерацией
|
||
- Добавление входных/выходных товаров с автоматическим резервированием
|
||
- Проведение трансформации (FIFO списание + оприходование)
|
||
- Отмену трансформации (откат операций)
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
from django.core.exceptions import ValidationError
|
||
|
||
from inventory.models import (
|
||
Transformation, TransformationInput, TransformationOutput,
|
||
Reservation, Stock, StockBatch, DocumentCounter
|
||
)
|
||
from inventory.services.batch_manager import StockBatchManager
|
||
|
||
|
||
class TransformationService:
|
||
"""
|
||
Сервис для работы с трансформациями товаров.
|
||
"""
|
||
|
||
@classmethod
|
||
def generate_document_number(cls):
|
||
"""Генерация номера документа трансформации"""
|
||
next_num = DocumentCounter.get_next_value('transformation')
|
||
return f"TR-{next_num:05d}"
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def create_transformation(cls, warehouse, comment=None, employee=None):
|
||
"""
|
||
Создать новый документ трансформации (черновик).
|
||
|
||
Args:
|
||
warehouse: объект Warehouse
|
||
comment: комментарий (str, опционально)
|
||
employee: сотрудник (User, опционально)
|
||
|
||
Returns:
|
||
Transformation
|
||
"""
|
||
transformation = Transformation.objects.create(
|
||
document_number=cls.generate_document_number(),
|
||
warehouse=warehouse,
|
||
status='draft',
|
||
comment=comment or '',
|
||
employee=employee
|
||
)
|
||
return transformation
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def add_input(cls, transformation, product, quantity):
|
||
"""
|
||
Добавить входной товар в трансформацию.
|
||
Автоматически создает резерв (через сигнал).
|
||
|
||
Args:
|
||
transformation: Transformation
|
||
product: Product
|
||
quantity: Decimal - количество для списания
|
||
|
||
Returns:
|
||
TransformationInput
|
||
|
||
Raises:
|
||
ValidationError: если трансформация не черновик или недостаточно товара
|
||
"""
|
||
if transformation.status != 'draft':
|
||
raise ValidationError(
|
||
"Нельзя добавлять позиции в проведённую или отменённую трансформацию"
|
||
)
|
||
|
||
quantity = Decimal(str(quantity))
|
||
if quantity <= 0:
|
||
raise ValidationError("Количество должно быть больше нуля")
|
||
|
||
# Проверяем что товар еще не добавлен
|
||
if transformation.inputs.filter(product=product).exists():
|
||
raise ValidationError(
|
||
f"Товар '{product.name}' уже добавлен в качестве входного"
|
||
)
|
||
|
||
# Проверяем доступное количество
|
||
stock = Stock.objects.filter(
|
||
product=product,
|
||
warehouse=transformation.warehouse
|
||
).first()
|
||
|
||
if not stock:
|
||
raise ValidationError(
|
||
f"Товар '{product.name}' отсутствует на складе '{transformation.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}"
|
||
)
|
||
|
||
# Создаем входной товар (резерв создастся автоматически через сигнал)
|
||
trans_input = TransformationInput.objects.create(
|
||
transformation=transformation,
|
||
product=product,
|
||
quantity=quantity
|
||
)
|
||
|
||
return trans_input
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def add_output(cls, transformation, product, quantity):
|
||
"""
|
||
Добавить выходной товар в трансформацию.
|
||
|
||
Args:
|
||
transformation: Transformation
|
||
product: Product
|
||
quantity: Decimal - количество получаемого товара
|
||
|
||
Returns:
|
||
TransformationOutput
|
||
|
||
Raises:
|
||
ValidationError: если трансформация не черновик
|
||
"""
|
||
if transformation.status != 'draft':
|
||
raise ValidationError(
|
||
"Нельзя добавлять позиции в проведённую или отменённую трансформацию"
|
||
)
|
||
|
||
quantity = Decimal(str(quantity))
|
||
if quantity <= 0:
|
||
raise ValidationError("Количество должно быть больше нуля")
|
||
|
||
# Проверяем что товар еще не добавлен
|
||
if transformation.outputs.filter(product=product).exists():
|
||
raise ValidationError(
|
||
f"Товар '{product.name}' уже добавлен в качестве выходного"
|
||
)
|
||
|
||
# Создаем выходной товар
|
||
trans_output = TransformationOutput.objects.create(
|
||
transformation=transformation,
|
||
product=product,
|
||
quantity=quantity
|
||
)
|
||
|
||
return trans_output
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def remove_input(cls, trans_input):
|
||
"""
|
||
Удалить входной товар из трансформации.
|
||
Автоматически освобождает резерв (через сигнал).
|
||
|
||
Args:
|
||
trans_input: TransformationInput
|
||
|
||
Raises:
|
||
ValidationError: если трансформация не черновик
|
||
"""
|
||
if trans_input.transformation.status != 'draft':
|
||
raise ValidationError(
|
||
"Нельзя удалять позиции из проведённой или отменённой трансформации"
|
||
)
|
||
|
||
trans_input.delete()
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def remove_output(cls, trans_output):
|
||
"""
|
||
Удалить выходной товар из трансформации.
|
||
|
||
Args:
|
||
trans_output: TransformationOutput
|
||
|
||
Raises:
|
||
ValidationError: если трансформация не черновик
|
||
"""
|
||
if trans_output.transformation.status != 'draft':
|
||
raise ValidationError(
|
||
"Нельзя удалять позиции из проведённой или отменённой трансформации"
|
||
)
|
||
|
||
trans_output.delete()
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def confirm(cls, transformation):
|
||
"""
|
||
Провести трансформацию (completed).
|
||
FIFO списание входных товаров и оприходование выходных.
|
||
Выполняется через сигнал process_transformation_on_complete.
|
||
|
||
Args:
|
||
transformation: Transformation
|
||
|
||
Raises:
|
||
ValidationError: если документ не готов к проведению
|
||
"""
|
||
if transformation.status != 'draft':
|
||
raise ValidationError("Документ уже проведён или отменён")
|
||
|
||
if not transformation.inputs.exists():
|
||
raise ValidationError("Добавьте хотя бы один входной товар")
|
||
|
||
if not transformation.outputs.exists():
|
||
raise ValidationError("Добавьте хотя бы один выходной товар")
|
||
|
||
# Проверяем что сумма входных количеств равна сумме выходных
|
||
total_input_quantity = sum(
|
||
trans_input.quantity for trans_input in transformation.inputs.all()
|
||
)
|
||
total_output_quantity = sum(
|
||
trans_output.quantity for trans_output in transformation.outputs.all()
|
||
)
|
||
|
||
if total_input_quantity != total_output_quantity:
|
||
raise ValidationError(
|
||
f"Сумма входных количеств ({total_input_quantity}) должна быть равна "
|
||
f"сумме выходных количеств ({total_output_quantity})"
|
||
)
|
||
|
||
# Проверяем наличие всех входных товаров
|
||
for trans_input in transformation.inputs.all():
|
||
stock = Stock.objects.filter(
|
||
product=trans_input.product,
|
||
warehouse=transformation.warehouse
|
||
).first()
|
||
|
||
if not stock:
|
||
raise ValidationError(
|
||
f"Товар '{trans_input.product.name}' отсутствует на складе"
|
||
)
|
||
|
||
# Учитываем резервы (включая резерв этой трансформации)
|
||
reserved_qty = Reservation.objects.filter(
|
||
product=trans_input.product,
|
||
warehouse=transformation.warehouse,
|
||
status='reserved'
|
||
).exclude(
|
||
transformation_input__transformation=transformation
|
||
).aggregate(total=models.Sum('quantity_base'))['total'] or Decimal('0')
|
||
|
||
available = stock.quantity_available - reserved_qty
|
||
if trans_input.quantity > available:
|
||
raise ValidationError(
|
||
f"Недостаточно свободного товара '{trans_input.product.name}'. "
|
||
f"Доступно: {available}, требуется: {trans_input.quantity}"
|
||
)
|
||
|
||
# Меняем статус (списание и оприходование происходит через сигнал)
|
||
transformation.status = 'completed'
|
||
transformation.save(update_fields=['status', 'updated_at'])
|
||
|
||
return transformation
|
||
|
||
@classmethod
|
||
@transaction.atomic
|
||
def cancel(cls, transformation):
|
||
"""
|
||
Отменить трансформацию.
|
||
Откатывает операции если была проведена (через сигнал).
|
||
|
||
Args:
|
||
transformation: Transformation
|
||
|
||
Raises:
|
||
ValidationError: если уже отменена
|
||
"""
|
||
if transformation.status == 'cancelled':
|
||
raise ValidationError("Трансформация уже отменена")
|
||
|
||
# Меняем статус (откат через сигнал если было completed)
|
||
transformation.status = 'cancelled'
|
||
transformation.save(update_fields=['status', 'updated_at'])
|
||
|
||
return transformation
|
||
|
||
|
||
# Импорт models для использования в методе confirm
|
||
from django.db import models
|