Добавлена система трансформации товаров
Реализована полная система трансформации товаров (превращение одного товара в другой). Пример: белая гипсофила → крашеная гипсофила. Особенности реализации: - Резервирование входных товаров в статусе 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:
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user