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:
2025-10-29 03:26:06 +03:00
parent 097d4ea304
commit 6735be9b08
73 changed files with 6536 additions and 122 deletions

View 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()