Files
octopus/myproject/inventory/services/transformation_service.py
Andrey Smakotin d2b49cca56 Исправлено: агрегация резервов теперь использует quantity_base
КРИТИЧНО: Все агрегации 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 хранит ссылку на единицу продажи для аудита
2026-01-02 14:46:02 +03:00

294 lines
11 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.
"""
Сервис для работы с трансформациями товаров (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