feat: Реализовать систему поступления товаров с партиями (IncomingBatch)
Основные изменения: - Создана модель IncomingBatch для группировки товаров по документам - Каждое поступление (Incoming) связано с одной батчем поступления - Автоматическое создание StockBatch для каждого товара в приходе - Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД - Обновлены все представления (views) для работы с новой архитектурой - Добавлены детальные страницы просмотра партий поступлений - Обновлены шаблоны для отображения информации о партиях и их товарах - Исправлена логика сигналов для создания StockBatch при приходе товара - Обновлены формы для работы с новой структурой IncomingBatch Архитектура FIFO: - IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX) - Incoming: товар в партии поступления - StockBatch: одна партия товара на складе (создается для каждого товара) Это позволяет системе правильно применять FIFO при продаже товаров. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
340
myproject/inventory/signals.py
Normal file
340
myproject/inventory/signals.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Сигналы для автоматического управления резервами и списаниями.
|
||||
|
||||
Подключаются при создании, изменении и удалении заказов.
|
||||
"""
|
||||
|
||||
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
|
||||
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
|
||||
from inventory.models import 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 для этого товара
|
||||
"""
|
||||
from inventory.models import Stock
|
||||
|
||||
# Получаем или создаем Stock запись
|
||||
stock, _ = Stock.objects.get_or_create(
|
||||
product=instance.batch.product,
|
||||
warehouse=instance.batch.warehouse
|
||||
)
|
||||
|
||||
# Пересчитываем остаток из всех активных партий
|
||||
# refresh_from_batches() уже вызывает save()
|
||||
stock.refresh_from_batches()
|
||||
Reference in New Issue
Block a user