Fix: FIFO учитывает резервы, автоматическая дата/время в POS, исправлен фильтр Product в transfers

- Исправлен метод write_off_by_fifo() для учета зарезервированных партий
- Добавлено автоматическое проставление даты и времени при создании заказов в POS
- Исправлена ошибка фильтрации Product (is_active -> status='active') в transfers
- Предотвращает списание из зарезервированных партий, устраняя отрицательные остатки

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-05 23:40:03 +03:00
parent 456ae0b742
commit 2f7fed4a1a
3 changed files with 42 additions and 10 deletions

View File

@@ -22,8 +22,9 @@ class StockBatchManager:
@staticmethod @staticmethod
def get_batches_for_fifo(product, warehouse): def get_batches_for_fifo(product, warehouse):
""" """
Получить все активные партии товара на складе, Получить все активные партии товара на складе для FIFO списания.
отсортированные по created_at (старые первыми для FIFO). Возвращает ВСЕ партии с quantity > 0, отсортированные по created_at.
ВАЖНО: Логика учета резервов реализована в write_off_by_fifo().
Args: Args:
product: объект Product product: объект Product
@@ -72,6 +73,7 @@ class StockBatchManager:
def write_off_by_fifo(product, warehouse, quantity_to_write_off): def write_off_by_fifo(product, warehouse, quantity_to_write_off):
""" """
Списать товар по FIFO (старые партии первыми). Списать товар по FIFO (старые партии первыми).
ВАЖНО: Учитывает зарезервированное количество товара.
Возвращает список (batch, written_off_quantity) кортежей. Возвращает список (batch, written_off_quantity) кортежей.
Args: Args:
@@ -83,20 +85,44 @@ class StockBatchManager:
list: [(batch, qty_written), ...] - какие партии и сколько списано list: [(batch, qty_written), ...] - какие партии и сколько списано
Raises: Raises:
ValueError: если недостаточно товара на складе ValueError: если недостаточно свободного товара на складе
""" """
from inventory.models import Reservation
remaining = quantity_to_write_off remaining = quantity_to_write_off
allocations = [] allocations = []
# Получаем общее количество зарезервированного товара (все резервы со статусом 'reserved')
total_reserved = Reservation.objects.filter(
product=product,
warehouse=warehouse,
status='reserved'
).aggregate(total=Sum('quantity'))['total'] or Decimal('0')
# Получаем партии по FIFO # Получаем партии по FIFO
batches = StockBatchManager.get_batches_for_fifo(product, warehouse) batches = StockBatchManager.get_batches_for_fifo(product, warehouse)
# Проходим партии, списывая только СВОБОДНОЕ количество
reserved_remaining = total_reserved # Сколько резерва еще не распределено по партиям
for batch in batches: for batch in batches:
if remaining <= 0: if remaining <= 0:
break break
# Сколько можем списать из этой партии # Определяем сколько в этой партии зарезервировано (пропорционально)
qty_from_this_batch = min(batch.quantity, remaining) # Логика: старые партии "съедают" резерв первыми (как и при списании)
batch_reserved = min(batch.quantity, reserved_remaining)
reserved_remaining -= batch_reserved
# Свободное количество в партии
batch_free = batch.quantity - batch_reserved
if batch_free <= 0:
# Партия полностью зарезервирована → пропускаем
continue
# Сколько можем списать из этой партии (только свободное)
qty_from_this_batch = min(batch_free, remaining)
# Списываем # Списываем
batch.quantity -= qty_from_this_batch batch.quantity -= qty_from_this_batch
@@ -114,8 +140,9 @@ class StockBatchManager:
if remaining > 0: if remaining > 0:
raise ValueError( raise ValueError(
f"Недостаточно товара на складе. " f"Недостаточно СВОБОДНОГО товара на складе. "
f"Требуется {quantity_to_write_off}, доступно {quantity_to_write_off - remaining}" f"Требуется {quantity_to_write_off}, доступно {quantity_to_write_off - remaining}. "
f"(Общий резерв: {total_reserved})"
) )
# Обновляем кеш остатков # Обновляем кеш остатков

View File

@@ -91,10 +91,10 @@ class TransferBulkCreateView(LoginRequiredMixin, View):
return render(request, self.template_name, {'form': form}, status=400) return render(request, self.template_name, {'form': form}, status=400)
try: try:
product = Product.objects.get(id=product_id, is_active=True) product = Product.objects.get(id=product_id, status='active')
products.append((product, quantity)) products.append((product, quantity))
except Product.DoesNotExist: except Product.DoesNotExist:
messages.error(request, f'Товар с ID {product_id} не найден') messages.error(request, f'Товар с ID {product_id} не найден или неактивен')
return render(request, self.template_name, {'form': form}, status=400) return render(request, self.template_name, {'form': form}, status=400)
# Начинаем транзакцию # Начинаем транзакцию

View File

@@ -1312,12 +1312,17 @@ def pos_checkout(request):
# Атомарная операция # Атомарная операция
with db_transaction.atomic(): with db_transaction.atomic():
# 1. Создаём заказ # 1. Создаём заказ с автоматическим проставлением текущей даты и времени
now = timezone.now()
current_time = now.time()
order = Order.objects.create( order = Order.objects.create(
customer=customer, customer=customer,
is_delivery=False, # POS - всегда самовывоз is_delivery=False, # POS - всегда самовывоз
pickup_warehouse=warehouse, pickup_warehouse=warehouse,
status=completed_status, # Сразу "Выполнен" status=completed_status, # Сразу "Выполнен"
delivery_date=now.date(), # Текущая дата
delivery_time_start=current_time, # Текущее время
delivery_time_end=current_time, # То же время (точное время)
special_instructions=order_notes, special_instructions=order_notes,
modified_by=request.user modified_by=request.user
) )