""" Сигналы для автоматического управления резервами и списаниями. Подключаются при создании, изменении и удалении заказов. """ from django.db.models.signals import post_save, pre_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() 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(pre_delete, sender=Stock) def update_product_in_stock_on_stock_delete(sender, instance, **kwargs): """ Сигнал: При удалении Stock записи обновляем Product.in_stock. """ product_id = instance.product_id # Сначала удаляем Stock, потом проверяем остаток # Используем post_delete был бы лучше, но pre_delete сработает раньше # Поэтому нужно проверить есть ли ещё остатки до удаления _update_product_in_stock(product_id)