Files
octopus/myproject/inventory/signals.py
Andrey Smakotin d44687649c Добавлен автоматический возврат витринных экземпляров на витрину при откате заказа
Проблема:
При отмене заказа (completed → cancelled) резервы корректно возвращались
в статус 'reserved', но ShowcaseItem оставались в статусе 'sold'.
Из-за этого витринные букеты не отображались в POS после отмены заказа,
хотя физически должны были вернуться на витрину.

Решение:
В существующий сигнал rollback_sale_on_status_change добавлена логика
возврата витринных экземпляров на витрину:

1. После отката Sale и Reservation находим все ShowcaseItem, проданные
   в рамках отменяемого заказа (sold_order_item__order=instance)

2. Для каждого экземпляра:
   - Меняем status: sold → available
   - Очищаем sold_order_item = None
   - Очищаем sold_at = None
   - НЕ трогаем showcase и product_kit (букет остаётся на той же витрине)

3. Логируем количество возвращённых экземпляров

Преимущества:
- Элегантно: вся логика отката в одном месте (сигнал)
- Транзакционно: откат Sale, Reservation и ShowcaseItem в одной транзакции
- Универсально: работает для POS и обычных заказов
- Без костылей: используем существующую архитектуру сигналов

Теперь при отмене заказа витринный букет автоматически возвращается
на витрину и снова виден в POS - как в реальной жизни.
2025-12-11 23:09:56 +03:00

