Files
octopus/myproject/inventory/services/batch_manager.py
Andrey Smakotin bc13750d16 Исправление конфликта сигналов при отмене трансформации
Исправлена проблема, когда при отмене проведенной трансформации оба сигнала выполнялись последовательно:
- rollback_transformation_on_cancel возвращал резервы в 'reserved'
- release_reservations_on_draft_cancel ошибочно освобождал их в 'released'

Изменена проверка в release_reservations_on_draft_cancel: вместо проверки наличия партий Output (которые уже удалены) теперь проверяется статус резервов ('converted_to_transformation') или наличие поля converted_at, что работает независимо от порядка выполнения сигналов.
2025-12-25 22:54:39 +03:00

389 lines
17 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.
"""
Менеджер для работы с партиями товаров (StockBatch).
Основной функционал:
- Получение партий для FIFO списания
- Создание новых партий при поступлении
- Списание товара по FIFO при продажах и инвентаризации
"""
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum, Q
from inventory.models import StockBatch, Stock, SaleBatchAllocation
class StockBatchManager:
"""
Менеджер для работы с партиями товаров.
Реализует логику FIFO для списания товаров.
"""
@staticmethod
def get_batches_for_fifo(product, warehouse):
"""
Получить все активные партии товара на складе для FIFO списания.
Возвращает ВСЕ партии с quantity > 0, отсортированные по created_at.
ВАЖНО: Логика учета резервов реализована в write_off_by_fifo().
Args:
product: объект Product
warehouse: объект Warehouse
Returns:
QuerySet отсортированных партий
"""
return StockBatch.objects.filter(
product=product,
warehouse=warehouse,
is_active=True,
quantity__gt=0 # Только партии с остатком
).order_by('created_at') # FIFO: старые первыми
@staticmethod
def create_batch(product, warehouse, quantity, cost_price):
"""
Создать новую партию товара при поступлении.
Args:
product: объект Product
warehouse: объект Warehouse
quantity: Decimal - количество товара
cost_price: Decimal - закупочная цена
Returns:
Созданный объект StockBatch
"""
if quantity <= 0:
raise ValueError("Количество должно быть больше нуля")
batch = StockBatch.objects.create(
product=product,
warehouse=warehouse,
quantity=quantity,
cost_price=cost_price
)
# Обновляем кеш остатков
StockBatchManager.refresh_stock_cache(product, warehouse)
return batch
@staticmethod
def write_off_by_fifo(product, warehouse, quantity_to_write_off, exclude_order=None, exclude_transformation=None):
"""
Списать товар по FIFO (старые партии первыми).
ВАЖНО: Учитывает зарезервированное количество товара.
Возвращает список (batch, written_off_quantity) кортежей.
Args:
product: объект Product
warehouse: объект Warehouse
quantity_to_write_off: Decimal - сколько списать
exclude_order: (опционально) объект Order - исключить резервы этого заказа из расчёта.
Используется при переводе заказа в 'completed', когда резервы
заказа ещё не переведены в 'converted_to_sale'.
exclude_transformation: (опционально) объект Transformation - исключить резервы этой трансформации из расчёта.
Используется при переводе трансформации в 'completed', когда резервы
трансформации ещё не переведены в 'converted_to_transformation'.
Returns:
list: [(batch, qty_written), ...] - какие партии и сколько списано
Raises:
ValueError: если недостаточно свободного товара на складе
"""
from inventory.models import Reservation
remaining = quantity_to_write_off
allocations = []
# Получаем общее количество зарезервированного товара (все резервы со статусом 'reserved')
# Исключаем резервы заказа или трансформации, для которых делается списание (если переданы)
reservation_filter = Reservation.objects.filter(
product=product,
warehouse=warehouse,
status='reserved'
)
# Специальная обработка для трансформации: нужно списывать из зарезервированного товара трансформации
transformation_reserved_qty = Decimal('0')
if exclude_transformation:
transformation_reservations = Reservation.objects.filter(
product=product,
warehouse=warehouse,
status='reserved',
transformation_input__transformation=exclude_transformation
)
transformation_reserved_qty = transformation_reservations.aggregate(total=Sum('quantity'))['total'] or Decimal('0')
# Исключаем резервы трансформации из общего расчета резервов
reservation_filter = reservation_filter.exclude(transformation_input__transformation=exclude_transformation)
if exclude_order:
reservation_filter = reservation_filter.exclude(order_item__order=exclude_order)
total_reserved = reservation_filter.aggregate(total=Sum('quantity'))['total'] or Decimal('0')
# Получаем партии по FIFO
batches = StockBatchManager.get_batches_for_fifo(product, warehouse)
# Проходим партии, списывая товар
# Если есть exclude_transformation, сначала списываем из зарезервированного товара трансформации
reserved_remaining = total_reserved # Сколько резерва (кроме трансформации) еще не распределено по партиям
transformation_reserved_remaining = transformation_reserved_qty # Сколько резерва трансформации еще не распределено
for batch in batches:
if remaining <= 0:
break
# Определяем сколько в этой партии зарезервировано (пропорционально)
# Логика: старые партии "съедают" резерв первыми (как и при списании)
batch_reserved_other = min(batch.quantity, reserved_remaining)
reserved_remaining -= batch_reserved_other
# Если есть резерв трансформации, распределяем его по партиям
batch_reserved_transformation = Decimal('0')
if transformation_reserved_remaining > 0:
# Распределяем резерв трансформации по партиям
batch_reserved_transformation = min(batch.quantity - batch_reserved_other, transformation_reserved_remaining)
transformation_reserved_remaining -= batch_reserved_transformation
# Общее зарезервированное в партии
batch_reserved = batch_reserved_other + batch_reserved_transformation
# Свободное количество в партии
batch_free = batch.quantity - batch_reserved
# Если есть резерв трансформации в этой партии, списываем из него
if batch_reserved_transformation > 0:
# Списываем из зарезервированного товара трансформации
qty_from_reserved = min(batch_reserved_transformation, remaining)
batch.quantity -= qty_from_reserved
batch.save(update_fields=['quantity', 'updated_at'])
remaining -= qty_from_reserved
allocations.append((batch, qty_from_reserved))
if remaining <= 0:
break
# Если партия опустошена, деактивируем её
if batch.quantity <= 0:
batch.is_active = False
batch.save(update_fields=['is_active'])
continue
# Если осталось списать, списываем из свободного
if batch_free > 0 and remaining > 0:
qty_from_this_batch = min(batch_free, remaining)
# Списываем
batch.quantity -= qty_from_this_batch
batch.save(update_fields=['quantity', 'updated_at'])
remaining -= qty_from_this_batch
# Фиксируем распределение
allocations.append((batch, qty_from_this_batch))
# Если партия опустошена, деактивируем её
if batch.quantity <= 0:
batch.is_active = False
batch.save(update_fields=['is_active'])
if remaining > 0:
raise ValueError(
f"Недостаточно СВОБОДНОГО товара на складе. "
f"Требуется {quantity_to_write_off}, доступно {quantity_to_write_off - remaining}. "
f"(Общий резерв: {total_reserved})"
)
# Обновляем кеш остатков
StockBatchManager.refresh_stock_cache(product, warehouse)
return allocations
@staticmethod
def transfer_batch(batch, to_warehouse, quantity):
"""
Перенести товар из одной партии на другой склад.
Сохраняет cost_price партии.
Args:
batch: объект StockBatch (источник)
to_warehouse: объект Warehouse (пункт назначения)
quantity: Decimal - сколько перенести
Returns:
Новый объект StockBatch на целевом складе
"""
if quantity <= 0:
raise ValueError("Количество должно быть больше нуля")
if quantity > batch.quantity:
raise ValueError(
f"Недостаточно товара в партии. "
f"Требуется {quantity}, доступно {batch.quantity}"
)
# Уменьшаем исходную партию
batch.quantity -= quantity
batch.save(update_fields=['quantity', 'updated_at'])
# Если исходная партия опустошена, деактивируем
if batch.quantity <= 0:
batch.is_active = False
batch.save(update_fields=['is_active'])
# Создаем новую партию на целевом складе с той же ценой
new_batch = StockBatch.objects.create(
product=batch.product,
warehouse=to_warehouse,
quantity=quantity,
cost_price=batch.cost_price # Сохраняем цену!
)
# Обновляем кеш остатков на обоих складах
StockBatchManager.refresh_stock_cache(batch.product, batch.warehouse)
StockBatchManager.refresh_stock_cache(batch.product, to_warehouse)
return new_batch
@staticmethod
def refresh_stock_cache(product, warehouse):
"""
Пересчитать кеш остатков для товара на складе.
Обновляет модель Stock с агрегированными данными.
Args:
product: объект Product
warehouse: объект Warehouse
"""
# Получаем или создаем запись Stock
stock, created = Stock.objects.get_or_create(
product=product,
warehouse=warehouse
)
# Обновляем её из батчей
# refresh_from_batches() уже вызывает save() внутри
stock.refresh_from_batches()
@staticmethod
def get_total_stock(product, warehouse):
"""
Получить общее доступное количество товара на складе.
Args:
product: объект Product
warehouse: объект Warehouse
Returns:
Decimal - количество товара
"""
total = StockBatch.objects.filter(
product=product,
warehouse=warehouse,
is_active=True
).aggregate(total=Sum('quantity'))['total'] or Decimal('0')
return total
@staticmethod
def get_batch_details(warehouse, product=None):
"""
Получить подробную информацию о партиях на складе.
Полезно для отчетов.
Args:
warehouse: объект Warehouse
product: (опционально) объект Product для фильтрации
Returns:
list: QuerySet партий с деталями
"""
qs = StockBatch.objects.filter(warehouse=warehouse, is_active=True)
if product:
qs = qs.filter(product=product)
return qs.select_related('product', 'warehouse').order_by('product', 'created_at')
@staticmethod
@transaction.atomic
def close_batch(batch):
"""
Закрыть партию (например, при окончании срока годности).
Невозможно списывать из закрытой партии.
Args:
batch: объект StockBatch
"""
if batch.quantity > 0:
raise ValueError(f"Невозможно закрыть партию с остатком {batch.quantity}")
batch.is_active = False
batch.save(update_fields=['is_active'])
@staticmethod
@transaction.atomic
def transfer_product_by_fifo(product, from_warehouse, to_warehouse, quantity):
"""
Переместить товар с одного склада на другой по FIFO логике.
Старые партии перемещаются первыми.
Args:
product: объект Product
from_warehouse: объект Warehouse (источник)
to_warehouse: объект Warehouse (назначение)
quantity: Decimal - количество товара для перемещения
Returns:
list: [(source_batch, qty_transferred, new_batch), ...]
список кортежей с исходной партией, количеством и созданной партией
Raises:
ValueError: если недостаточно товара на складе-источнике
"""
# Получаем партии по FIFO (старые первыми)
allocations = StockBatchManager.get_batches_for_fifo(product, from_warehouse)
result = []
remaining = quantity
for batch in allocations:
if remaining <= 0:
break
# Определяем сколько перемещаем из этой партии
qty_to_transfer = min(batch.quantity, remaining)
# Уменьшаем исходную партию
batch.quantity -= qty_to_transfer
if batch.quantity <= 0:
batch.is_active = False
batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
# Создаем новую партию на целевом складе с СОХРАНЕНИЕМ cost_price
new_batch = StockBatch.objects.create(
product=product,
warehouse=to_warehouse,
quantity=qty_to_transfer,
cost_price=batch.cost_price # ВАЖНО: сохраняем цену!
)
result.append((batch, qty_to_transfer, new_batch))
remaining -= qty_to_transfer
# Проверяем что было достаточно товара
if remaining > 0:
raise ValueError(
f"Недостаточно товара '{product.name}' на складе '{from_warehouse.name}'. "
f"Не хватает {remaining} шт из запрашиваемых {quantity} шт"
)
# Обновляем кеш остатков на обоих складах
StockBatchManager.refresh_stock_cache(product, from_warehouse)
StockBatchManager.refresh_stock_cache(product, to_warehouse)
return result