- Add management command to recalculate quantity_reserved for all Stock records - Add signals to automatically update Stock when Reservation changes - Implement post_save signal for Reservation creation/updates - Implement post_delete signal for Reservation deletion - Both signals call Stock.refresh_from_batches() to recalculate quantities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
563 lines
23 KiB
Python
563 lines
23 KiB
Python
"""
|
||
Сигналы для автоматического управления резервами и списаниями.
|
||
|
||
Подключаются при создании, изменении и удалении заказов.
|
||
"""
|
||
|
||
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
|
||
)
|