feat(inventory): add support for selling in negative stock
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
This commit is contained in:
@@ -70,11 +70,10 @@ class StockBatchManager:
|
||||
return batch
|
||||
|
||||
@staticmethod
|
||||
def write_off_by_fifo(product, warehouse, quantity_to_write_off, exclude_order=None, exclude_transformation=None):
|
||||
def write_off_by_fifo(product, warehouse, quantity_to_write_off, exclude_order=None, exclude_transformation=None, allow_negative=False):
|
||||
"""
|
||||
Списать товар по FIFO (старые партии первыми).
|
||||
ВАЖНО: Учитывает зарезервированное количество товара.
|
||||
Возвращает список (batch, written_off_quantity) кортежей.
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
@@ -86,12 +85,16 @@ class StockBatchManager:
|
||||
exclude_transformation: (опционально) объект Transformation - исключить резервы этой трансформации из расчёта.
|
||||
Используется при переводе трансформации в 'completed', когда резервы
|
||||
трансформации ещё не переведены в 'converted_to_transformation'.
|
||||
allow_negative: (опционально) bool - разрешить продажи "в минус".
|
||||
Если True и товара не хватает, возвращает pending_quantity вместо исключения.
|
||||
|
||||
Returns:
|
||||
list: [(batch, qty_written), ...] - какие партии и сколько списано
|
||||
tuple: (allocations, pending_quantity)
|
||||
- allocations: [(batch, qty_written), ...] - какие партии и сколько списано
|
||||
- pending_quantity: Decimal - сколько не удалось списать (для продаж "в минус")
|
||||
|
||||
Raises:
|
||||
ValueError: если недостаточно свободного товара на складе
|
||||
ValueError: если недостаточно свободного товара на складе и allow_negative=False
|
||||
"""
|
||||
from inventory.models import Reservation
|
||||
|
||||
@@ -191,16 +194,21 @@ class StockBatchManager:
|
||||
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})"
|
||||
)
|
||||
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
|
||||
return (allocations, Decimal('0'))
|
||||
|
||||
@staticmethod
|
||||
def transfer_batch(batch, to_warehouse, quantity):
|
||||
|
||||
@@ -203,6 +203,13 @@ class IncomingDocumentService:
|
||||
batches_created.append(stock_batch)
|
||||
total_cost += item.total_cost
|
||||
|
||||
# Обрабатываем ожидающие продажи "в минус" для этого товара
|
||||
cls._process_pending_sales(
|
||||
product=item.product,
|
||||
warehouse=document.warehouse,
|
||||
new_batch=stock_batch
|
||||
)
|
||||
|
||||
# Обновляем или создаем запись в Stock
|
||||
stock, _ = Stock.objects.get_or_create(
|
||||
product=item.product,
|
||||
@@ -224,6 +231,58 @@ class IncomingDocumentService:
|
||||
'total_cost': total_cost
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _process_pending_sales(cls, product, warehouse, new_batch):
|
||||
"""
|
||||
Привязать ожидающие продажи "в минус" к новой партии по FIFO.
|
||||
Себестоимость берётся из этой партии.
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
new_batch: объект StockBatch (только что созданная партия)
|
||||
"""
|
||||
from inventory.models import Sale, SaleBatchAllocation
|
||||
|
||||
# Ожидающие продажи по дате (старые первыми - FIFO)
|
||||
pending_sales = Sale.objects.filter(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
is_pending_cost=True,
|
||||
pending_quantity__gt=0
|
||||
).order_by('date')
|
||||
|
||||
available_qty = new_batch.quantity
|
||||
|
||||
for sale in pending_sales:
|
||||
if available_qty <= 0:
|
||||
break
|
||||
|
||||
qty_to_allocate = min(sale.pending_quantity, available_qty)
|
||||
|
||||
# Создаем SaleBatchAllocation с себестоимостью из приёмки
|
||||
SaleBatchAllocation.objects.create(
|
||||
sale=sale,
|
||||
batch=new_batch,
|
||||
quantity=qty_to_allocate,
|
||||
cost_price=new_batch.cost_price
|
||||
)
|
||||
|
||||
# Уменьшаем pending в Sale
|
||||
sale.pending_quantity -= qty_to_allocate
|
||||
if sale.pending_quantity <= 0:
|
||||
sale.is_pending_cost = False
|
||||
sale.save(update_fields=['pending_quantity', 'is_pending_cost'])
|
||||
|
||||
# Уменьшаем партию (товар уже был "продан" ранее)
|
||||
new_batch.quantity -= qty_to_allocate
|
||||
available_qty -= qty_to_allocate
|
||||
|
||||
# Сохраняем партию с оставшимся количеством
|
||||
if new_batch.quantity <= 0:
|
||||
new_batch.is_active = False
|
||||
new_batch.save(update_fields=['quantity', 'is_active'])
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def cancel_document(cls, document):
|
||||
|
||||
@@ -104,33 +104,38 @@ class SaleProcessor:
|
||||
document_number=document_number,
|
||||
processed=True, # Сразу отмечаем как обработанную
|
||||
sales_unit=sales_unit,
|
||||
unit_name_snapshot=unit_name_snapshot
|
||||
unit_name_snapshot=unit_name_snapshot,
|
||||
is_pending_cost=False,
|
||||
pending_quantity=Decimal('0')
|
||||
)
|
||||
|
||||
try:
|
||||
# Списываем товар по FIFO в БАЗОВЫХ единицах
|
||||
# exclude_order позволяет не считать резервы этого заказа как занятые
|
||||
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
||||
allocations = StockBatchManager.write_off_by_fifo(
|
||||
product, warehouse, quantity_base, exclude_order=order
|
||||
# Списываем товар по FIFO в БАЗОВЫХ единицах
|
||||
# exclude_order позволяет не считать резервы этого заказа как занятые
|
||||
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
||||
# allow_negative=True разрешает продажи "в минус"
|
||||
allocations, pending = StockBatchManager.write_off_by_fifo(
|
||||
product, warehouse, quantity_base, exclude_order=order, allow_negative=True
|
||||
)
|
||||
|
||||
# Фиксируем распределение для аудита
|
||||
for batch, qty_allocated in allocations:
|
||||
SaleBatchAllocation.objects.create(
|
||||
sale=sale,
|
||||
batch=batch,
|
||||
quantity=qty_allocated,
|
||||
cost_price=batch.cost_price
|
||||
)
|
||||
|
||||
# Фиксируем распределение для аудита
|
||||
for batch, qty_allocated in allocations:
|
||||
SaleBatchAllocation.objects.create(
|
||||
sale=sale,
|
||||
batch=batch,
|
||||
quantity=qty_allocated,
|
||||
cost_price=batch.cost_price
|
||||
)
|
||||
# Если есть pending - это продажа "в минус"
|
||||
if pending > 0:
|
||||
sale.is_pending_cost = True
|
||||
sale.pending_quantity = pending
|
||||
sale.save(update_fields=['is_pending_cost', 'pending_quantity'])
|
||||
|
||||
# processed уже установлен в True при создании Sale
|
||||
return sale
|
||||
# Обновляем Stock (теперь учитывает pending_sales)
|
||||
StockBatchManager.refresh_stock_cache(product, warehouse)
|
||||
|
||||
except ValueError as e:
|
||||
# Если ошибка при списании - удаляем Sale и пробрасываем исключение
|
||||
sale.delete()
|
||||
raise
|
||||
return sale
|
||||
|
||||
@staticmethod
|
||||
def get_sale_cost_analysis(sale):
|
||||
|
||||
Reference in New Issue
Block a user