Исправлена проблема, когда при отмене проведенной трансформации оба сигнала выполнялись последовательно:
- rollback_transformation_on_cancel возвращал резервы в 'reserved'
- release_reservations_on_draft_cancel ошибочно освобождал их в 'released'
Изменена проверка в release_reservations_on_draft_cancel: вместо проверки наличия партий Output (которые уже удалены) теперь проверяется статус резервов ('converted_to_transformation') или наличие поля converted_at, что работает независимо от порядка выполнения сигналов.
1714 lines
79 KiB
Python
1714 lines
79 KiB
Python
"""
|
||
Сигналы для автоматического управления резервами и списаниями.
|
||
|
||
Подключаются при создании, изменении и удалении заказов.
|
||
"""
|
||
|
||
from django.db.models.signals import post_save, pre_delete, post_delete
|
||
from django.db.models import Q
|
||
from django.db import transaction
|
||
from django.dispatch import receiver
|
||
from django.utils import timezone
|
||
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, Transformation, TransformationInput, TransformationOutput
|
||
from inventory.services import SaleProcessor
|
||
from inventory.services.batch_manager import StockBatchManager
|
||
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
||
|
||
|
||
def update_is_returned_flag(order):
|
||
"""
|
||
Обновляет флаг is_returned на основе фактического состояния заказа.
|
||
|
||
Логика:
|
||
- Если есть хотя бы одна Sale по этому заказу → is_returned = False
|
||
- Если Sale нет, но заказ когда-либо был в статусе completed → is_returned = True
|
||
- Если заказ ни разу не был completed → is_returned = False
|
||
|
||
Это гарантирует что флаг отражает реальность:
|
||
- Заказ продан и не возвращён → False
|
||
- Заказ был продан, но продажи откачены (возврат) → True
|
||
- Новый заказ без продаж → False
|
||
"""
|
||
has_sale_now = Sale.objects.filter(order=order).exists()
|
||
|
||
if has_sale_now:
|
||
# Есть актуальные продажи → заказ не возвращён
|
||
new_flag = False
|
||
else:
|
||
# Проверяем историю только если нет Sale (оптимизация производительности)
|
||
was_completed_ever = order.history.filter(
|
||
status__is_positive_end=True
|
||
).exists()
|
||
# Продаж нет → возвращён только если был когда-то completed
|
||
new_flag = was_completed_ever
|
||
|
||
# Обновляем только если значение изменилось
|
||
if order.is_returned != new_flag:
|
||
Order.objects.filter(pk=order.pk).update(is_returned=new_flag)
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании нового заказа зарезервировать товар.
|
||
|
||
Процесс:
|
||
1. Проверяем, новый ли заказ (создан только что)
|
||
2. Для обычных товаров - создаём резерв напрямую
|
||
3. Для комплектов - резервируем компоненты (группируя одинаковые товары)
|
||
4. Статус резерва = 'reserved'
|
||
5. Проверяем на существующие резервы (защита от дубликатов)
|
||
"""
|
||
from collections import defaultdict
|
||
|
||
if not created:
|
||
return # Только для новых заказов
|
||
|
||
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
||
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||
|
||
if not warehouse:
|
||
# Если нет активных складов, зарезервировать не можем
|
||
# Можно логировать ошибку или выбросить исключение
|
||
return
|
||
|
||
# Для каждого товара в заказе
|
||
for item in instance.items.all():
|
||
if item.product:
|
||
# Обычный товар - резервируем как раньше
|
||
_create_or_update_reservation(item, item.product, warehouse, Decimal(str(item.quantity)))
|
||
|
||
elif item.product_kit and item.kit_snapshot:
|
||
# Комплект - резервируем КОМПОНЕНТЫ из снимка
|
||
# Группируем одинаковые товары для создания одного резерва
|
||
product_quantities = defaultdict(Decimal)
|
||
|
||
for kit_item in item.kit_snapshot.items.select_related('original_product'):
|
||
if kit_item.original_product:
|
||
# Суммируем количество: qty компонента * qty комплектов в заказе
|
||
product_quantities[kit_item.original_product_id] += (
|
||
kit_item.quantity * Decimal(str(item.quantity))
|
||
)
|
||
|
||
# Создаём по одному резерву на каждый уникальный товар
|
||
from products.models import Product
|
||
for product_id, total_qty in product_quantities.items():
|
||
product = Product.objects.get(pk=product_id)
|
||
_create_or_update_reservation(
|
||
item, product, warehouse, total_qty, product_kit=item.product_kit
|
||
)
|
||
|
||
|
||
def _create_or_update_reservation(order_item, product, warehouse, quantity, product_kit=None):
|
||
"""
|
||
Вспомогательная функция для создания или обновления резерва.
|
||
"""
|
||
# Формируем фильтр для поиска существующего резерва
|
||
filter_kwargs = {
|
||
'order_item': order_item,
|
||
'product': product,
|
||
'warehouse': warehouse,
|
||
}
|
||
if product_kit:
|
||
filter_kwargs['product_kit'] = product_kit
|
||
|
||
existing_reservation = Reservation.objects.filter(**filter_kwargs).first()
|
||
|
||
if existing_reservation:
|
||
# Резерв уже существует - обновляем его
|
||
existing_reservation.quantity = quantity
|
||
existing_reservation.status = 'reserved'
|
||
existing_reservation.save()
|
||
else:
|
||
# Резерва нет - создаём новый
|
||
Reservation.objects.create(
|
||
order_item=order_item,
|
||
product=product,
|
||
product_kit=product_kit,
|
||
warehouse=warehouse,
|
||
quantity=quantity,
|
||
status='reserved'
|
||
)
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
@transaction.atomic
|
||
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: Когда заказ переходит в статус 'completed' (доставлен),
|
||
создается операция Sale и резервы преобразуются в продажу.
|
||
|
||
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
|
||
|
||
ВАЛИДАЦИЯ:
|
||
- Запрещаем переход в положительный финальный статус для заказов с is_returned=True,
|
||
у которых нет резервов (товар уже продан в другом заказе).
|
||
|
||
Процесс:
|
||
1. Проверяем, изменился ли статус на 'completed'
|
||
2. ВАЛИДАЦИЯ: если is_returned=True и резервов нет → запрещаем
|
||
3. Для каждого товара создаем Sale (автоматический FIFO-список)
|
||
4. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
|
||
5. Обновляем флаг is_returned
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
if created:
|
||
return # Только для обновлений
|
||
|
||
# Проверяем наличие статуса (может быть None при создании)
|
||
if not instance.status:
|
||
return
|
||
|
||
# === ВАЛИДАЦИЯ: Запрет изменения статуса для возвращённых заказов без резервов ===
|
||
# Если заказ был возвращён (is_returned=True) и резервов нет, можно использовать
|
||
# только статусы отрицательного исхода (отменён и т.п.)
|
||
if instance.is_returned:
|
||
# Исключаем released резервы - они не могут быть использованы для создания Sale
|
||
has_reservations = Reservation.objects.filter(
|
||
order_item__order=instance
|
||
).exclude(status='released').exists()
|
||
|
||
if not has_reservations:
|
||
# Резервов нет — разрешены только отрицательные статусы
|
||
if not instance.status.is_negative_end:
|
||
logger.error(
|
||
f"❌ Заказ {instance.order_number}: is_returned=True, резервов нет. "
|
||
f"Попытка установить '{instance.status.name}' запрещена."
|
||
)
|
||
raise ValidationError(
|
||
f"Заказ {instance.order_number} был отменён, товары проданы в другом заказе. "
|
||
f"Невозможно изменить статус. Для новой продажи создайте новый заказ."
|
||
)
|
||
|
||
# Проверяем: это положительный финальный статус?
|
||
is_positive_end = instance.status.is_positive_end
|
||
|
||
if not is_positive_end:
|
||
return # Только для положительных финальных статусов (completed и т.п.)
|
||
|
||
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
|
||
if Sale.objects.filter(order=instance).exists():
|
||
# Продажи уже созданы — просто обновляем флаг is_returned и выходим
|
||
update_is_returned_flag(instance)
|
||
return
|
||
|
||
# Проверяем наличие резервов для этого заказа
|
||
# Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката)
|
||
# Исключаем уже обработанные 'converted_to_sale'
|
||
reservations_to_update = Reservation.objects.filter(
|
||
order_item__order=instance
|
||
).exclude(status='converted_to_sale')
|
||
|
||
if not reservations_to_update.exists():
|
||
# Проверяем, есть ли вообще резервы (включая released)
|
||
has_any_reservations = Reservation.objects.filter(
|
||
order_item__order=instance
|
||
).exists()
|
||
|
||
if has_any_reservations:
|
||
logger.warning(
|
||
f"⚠ Заказ {instance.order_number}: все резервы в статусе 'converted_to_sale' "
|
||
f"или 'released'. Sale уже созданы или резервы освобождены."
|
||
)
|
||
else:
|
||
logger.error(
|
||
f"❌ Заказ {instance.order_number}: нет резервов для создания Sale. "
|
||
f"Заказ в статусе '{instance.status.name}' без возможности списания."
|
||
)
|
||
|
||
# Обновляем флаг is_returned и выходим
|
||
update_is_returned_flag(instance)
|
||
return
|
||
|
||
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
||
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||
|
||
if not warehouse:
|
||
logger.error(f"❌ Не найден склад для заказа {instance.order_number}. Списание невозможно.")
|
||
return
|
||
|
||
# Для каждого товара в заказе создаем Sale
|
||
sales_created = []
|
||
sale_creation_failed = False
|
||
|
||
for item in instance.items.all():
|
||
# Для комплектов (витринных и обычных) создаем Sale для каждого компонента
|
||
if item.product_kit:
|
||
kit = item.product_kit
|
||
|
||
# Находим все резервы компонентов этого комплекта для этого заказа
|
||
kit_reservations = Reservation.objects.filter(
|
||
order_item=item,
|
||
product_kit=kit
|
||
).exclude(status='converted_to_sale')
|
||
|
||
if not kit_reservations.exists():
|
||
logger.warning(
|
||
f"⚠ Комплект '{kit.name}': не найдено резервов компонентов"
|
||
)
|
||
continue
|
||
|
||
# Создаем Sale для каждого компонента комплекта
|
||
for reservation in kit_reservations:
|
||
try:
|
||
# Рассчитываем цену продажи компонента пропорционально цене комплекта
|
||
# Используем actual_price компонента как цену продажи
|
||
component_sale_price = reservation.product.actual_price
|
||
|
||
sale = SaleProcessor.create_sale(
|
||
product=reservation.product,
|
||
warehouse=warehouse,
|
||
quantity=reservation.quantity,
|
||
sale_price=component_sale_price,
|
||
order=instance,
|
||
document_number=instance.order_number
|
||
)
|
||
sales_created.append(sale)
|
||
logger.info(
|
||
f"✓ Sale создан для компонента комплекта '{kit.name}': "
|
||
f"{reservation.product.name} - {reservation.quantity} шт."
|
||
)
|
||
except ValueError as e:
|
||
logger.error(
|
||
f"❌ ОШИБКА при создании Sale для компонента комплекта '{kit.name}': {e}"
|
||
)
|
||
sale_creation_failed = True
|
||
break
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ КРИТИЧЕСКАЯ ОШИБКА при создании Sale для комплекта: {e}"
|
||
)
|
||
sale_creation_failed = True
|
||
break
|
||
|
||
if sale_creation_failed:
|
||
break
|
||
|
||
continue
|
||
|
||
# Обычный товар
|
||
product = item.product
|
||
if not product:
|
||
continue
|
||
|
||
try:
|
||
# Создаем Sale (с автоматическим FIFO-списанием)
|
||
sale = SaleProcessor.create_sale(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
quantity=Decimal(str(item.quantity)),
|
||
sale_price=Decimal(str(item.price)),
|
||
order=instance,
|
||
document_number=instance.order_number
|
||
)
|
||
sales_created.append(sale)
|
||
logger.info(f"✓ Sale создан для {product.name}: {item.quantity} шт.")
|
||
|
||
except ValueError as e:
|
||
# Логируем ошибку и прерываем процесс
|
||
logger.error(
|
||
f"❌ ОШИБКА при создании Sale для заказа {instance.order_number}, товар {product.name}: {e}"
|
||
)
|
||
sale_creation_failed = True
|
||
break
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ КРИТИЧЕСКАЯ ОШИБКА при создании Sale для заказа {instance.order_number}: {e}"
|
||
)
|
||
sale_creation_failed = True
|
||
break
|
||
|
||
# КРИТИЧНО: Обновляем резервы ТОЛЬКО если ВСЕ Sale созданы успешно
|
||
if sale_creation_failed:
|
||
logger.error(
|
||
f"❌ Не удалось создать Sale для заказа {instance.order_number}. "
|
||
f"Резервы НЕ будут обновлены. Транзакция откатится."
|
||
)
|
||
# Поднимаем исключение чтобы откатить всю транзакцию
|
||
raise ValueError(f"Не удалось создать Sale для заказа {instance.order_number}")
|
||
|
||
# Все Sale созданы успешно - теперь обновляем резервы
|
||
if reservations_to_update.exists():
|
||
# Обновляем резервы через .save() чтобы сработал сигнал обновления Stock
|
||
# Сигнал update_stock_on_reservation_change автоматически обновит Stock
|
||
for reservation in reservations_to_update:
|
||
reservation.status = 'converted_to_sale'
|
||
reservation.converted_at = timezone.now()
|
||
reservation.save(update_fields=['status', 'converted_at'])
|
||
|
||
updated_count = reservations_to_update.count()
|
||
logger.info(
|
||
f"✓ Обновлено {updated_count} резервов для заказа {instance.order_number}: reserved → converted_to_sale"
|
||
)
|
||
|
||
logger.info(
|
||
f"🎉 Заказ {instance.order_number} успешно обработан: создано {len(sales_created)} Sale, "
|
||
f"обновлено {reservations_to_update.count() if reservations_to_update.exists() else 0} резервов"
|
||
)
|
||
|
||
# Обновляем флаг is_returned на основе фактического состояния
|
||
update_is_returned_flag(instance)
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
@transaction.atomic
|
||
def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: Откатить Sale при уходе от статуса 'completed'.
|
||
|
||
Триггер: completed → любой другой статус (draft, cancelled и т.д.)
|
||
|
||
Процесс:
|
||
1. Отслеживаем переход ОТ статуса 'completed'
|
||
2. Удаляем Sale и восстанавливаем StockBatch через SaleBatchAllocation
|
||
3. Обновляем резервы:
|
||
- Обычные резервы: reserved или released в зависимости от сценария
|
||
- Витринные временные комплекты: ВСЕГДА reserved (остаются на витрине)
|
||
4. Обновляем Stock
|
||
|
||
Сценарии:
|
||
- А (ошибка/возврат): completed → draft/in_delivery → обычные резервы в 'reserved'
|
||
- Б (отмена): completed → cancelled → обычные резервы в 'released'
|
||
- В (витринные комплекты): любой уход от completed → резервы в 'reserved' (букет остаётся на витрине)
|
||
|
||
ПРИМЕЧАНИЕ: Этот сигнал ОБРАБАТЫВАЕТ ТОЛЬКО переход ОТ 'completed'!
|
||
Для перехода к 'cancelled' из любого статуса см. release_reservations_on_cancellation
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Пропускаем новые заказы
|
||
if created:
|
||
return
|
||
|
||
# Проверяем наличие статуса
|
||
if not instance.status:
|
||
return
|
||
|
||
current_status = instance.status
|
||
|
||
# === Получаем предыдущий статус через django-simple-history ===
|
||
try:
|
||
# Получаем предыдущую запись из истории (индекс [1] = предпоследняя)
|
||
history_count = instance.history.count()
|
||
if history_count < 2:
|
||
return # Нет истории для сравнения
|
||
|
||
previous_record = instance.history.all()[1]
|
||
|
||
if not previous_record.status_id:
|
||
return
|
||
|
||
# Импортируем OrderStatus если еще не импортирован
|
||
from orders.models import OrderStatus
|
||
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
|
||
|
||
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
|
||
# Нет истории или статус удалён
|
||
return
|
||
|
||
# === Проверяем: был ли переход ОТ 'completed'? ===
|
||
if previous_status.code != 'completed':
|
||
return # Не было перехода от 'completed', выходим
|
||
|
||
# Проверяем: не остались ли мы в 'completed'?
|
||
if current_status.code == 'completed':
|
||
return # Всё ещё 'completed', откат не нужен
|
||
|
||
# === Если дошли сюда: был переход completed → другой_статус ===
|
||
logger.info(
|
||
f"🔄 Обнаружен уход от статуса 'completed' для заказа {instance.order_number}: "
|
||
f"{previous_status.name} → {current_status.name}"
|
||
)
|
||
|
||
# === Определяем сценарий отката ===
|
||
is_cancellation = current_status.is_negative_end # True для 'cancelled'
|
||
|
||
if is_cancellation:
|
||
# Сценарий Б: отмена заказа
|
||
reservation_target_status = 'released'
|
||
logger.info(f" Сценарий Б: Отмена заказа (статус: {current_status.name})")
|
||
else:
|
||
# Сценарий А: возврат к промежуточному статусу
|
||
reservation_target_status = 'reserved'
|
||
logger.info(f" Сценарий А: Возврат к промежуточному статусу (статус: {current_status.name})")
|
||
|
||
# === Откатываем Sale ===
|
||
sales = Sale.objects.filter(order=instance).select_related('product', 'warehouse')
|
||
|
||
if not sales.exists():
|
||
logger.warning(
|
||
f"⚠ Для заказа {instance.order_number} нет Sale для отката. "
|
||
f"Обновляем только резервы."
|
||
)
|
||
else:
|
||
# Группируем Stock для обновления
|
||
stocks_to_refresh = set()
|
||
sales_count = sales.count()
|
||
restored_count = 0
|
||
|
||
try:
|
||
for sale in sales:
|
||
# Находим все распределения партий
|
||
allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch')
|
||
|
||
if not allocations.exists():
|
||
logger.warning(
|
||
f"⚠ Sale {sale.id} не имеет SaleBatchAllocation. "
|
||
f"Удаляем Sale без восстановления товара."
|
||
)
|
||
sale.delete()
|
||
continue
|
||
|
||
# Восстанавливаем товар в каждой партии
|
||
for alloc in allocations:
|
||
try:
|
||
batch = alloc.batch
|
||
|
||
logger.debug(
|
||
f" Восстанавливаем партию {batch.id}: "
|
||
f"+{alloc.quantity} для {sale.product.name}"
|
||
)
|
||
|
||
batch.quantity += alloc.quantity
|
||
batch.is_active = True # Реактивируем если была деактивирована
|
||
batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
|
||
|
||
restored_count += 1
|
||
|
||
# Запоминаем что нужно обновить Stock
|
||
stocks_to_refresh.add((sale.product, sale.warehouse))
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Ошибка при восстановлении партии {alloc.batch_id} "
|
||
f"для Sale {sale.id}: {e}",
|
||
exc_info=True
|
||
)
|
||
# Не прерываем процесс, продолжаем с другими партиями
|
||
|
||
# Удаляем Sale (каскадно удалит SaleBatchAllocation)
|
||
sale.delete()
|
||
|
||
logger.info(
|
||
f"✓ Удалено {sales_count} Sale, "
|
||
f"восстановлено {restored_count} партий товара"
|
||
)
|
||
|
||
# Обновляем все затронутые Stock записи
|
||
for product, warehouse in stocks_to_refresh:
|
||
try:
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=product,
|
||
warehouse=warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
logger.debug(
|
||
f" Stock обновлен для {product.name} на {warehouse.name}: "
|
||
f"доступно={stock.quantity_available}, "
|
||
f"зарезервировано={stock.quantity_reserved}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Ошибка при обновлении Stock для {product.name}: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Критическая ошибка при откате Sale для заказа {instance.order_number}: {e}",
|
||
exc_info=True
|
||
)
|
||
# transaction.atomic откатит все изменения
|
||
raise
|
||
|
||
# === Обновляем резервы ===
|
||
reservations = Reservation.objects.filter(
|
||
order_item__order=instance,
|
||
status='converted_to_sale'
|
||
)
|
||
|
||
# Разделяем резервы на витринные временные комплекты и обычные
|
||
# Витринные временные комплекты: is_temporary=True и showcase не null
|
||
showcase_kit_reservations = reservations.filter(
|
||
product_kit__is_temporary=True,
|
||
product_kit__showcase__isnull=False
|
||
)
|
||
|
||
# Обычные резервы (все остальные)
|
||
normal_reservations = reservations.exclude(
|
||
id__in=showcase_kit_reservations.values_list('id', flat=True)
|
||
)
|
||
|
||
showcase_count = showcase_kit_reservations.count()
|
||
normal_count = normal_reservations.count()
|
||
total_count = showcase_count + normal_count
|
||
|
||
if total_count > 0:
|
||
# Обновляем обычные резервы согласно сценарию (released при отмене, reserved при возврате)
|
||
if normal_count > 0:
|
||
for reservation in normal_reservations:
|
||
reservation.status = reservation_target_status
|
||
if reservation_target_status == 'released':
|
||
reservation.released_at = timezone.now()
|
||
# converted_at оставляем (для истории)
|
||
|
||
# Используем save() с указанием измененных полей
|
||
update_fields = ['status']
|
||
if reservation_target_status == 'released':
|
||
update_fields.append('released_at')
|
||
reservation.save(update_fields=update_fields)
|
||
|
||
logger.info(
|
||
f"✓ Обновлено {normal_count} обычных резервов: "
|
||
f"converted_to_sale → {reservation_target_status}"
|
||
)
|
||
|
||
# Витринные временные комплекты ВСЕГДА возвращаются в reserved (остаются на витрине)
|
||
if showcase_count > 0:
|
||
for reservation in showcase_kit_reservations:
|
||
reservation.status = 'reserved'
|
||
# Очищаем ТОЛЬКО блокировки корзины при отмене заказа
|
||
# НЕ трогаем order_item - он нужен для повторной продажи при возврате в completed
|
||
reservation.cart_lock_expires_at = None
|
||
reservation.locked_by_user = None
|
||
reservation.cart_session_id = None
|
||
# Не трогаем showcase и product_kit - они остаются привязанными
|
||
# converted_at оставляем (для истории)
|
||
reservation.save(update_fields=['status', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||
|
||
logger.info(
|
||
f"✓ Обновлено {showcase_count} резервов витринных комплектов: "
|
||
f"converted_to_sale → reserved (возвращены на витрину, блокировки сняты, order_item сохранён)"
|
||
)
|
||
else:
|
||
logger.warning(
|
||
f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'"
|
||
)
|
||
|
||
# === Возвращаем витринные экземпляры обратно на витрину ===
|
||
from inventory.models import ShowcaseItem
|
||
|
||
# Находим все ShowcaseItem, проданные в рамках этого заказа
|
||
showcase_items = ShowcaseItem.objects.filter(
|
||
sold_order_item__order=instance,
|
||
status='sold'
|
||
)
|
||
|
||
showcase_items_count = showcase_items.count()
|
||
|
||
if showcase_items_count > 0:
|
||
logger.info(
|
||
f"🔄 Возвращаем {showcase_items_count} витринных экземпляров обратно на витрину..."
|
||
)
|
||
|
||
# Возвращаем каждый экземпляр на витрину
|
||
for item in showcase_items:
|
||
item.status = 'available'
|
||
item.sold_order_item = None
|
||
item.sold_at = None
|
||
# showcase и product_kit не трогаем - букет остаётся на той же витрине
|
||
item.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at'])
|
||
|
||
logger.info(
|
||
f"✓ {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available"
|
||
)
|
||
|
||
# === Обновляем is_returned ===
|
||
# Используем единую функцию для обновления флага на основе фактического состояния
|
||
update_is_returned_flag(instance)
|
||
|
||
logger.info(
|
||
f"🎉 Откат для заказа {instance.order_number} завершён успешно: "
|
||
f"{previous_status.name} → {current_status.name}"
|
||
)
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
@transaction.atomic
|
||
def release_reservations_on_cancellation(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: Освобождение резервов при переходе К cancelled из ЛЮБОГО статуса.
|
||
|
||
Триггер: любой_статус → cancelled
|
||
|
||
Процесс:
|
||
1. Проверяем что текущий статус = 'cancelled' (или is_negative_end)
|
||
2. Проверяем что предыдущий статус НЕ 'cancelled' (чтобы избежать повторной обработки)
|
||
3. Освобождаем все резервы в статусе 'reserved': status → 'released'
|
||
4. Устанавливаем released_at
|
||
|
||
ПРИМЕРЫ сценариев:
|
||
- draft → cancelled: резервы 'reserved' → 'released' ✅
|
||
- pending → cancelled: резервы 'reserved' → 'released' ✅
|
||
- completed → cancelled: обрабатывается rollback_sale_on_status_change ⚠️
|
||
|
||
ПРИМЕЧАНИЕ: Для completed → cancelled резервы в 'converted_to_sale',
|
||
поэтому этот сигнал их не затронет (обрабатывает rollback_sale_on_status_change).
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Пропускаем новые заказы
|
||
if created:
|
||
return
|
||
|
||
# Проверяем наличие статуса
|
||
if not instance.status:
|
||
return
|
||
|
||
current_status = instance.status
|
||
|
||
# Проверяем: это статус отмены?
|
||
if not current_status.is_negative_end:
|
||
return # Не отмена, выходим
|
||
|
||
# === Получаем предыдущий статус ===
|
||
try:
|
||
history_count = instance.history.count()
|
||
if history_count < 2:
|
||
# Нет истории - значит заказ создан сразу в cancelled (необычно, но возможно)
|
||
# Продолжаем обработку
|
||
previous_status = None
|
||
else:
|
||
previous_record = instance.history.all()[1]
|
||
|
||
if not previous_record.status_id:
|
||
previous_status = None
|
||
else:
|
||
from orders.models import OrderStatus
|
||
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
|
||
|
||
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
|
||
previous_status = None
|
||
|
||
# Проверяем: не был ли уже в cancelled?
|
||
if previous_status and previous_status.is_negative_end:
|
||
return # Уже был в отмене, не обрабатываем повторно
|
||
|
||
# === Освобождаем резервы ===
|
||
# Ищем только резервы в статусе 'reserved'
|
||
# Резервы в 'converted_to_sale' обрабатывает rollback_sale_on_status_change
|
||
reservations = Reservation.objects.filter(
|
||
order_item__order=instance,
|
||
status='reserved'
|
||
)
|
||
|
||
# Исключаем витринные временные комплекты - они остаются на витрине
|
||
showcase_kit_reservations = reservations.filter(
|
||
product_kit__is_temporary=True,
|
||
product_kit__showcase__isnull=False
|
||
)
|
||
|
||
# Освобождаем только обычные резервы
|
||
normal_reservations = reservations.exclude(
|
||
id__in=showcase_kit_reservations.values_list('id', flat=True)
|
||
)
|
||
|
||
normal_count = normal_reservations.count()
|
||
showcase_count = showcase_kit_reservations.count()
|
||
|
||
if normal_count > 0:
|
||
logger.info(
|
||
f"🔄 Переход к статусу '{current_status.name}' для заказа {instance.order_number}. "
|
||
f"Освобождаем {normal_count} обычных резервов..."
|
||
)
|
||
|
||
# Обновляем резервы через .save() чтобы сработал сигнал обновления Stock
|
||
for reservation in normal_reservations:
|
||
reservation.status = 'released'
|
||
reservation.released_at = timezone.now()
|
||
reservation.save(update_fields=['status', 'released_at'])
|
||
|
||
logger.info(
|
||
f"✅ Освобождено {normal_count} обычных резервов: reserved → released"
|
||
)
|
||
|
||
if showcase_count > 0:
|
||
# Для витринных комплектов очищаем блокировки корзины
|
||
# НЕ трогаем order_item - он нужен для повторной продажи если статус вернётся в completed
|
||
for reservation in showcase_kit_reservations:
|
||
reservation.cart_lock_expires_at = None
|
||
reservation.locked_by_user = None
|
||
reservation.cart_session_id = None
|
||
reservation.save(update_fields=['cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||
|
||
logger.info(
|
||
f"ℹ️ Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, блокировки сняты, order_item сохранён)"
|
||
)
|
||
|
||
if normal_count == 0 and showcase_count == 0:
|
||
logger.debug(
|
||
f"ℹ️ Для заказа {instance.order_number} нет резервов в статусе 'reserved'"
|
||
)
|
||
|
||
# === Обновляем is_returned ===
|
||
# Используем единую функцию для обновления флага
|
||
update_is_returned_flag(instance)
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
@transaction.atomic
|
||
def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: Резервирование товара при переходе ОТ статуса 'cancelled' к другим статусам.
|
||
|
||
Триггер: cancelled → любой НЕ отменённый статус (draft, pending, completed и т.д.)
|
||
|
||
Процесс:
|
||
1. Проверяем что предыдущий статус был 'cancelled' (is_negative_end)
|
||
2. Проверяем что текущий статус НЕ 'cancelled'
|
||
3. Находим резервы в статусе 'released'
|
||
4. Переводим их обратно в 'reserved'
|
||
5. Stock автоматически обновится через сигнал
|
||
|
||
ПРИМЕРЫ сценариев:
|
||
- cancelled → pending: резервы 'released' → 'reserved' ✅
|
||
- cancelled → draft: резервы 'released' → 'reserved' ✅
|
||
- cancelled → completed: резервы 'released' → 'reserved', затем create_sale_on_order_completion обработает ✅
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Пропускаем новые заказы
|
||
if created:
|
||
return
|
||
|
||
# Проверяем наличие статуса
|
||
if not instance.status:
|
||
return
|
||
|
||
current_status = instance.status
|
||
|
||
# Проверяем: текущий статус НЕ отмена?
|
||
if current_status.is_negative_end:
|
||
return # Всё ещё в отмене, выходим
|
||
|
||
# === Получаем предыдущий статус ===
|
||
try:
|
||
history_count = instance.history.count()
|
||
if history_count < 2:
|
||
return # Нет истории для сравнения
|
||
|
||
previous_record = instance.history.all()[1]
|
||
|
||
if not previous_record.status_id:
|
||
return
|
||
|
||
from orders.models import OrderStatus
|
||
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
|
||
|
||
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
|
||
return
|
||
|
||
# Проверяем: был ли предыдущий статус = cancelled?
|
||
if not previous_status.is_negative_end:
|
||
return # Не было перехода от cancelled, выходим
|
||
|
||
# === Резервируем товар заново ===
|
||
# Ищем резервы в статусе 'released'
|
||
reservations = Reservation.objects.filter(
|
||
order_item__order=instance,
|
||
status='released'
|
||
)
|
||
|
||
reservations_count = reservations.count()
|
||
|
||
if reservations_count > 0:
|
||
logger.info(
|
||
f"🔄 Переход от статуса '{previous_status.name}' к '{current_status.name}' для заказа {instance.order_number}. "
|
||
f"Резервируем {reservations_count} освобождённых резервов..."
|
||
)
|
||
|
||
# Обновляем резервы через .save() чтобы сработал сигнал обновления Stock
|
||
for reservation in reservations:
|
||
reservation.status = 'reserved'
|
||
reservation.reserved_at = timezone.now() # Обновляем время резервирования
|
||
reservation.save(update_fields=['status', 'reserved_at'])
|
||
|
||
logger.info(
|
||
f"✅ Зарезервировано {reservations_count} резервов: released → reserved"
|
||
)
|
||
else:
|
||
logger.debug(
|
||
f"ℹ️ Для заказа {instance.order_number} нет резервов в статусе 'released'"
|
||
)
|
||
|
||
|
||
@receiver(pre_delete, sender=Order)
|
||
@transaction.atomic
|
||
def release_stock_on_order_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении/отмене заказа освободить резервы.
|
||
|
||
Процесс:
|
||
1. Ищем все резервы для этого заказа ДО удаления
|
||
2. Освобождаем резервы ПОСЛЕ успешного коммита транзакции (кроме витринных комплектов)
|
||
3. Это гарантирует, что резервы освободятся только если удаление успешно
|
||
|
||
ИСКЛЮЧЕНИЕ: Витринные временные комплекты (is_temporary=True, showcase!=null)
|
||
остаются в статусе 'reserved' даже при удалении заказа.
|
||
"""
|
||
# Находим все резервы для этого заказа ДО удаления
|
||
# Используем list() чтобы выполнить запрос сейчас, пока Order ещё существует
|
||
all_reservations = list(
|
||
Reservation.objects.filter(
|
||
order_item__order=instance,
|
||
status='reserved'
|
||
).select_related('product_kit')
|
||
)
|
||
|
||
# Разделяем на витринные комплекты и обычные резервы
|
||
showcase_reservations = [
|
||
r for r in all_reservations
|
||
if r.product_kit and r.product_kit.is_temporary and r.product_kit.showcase
|
||
]
|
||
|
||
normal_reservations = [
|
||
r for r in all_reservations
|
||
if r not in showcase_reservations
|
||
]
|
||
|
||
# Освобождаем только обычные резервы ПОСЛЕ успешного коммита транзакции
|
||
# Это гарантирует целостность: резервы освободятся только если удаление прошло успешно
|
||
def release_reservations():
|
||
for res in normal_reservations:
|
||
res.status = 'released'
|
||
res.released_at = timezone.now()
|
||
res.save()
|
||
|
||
# Витринные комплекты остаются зарезервированными, но отвязываем блокировки корзины
|
||
# НЕ трогаем order_item - он нужен если заказ снова перейдёт в completed
|
||
for res in showcase_reservations:
|
||
res.cart_lock_expires_at = None
|
||
res.locked_by_user = None
|
||
res.cart_session_id = None
|
||
res.save(update_fields=['cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||
|
||
transaction.on_commit(release_reservations)
|
||
|
||
|
||
@receiver(post_save, sender=OrderItem)
|
||
@transaction.atomic
|
||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении позиции заказа управляем резервами.
|
||
|
||
Процесс:
|
||
1. Ищем существующий резерв для этой позиции (в ЛЮБОМ статусе)
|
||
2. Если резерв ЕСТЬ - обновляем ТОЛЬКО количество (статус НЕ меняем!)
|
||
3. Если резерва НЕТ - создаем новый
|
||
|
||
Покрывает все сценарии:
|
||
- Создание заказа с товарами → создаёт резервы
|
||
- Редактирование + добавление товаров → создаёт резервы для новых
|
||
- Изменение количества → обновляет резервы (даже если уже converted_to_sale)
|
||
- Витринные комплекты → привязывает существующие витринные резервы к заказу
|
||
|
||
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
|
||
Резерв ищется по order_item независимо от статуса.
|
||
Это предотвращает создание нового резерва для заказа в статусе 'completed'.
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Проверяем, это витринный комплект или обычный товар/комплект
|
||
is_showcase_kit = instance.product_kit and instance.product_kit.is_temporary and instance.product_kit.showcase
|
||
|
||
if is_showcase_kit:
|
||
# Витринный комплект - привязываем существующие витринные резервы к заказу
|
||
kit = instance.product_kit
|
||
|
||
# Находим все витринные резервы для компонентов этого комплекта
|
||
showcase_reservations = Reservation.objects.filter(
|
||
product_kit=kit,
|
||
showcase__isnull=False,
|
||
status='reserved'
|
||
)
|
||
|
||
if showcase_reservations.exists():
|
||
# Привязываем витринные резервы к позиции заказа
|
||
updated_count = showcase_reservations.update(
|
||
order_item=instance,
|
||
# Оставляем showcase и product_kit для аудита
|
||
)
|
||
|
||
logger.info(
|
||
f"✓ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{instance.id}, "
|
||
f"заказ {instance.order.order_number}"
|
||
)
|
||
else:
|
||
logger.warning(
|
||
f"⚠ Витринный комплект '{kit.name}': не найдено активных витринных резервов для OrderItem #{instance.id}"
|
||
)
|
||
return # Для витринных комплектов не создаем новые резервы
|
||
|
||
# Обычный товар или постоянный комплект
|
||
# Ищем резервы для этой позиции в ЛЮБОМ статусе (не только 'reserved')
|
||
# КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов
|
||
reservations = Reservation.objects.filter(order_item=instance)
|
||
|
||
if reservations.exists():
|
||
if instance.product:
|
||
# Обычный товар - один резерв, обновляем количество напрямую
|
||
reservation = reservations.first()
|
||
old_quantity = reservation.quantity
|
||
reservation.quantity = Decimal(str(instance.quantity))
|
||
reservation.save(update_fields=['quantity'])
|
||
|
||
logger.info(
|
||
f"✓ Резерв #{reservation.id} обновлён: quantity {old_quantity} → {reservation.quantity} "
|
||
f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||
)
|
||
|
||
elif instance.product_kit and instance.kit_snapshot:
|
||
# Комплект - несколько резервов (по одному на компонент)
|
||
# Обновляем количество каждого резерва пропорционально изменению количества комплектов
|
||
from collections import defaultdict
|
||
|
||
# Собираем ожидаемые количества компонентов
|
||
product_quantities = defaultdict(Decimal)
|
||
for kit_item in instance.kit_snapshot.items.select_related('original_product'):
|
||
if kit_item.original_product:
|
||
product_quantities[kit_item.original_product_id] = (
|
||
kit_item.quantity * Decimal(str(instance.quantity))
|
||
)
|
||
|
||
# Обновляем каждый резерв
|
||
for reservation in reservations:
|
||
expected_qty = product_quantities.get(reservation.product_id, Decimal('0'))
|
||
if expected_qty > 0:
|
||
old_quantity = reservation.quantity
|
||
reservation.quantity = expected_qty
|
||
reservation.save(update_fields=['quantity'])
|
||
|
||
logger.info(
|
||
f"✓ Резерв #{reservation.id} ({reservation.product.name}) обновлён: "
|
||
f"quantity {old_quantity} → {reservation.quantity} "
|
||
f"(статус: {reservation.status}, OrderItem #{instance.id})"
|
||
)
|
||
else:
|
||
# Резерва нет - создаем новый
|
||
from collections import defaultdict
|
||
|
||
warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||
|
||
if not warehouse:
|
||
logger.warning(f"⚠ Не найден склад для резервирования (OrderItem #{instance.id})")
|
||
return
|
||
|
||
if instance.product:
|
||
# Обычный товар - создаем один резерв
|
||
reservation = Reservation.objects.create(
|
||
order_item=instance,
|
||
product=instance.product,
|
||
warehouse=warehouse,
|
||
quantity=Decimal(str(instance.quantity)),
|
||
status='reserved'
|
||
)
|
||
|
||
logger.info(
|
||
f"✓ Создан новый резерв #{reservation.id}: {instance.product.name}, quantity={reservation.quantity} "
|
||
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||
)
|
||
|
||
elif instance.product_kit and instance.kit_snapshot:
|
||
# Обычный комплект - резервируем КОМПОНЕНТЫ из снимка
|
||
# Группируем одинаковые товары
|
||
product_quantities = defaultdict(Decimal)
|
||
|
||
for kit_item in instance.kit_snapshot.items.select_related('original_product'):
|
||
if kit_item.original_product:
|
||
product_quantities[kit_item.original_product_id] += (
|
||
kit_item.quantity * Decimal(str(instance.quantity))
|
||
)
|
||
|
||
# Создаём резервы для компонентов
|
||
from products.models import Product
|
||
for product_id, total_qty in product_quantities.items():
|
||
product = Product.objects.get(pk=product_id)
|
||
_create_or_update_reservation(
|
||
instance, product, warehouse, total_qty, product_kit=instance.product_kit
|
||
)
|
||
|
||
logger.info(
|
||
f"✓ Комплект '{instance.product_kit.name}': создано {len(product_quantities)} резервов компонентов "
|
||
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||
)
|
||
|
||
|
||
@receiver(post_save, sender=Incoming)
|
||
def create_stock_batch_on_incoming(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании товара в приходе (Incoming) автоматически создается StockBatch и обновляется Stock.
|
||
|
||
Архитектура:
|
||
- IncomingBatch: одна партия поступления (IN-0000-0001) содержит несколько товаров
|
||
- Incoming: один товар в партии поступления
|
||
- StockBatch: одна партия товара на складе (создается для каждого товара в приходе)
|
||
Для FIFO: каждый товар имеет свою partия, чтобы можно было списывать отдельно
|
||
|
||
Процесс:
|
||
1. Проверяем, новый ли товар в приходе
|
||
2. Если stock_batch еще не создан - создаем StockBatch для этого товара
|
||
3. Связываем Incoming с созданной StockBatch
|
||
4. Обновляем остатки на складе (Stock)
|
||
"""
|
||
if not created:
|
||
return # Только для новых приходов
|
||
|
||
# Если stock_batch уже установлен - не создаем новый
|
||
if instance.stock_batch:
|
||
return
|
||
|
||
# Получаем данные из партии поступления
|
||
incoming_batch = instance.batch
|
||
warehouse = incoming_batch.warehouse
|
||
|
||
# Создаем новую партию товара на складе
|
||
# Каждый товар в партии поступления → отдельная StockBatch
|
||
stock_batch = StockBatch.objects.create(
|
||
product=instance.product,
|
||
warehouse=warehouse,
|
||
quantity=instance.quantity,
|
||
cost_price=instance.cost_price,
|
||
is_active=True
|
||
)
|
||
|
||
# Связываем Incoming с созданной StockBatch
|
||
instance.stock_batch = stock_batch
|
||
instance.save(update_fields=['stock_batch'])
|
||
|
||
# Обновляем или создаем запись в Stock
|
||
stock, created_stock = Stock.objects.get_or_create(
|
||
product=instance.product,
|
||
warehouse=warehouse
|
||
)
|
||
# Пересчитываем остаток из всех активных партий
|
||
# refresh_from_batches() уже вызывает save(), поэтому не вызываем ещё раз
|
||
stock.refresh_from_batches()
|
||
|
||
|
||
@receiver(post_save, sender=Incoming)
|
||
def update_stock_batch_on_incoming_edit(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При редактировании товара в приходе (Incoming) автоматически
|
||
обновляется связанная партия товара на складе (StockBatch).
|
||
|
||
Это обеспечивает синхронизацию данных между Incoming и StockBatch.
|
||
|
||
Архитектура:
|
||
- Если Incoming редактируется - обновляем StockBatch с новыми значениями
|
||
- Обновление StockBatch автоматически пересчитывает себестоимость товара (Product.cost_price)
|
||
через сигнал update_product_cost_on_batch_change()
|
||
|
||
Процесс:
|
||
1. Проверяем, это редактирование (created=False), а не создание
|
||
2. Получаем связанный StockBatch
|
||
3. Проверяем, изменились ли quantity или cost_price
|
||
4. Если да - обновляем StockBatch
|
||
5. Сохраняем StockBatch (запускает цепь пересчета себестоимости)
|
||
6. Обновляем остатки на складе (Stock)
|
||
"""
|
||
if created:
|
||
return # Только для редактирования (не для создания)
|
||
|
||
# Получаем связанный StockBatch
|
||
if not instance.stock_batch:
|
||
return # Если нет связи со StockBatch - нечего обновлять
|
||
|
||
stock_batch = instance.stock_batch
|
||
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
try:
|
||
# Проверяем, отличаются ли значения в StockBatch от Incoming
|
||
# Это говорит нам о том, что произошло редактирование
|
||
needs_update = (
|
||
stock_batch.quantity != instance.quantity or
|
||
stock_batch.cost_price != instance.cost_price
|
||
)
|
||
|
||
if not needs_update:
|
||
return # Никаких изменений
|
||
|
||
# Обновляем StockBatch с новыми значениями из Incoming
|
||
stock_batch.quantity = instance.quantity
|
||
stock_batch.cost_price = instance.cost_price
|
||
stock_batch.save()
|
||
|
||
logger.info(
|
||
f"✓ StockBatch #{stock_batch.id} обновлён при редактировании Incoming: "
|
||
f"quantity={instance.quantity}, cost_price={instance.cost_price} "
|
||
f"(товар: {instance.product.sku})"
|
||
)
|
||
|
||
# Обновляем Stock (остатки на складе)
|
||
warehouse = stock_batch.warehouse
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.product,
|
||
warehouse=warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
logger.info(
|
||
f"✓ Stock обновлён для товара {instance.product.sku} "
|
||
f"на складе {warehouse.name}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"Ошибка при обновлении StockBatch при редактировании Incoming #{instance.id}: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
|
||
@receiver(post_save, sender=Sale)
|
||
def process_sale_fifo(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании продажи (Sale) автоматически применяется FIFO-списание.
|
||
|
||
Процесс:
|
||
1. Проверяем, новая ли продажа
|
||
2. Если уже обработана - пропускаем
|
||
3. Списываем товар по FIFO из партий
|
||
4. Создаем SaleBatchAllocation для аудита
|
||
"""
|
||
if not created:
|
||
return # Только для новых продаж
|
||
|
||
# Если уже обработана - пропускаем
|
||
if instance.processed:
|
||
return
|
||
|
||
try:
|
||
# Списываем товар по FIFO
|
||
allocations = StockBatchManager.write_off_by_fifo(
|
||
instance.product,
|
||
instance.warehouse,
|
||
instance.quantity
|
||
)
|
||
|
||
# Фиксируем распределение для аудита
|
||
for batch, qty_allocated in allocations:
|
||
SaleBatchAllocation.objects.create(
|
||
sale=instance,
|
||
batch=batch,
|
||
quantity=qty_allocated,
|
||
cost_price=batch.cost_price
|
||
)
|
||
|
||
# Отмечаем продажу как обработанную (используем update() чтобы избежать повторного срабатывания сигнала)
|
||
Sale.objects.filter(pk=instance.pk).update(processed=True)
|
||
|
||
# Stock уже обновлен в StockBatchManager.write_off_by_fifo() → refresh_stock_cache()
|
||
# Не нужно вызывать ещё раз чтобы избежать race condition
|
||
|
||
except ValueError as e:
|
||
# Логируем ошибку, но не прерываем процесс
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f"Ошибка при обработке Sale {instance.id}: {str(e)}")
|
||
|
||
|
||
@receiver(pre_delete, sender=Sale)
|
||
def update_order_on_sale_delete(sender, instance, **kwargs):
|
||
"""
|
||
Обновляет флаг is_returned заказа при удалении Sale.
|
||
Вызывается ДО удаления, чтобы можно было получить order.
|
||
"""
|
||
if instance.order:
|
||
# Используем on_commit чтобы обновить после завершения транзакции
|
||
from django.db import transaction
|
||
transaction.on_commit(
|
||
lambda: update_is_returned_flag(instance.order)
|
||
)
|
||
|
||
|
||
# Сигнал process_inventory_reconciliation удален
|
||
# Теперь обработка инвентаризации вызывается явно через InventoryCompleteView
|
||
# Это позволяет пользователю контролировать момент создания документов
|
||
|
||
|
||
@receiver(post_save, sender=WriteOff)
|
||
def update_stock_on_writeoff(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении WriteOff (списание) обновляем Stock.
|
||
|
||
Процесс:
|
||
1. При создании списания - товар удаляется из StockBatch
|
||
2. Обновляем запись Stock для этого товара
|
||
"""
|
||
# Получаем или создаем Stock запись
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.batch.product,
|
||
warehouse=instance.batch.warehouse
|
||
)
|
||
|
||
# Пересчитываем остаток из всех активных партий
|
||
# refresh_from_batches() уже вызывает save()
|
||
stock.refresh_from_batches()
|
||
|
||
|
||
@receiver(post_save, sender=Reservation)
|
||
@transaction.atomic
|
||
def update_stock_on_reservation_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении резерва (Reservation) обновляем Stock.
|
||
|
||
ОПТИМИЗАЦИЯ: Обновляем Stock только если изменились поля, влияющие на резервы:
|
||
- status (reserved/converted_to_sale/released)
|
||
- quantity (количество резерва)
|
||
- created (новый резерв)
|
||
|
||
Процесс:
|
||
1. При создании резерва пересчитываем quantity_reserved
|
||
2. При изменении статуса или количества пересчитываем quantity_reserved
|
||
3. При других изменениях (например, дата) НЕ пересчитываем Stock (оптимизация)
|
||
"""
|
||
if not instance.product or not instance.warehouse:
|
||
return
|
||
|
||
# Если это создание нового резерва - всегда обновляем Stock
|
||
if created:
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.product,
|
||
warehouse=instance.warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
return
|
||
|
||
# Для обновления - проверяем, изменились ли поля, влияющие на Stock
|
||
# Используем django-simple-history или проверяем через update_fields
|
||
update_fields = kwargs.get('update_fields', None)
|
||
|
||
# Если update_fields указаны (вызов через save(update_fields=[...]))
|
||
# проверяем, есть ли среди них 'status' или 'quantity'
|
||
if update_fields is not None:
|
||
fields_affecting_stock = {'status', 'quantity'}
|
||
if not fields_affecting_stock.intersection(update_fields):
|
||
# Изменились другие поля (например, дата) - не обновляем Stock
|
||
return
|
||
|
||
# Если дошли сюда - нужно обновить Stock
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.product,
|
||
warehouse=instance.warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
|
||
@receiver(post_delete, sender=Reservation)
|
||
@transaction.atomic
|
||
def update_stock_on_reservation_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении резерва (Reservation) обновляем Stock.
|
||
|
||
Процесс:
|
||
1. После удаления резерва пересчитываем quantity_reserved
|
||
2. Обновляем запись Stock для этого товара
|
||
"""
|
||
if not instance.product or not instance.warehouse:
|
||
return
|
||
|
||
try:
|
||
# Получаем Stock запись (не создаем новую при удалении)
|
||
stock = Stock.objects.get(
|
||
product=instance.product,
|
||
warehouse=instance.warehouse
|
||
)
|
||
|
||
# Пересчитываем остатки из всех активных партий и резервов
|
||
# refresh_from_batches() уже вызывает save()
|
||
stock.refresh_from_batches()
|
||
|
||
except Stock.DoesNotExist:
|
||
# Если Stock записи нет - ничего не делаем
|
||
pass
|
||
|
||
|
||
def _update_product_in_stock(product_id):
|
||
"""
|
||
Вспомогательная функция: обновить статус in_stock для товара на основе остатков.
|
||
Товар считается в наличии, если существует хотя бы одна Stock запись
|
||
с положительным quantity_available (free quantity).
|
||
"""
|
||
from products.models import Product
|
||
|
||
try:
|
||
product = Product.objects.get(id=product_id)
|
||
|
||
# Проверяем есть ли остаток где-нибудь на складе
|
||
# Товар в наличии если есть хотя бы один Stock с положительным quantity_available
|
||
has_stock = Stock.objects.filter(
|
||
product=product,
|
||
quantity_available__gt=0
|
||
).exists()
|
||
|
||
# Обновляем in_stock если изменился статус
|
||
if product.in_stock != has_stock:
|
||
product.in_stock = has_stock
|
||
# Обновляем без повторного срабатывания сигналов
|
||
Product.objects.filter(id=product.id).update(in_stock=has_stock)
|
||
|
||
except Product.DoesNotExist:
|
||
pass
|
||
|
||
|
||
@receiver(post_save, sender=Stock)
|
||
def update_product_in_stock_on_stock_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При изменении остатков (Stock) обновляем Product.in_stock.
|
||
|
||
Процесс:
|
||
1. После обновления Stock проверяем наличие товара
|
||
2. Если есть положительный остаток - в_наличии=True
|
||
3. Если нет остатков - в_наличии=False
|
||
"""
|
||
_update_product_in_stock(instance.product_id)
|
||
|
||
|
||
@receiver(post_delete, sender=Stock)
|
||
def update_product_in_stock_on_stock_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении Stock записи обновляем Product.in_stock.
|
||
Используем post_delete чтобы правильно проверить остались ли ещё Stock записи.
|
||
"""
|
||
product_id = instance.product_id
|
||
_update_product_in_stock(product_id)
|
||
|
||
|
||
# ============================================================================
|
||
# Сигналы для автоматического обновления себестоимости товара (cost_price)
|
||
# ============================================================================
|
||
|
||
|
||
@receiver(post_save, sender=StockBatch)
|
||
def update_product_cost_on_batch_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении партии (StockBatch) автоматически
|
||
обновляется себестоимость товара (Product.cost_price).
|
||
|
||
Процесс:
|
||
1. Проверяем, есть ли связанный товар
|
||
2. Вызываем ProductCostCalculator для пересчета средневзвешенной стоимости
|
||
3. Обновляем поле cost_price в БД
|
||
|
||
Триггеры:
|
||
- Создание новой партии (поступление товара)
|
||
- Изменение количества в партии
|
||
- Изменение стоимости партии
|
||
"""
|
||
if not instance.product:
|
||
return
|
||
|
||
# Импортируем здесь чтобы избежать circular import
|
||
from products.services.cost_calculator import ProductCostCalculator
|
||
|
||
try:
|
||
# Пересчитываем и обновляем себестоимость товара
|
||
ProductCostCalculator.update_product_cost(instance.product, save=True)
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(
|
||
f"Ошибка при обновлении себестоимости товара {instance.product.sku} "
|
||
f"после изменения партии {instance.id}: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
|
||
@receiver(post_delete, sender=StockBatch)
|
||
def update_product_cost_on_batch_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении партии (StockBatch) автоматически
|
||
обновляется себестоимость товара.
|
||
|
||
Процесс:
|
||
1. После удаления партии пересчитываем себестоимость
|
||
2. Если партий не осталось - cost_price становится 0.00
|
||
"""
|
||
if not instance.product:
|
||
return
|
||
|
||
# Импортируем здесь чтобы избежать circular import
|
||
from products.services.cost_calculator import ProductCostCalculator
|
||
|
||
try:
|
||
# Пересчитываем и обновляем себестоимость товара
|
||
ProductCostCalculator.update_product_cost(instance.product, save=True)
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(
|
||
f"Ошибка при обновлении себестоимости товара после удаления партии: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# Сигналы для динамического пересчета цен комплектов
|
||
# ============================================================================
|
||
|
||
|
||
@receiver(post_save, sender='products.Product')
|
||
def update_kit_prices_on_product_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При изменении цены товара (price или sale_price)
|
||
автоматически пересчитываются цены всех комплектов, где используется этот товар.
|
||
|
||
Процесс:
|
||
1. Находим все KitItem с этим товаром
|
||
2. Для каждого комплекта вызываем recalculate_base_price()
|
||
3. base_price и price обновляются в БД
|
||
|
||
Триггеры:
|
||
- Изменение price (основная цена товара)
|
||
- Изменение sale_price (цена со скидкой товара)
|
||
"""
|
||
from products.models import KitItem
|
||
|
||
# Если это создание товара (не обновление), нет комплектов для пересчета
|
||
if created:
|
||
return
|
||
|
||
# Находим все KitItem с этим товаром
|
||
kit_items = KitItem.objects.filter(product=instance)
|
||
|
||
if not kit_items.exists():
|
||
return # Товар не используется в комплектах
|
||
|
||
# Для каждого комплекта пересчитываем цены
|
||
kits_to_update = set()
|
||
for item in kit_items:
|
||
kits_to_update.add(item.kit_id)
|
||
|
||
# Обновляем цены каждого комплекта
|
||
from products.models import ProductKit
|
||
for kit_id in kits_to_update:
|
||
try:
|
||
kit = ProductKit.objects.get(id=kit_id)
|
||
kit.recalculate_base_price()
|
||
except ProductKit.DoesNotExist:
|
||
pass
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(
|
||
f"Ошибка при пересчете цены комплекта {kit_id} "
|
||
f"после изменения цены товара {instance.sku}: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
|
||
# ==================== WRITEOFF DOCUMENT SIGNALS ====================
|
||
|
||
@receiver(pre_delete, sender=WriteOffDocumentItem)
|
||
def release_reservation_on_writeoff_item_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении позиции документа списания освобождаем связанный резерв.
|
||
|
||
Это fallback для случаев удаления напрямую через ORM/Admin,
|
||
минуя WriteOffDocumentService.remove_item().
|
||
"""
|
||
if instance.reservation and instance.reservation.status == 'reserved':
|
||
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):
|
||
"""
|
||
При создании входного товара в черновике - резервируем его.
|
||
"""
|
||
# Резервируем только при создании нового входного товара
|
||
if not created:
|
||
return
|
||
|
||
# Резервируем только если трансформация в draft
|
||
if instance.transformation.status != 'draft':
|
||
return
|
||
|
||
# Создаем резерв
|
||
Reservation.objects.create(
|
||
transformation_input=instance,
|
||
product=instance.product,
|
||
warehouse=instance.transformation.warehouse,
|
||
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,
|
||
exclude_transformation=instance # Исключаем резервы этой трансформации
|
||
)
|
||
|
||
# Суммируем себестоимость списанного
|
||
for batch, qty in allocations:
|
||
total_input_cost += batch.cost_price * qty
|
||
|
||
# Обновляем резерв
|
||
reservations_updated = Reservation.objects.filter(
|
||
transformation_input=trans_input,
|
||
status='reserved'
|
||
).update(
|
||
status='converted_to_transformation',
|
||
converted_at=timezone.now()
|
||
)
|
||
|
||
# ВАЖНО: .update() не вызывает сигналы, поэтому нужно вручную обновить Stock
|
||
if reservations_updated > 0:
|
||
stock = Stock.objects.filter(
|
||
product=trans_input.product,
|
||
warehouse=instance.warehouse
|
||
).first()
|
||
if stock:
|
||
stock.refresh_from_batches()
|
||
|
||
# 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
|
||
|
||
# Проверяем, были ли резервы в статусе 'converted_to_transformation'
|
||
# или имеют заполненное поле converted_at (что означает, что трансформация была проведена)
|
||
# Это работает независимо от порядка выполнения сигналов
|
||
has_converted_reservations = Reservation.objects.filter(
|
||
transformation_input__transformation=instance
|
||
).filter(
|
||
Q(status='converted_to_transformation') | Q(converted_at__isnull=False)
|
||
).exists()
|
||
|
||
if has_converted_reservations:
|
||
return # Это была проведенная трансформация, обрабатывается другим сигналом
|
||
|
||
# Освобождаем все резервы
|
||
Reservation.objects.filter(
|
||
transformation_input__transformation=instance,
|
||
status='reserved'
|
||
).update(
|
||
status='released',
|
||
released_at=timezone.now()
|
||
)
|