- inventory/signals.py: обработчик изменения статуса Order * При смене статуса на 'завершён' (is_positive_end=True): reserved → sold * При смене на 'отменён' (is_negative_end=True): reserved → available - inventory/services/showcase_manager.py: метод reserve_for_order() * Переводит ShowcaseItem: in_cart → reserved * Создаёт жёсткую связь с OrderItem * Автоматическое управление статусами через сигналы - Транзакционная безопасность через @transaction.atomic
1967 lines
94 KiB
Python
1967 lines
94 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, 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)
|
||
|
||
|
||
def check_released_reservations_available(order):
|
||
"""
|
||
Проверяет, доступны ли товары из освобождённых резервов для повторной продажи.
|
||
|
||
Используется при попытке вернуть отменённый заказ в статус выполнения.
|
||
Проверяет что товары из released резервов ещё не использованы в других заказах.
|
||
|
||
Args:
|
||
order: Order instance with is_returned=True
|
||
|
||
Returns:
|
||
bool: True если все товары доступны, False если хотя бы один использован
|
||
|
||
Logic:
|
||
- Для каждого released резерва проверяем Stock.quantity_free
|
||
- quantity_free = quantity_available - quantity_reserved
|
||
- Если quantity_free >= reservation.quantity для ВСЕХ резервов → True
|
||
- Иначе → False (товары частично/полностью использованы в других заказах)
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from inventory.models import Stock, Reservation
|
||
|
||
released_reservations = Reservation.objects.filter(
|
||
order_item__order=order,
|
||
status='released'
|
||
).select_related('product', 'warehouse')
|
||
|
||
if not released_reservations.exists():
|
||
# Нет released резервов - ничего проверять не нужно
|
||
return True
|
||
|
||
for reservation in released_reservations:
|
||
# Получаем текущее состояние склада
|
||
stock = Stock.objects.filter(
|
||
product=reservation.product,
|
||
warehouse=reservation.warehouse
|
||
).first()
|
||
|
||
if not stock:
|
||
# Нет записи Stock = товара нет на складе
|
||
logger.warning(
|
||
f"Заказ {order.order_number}: нет Stock для {reservation.product.name} "
|
||
f"на складе {reservation.warehouse.name}"
|
||
)
|
||
return False
|
||
|
||
# Обновляем Stock на случай рассинхронизации
|
||
stock.refresh_from_batches()
|
||
|
||
# Проверяем свободное количество (доступное минус зарезервированное)
|
||
if stock.quantity_free < reservation.quantity:
|
||
logger.info(
|
||
f"Заказ {order.order_number}: недостаточно свободного товара "
|
||
f"{reservation.product.name}. Нужно: {reservation.quantity}, "
|
||
f"доступно свободно: {stock.quantity_free} "
|
||
f"(всего: {stock.quantity_available}, зарезервировано: {stock.quantity_reserved})"
|
||
)
|
||
return False
|
||
|
||
# Все товары доступны
|
||
logger.info(
|
||
f"✅ Заказ {order.order_number}: все товары из released резервов доступны "
|
||
f"для повторной продажи ({released_reservations.count()} позиций)"
|
||
)
|
||
return True
|
||
|
||
|
||
@receiver(post_save, sender='orders.OrderItem')
|
||
def reserve_stock_on_item_create(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании позиции заказа резервируем товар.
|
||
|
||
ВАЖНО: Срабатывает ПОСЛЕ OrderItem.save(), когда quantity_in_base_units уже вычислено!
|
||
|
||
Процесс:
|
||
1. Проверяем, новая ли позиция (создана только что)
|
||
2. Для обычных товаров - создаём резерв с учетом единиц продажи
|
||
3. Для комплектов - резервируем компоненты (группируя одинаковые товары)
|
||
4. Для ВИТРИННЫХ комплектов - НЕ создаём резервы (они уже есть от ShowcaseItem)
|
||
5. Статус резерва = 'reserved'
|
||
6. Проверяем на существующие резервы (защита от дубликатов)
|
||
"""
|
||
from collections import defaultdict
|
||
|
||
if not created:
|
||
return # Только для новых позиций
|
||
|
||
order = instance.order
|
||
|
||
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
||
warehouse = order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||
|
||
if not warehouse:
|
||
# Если нет активных складов, зарезервировать не можем
|
||
return
|
||
|
||
# Резервируем товар или комплект
|
||
if instance.product:
|
||
# Обычный товар - резервируем с учетом единиц продажи
|
||
# quantity_in_base_units УЖЕ вычислено в OrderItem.save()
|
||
reservation_quantity = instance.quantity_in_base_units if instance.quantity_in_base_units else Decimal(str(instance.quantity))
|
||
_create_or_update_reservation(
|
||
instance,
|
||
instance.product,
|
||
warehouse,
|
||
reservation_quantity,
|
||
sales_unit=instance.sales_unit
|
||
)
|
||
|
||
elif instance.product_kit and instance.kit_snapshot:
|
||
# КРИТИЧНО: Проверяем витринный ли это комплект
|
||
is_showcase_kit = instance.product_kit.is_temporary and instance.product_kit.showcase
|
||
|
||
if is_showcase_kit:
|
||
# Витринный комплект - резервы УЖЕ созданы через ShowcaseManager.reserve_kit_to_showcase
|
||
# Привязка резервов к OrderItem происходит в update_reservation_on_item_change
|
||
# НЕ создаём новые резервы!
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.info(
|
||
f"ℹ️ Витринный комплект '{instance.product_kit.name}': пропускаем создание резервов "
|
||
f"(уже созданы ShowcaseManager), OrderItem #{instance.id}"
|
||
)
|
||
return
|
||
|
||
# Обычный (постоянный) комплект - резервируем КОМПОНЕНТЫ из снимка
|
||
# Группируем одинаковые товары для создания одного резерва
|
||
product_quantities = defaultdict(Decimal)
|
||
|
||
for kit_item in instance.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(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
|
||
)
|
||
|
||
|
||
def _create_or_update_reservation(order_item, product, warehouse, quantity, product_kit=None, sales_unit=None):
|
||
"""
|
||
Вспомогательная функция для создания или обновления резерва.
|
||
|
||
Args:
|
||
order_item: Позиция заказа
|
||
product: Товар
|
||
warehouse: Склад
|
||
quantity: Количество (в базовых единицах)
|
||
product_kit: Комплект (для резервов компонентов)
|
||
sales_unit: Единица продажи (опционально)
|
||
"""
|
||
# Формируем фильтр для поиска существующего резерва
|
||
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.quantity_base = quantity # quantity уже в базовых единицах
|
||
existing_reservation.sales_unit = sales_unit
|
||
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,
|
||
quantity_base=quantity, # quantity уже в базовых единицах
|
||
sales_unit=sales_unit,
|
||
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 резервы - проверяем активные резервы
|
||
has_active_reservations = Reservation.objects.filter(
|
||
order_item__order=instance
|
||
).exclude(status='released').exists()
|
||
|
||
if not has_active_reservations:
|
||
# Активных резервов нет — проверяем доступность товаров
|
||
items_available = check_released_reservations_available(instance)
|
||
|
||
if not items_available:
|
||
# Товары использованы — блокируем
|
||
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"Невозможно изменить статус. Для новой продажи создайте новый заказ."
|
||
)
|
||
else:
|
||
# Товары доступны — разрешаем переход
|
||
logger.info(
|
||
f"✅ Заказ {instance.order_number}: is_returned=True, но товары доступны. "
|
||
f"Разрешаем переход в '{instance.status.name}'."
|
||
)
|
||
|
||
# Проверяем: это положительный финальный статус?
|
||
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():
|
||
# Проверяем, может быть витринный комплект уже продан через ShowcaseManager?
|
||
already_sold = Reservation.objects.filter(
|
||
order_item=item,
|
||
product_kit=kit,
|
||
status='converted_to_sale'
|
||
).exists()
|
||
|
||
if already_sold:
|
||
logger.info(
|
||
f"ℹ️ Витринный комплект '{kit.name}': резервы уже обработаны через ShowcaseManager.sell_showcase_items"
|
||
)
|
||
else:
|
||
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_base or reservation.quantity, # Используем quantity_base!
|
||
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_base or 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:
|
||
# Находим резерв для этого OrderItem
|
||
item_reservation = Reservation.objects.filter(
|
||
order_item=item,
|
||
product=product
|
||
).exclude(status='converted_to_sale').first()
|
||
|
||
if item_reservation:
|
||
# Используем quantity_base из резерва (всегда в базовых единицах)
|
||
sale_quantity = item_reservation.quantity_base or item_reservation.quantity
|
||
else:
|
||
# Fallback: используем quantity_in_base_units из OrderItem
|
||
sale_quantity = item.quantity_in_base_units if item.quantity_in_base_units else Decimal(str(item.quantity))
|
||
logger.warning(
|
||
f"⚠ Не найден резерв для OrderItem {item.id}. "
|
||
f"Используем quantity_in_base_units: {sale_quantity}"
|
||
)
|
||
|
||
# Создаем Sale (с автоматическим FIFO-списанием)
|
||
sale = SaleProcessor.create_sale(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
quantity=sale_quantity,
|
||
sale_price=Decimal(str(item.price)),
|
||
order=instance,
|
||
document_number=instance.order_number,
|
||
sales_unit=item.sales_unit # Передаем sales_unit в Sale
|
||
)
|
||
sales_created.append(sale)
|
||
logger.info(f"✓ Sale создан для {product.name}: {sale_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"
|
||
)
|
||
|
||
# === Финализация витринных экземпляров: reserved → sold ===
|
||
# Находим все витринные комплекты в этом заказе, которые в статусе reserved
|
||
from inventory.models import ShowcaseItem
|
||
|
||
showcase_items_to_finalize = ShowcaseItem.objects.filter(
|
||
sold_order_item__order=instance,
|
||
status='reserved'
|
||
)
|
||
|
||
finalized_count = 0
|
||
for showcase_item in showcase_items_to_finalize:
|
||
try:
|
||
showcase_item.mark_sold_from_reserved()
|
||
finalized_count += 1
|
||
logger.info(
|
||
f"✓ Витринный экземпляр #{showcase_item.id} финализирован: reserved → sold"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Ошибка финализации ShowcaseItem #{showcase_item.id}: {e}"
|
||
)
|
||
|
||
if finalized_count > 0:
|
||
logger.info(
|
||
f"🎉 Финализировано {finalized_count} витринных экземпляров для заказа {instance.order_number}"
|
||
)
|
||
|
||
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'
|
||
|
||
logger.info(
|
||
f" Проверка сценария: current_status.code='{current_status.code}', "
|
||
f"is_negative_end={current_status.is_negative_end}, is_cancellation={is_cancellation}"
|
||
)
|
||
|
||
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:
|
||
# Запоминаем product и warehouse для обновления Stock
|
||
# (важно для pending продаж, которые учитываются в Stock.refresh_from_batches())
|
||
stocks_to_refresh.add((sale.product, sale.warehouse))
|
||
|
||
# Находим все распределения партий
|
||
allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch')
|
||
|
||
if not allocations.exists():
|
||
# Для pending продаж (is_pending_cost=True) это нормально -
|
||
# партии ещё не были созданы. При удалении Sale
|
||
# Stock.refresh_from_batches() автоматически уберёт pending из расчёта.
|
||
if sale.is_pending_cost:
|
||
logger.info(
|
||
f" Sale {sale.id} - pending продажа (в минус). "
|
||
f"Удаляем Sale, Stock обновится автоматически."
|
||
)
|
||
else:
|
||
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}"
|
||
)
|
||
|
||
# Витринные временные комплекты: логика зависит от сценария
|
||
if showcase_count > 0:
|
||
if is_cancellation:
|
||
# Сценарий Б: Отмена - возвращаем на витрину
|
||
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_item и 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:
|
||
# Сценарий А: Возврат к нейтральному - резервы ОСТАЮТСЯ в заказе
|
||
for reservation in showcase_kit_reservations:
|
||
reservation.status = 'reserved'
|
||
# Очищаем ТОЛЬКО блокировки корзины
|
||
# order_item НЕ ТРОГАЕМ - резерв остаётся за заказом!
|
||
reservation.cart_lock_expires_at = None
|
||
reservation.locked_by_user = None
|
||
reservation.cart_session_id = None
|
||
# 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 (остаются в заказе, блокировки сняты)"
|
||
)
|
||
else:
|
||
logger.warning(
|
||
f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'"
|
||
)
|
||
|
||
# === Возвращаем витринные экземпляры обратно на витрину ===
|
||
# ТОЛЬКО при отмене (отрицательный статус)!
|
||
logger.info(
|
||
f" Проверка возврата ShowcaseItem: is_cancellation={is_cancellation}"
|
||
)
|
||
|
||
if is_cancellation:
|
||
from inventory.models import ShowcaseItem
|
||
|
||
# Находим все ShowcaseItem, проданные в рамках этого заказа
|
||
showcase_items = ShowcaseItem.objects.filter(
|
||
sold_order_item__order=instance,
|
||
status='sold'
|
||
)
|
||
|
||
showcase_items_count = showcase_items.count()
|
||
|
||
logger.info(
|
||
f" Найдено {showcase_items_count} ShowcaseItem для возврата на витрину"
|
||
)
|
||
|
||
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'])
|
||
|
||
# КРИТИЧНО: Восстанавливаем связь между ShowcaseItem и Reservation
|
||
# Находим все резервы этого комплекта для данного OrderItem
|
||
order_item = item.sold_order_item if hasattr(item, '_original_sold_order_item') else None
|
||
if not order_item:
|
||
# Пытаемся найти через заказ и product_kit
|
||
order_items = instance.items.filter(product_kit=item.product_kit)
|
||
if order_items.exists():
|
||
order_item = order_items.first()
|
||
|
||
if order_item:
|
||
# Восстанавливаем связь showcase_item в резервах
|
||
reservations_updated = Reservation.objects.filter(
|
||
order_item=order_item,
|
||
product_kit=item.product_kit,
|
||
status='reserved'
|
||
).update(showcase_item=item)
|
||
|
||
if reservations_updated > 0:
|
||
logger.debug(
|
||
f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}"
|
||
)
|
||
|
||
logger.info(
|
||
f"✅ {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available со связью с резервами"
|
||
)
|
||
else:
|
||
# Сценарий А: Возврат к нейтральному - ShowcaseItem sold → reserved
|
||
from inventory.models import ShowcaseItem
|
||
|
||
# Находим все ShowcaseItem в статусе 'sold' для этого заказа
|
||
showcase_items_to_unreserve = ShowcaseItem.objects.filter(
|
||
sold_order_item__order=instance,
|
||
status='sold'
|
||
)
|
||
|
||
unreserved_count = 0
|
||
for showcase_item in showcase_items_to_unreserve:
|
||
try:
|
||
# Возвращаем в reserved (букет остаётся занят под заказ)
|
||
showcase_item.return_to_reserved(showcase_item.sold_order_item)
|
||
unreserved_count += 1
|
||
logger.info(
|
||
f"✓ Витринный экземпляр #{showcase_item.id} возвращён в резерв: sold → reserved"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Ошибка возврата ShowcaseItem #{showcase_item.id} в reserved: {e}"
|
||
)
|
||
|
||
if unreserved_count > 0:
|
||
logger.info(
|
||
f"🔄 {unreserved_count} витринных экземпляров возвращено в резерв: sold → reserved (заказ в нейтральном статусе)"
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"ℹ️ Сценарий А: Нет витринных экземпляров для возврата в reserved"
|
||
)
|
||
|
||
# === Обновляем 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:
|
||
# Для витринных комплектов очищаем блокировки корзины
|
||
# КРИТИЧНО: Отвязываем резервы от заказа при отмене
|
||
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'"
|
||
)
|
||
|
||
# === Освобождаем ShowcaseItem при отмене: reserved/sold → available ===
|
||
from inventory.models import ShowcaseItem
|
||
|
||
# Находим все ShowcaseItem для этого заказа в статусах reserved или sold
|
||
showcase_items_to_release = ShowcaseItem.objects.filter(
|
||
sold_order_item__order=instance,
|
||
status__in=['reserved', 'sold']
|
||
)
|
||
|
||
released_showcase_count = 0
|
||
for showcase_item in showcase_items_to_release:
|
||
try:
|
||
showcase_item.return_to_available()
|
||
released_showcase_count += 1
|
||
logger.info(
|
||
f"✓ Витринный экземпляр #{showcase_item.id} освобождён: {showcase_item.status} → available"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Ошибка освобождения ShowcaseItem #{showcase_item.id}: {e}"
|
||
)
|
||
|
||
if released_showcase_count > 0:
|
||
logger.info(
|
||
f"🎉 {released_showcase_count} витринных экземпляров освобождено и возвращено на витрину при отмене заказа"
|
||
)
|
||
|
||
# === Обновляем 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'"
|
||
)
|
||
|
||
# === Привязываем витринные резервы обратно к заказу ===
|
||
# Витринные резервы остаются в статусе 'reserved' при отмене,
|
||
# но отвязываются от order_item. При возврате нужно привязать их обратно.
|
||
|
||
# Находим все OrderItem витринных комплектов в этом заказе
|
||
showcase_order_items = instance.items.filter(
|
||
product_kit__is_temporary=True,
|
||
product_kit__showcase__isnull=False
|
||
).select_related('product_kit')
|
||
|
||
showcase_items_count = showcase_order_items.count()
|
||
|
||
if showcase_items_count > 0:
|
||
logger.info(
|
||
f"🔄 Найдено {showcase_items_count} витринных комплектов в заказе. "
|
||
f"Привязываем резервы обратно к заказу..."
|
||
)
|
||
|
||
for order_item in showcase_order_items:
|
||
kit = order_item.product_kit
|
||
|
||
# Находим витринные резервы для этого комплекта
|
||
# (они в статусе 'reserved', но order_item=None)
|
||
showcase_reservations = Reservation.objects.filter(
|
||
product_kit=kit,
|
||
showcase__isnull=False,
|
||
status='reserved',
|
||
order_item__isnull=True
|
||
)
|
||
|
||
if showcase_reservations.exists():
|
||
# Привязываем резервы обратно к OrderItem
|
||
updated_count = showcase_reservations.update(order_item=order_item)
|
||
|
||
logger.info(
|
||
f" ✅ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{order_item.id}"
|
||
)
|
||
else:
|
||
logger.warning(
|
||
f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов без order_item"
|
||
)
|
||
|
||
logger.info(
|
||
f"✅ Обработано {showcase_items_count} витринных комплектов"
|
||
)
|
||
|
||
|
||
@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
|
||
]
|
||
|
||
# Освобождаем резервы СРАЗУ в pre_delete (до каскадного удаления OrderItem)
|
||
# Это предотвращает ошибку FK constraint при попытке сохранить резерв после удаления OrderItem
|
||
for res in normal_reservations:
|
||
res.status = 'released'
|
||
res.released_at = timezone.now()
|
||
res.order_item = None # Обнуляем ссылку на удаляемый OrderItem
|
||
res.save()
|
||
|
||
# Витринные комплекты остаются зарезервированными, но отвязываем от заказа и блокировки корзины
|
||
for res in showcase_reservations:
|
||
res.order_item = None # Обнуляем ссылку на удаляемый OrderItem
|
||
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'])
|
||
|
||
|
||
@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
|
||
|
||
# Обновляем quantity и quantity_base
|
||
reservation.quantity = Decimal(str(instance.quantity))
|
||
reservation.quantity_base = instance.quantity_in_base_units or Decimal(str(instance.quantity))
|
||
reservation.sales_unit = instance.sales_unit
|
||
reservation.save(update_fields=['quantity', 'quantity_base', 'sales_unit'])
|
||
|
||
logger.info(
|
||
f"✓ Резерв #{reservation.id} обновлён: quantity {old_quantity} → {reservation.quantity}, "
|
||
f"quantity_base → {reservation.quantity_base} "
|
||
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.quantity_base = expected_qty
|
||
reservation.save(update_fields=['quantity', 'quantity_base'])
|
||
|
||
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})"
|
||
)
|
||
|
||
|
||
# Сигналы для Incoming удалены - теперь StockBatch создается напрямую в IncomingDocumentService
|
||
|
||
|
||
@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
|
||
|
||
# Списываем товар по FIFO с allow_negative=True для поддержки продаж "в минус"
|
||
allocations, pending = StockBatchManager.write_off_by_fifo(
|
||
instance.product,
|
||
instance.warehouse,
|
||
instance.quantity,
|
||
allow_negative=True
|
||
)
|
||
|
||
# Фиксируем распределение для аудита
|
||
for batch, qty_allocated in allocations:
|
||
SaleBatchAllocation.objects.create(
|
||
sale=instance,
|
||
batch=batch,
|
||
quantity=qty_allocated,
|
||
cost_price=batch.cost_price
|
||
)
|
||
|
||
# Если есть pending - это продажа "в минус"
|
||
update_fields = ['processed']
|
||
instance.processed = True
|
||
if pending > 0:
|
||
instance.is_pending_cost = True
|
||
instance.pending_quantity = pending
|
||
update_fields.extend(['is_pending_cost', 'pending_quantity'])
|
||
|
||
# Отмечаем продажу как обработанную (используем update() чтобы избежать повторного срабатывания сигнала)
|
||
Sale.objects.filter(pk=instance.pk).update(**{field: getattr(instance, field) for field in update_fields})
|
||
|
||
# Stock уже обновлен в StockBatchManager.write_off_by_fifo() → refresh_stock_cache()
|
||
# Не нужно вызывать ещё раз чтобы избежать race condition
|
||
|
||
|
||
@receiver(pre_delete, sender=Sale)
|
||
def update_order_on_sale_delete(sender, instance, **kwargs):
|
||
"""
|
||
Обновляет флаг is_returned заказа при удалении Sale.
|
||
Вызывается ДО удаления, чтобы можно было получить order.
|
||
"""
|
||
if instance.order:
|
||
# Сохраняем order_id для использования в post_delete
|
||
# (instance.order может быть недоступен после удаления)
|
||
instance._order_for_update = instance.order
|
||
|
||
|
||
@receiver(post_delete, sender=Sale)
|
||
def update_order_on_sale_post_delete(sender, instance, **kwargs):
|
||
"""
|
||
Обновляет флаг is_returned заказа ПОСЛЕ удаления Sale.
|
||
Использует order, сохранённый в pre_delete.
|
||
"""
|
||
order = getattr(instance, '_order_for_update', None)
|
||
if order:
|
||
# Обновляем флаг напрямую после удаления Sale
|
||
update_is_returned_flag(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 (количество резерва в единицах продажи)
|
||
- quantity_base (количество резерва в базовых единицах)
|
||
- 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' или 'quantity_base'
|
||
if update_fields is not None:
|
||
fields_affecting_stock = {'status', 'quantity', 'quantity_base'}
|
||
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, pending = StockBatchManager.write_off_by_fifo(
|
||
product=trans_input.product,
|
||
warehouse=instance.warehouse,
|
||
quantity_to_write_off=trans_input.quantity,
|
||
exclude_transformation=instance, # Исключаем резервы этой трансформации
|
||
allow_negative=False # Трансформация требует наличия товара
|
||
)
|
||
|
||
# Суммируем себестоимость списанного
|
||
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:
|
||
# Сохраняем ссылки на товар и склад перед удалением партии
|
||
output_product = trans_output.product
|
||
output_warehouse = instance.warehouse
|
||
batch = trans_output.stock_batch
|
||
batch.delete()
|
||
|
||
# ВАЖНО: Обновляем Stock после удаления партии Output
|
||
# Сигнал post_delete для StockBatch не обновляет Stock, только себестоимость
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=output_product,
|
||
warehouse=output_warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
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()
|
||
)
|