Проблема: - При смене статуса заказа ОТМЕНЁН → ВЫПОЛНЕН - Sale создавался и товар списывался корректно ✓ - НО резервы оставались в статусе 'released' вместо 'converted_to_sale' - Это приводило к некорректной истории и возможным проблемам при откате Причина: - Сигнал искал только резервы в статусе 'reserved' - После отмены резервы были в статусе 'released' - При повторном выполнении они не обновлялись Решение: - Изменён фильтр резервов: берём ВСЕ кроме 'converted_to_sale' - Теперь обрабатываются резервы в любом статусе (reserved, released, и др.) - Элегантное решение без хардкода конкретных статусов Дополнительно: - Добавлен @transaction.atomic к сигналам обновления Stock - Защита от race conditions при одновременном изменении резервов - Минимальные издержки, максимальная надёжность Результат: - Корректная работа при ЛЮБЫХ переходах статусов: * reserved → converted_to_sale ✓ * released → converted_to_sale ✓ * повторный вызов → пропуск ✓ - Целостность данных гарантирована транзакциями - Элегантный код без костылей
960 lines
43 KiB
Python
960 lines
43 KiB
Python
"""
|
||
Сигналы для автоматического управления резервами и списаниями.
|
||
|
||
Подключаются при создании, изменении и удалении заказов.
|
||
"""
|
||
|
||
from django.db.models.signals import post_save, pre_delete, post_delete
|
||
from django.db import transaction
|
||
from django.dispatch import receiver
|
||
from django.utils import timezone
|
||
from decimal import Decimal
|
||
|
||
from orders.models import Order, OrderItem
|
||
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock
|
||
from inventory.services import SaleProcessor
|
||
from inventory.services.batch_manager import StockBatchManager
|
||
from inventory.services.inventory_processor import InventoryProcessor
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании нового заказа зарезервировать товар.
|
||
|
||
Процесс:
|
||
1. Проверяем, новый ли заказ (создан только что)
|
||
2. Для каждого товара в заказе создаем Reservation
|
||
3. Статус резерва = 'reserved'
|
||
4. Проверяем на существующие резервы (защита от дубликатов)
|
||
"""
|
||
if not created:
|
||
return # Только для новых заказов
|
||
|
||
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
||
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||
|
||
if not warehouse:
|
||
# Если нет активных складов, зарезервировать не можем
|
||
# Можно логировать ошибку или выбросить исключение
|
||
return
|
||
|
||
# Для каждого товара в заказе
|
||
for item in instance.items.all():
|
||
# Определяем товар (может быть product или product_kit)
|
||
product = item.product if item.product else item.product_kit
|
||
|
||
if product:
|
||
# ЗАЩИТА ОТ ДУБЛИКАТОВ: Проверяем, нет ли уже резерва для этой позиции
|
||
existing_reservation = Reservation.objects.filter(
|
||
order_item=item,
|
||
product=product,
|
||
warehouse=warehouse
|
||
).first()
|
||
|
||
if existing_reservation:
|
||
# Резерв уже существует - обновляем его вместо создания нового
|
||
existing_reservation.quantity = Decimal(str(item.quantity))
|
||
existing_reservation.status = 'reserved'
|
||
existing_reservation.save()
|
||
else:
|
||
# Резерва нет - создаем новый
|
||
Reservation.objects.create(
|
||
order_item=item,
|
||
product=product,
|
||
warehouse=warehouse,
|
||
quantity=Decimal(str(item.quantity)),
|
||
status='reserved'
|
||
)
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
@transaction.atomic
|
||
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: Когда заказ переходит в статус 'completed' (доставлен),
|
||
создается операция Sale и резервы преобразуются в продажу.
|
||
|
||
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
|
||
|
||
Процесс:
|
||
1. Проверяем, изменился ли статус на 'completed'
|
||
2. Для каждого товара создаем Sale (автоматический FIFO-список)
|
||
3. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
if created:
|
||
return # Только для обновлений
|
||
|
||
# Проверяем наличие статуса (может быть None при создании)
|
||
if not instance.status or instance.status.code != 'completed':
|
||
return # Только для статуса 'completed'
|
||
|
||
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
|
||
if Sale.objects.filter(order=instance).exists():
|
||
return # Продажи уже созданы, выходим БЕЗ обновления резервов
|
||
|
||
# Проверяем наличие резервов для этого заказа
|
||
# Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката)
|
||
# Исключаем уже обработанные 'converted_to_sale'
|
||
reservations_to_update = Reservation.objects.filter(
|
||
order_item__order=instance
|
||
).exclude(status='converted_to_sale')
|
||
|
||
if not reservations_to_update.exists():
|
||
logger.warning(
|
||
f"⚠ Заказ {instance.order_number} переведён в 'completed', "
|
||
f"но нет резервов для обновления (все уже converted_to_sale или отсутствуют)"
|
||
)
|
||
|
||
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
||
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||
|
||
if not warehouse:
|
||
logger.error(f"❌ Не найден склад для заказа {instance.order_number}. Списание невозможно.")
|
||
return
|
||
|
||
# Для каждого товара в заказе создаем Sale
|
||
sales_created = []
|
||
sale_creation_failed = False
|
||
|
||
for item in instance.items.all():
|
||
# Определяем товар
|
||
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
|
||
)
|
||
sales_created.append(sale)
|
||
logger.info(f"✓ Sale создан для {product.name}: {item.quantity} шт.")
|
||
|
||
except ValueError as e:
|
||
# Логируем ошибку и прерываем процесс
|
||
logger.error(
|
||
f"❌ ОШИБКА при создании Sale для заказа {instance.order_number}, товар {product.name}: {e}"
|
||
)
|
||
sale_creation_failed = True
|
||
break
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ КРИТИЧЕСКАЯ ОШИБКА при создании Sale для заказа {instance.order_number}: {e}"
|
||
)
|
||
sale_creation_failed = True
|
||
break
|
||
|
||
# КРИТИЧНО: Обновляем резервы ТОЛЬКО если ВСЕ Sale созданы успешно
|
||
if sale_creation_failed:
|
||
logger.error(
|
||
f"❌ Не удалось создать Sale для заказа {instance.order_number}. "
|
||
f"Резервы НЕ будут обновлены. Транзакция откатится."
|
||
)
|
||
# Поднимаем исключение чтобы откатить всю транзакцию
|
||
raise ValueError(f"Не удалось создать Sale для заказа {instance.order_number}")
|
||
|
||
# Все Sale созданы успешно - теперь обновляем резервы
|
||
if reservations_to_update.exists():
|
||
# Обновляем резервы через .save() чтобы сработал сигнал обновления Stock
|
||
# Сигнал update_stock_on_reservation_change автоматически обновит Stock
|
||
for reservation in reservations_to_update:
|
||
reservation.status = 'converted_to_sale'
|
||
reservation.converted_at = timezone.now()
|
||
reservation.save(update_fields=['status', 'converted_at'])
|
||
|
||
updated_count = reservations_to_update.count()
|
||
logger.info(
|
||
f"✓ Обновлено {updated_count} резервов для заказа {instance.order_number}: reserved → converted_to_sale"
|
||
)
|
||
|
||
logger.info(
|
||
f"🎉 Заказ {instance.order_number} успешно обработан: создано {len(sales_created)} Sale, "
|
||
f"обновлено {reservations_to_update.count() if reservations_to_update.exists() else 0} резервов"
|
||
)
|
||
|
||
|
||
@receiver(post_save, sender=Order)
|
||
@transaction.atomic
|
||
def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: Откатить Sale при уходе от статуса 'completed'.
|
||
|
||
Триггер: completed → любой другой статус (draft, cancelled и т.д.)
|
||
|
||
Процесс:
|
||
1. Отслеживаем переход ОТ статуса 'completed'
|
||
2. Удаляем Sale и восстанавливаем StockBatch через SaleBatchAllocation
|
||
3. Обновляем резервы (reserved или released в зависимости от сценария)
|
||
4. Обновляем Stock
|
||
5. Устанавливаем is_returned для отмены
|
||
|
||
Сценарии:
|
||
- А (ошибка): completed → draft/in_delivery → резервы возвращаются в 'reserved'
|
||
- Б (отмена): completed → cancelled → резервы освобождаются в 'released'
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Пропускаем новые заказы
|
||
if created:
|
||
return
|
||
|
||
# Проверяем наличие статуса
|
||
if not instance.status:
|
||
return
|
||
|
||
current_status = instance.status
|
||
|
||
# === Получаем предыдущий статус через django-simple-history ===
|
||
try:
|
||
# Получаем предыдущую запись из истории (индекс [1] = предпоследняя)
|
||
history_count = instance.history.count()
|
||
if history_count < 2:
|
||
return # Нет истории для сравнения
|
||
|
||
previous_record = instance.history.all()[1]
|
||
|
||
if not previous_record.status_id:
|
||
return
|
||
|
||
# Импортируем OrderStatus если еще не импортирован
|
||
from orders.models import OrderStatus
|
||
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
|
||
|
||
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
|
||
# Нет истории или статус удалён
|
||
return
|
||
|
||
# === Проверяем: был ли переход ОТ 'completed'? ===
|
||
if previous_status.code != 'completed':
|
||
return # Не было перехода от 'completed', выходим
|
||
|
||
# Проверяем: не остались ли мы в 'completed'?
|
||
if current_status.code == 'completed':
|
||
return # Всё ещё 'completed', откат не нужен
|
||
|
||
# === Если дошли сюда: был переход completed → другой_статус ===
|
||
logger.info(
|
||
f"🔄 Обнаружен уход от статуса 'completed' для заказа {instance.order_number}: "
|
||
f"{previous_status.name} → {current_status.name}"
|
||
)
|
||
|
||
# === Определяем сценарий отката ===
|
||
is_cancellation = current_status.is_negative_end # True для 'cancelled'
|
||
|
||
if is_cancellation:
|
||
# Сценарий Б: отмена заказа
|
||
reservation_target_status = 'released'
|
||
logger.info(f" Сценарий Б: Отмена заказа (статус: {current_status.name})")
|
||
else:
|
||
# Сценарий А: возврат к промежуточному статусу
|
||
reservation_target_status = 'reserved'
|
||
logger.info(f" Сценарий А: Возврат к промежуточному статусу (статус: {current_status.name})")
|
||
|
||
# === Откатываем Sale ===
|
||
sales = Sale.objects.filter(order=instance).select_related('product', 'warehouse')
|
||
|
||
if not sales.exists():
|
||
logger.warning(
|
||
f"⚠ Для заказа {instance.order_number} нет Sale для отката. "
|
||
f"Обновляем только резервы."
|
||
)
|
||
else:
|
||
# Группируем Stock для обновления
|
||
stocks_to_refresh = set()
|
||
sales_count = sales.count()
|
||
restored_count = 0
|
||
|
||
try:
|
||
for sale in sales:
|
||
# Находим все распределения партий
|
||
allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch')
|
||
|
||
if not allocations.exists():
|
||
logger.warning(
|
||
f"⚠ Sale {sale.id} не имеет SaleBatchAllocation. "
|
||
f"Удаляем Sale без восстановления товара."
|
||
)
|
||
sale.delete()
|
||
continue
|
||
|
||
# Восстанавливаем товар в каждой партии
|
||
for alloc in allocations:
|
||
try:
|
||
batch = alloc.batch
|
||
|
||
logger.debug(
|
||
f" Восстанавливаем партию {batch.id}: "
|
||
f"+{alloc.quantity} для {sale.product.name}"
|
||
)
|
||
|
||
batch.quantity += alloc.quantity
|
||
batch.is_active = True # Реактивируем если была деактивирована
|
||
batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
|
||
|
||
restored_count += 1
|
||
|
||
# Запоминаем что нужно обновить Stock
|
||
stocks_to_refresh.add((sale.product, sale.warehouse))
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Ошибка при восстановлении партии {alloc.batch_id} "
|
||
f"для Sale {sale.id}: {e}",
|
||
exc_info=True
|
||
)
|
||
# Не прерываем процесс, продолжаем с другими партиями
|
||
|
||
# Удаляем Sale (каскадно удалит SaleBatchAllocation)
|
||
sale.delete()
|
||
|
||
logger.info(
|
||
f"✓ Удалено {sales_count} Sale, "
|
||
f"восстановлено {restored_count} партий товара"
|
||
)
|
||
|
||
# Обновляем все затронутые Stock записи
|
||
for product, warehouse in stocks_to_refresh:
|
||
try:
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=product,
|
||
warehouse=warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
logger.debug(
|
||
f" Stock обновлен для {product.name} на {warehouse.name}: "
|
||
f"доступно={stock.quantity_available}, "
|
||
f"зарезервировано={stock.quantity_reserved}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Ошибка при обновлении Stock для {product.name}: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Критическая ошибка при откате Sale для заказа {instance.order_number}: {e}",
|
||
exc_info=True
|
||
)
|
||
# transaction.atomic откатит все изменения
|
||
raise
|
||
|
||
# === Обновляем резервы ===
|
||
reservations = Reservation.objects.filter(
|
||
order_item__order=instance,
|
||
status='converted_to_sale'
|
||
)
|
||
|
||
reservations_count = reservations.count()
|
||
|
||
if reservations_count > 0:
|
||
# Обновляем резервы через .save() чтобы сработал сигнал обновления Stock
|
||
# Сигнал update_stock_on_reservation_change автоматически обновит Stock
|
||
for reservation in 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"✓ Обновлено {reservations_count} резервов: "
|
||
f"converted_to_sale → {reservation_target_status}"
|
||
)
|
||
else:
|
||
logger.warning(
|
||
f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'"
|
||
)
|
||
|
||
# === Обновляем is_returned ===
|
||
if is_cancellation:
|
||
# Сценарий Б: устанавливаем is_returned = True
|
||
Order.objects.filter(pk=instance.pk).update(is_returned=True)
|
||
logger.info(f"✓ Установлен флаг is_returned = True")
|
||
else:
|
||
# Сценарий А: сбрасываем is_returned = False
|
||
# (на случай если ранее был cancelled, а теперь вернули в промежуточный)
|
||
if instance.is_returned:
|
||
Order.objects.filter(pk=instance.pk).update(is_returned=False)
|
||
logger.info(f"✓ Сброшен флаг is_returned = False")
|
||
|
||
logger.info(
|
||
f"🎉 Откат для заказа {instance.order_number} завершён успешно: "
|
||
f"{previous_status.name} → {current_status.name}"
|
||
)
|
||
|
||
|
||
@receiver(pre_delete, sender=Order)
|
||
@transaction.atomic
|
||
def release_stock_on_order_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении/отмене заказа освободить резервы.
|
||
|
||
Процесс:
|
||
1. Ищем все резервы для этого заказа ДО удаления
|
||
2. Освобождаем резервы ПОСЛЕ успешного коммита транзакции
|
||
3. Это гарантирует, что резервы освободятся только если удаление успешно
|
||
"""
|
||
# Находим все резервы для этого заказа ДО удаления
|
||
# Используем list() чтобы выполнить запрос сейчас, пока Order ещё существует
|
||
reservations_to_release = list(
|
||
Reservation.objects.filter(
|
||
order_item__order=instance,
|
||
status='reserved'
|
||
)
|
||
)
|
||
|
||
# Освобождаем резервы ПОСЛЕ успешного коммита транзакции
|
||
# Это гарантирует целостность: резервы освободятся только если удаление прошло успешно
|
||
def release_reservations():
|
||
for res in reservations_to_release:
|
||
res.status = 'released'
|
||
res.released_at = timezone.now()
|
||
res.save()
|
||
|
||
transaction.on_commit(release_reservations)
|
||
|
||
|
||
@receiver(post_save, sender=OrderItem)
|
||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении позиции заказа управляем резервами.
|
||
|
||
Процесс:
|
||
1. Ищем существующий резерв для этой позиции
|
||
2. Если резерв ЕСТЬ - обновляем количество
|
||
3. Если резерва НЕТ - создаем новый
|
||
|
||
Покрывает все сценарии:
|
||
- Создание заказа с товарами → создаёт резервы
|
||
- Редактирование + добавление товаров → создаёт резервы для новых
|
||
- Изменение количества → обновляет резервы
|
||
"""
|
||
# Получаем резерв для этой позиции в статусе 'reserved'
|
||
reservation = Reservation.objects.filter(
|
||
order_item=instance,
|
||
status='reserved'
|
||
).first()
|
||
|
||
if reservation:
|
||
# Резерв существует - обновляем его количество
|
||
reservation.quantity = Decimal(str(instance.quantity))
|
||
reservation.save()
|
||
else:
|
||
# Резерва нет - создаем новый
|
||
# Это происходит при создании нового OrderItem (через форму или при редактировании)
|
||
warehouse = instance.order.pickup_warehouse or 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=Incoming)
|
||
def update_stock_batch_on_incoming_edit(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При редактировании товара в приходе (Incoming) автоматически
|
||
обновляется связанная партия товара на складе (StockBatch).
|
||
|
||
Это обеспечивает синхронизацию данных между Incoming и StockBatch.
|
||
|
||
Архитектура:
|
||
- Если Incoming редактируется - обновляем StockBatch с новыми значениями
|
||
- Обновление StockBatch автоматически пересчитывает себестоимость товара (Product.cost_price)
|
||
через сигнал update_product_cost_on_batch_change()
|
||
|
||
Процесс:
|
||
1. Проверяем, это редактирование (created=False), а не создание
|
||
2. Получаем связанный StockBatch
|
||
3. Проверяем, изменились ли quantity или cost_price
|
||
4. Если да - обновляем StockBatch
|
||
5. Сохраняем StockBatch (запускает цепь пересчета себестоимости)
|
||
6. Обновляем остатки на складе (Stock)
|
||
"""
|
||
if created:
|
||
return # Только для редактирования (не для создания)
|
||
|
||
# Получаем связанный StockBatch
|
||
if not instance.stock_batch:
|
||
return # Если нет связи со StockBatch - нечего обновлять
|
||
|
||
stock_batch = instance.stock_batch
|
||
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
try:
|
||
# Проверяем, отличаются ли значения в StockBatch от Incoming
|
||
# Это говорит нам о том, что произошло редактирование
|
||
needs_update = (
|
||
stock_batch.quantity != instance.quantity or
|
||
stock_batch.cost_price != instance.cost_price
|
||
)
|
||
|
||
if not needs_update:
|
||
return # Никаких изменений
|
||
|
||
# Обновляем StockBatch с новыми значениями из Incoming
|
||
stock_batch.quantity = instance.quantity
|
||
stock_batch.cost_price = instance.cost_price
|
||
stock_batch.save()
|
||
|
||
logger.info(
|
||
f"✓ StockBatch #{stock_batch.id} обновлён при редактировании Incoming: "
|
||
f"quantity={instance.quantity}, cost_price={instance.cost_price} "
|
||
f"(товар: {instance.product.sku})"
|
||
)
|
||
|
||
# Обновляем Stock (остатки на складе)
|
||
warehouse = stock_batch.warehouse
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.product,
|
||
warehouse=warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
logger.info(
|
||
f"✓ Stock обновлён для товара {instance.product.sku} "
|
||
f"на складе {warehouse.name}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"Ошибка при обновлении StockBatch при редактировании Incoming #{instance.id}: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
|
||
@receiver(post_save, sender=Sale)
|
||
def process_sale_fifo(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании продажи (Sale) автоматически применяется FIFO-списание.
|
||
|
||
Процесс:
|
||
1. Проверяем, новая ли продажа
|
||
2. Если уже обработана - пропускаем
|
||
3. Списываем товар по FIFO из партий
|
||
4. Создаем SaleBatchAllocation для аудита
|
||
"""
|
||
if not created:
|
||
return # Только для новых продаж
|
||
|
||
# Если уже обработана - пропускаем
|
||
if instance.processed:
|
||
return
|
||
|
||
try:
|
||
# Списываем товар по FIFO
|
||
allocations = StockBatchManager.write_off_by_fifo(
|
||
instance.product,
|
||
instance.warehouse,
|
||
instance.quantity
|
||
)
|
||
|
||
# Фиксируем распределение для аудита
|
||
for batch, qty_allocated in allocations:
|
||
SaleBatchAllocation.objects.create(
|
||
sale=instance,
|
||
batch=batch,
|
||
quantity=qty_allocated,
|
||
cost_price=batch.cost_price
|
||
)
|
||
|
||
# Отмечаем продажу как обработанную (используем update() чтобы избежать повторного срабатывания сигнала)
|
||
Sale.objects.filter(pk=instance.pk).update(processed=True)
|
||
|
||
# Stock уже обновлен в StockBatchManager.write_off_by_fifo() → refresh_stock_cache()
|
||
# Не нужно вызывать ещё раз чтобы избежать race condition
|
||
|
||
except ValueError as e:
|
||
# Логируем ошибку, но не прерываем процесс
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f"Ошибка при обработке Sale {instance.id}: {str(e)}")
|
||
|
||
|
||
@receiver(post_save, sender=Inventory)
|
||
def process_inventory_reconciliation(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При завершении инвентаризации (status='completed')
|
||
автоматически обрабатываются расхождения.
|
||
|
||
Процесс:
|
||
1. Проверяем, изменился ли статус на 'completed'
|
||
2. Вызываем InventoryProcessor для обработки дефицитов/излишков
|
||
3. Создаются WriteOff для недостач и Incoming для излишков
|
||
"""
|
||
if created:
|
||
return # Только для обновлений
|
||
|
||
# Проверяем, изменился ли статус на 'completed'
|
||
if instance.status != 'completed':
|
||
return
|
||
|
||
try:
|
||
# Обрабатываем инвентаризацию
|
||
result = InventoryProcessor.process_inventory(instance.id)
|
||
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.info(
|
||
f"Inventory {instance.id} processed: "
|
||
f"lines={result['processed_lines']}, "
|
||
f"writeoffs={result['writeoffs_created']}, "
|
||
f"incomings={result['incomings_created']}"
|
||
)
|
||
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f"Ошибка при обработке Inventory {instance.id}: {str(e)}", exc_info=True)
|
||
|
||
|
||
@receiver(post_save, sender=WriteOff)
|
||
def update_stock_on_writeoff(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении WriteOff (списание) обновляем Stock.
|
||
|
||
Процесс:
|
||
1. При создании списания - товар удаляется из StockBatch
|
||
2. Обновляем запись Stock для этого товара
|
||
"""
|
||
# Получаем или создаем Stock запись
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.batch.product,
|
||
warehouse=instance.batch.warehouse
|
||
)
|
||
|
||
# Пересчитываем остаток из всех активных партий
|
||
# refresh_from_batches() уже вызывает save()
|
||
stock.refresh_from_batches()
|
||
|
||
|
||
@receiver(post_save, sender=Reservation)
|
||
@transaction.atomic
|
||
def update_stock_on_reservation_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении резерва (Reservation) обновляем Stock.
|
||
|
||
ОПТИМИЗАЦИЯ: Обновляем Stock только если изменились поля, влияющие на резервы:
|
||
- status (reserved/converted_to_sale/released)
|
||
- quantity (количество резерва)
|
||
- created (новый резерв)
|
||
|
||
Процесс:
|
||
1. При создании резерва пересчитываем quantity_reserved
|
||
2. При изменении статуса или количества пересчитываем quantity_reserved
|
||
3. При других изменениях (например, дата) НЕ пересчитываем Stock (оптимизация)
|
||
"""
|
||
if not instance.product or not instance.warehouse:
|
||
return
|
||
|
||
# Если это создание нового резерва - всегда обновляем Stock
|
||
if created:
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.product,
|
||
warehouse=instance.warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
return
|
||
|
||
# Для обновления - проверяем, изменились ли поля, влияющие на Stock
|
||
# Используем django-simple-history или проверяем через update_fields
|
||
update_fields = kwargs.get('update_fields', None)
|
||
|
||
# Если update_fields указаны (вызов через save(update_fields=[...]))
|
||
# проверяем, есть ли среди них 'status' или 'quantity'
|
||
if update_fields is not None:
|
||
fields_affecting_stock = {'status', 'quantity'}
|
||
if not fields_affecting_stock.intersection(update_fields):
|
||
# Изменились другие поля (например, дата) - не обновляем Stock
|
||
return
|
||
|
||
# Если дошли сюда - нужно обновить Stock
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=instance.product,
|
||
warehouse=instance.warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
|
||
@receiver(post_delete, sender=Reservation)
|
||
@transaction.atomic
|
||
def update_stock_on_reservation_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении резерва (Reservation) обновляем Stock.
|
||
|
||
Процесс:
|
||
1. После удаления резерва пересчитываем quantity_reserved
|
||
2. Обновляем запись Stock для этого товара
|
||
"""
|
||
if not instance.product or not instance.warehouse:
|
||
return
|
||
|
||
try:
|
||
# Получаем Stock запись (не создаем новую при удалении)
|
||
stock = Stock.objects.get(
|
||
product=instance.product,
|
||
warehouse=instance.warehouse
|
||
)
|
||
|
||
# Пересчитываем остатки из всех активных партий и резервов
|
||
# refresh_from_batches() уже вызывает save()
|
||
stock.refresh_from_batches()
|
||
|
||
except Stock.DoesNotExist:
|
||
# Если Stock записи нет - ничего не делаем
|
||
pass
|
||
|
||
|
||
def _update_product_in_stock(product_id):
|
||
"""
|
||
Вспомогательная функция: обновить статус in_stock для товара на основе остатков.
|
||
Товар считается в наличии, если существует хотя бы одна Stock запись
|
||
с положительным quantity_available (free quantity).
|
||
"""
|
||
from products.models import Product
|
||
|
||
try:
|
||
product = Product.objects.get(id=product_id)
|
||
|
||
# Проверяем есть ли остаток где-нибудь на складе
|
||
# Товар в наличии если есть хотя бы один Stock с положительным quantity_available
|
||
has_stock = Stock.objects.filter(
|
||
product=product,
|
||
quantity_available__gt=0
|
||
).exists()
|
||
|
||
# Обновляем in_stock если изменился статус
|
||
if product.in_stock != has_stock:
|
||
product.in_stock = has_stock
|
||
# Обновляем без повторного срабатывания сигналов
|
||
Product.objects.filter(id=product.id).update(in_stock=has_stock)
|
||
|
||
except Product.DoesNotExist:
|
||
pass
|
||
|
||
|
||
@receiver(post_save, sender=Stock)
|
||
def update_product_in_stock_on_stock_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При изменении остатков (Stock) обновляем Product.in_stock.
|
||
|
||
Процесс:
|
||
1. После обновления Stock проверяем наличие товара
|
||
2. Если есть положительный остаток - в_наличии=True
|
||
3. Если нет остатков - в_наличии=False
|
||
"""
|
||
_update_product_in_stock(instance.product_id)
|
||
|
||
|
||
@receiver(post_delete, sender=Stock)
|
||
def update_product_in_stock_on_stock_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении Stock записи обновляем Product.in_stock.
|
||
Используем post_delete чтобы правильно проверить остались ли ещё Stock записи.
|
||
"""
|
||
product_id = instance.product_id
|
||
_update_product_in_stock(product_id)
|
||
|
||
|
||
# ============================================================================
|
||
# Сигналы для автоматического обновления себестоимости товара (cost_price)
|
||
# ============================================================================
|
||
|
||
|
||
@receiver(post_save, sender=StockBatch)
|
||
def update_product_cost_on_batch_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При создании или изменении партии (StockBatch) автоматически
|
||
обновляется себестоимость товара (Product.cost_price).
|
||
|
||
Процесс:
|
||
1. Проверяем, есть ли связанный товар
|
||
2. Вызываем ProductCostCalculator для пересчета средневзвешенной стоимости
|
||
3. Обновляем поле cost_price в БД
|
||
|
||
Триггеры:
|
||
- Создание новой партии (поступление товара)
|
||
- Изменение количества в партии
|
||
- Изменение стоимости партии
|
||
"""
|
||
if not instance.product:
|
||
return
|
||
|
||
# Импортируем здесь чтобы избежать circular import
|
||
from products.services.cost_calculator import ProductCostCalculator
|
||
|
||
try:
|
||
# Пересчитываем и обновляем себестоимость товара
|
||
ProductCostCalculator.update_product_cost(instance.product, save=True)
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(
|
||
f"Ошибка при обновлении себестоимости товара {instance.product.sku} "
|
||
f"после изменения партии {instance.id}: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
|
||
@receiver(post_delete, sender=StockBatch)
|
||
def update_product_cost_on_batch_delete(sender, instance, **kwargs):
|
||
"""
|
||
Сигнал: При удалении партии (StockBatch) автоматически
|
||
обновляется себестоимость товара.
|
||
|
||
Процесс:
|
||
1. После удаления партии пересчитываем себестоимость
|
||
2. Если партий не осталось - cost_price становится 0.00
|
||
"""
|
||
if not instance.product:
|
||
return
|
||
|
||
# Импортируем здесь чтобы избежать circular import
|
||
from products.services.cost_calculator import ProductCostCalculator
|
||
|
||
try:
|
||
# Пересчитываем и обновляем себестоимость товара
|
||
ProductCostCalculator.update_product_cost(instance.product, save=True)
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(
|
||
f"Ошибка при обновлении себестоимости товара после удаления партии: {e}",
|
||
exc_info=True
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# Сигналы для динамического пересчета цен комплектов
|
||
# ============================================================================
|
||
|
||
|
||
@receiver(post_save, sender='products.Product')
|
||
def update_kit_prices_on_product_change(sender, instance, created, **kwargs):
|
||
"""
|
||
Сигнал: При изменении цены товара (price или sale_price)
|
||
автоматически пересчитываются цены всех комплектов, где используется этот товар.
|
||
|
||
Процесс:
|
||
1. Находим все KitItem с этим товаром
|
||
2. Для каждого комплекта вызываем recalculate_base_price()
|
||
3. base_price и price обновляются в БД
|
||
|
||
Триггеры:
|
||
- Изменение price (основная цена товара)
|
||
- Изменение sale_price (цена со скидкой товара)
|
||
"""
|
||
from products.models import KitItem
|
||
|
||
# Если это создание товара (не обновление), нет комплектов для пересчета
|
||
if created:
|
||
return
|
||
|
||
# Находим все KitItem с этим товаром
|
||
kit_items = KitItem.objects.filter(product=instance)
|
||
|
||
if not kit_items.exists():
|
||
return # Товар не используется в комплектах
|
||
|
||
# Для каждого комплекта пересчитываем цены
|
||
kits_to_update = set()
|
||
for item in kit_items:
|
||
kits_to_update.add(item.kit_id)
|
||
|
||
# Обновляем цены каждого комплекта
|
||
from products.models import ProductKit
|
||
for kit_id in kits_to_update:
|
||
try:
|
||
kit = ProductKit.objects.get(id=kit_id)
|
||
kit.recalculate_base_price()
|
||
except ProductKit.DoesNotExist:
|
||
pass
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(
|
||
f"Ошибка при пересчете цены комплекта {kit_id} "
|
||
f"после изменения цены товара {instance.sku}: {e}",
|
||
exc_info=True
|
||
)
|