КРИТИЧНО: Все агрегации Reservation.quantity заменены на quantity_base Проблемы и решения: 🔴 КРИТИЧНО - BatchManager.write_off_by_fifo(): - Проблема: суммировал quantity вместо quantity_base - Влияние: FIFO расчет свободного товара был некорректен - Решение: aggregate(Sum('quantity_base')) в строках 118, 125 🟡 СРЕДНЯЯ ВАЖНОСТЬ - ShowcaseManager: - reserve_showcase_item(): обновление quantity и quantity_base (строка 403) - release_showcase_reservation(): обновление обоих полей (строка 481) - Теперь витринные резервы полностью консистентны 🟡 СРЕДНЯЯ ВАЖНОСТЬ - TransformationService: - confirm(): проверка доступности через quantity_base (строка 254) - Корректная валидация при трансформации товаров 🟢 НИЗКАЯ ВАЖНОСТЬ - WriteOffDocumentService: - update_item(): синхронизация quantity и quantity_base (строка 175) - Полнота данных в резервах документов списания 🟢 НИЗКАЯ ВАЖНОСТЬ - Сигналы (signals.py): - update_order_item_reservation(): обновление обоих полей для товаров - Для обычных товаров: quantity_base = quantity_in_base_units (строка 1081) - Для комплектов: quantity_base = quantity (компоненты в базовых) (строка 1107) - Добавлено обновление sales_unit при изменении OrderItem Архитектура: - Принцип: quantity_base ВСЕГДА содержит количество в базовых единицах - Все агрегации резервов используют quantity_base для корректных расчетов - quantity сохраняется для совместимости и отображения - sales_unit хранит ссылку на единицу продажи для аудита
389 lines
17 KiB
Python
389 lines
17 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):
|
||
"""
|
||
Списать товар по 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_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:
|
||
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
|