Добавлена система трансформации товаров

Реализована полная система трансформации товаров (превращение одного товара в другой).
Пример: белая гипсофила → крашеная гипсофила.

Особенности реализации:
- Резервирование входных товаров в статусе draft
- FIFO списание входных товаров при проведении
- Автоматический расчёт себестоимости выходных товаров
- Возможность отмены как черновиков, так и проведённых трансформаций

Модели (inventory/models.py):
- Transformation: документ трансформации (draft/completed/cancelled)
- TransformationInput: входные товары (списание)
- TransformationOutput: выходные товары (оприходование)
- Добавлен статус 'converted_to_transformation' в Reservation
- Добавлен тип 'transformation' в DocumentCounter

Бизнес-логика (inventory/services/transformation_service.py):
- TransformationService с методами CRUD
- Валидация наличия товаров
- Автоматическая генерация номеров документов

Сигналы (inventory/signals.py):
- Автоматическое резервирование входных товаров
- FIFO списание при проведении
- Создание партий выходных товаров
- Откат операций при отмене

Интерфейс без Django Admin:
- Список трансформаций (list.html)
- Форма создания (form.html)
- Детальный просмотр с добавлением товаров (detail.html)
- Интеграция с компонентом поиска товаров
- 8 views для полного CRUD + проведение/отмена

Миграция:
- 0003_alter_documentcounter_counter_type_and_more.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-25 18:27:31 +03:00
parent 56850e790e
commit 30ee077963
12 changed files with 1682 additions and 4 deletions

View File

@@ -13,7 +13,7 @@ from decimal import Decimal
from django.core.exceptions import ValidationError
from orders.models import Order, OrderItem
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem, Transformation, TransformationInput, TransformationOutput
from inventory.services import SaleProcessor
from inventory.services.batch_manager import StockBatchManager
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
@@ -1524,3 +1524,169 @@ def release_reservation_on_writeoff_item_delete(sender, instance, **kwargs):
instance.reservation.status = 'released'
instance.reservation.released_at = timezone.now()
instance.reservation.save(update_fields=['status', 'released_at'])
# ==================== TRANSFORMATION SIGNALS ====================
@receiver(post_save, sender=TransformationInput)
def reserve_on_transformation_input_create(sender, instance, created, **kwargs):
"""
При создании входного товара в черновике - резервируем его.
"""
# Резервируем только если трансформация в draft
if instance.transformation.status != 'draft':
return
# Создаем или обновляем резерв
Reservation.objects.update_or_create(
transformation_input=instance,
product=instance.product,
warehouse=instance.transformation.warehouse,
defaults={
'quantity': instance.quantity,
'status': 'reserved'
}
)
@receiver(pre_delete, sender=TransformationInput)
def release_reservation_on_input_delete(sender, instance, **kwargs):
"""
При удалении входного товара - освобождаем резерв.
"""
Reservation.objects.filter(
transformation_input=instance
).update(status='released', released_at=timezone.now())
@receiver(post_save, sender=Transformation)
@transaction.atomic
def process_transformation_on_complete(sender, instance, created, **kwargs):
"""
При переходе в статус 'completed':
1. FIFO списываем Input
2. Создаем партии Output с рассчитанной себестоимостью
3. Обновляем резервы в 'converted_to_transformation'
"""
if instance.status != 'completed':
return
# Проверяем что уже не обработано
if instance.outputs.filter(stock_batch__isnull=False).exists():
return # Уже проведено
# 1. Списываем Input по FIFO
total_input_cost = Decimal('0')
for trans_input in instance.inputs.all():
allocations = StockBatchManager.write_off_by_fifo(
product=trans_input.product,
warehouse=instance.warehouse,
quantity_to_write_off=trans_input.quantity
)
# Суммируем себестоимость списанного
for batch, qty in allocations:
total_input_cost += batch.cost_price * qty
# Обновляем резерв
Reservation.objects.filter(
transformation_input=trans_input,
status='reserved'
).update(
status='converted_to_transformation',
converted_at=timezone.now()
)
# 2. Создаем партии Output
for trans_output in instance.outputs.all():
# Рассчитываем себестоимость: сумма Input / количество Output
if trans_output.quantity > 0:
output_cost_price = total_input_cost / trans_output.quantity
else:
output_cost_price = Decimal('0')
# Создаем партию
batch = StockBatchManager.create_batch(
product=trans_output.product,
warehouse=instance.warehouse,
quantity=trans_output.quantity,
cost_price=output_cost_price
)
# Сохраняем ссылку на партию
trans_output.stock_batch = batch
trans_output.save(update_fields=['stock_batch'])
@receiver(post_save, sender=Transformation)
@transaction.atomic
def rollback_transformation_on_cancel(sender, instance, **kwargs):
"""
При отмене проведенной трансформации:
1. Удаляем партии Output
2. Восстанавливаем партии Input (обратное FIFO списание)
3. Возвращаем резервы в 'reserved'
"""
if instance.status != 'cancelled':
return
# Проверяем что была проведена (есть партии Output)
if not instance.outputs.filter(stock_batch__isnull=False).exists():
# Это был черновик - обрабатывается другим сигналом
return
# 1. Удаляем партии Output
for trans_output in instance.outputs.all():
if trans_output.stock_batch:
# Восстанавливаем количество из партии в Stock (автоматически через сигналы)
# Просто удаляем партию - остатки пересчитаются
batch = trans_output.stock_batch
batch.delete()
trans_output.stock_batch = None
trans_output.save(update_fields=['stock_batch'])
# 2. Восстанавливаем Input партии
# УПРОЩЕНИЕ: создаем новые партии с той же себестоимостью что была
# (в идеале нужно хранить SaleBatchAllocation-подобную таблицу)
for trans_input in instance.inputs.all():
# Получаем среднюю себестоимость товара
cost = trans_input.product.cost_price or Decimal('0')
# Создаем восстановленную партию
StockBatchManager.create_batch(
product=trans_input.product,
warehouse=instance.warehouse,
quantity=trans_input.quantity,
cost_price=cost
)
# Возвращаем резерв в reserved
Reservation.objects.filter(
transformation_input=trans_input
).update(
status='reserved',
converted_at=None
)
@receiver(post_save, sender=Transformation)
def release_reservations_on_draft_cancel(sender, instance, **kwargs):
"""
При отмене черновика (draft → cancelled) - освобождаем резервы.
"""
if instance.status != 'cancelled':
return
# Проверяем что это был черновик (нет созданных партий)
if instance.outputs.filter(stock_batch__isnull=False).exists():
return # Это была проведенная трансформация, обрабатывается другим сигналом
# Освобождаем все резервы
Reservation.objects.filter(
transformation_input__transformation=instance,
status='reserved'
).update(
status='released',
released_at=timezone.now()
)