Implement functionality to allow sales even when stock is insufficient, tracking pending quantities and resolving them when new stock arrives via incoming documents. This includes new fields in Sale model (is_pending_cost, pending_quantity), updates to batch manager for negative write-offs, and signal handlers for automatic processing. - Add is_pending_cost and pending_quantity fields to Sale model - Modify write_off_by_fifo to support allow_negative flag and return pending quantity - Update incoming document service to allocate pending sales to new batches - Enhance sale processor and signals to handle pending sales - Remove outdated tests.py file - Add migration for new Sale fields
397 lines
18 KiB
Python
397 lines
18 KiB
Python
"""
|
||
Менеджер для работы с партиями товаров (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, allow_negative=False):
|
||
"""
|
||
Списать товар по FIFO (старые партии первыми).
|
||
ВАЖНО: Учитывает зарезервированное количество товара.
|
||
|
||
Args:
|
||
product: объект Product
|
||
warehouse: объект Warehouse
|
||
quantity_to_write_off: Decimal - сколько списать
|
||
exclude_order: (опционально) объект Order - исключить резервы этого заказа из расчёта.
|
||
Используется при переводе заказа в 'completed', когда резервы
|
||
заказа ещё не переведены в 'converted_to_sale'.
|
||
exclude_transformation: (опционально) объект Transformation - исключить резервы этой трансформации из расчёта.
|
||
Используется при переводе трансформации в 'completed', когда резервы
|
||
трансформации ещё не переведены в 'converted_to_transformation'.
|
||
allow_negative: (опционально) bool - разрешить продажи "в минус".
|
||
Если True и товара не хватает, возвращает pending_quantity вместо исключения.
|
||
|
||
Returns:
|
||
tuple: (allocations, pending_quantity)
|
||
- allocations: [(batch, qty_written), ...] - какие партии и сколько списано
|
||
- pending_quantity: Decimal - сколько не удалось списать (для продаж "в минус")
|
||
|
||
Raises:
|
||
ValueError: если недостаточно свободного товара на складе и allow_negative=False
|
||
"""
|
||
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_base'))['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_base'))['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:
|
||
if allow_negative:
|
||
# Возвращаем сколько не удалось списать (для продаж "в минус")
|
||
StockBatchManager.refresh_stock_cache(product, warehouse)
|
||
return (allocations, remaining)
|
||
else:
|
||
raise ValueError(
|
||
f"Недостаточно СВОБОДНОГО товара на складе. "
|
||
f"Требуется {quantity_to_write_off}, доступно {quantity_to_write_off - remaining}. "
|
||
f"(Общий резерв: {total_reserved})"
|
||
)
|
||
|
||
# Обновляем кеш остатков
|
||
StockBatchManager.refresh_stock_cache(product, warehouse)
|
||
|
||
return (allocations, Decimal('0'))
|
||
|
||
@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
|