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:
@@ -574,14 +574,27 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
||||
|
||||
try:
|
||||
for sale in sales:
|
||||
# Запоминаем product и warehouse для обновления Stock
|
||||
# (важно для pending продаж, которые учитываются в Stock.refresh_from_batches())
|
||||
stocks_to_refresh.add((sale.product, sale.warehouse))
|
||||
|
||||
# Находим все распределения партий
|
||||
allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch')
|
||||
|
||||
if not allocations.exists():
|
||||
logger.warning(
|
||||
f"⚠ Sale {sale.id} не имеет SaleBatchAllocation. "
|
||||
f"Удаляем Sale без восстановления товара."
|
||||
)
|
||||
# Для pending продаж (is_pending_cost=True) это нормально -
|
||||
# партии ещё не были созданы. При удалении Sale
|
||||
# Stock.refresh_from_batches() автоматически уберёт pending из расчёта.
|
||||
if sale.is_pending_cost:
|
||||
logger.info(
|
||||
f" Sale {sale.id} - pending продажа (в минус). "
|
||||
f"Удаляем Sale, Stock обновится автоматически."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠ Sale {sale.id} не имеет SaleBatchAllocation. "
|
||||
f"Удаляем Sale без восстановления товара."
|
||||
)
|
||||
sale.delete()
|
||||
continue
|
||||
|
||||
@@ -1190,34 +1203,36 @@ def process_sale_fifo(sender, instance, created, **kwargs):
|
||||
if instance.processed:
|
||||
return
|
||||
|
||||
try:
|
||||
# Списываем товар по FIFO
|
||||
allocations = StockBatchManager.write_off_by_fifo(
|
||||
instance.product,
|
||||
instance.warehouse,
|
||||
instance.quantity
|
||||
# Списываем товар по FIFO с allow_negative=True для поддержки продаж "в минус"
|
||||
allocations, pending = StockBatchManager.write_off_by_fifo(
|
||||
instance.product,
|
||||
instance.warehouse,
|
||||
instance.quantity,
|
||||
allow_negative=True
|
||||
)
|
||||
|
||||
# Фиксируем распределение для аудита
|
||||
for batch, qty_allocated in allocations:
|
||||
SaleBatchAllocation.objects.create(
|
||||
sale=instance,
|
||||
batch=batch,
|
||||
quantity=qty_allocated,
|
||||
cost_price=batch.cost_price
|
||||
)
|
||||
|
||||
# Фиксируем распределение для аудита
|
||||
for batch, qty_allocated in allocations:
|
||||
SaleBatchAllocation.objects.create(
|
||||
sale=instance,
|
||||
batch=batch,
|
||||
quantity=qty_allocated,
|
||||
cost_price=batch.cost_price
|
||||
)
|
||||
# Если есть pending - это продажа "в минус"
|
||||
update_fields = ['processed']
|
||||
instance.processed = True
|
||||
if pending > 0:
|
||||
instance.is_pending_cost = True
|
||||
instance.pending_quantity = pending
|
||||
update_fields.extend(['is_pending_cost', 'pending_quantity'])
|
||||
|
||||
# Отмечаем продажу как обработанную (используем update() чтобы избежать повторного срабатывания сигнала)
|
||||
Sale.objects.filter(pk=instance.pk).update(processed=True)
|
||||
# Отмечаем продажу как обработанную (используем update() чтобы избежать повторного срабатывания сигнала)
|
||||
Sale.objects.filter(pk=instance.pk).update(**{field: getattr(instance, field) for field in update_fields})
|
||||
|
||||
# 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)}")
|
||||
# Stock уже обновлен в StockBatchManager.write_off_by_fifo() → refresh_stock_cache()
|
||||
# Не нужно вызывать ещё раз чтобы избежать race condition
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Sale)
|
||||
@@ -1227,11 +1242,21 @@ def update_order_on_sale_delete(sender, instance, **kwargs):
|
||||
Вызывается ДО удаления, чтобы можно было получить order.
|
||||
"""
|
||||
if instance.order:
|
||||
# Используем on_commit чтобы обновить после завершения транзакции
|
||||
from django.db import transaction
|
||||
transaction.on_commit(
|
||||
lambda: update_is_returned_flag(instance.order)
|
||||
)
|
||||
# Сохраняем order_id для использования в post_delete
|
||||
# (instance.order может быть недоступен после удаления)
|
||||
instance._order_for_update = instance.order
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Sale)
|
||||
def update_order_on_sale_post_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Обновляет флаг is_returned заказа ПОСЛЕ удаления Sale.
|
||||
Использует order, сохранённый в pre_delete.
|
||||
"""
|
||||
order = getattr(instance, '_order_for_update', None)
|
||||
if order:
|
||||
# Обновляем флаг напрямую после удаления Sale
|
||||
update_is_returned_flag(order)
|
||||
|
||||
|
||||
# Сигнал process_inventory_reconciliation удален
|
||||
@@ -1582,11 +1607,12 @@ def process_transformation_on_complete(sender, instance, created, **kwargs):
|
||||
total_input_cost = Decimal('0')
|
||||
|
||||
for trans_input in instance.inputs.all():
|
||||
allocations = StockBatchManager.write_off_by_fifo(
|
||||
allocations, pending = StockBatchManager.write_off_by_fifo(
|
||||
product=trans_input.product,
|
||||
warehouse=instance.warehouse,
|
||||
quantity_to_write_off=trans_input.quantity,
|
||||
exclude_transformation=instance # Исключаем резервы этой трансформации
|
||||
exclude_transformation=instance, # Исключаем резервы этой трансформации
|
||||
allow_negative=False # Трансформация требует наличия товара
|
||||
)
|
||||
|
||||
# Суммируем себестоимость списанного
|
||||
|
||||
Reference in New Issue
Block a user