""" Сигналы для автоматического управления резервами и списаниями. Подключаются при создании, изменении и удалении заказов. """ from django.db.models.signals import post_save, pre_delete, post_delete 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 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' """ if not created: return # Только для новых заказов # Определяем склад (пока используем первый активный) warehouse = 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: # Создаем резерв Reservation.objects.create( order_item=item, product=product, warehouse=warehouse, quantity=Decimal(str(item.quantity)), status='reserved' ) @receiver(post_save, sender=Order) def create_sale_on_order_shipment(sender, instance, created, **kwargs): """ Сигнал: Когда заказ переходит в статус 'in_delivery', создается операция Sale и резервы преобразуются в продажу. Процесс: 1. Проверяем, изменился ли статус на 'in_delivery' 2. Для каждого товара создаем Sale (автоматический FIFO-список) 3. Обновляем резерв на 'converted_to_sale' """ if created: return # Только для обновлений if instance.status != 'in_delivery': return # Только для статуса 'in_delivery' # Определяем склад warehouse = Warehouse.objects.filter(is_active=True).first() if not warehouse: return # Для каждого товара в заказе for item in instance.items.all(): # Определяем товар product = item.product if item.product else item.product_kit 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 ) # Обновляем резерв reservations = Reservation.objects.filter( order_item=item, status='reserved' ) for res in reservations: res.status = 'converted_to_sale' res.converted_at = timezone.now() res.save() except ValueError as e: # Логируем ошибку, но не прерываем процесс import logging logger = logging.getLogger(__name__) logger.error( f"Ошибка при создании Sale для заказа {instance.order_number}: {e}" ) @receiver(pre_delete, sender=Order) def release_stock_on_order_delete(sender, instance, **kwargs): """ Сигнал: При удалении/отмене заказа освободить резервы. Процесс: 1. Ищем все резервы для этого заказа 2. Меняем статус резерва на 'released' 3. Фиксируем время освобождения """ # Находим все резервы для этого заказа reservations = Reservation.objects.filter( order_item__order=instance, status='reserved' ) # Освобождаем каждый резерв for res in reservations: res.status = 'released' res.released_at = timezone.now() res.save() @receiver(post_save, sender=OrderItem) def update_reservation_on_item_change(sender, instance, created, **kwargs): """ Сигнал: Если изменилось количество товара в позиции заказа, обновить резерв. Процесс: 1. Если это новая позиция - игнорируем (резерв уже создан через Order) 2. Если изменилось количество - обновляем резерв или создаем новый """ if created: return # Новые позиции обрабатываются через Order signal # Получаем резерв для этой позиции try: reservation = Reservation.objects.get( order_item=instance, status='reserved' ) # Обновляем количество в резерве reservation.quantity = Decimal(str(instance.quantity)) reservation.save() except Reservation.DoesNotExist: # Если резерва нет - создаем новый # (может быть, если заказ был создан до системы резервов) warehouse = Warehouse.objects.filter(is_active=True).first() if warehouse: product = instance.product if instance.product else instance.product_kit if product: Reservation.objects.create( order_item=instance, product=product, warehouse=warehouse, quantity=Decimal(str(instance.quantity)), status='reserved' ) @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=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) def update_stock_on_reservation_change(sender, instance, created, **kwargs): """ Сигнал: При создании или изменении резерва (Reservation) обновляем Stock. Процесс: 1. При создании или изменении резерва пересчитываем quantity_reserved 2. Обновляем запись Stock для этого товара """ if not instance.product or not instance.warehouse: return # Получаем или создаем Stock запись stock, _ = Stock.objects.get_or_create( product=instance.product, warehouse=instance.warehouse ) # Пересчитываем остатки из всех активных партий и резервов # refresh_from_batches() уже вызывает save() stock.refresh_from_batches() @receiver(post_delete, sender=Reservation) 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 )