Files
octopus/myproject/inventory/signals.py
Andrey Smakotin 83412f3447 fix: Исправить логику обновления Product.in_stock из Stock остатков
Проблема: товары отображались как "нет в наличии" несмотря на наличие остатков на складе.

Причина: сигналы на обновление Product.in_stock срабатывают только при изменении Stock через Django ORM.
Если Stock была создана напрямую (импорт, миграция и т.д.), сигналы не срабатывали.

Решение:

1. Исправлена логика сигналов (inventory/signals.py):
   - Добавлен импорт post_delete для правильной обработки удаления Stock
   - Изменён pre_delete на post_delete для более надёжной проверки остатков
   - Сигналы теперь правильно срабатывают при любом изменении Stock

2. Добавлена миграция (products/migrations/0004_fix_product_in_stock.py):
   - Пересчитывает in_stock для всех существующих товаров на основе Stock.quantity_available
   - Товар считается в наличии если есть хотя бы один Stock с quantity_available > 0
   - Обратима и безопасна (может быть отменена)

3. Добавлена команда управления (products/management/commands/update_product_in_stock.py):
   - Позволяет вручную пересчитать in_stock если потребуется
   - Поддерживает параметр --verbose для подробного логирования
   - Может быть запущена по расписанию или вручную

После этого исправления:
- Все товары с остатками на складе автоматически обновляют статус in_stock
- Сигналы срабатывают при любом изменении Stock (создание, обновление, удаление)
- Отображение наличия товаров в UI будет корректным

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 23:42:20 +03:00

389 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сигналы для автоматического управления резервами и списаниями.
Подключаются при создании, изменении и удалении заказов.
"""
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()
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)