1404 lines
65 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""
Сигналы для автоматического управления резервами и списаниями.
Подключаются при создании, изменении и удалении заказов.
"""
from django.db.models.signals import post_save, pre_delete, post_delete
from django.db import transaction
from django.dispatch import receiver
from django.utils import timezone
from decimal import Decimal
from orders.models import Order, OrderItem
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem
from inventory.services import SaleProcessor
from inventory.services.batch_manager import StockBatchManager
from inventory.services.inventory_processor import InventoryProcessor
@receiver(post_save, sender=Order)
def reserve_stock_on_order_create(sender, instance, created, **kwargs):
"""
Сигнал: При создании нового заказа зарезервировать товар.
Процесс:
1. Проверяем, новый ли заказ (создан только что)
2. Для каждого товара в заказе создаем Reservation
3. Статус резерва = 'reserved'
4. Проверяем на существующие резервы (защита от дубликатов)
"""
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():
# Определяем товар (может быть product или product_kit)
product = item.product if item.product else item.product_kit
if product:
# ЗАЩИТА ОТ ДУБЛИКАТОВ: Проверяем, нет ли уже резерва для этой позиции
existing_reservation = Reservation.objects.filter(
order_item=item,
product=product,
warehouse=warehouse
).first()
if existing_reservation:
# Резерв уже существует - обновляем его вместо создания нового
existing_reservation.quantity = Decimal(str(item.quantity))
existing_reservation.status = 'reserved'
existing_reservation.save()
else:
# Резерва нет - создаем новый
Reservation.objects.create(
order_item=item,
product=product,
warehouse=warehouse,
quantity=Decimal(str(item.quantity)),
status='reserved'
)
@receiver(post_save, sender=Order)
@transaction.atomic
def create_sale_on_order_completion(sender, instance, created, **kwargs):
"""
Сигнал: Когда заказ переходит в статус 'completed' (доставлен),
создается операция Sale и резервы преобразуются в продажу.
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
Процесс:
1. Проверяем, изменился ли статус на 'completed'
2. Для каждого товара создаем Sale (автоматический FIFO-список)
3. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
"""
import logging
logger = logging.getLogger(__name__)
if created:
return # Только для обновлений
# Проверяем наличие статуса (может быть None при создании)
if not instance.status or instance.status.code != 'completed':
return # Только для статуса 'completed'
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists():
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():
logger.warning(
f"⚠ Заказ {instance.order_number} переведён в 'completed', "
f"но нет резервов для обновления (все уже converted_to_sale или отсутствуют)"
)
# Определяем склад (используем склад самовывоза из заказа или первый активный)
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} резервов"
)
@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'
# Очищаем блокировки корзины при отмене заказа
reservation.order_item = None
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', 'order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
logger.info(
f"✓ Обновлено {showcase_count} резервов витринных комплектов: "
f"converted_to_sale → reserved (возвращены на витрину, блокировки сняты)"
)
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 ===
if is_cancellation:
# Сценарий Б: устанавливаем is_returned = True
Order.objects.filter(pk=instance.pk).update(is_returned=True)
logger.info(f"✓ Установлен флаг is_returned = True")
else:
# Сценарий А: сбрасываем is_returned = False
# (на случай если ранее был cancelled, а теперь вернули в промежуточный)
if instance.is_returned:
Order.objects.filter(pk=instance.pk).update(is_returned=False)
logger.info(f"✓ Сброшен флаг is_returned = False")
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:
# Для витринных комплектов очищаем блокировки корзины
for reservation in showcase_kit_reservations:
reservation.order_item = None
reservation.cart_lock_expires_at = None
reservation.locked_by_user = None
reservation.cart_session_id = None
reservation.save(update_fields=['order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
logger.info(
f" Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, блокировки сняты)"
)
if normal_count == 0 and showcase_count == 0:
logger.debug(
f" Для заказа {instance.order_number} нет резервов в статусе 'reserved'"
)
# === Обновляем is_returned ===
# Проверяем: был ли заказ когда-либо в статусе completed (продан)?
# Если да, то это возврат/отмена проданного товара
try:
from orders.models import OrderStatus
# Проверяем всю историю заказа
was_completed = instance.history.filter(
status__is_positive_end=True
).exists()
if was_completed:
# Заказ был продан → это возврат
Order.objects.filter(pk=instance.pk).update(is_returned=True)
logger.info(
f"✓ Заказ {instance.order_number} был продан ранее. "
f"Установлен флаг is_returned = True"
)
except Exception as e:
logger.warning(
f"Не удалось проверить историю заказа {instance.order_number}: {e}"
)
@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()
# Витринные комплекты остаются зарезервированными, но отвязываем их от заказа и снимаем блокировки
for res in showcase_reservations:
res.order_item = None
res.cart_lock_expires_at = None
res.locked_by_user = None
res.cart_session_id = None
res.save(update_fields=['order_item', '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' для предотвращения дубликатов
reservation = Reservation.objects.filter(
order_item=instance
).first()
if reservation:
# Резерв существует - обновляем ТОЛЬКО количество
# НЕ меняем статус! (может быть 'converted_to_sale', 'reserved', 'released')
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})"
)
else:
# Резерва нет - создаем новый ТОЛЬКО для обычных товаров (не комплектов)
if not instance.product:
# Это обычный комплект (не витринный) - не создаем резерв на уровне комплекта
logger.info(
f" Обычный комплект '{instance.product_kit.name}': резервы управляются на уровне компонентов"
)
return
# Создаем резерв для обычного товара
warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
if warehouse:
product = instance.product
reservation = Reservation.objects.create(
order_item=instance,
product=product,
warehouse=warehouse,
quantity=Decimal(str(instance.quantity)),
status='reserved'
)
logger.info(
f"✓ Создан новый резерв #{reservation.id}: {product.name}, quantity={reservation.quantity} "
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(post_save, sender=Inventory)
def process_inventory_reconciliation(sender, instance, created, **kwargs):
"""
Сигнал: При завершении инвентаризации (status='completed')
автоматически обрабатываются расхождения.
Процесс:
1. Проверяем, изменился ли статус на 'completed'
2. Вызываем InventoryProcessor для обработки дефицитов/излишков
3. Создаются WriteOff для недостач и Incoming для излишков
"""
if created:
return # Только для обновлений
# Проверяем, изменился ли статус на 'completed'
if instance.status != 'completed':
return
try:
# Обрабатываем инвентаризацию
result = InventoryProcessor.process_inventory(instance.id)
import logging
logger = logging.getLogger(__name__)
logger.info(
f"Inventory {instance.id} processed: "
f"lines={result['processed_lines']}, "
f"writeoffs={result['writeoffs_created']}, "
f"incomings={result['incomings_created']}"
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Ошибка при обработке Inventory {instance.id}: {str(e)}", exc_info=True)
@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'])