- Добавлены поля snapshot_* в модель InventoryLine для фиксации значений на момент завершения - Обновлен InventoryProcessor для сохранения snapshot перед обработкой - Обновлен InventoryDetailView для отображения snapshot-значений в проведенных инвентаризациях - Добавлена миграция 0018 для новых полей - Теперь в проведенных инвентаризациях отображаются оригинальные значения и правильная разница, а не текущие скорректированные остатки
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""
|
||
Процессор для обработки инвентаризации.
|
||
Основной функционал:
|
||
- Обработка расхождений между фактом и системой
|
||
- Автоматическое создание WriteOffDocument для недостач (черновик)
|
||
- Автоматическое создание IncomingDocument для излишков (черновик)
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from inventory.models import (
|
||
Inventory, InventoryLine, StockBatch, Stock
|
||
)
|
||
from inventory.services.batch_manager import StockBatchManager
|
||
from inventory.services.writeoff_document_service import WriteOffDocumentService
|
||
from inventory.services.incoming_document_service import IncomingDocumentService
|
||
|
||
|
||
class InventoryProcessor:
|
||
"""
|
||
Обработчик инвентаризации с автоматической коррекцией остатков.
|
||
"""
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def process_inventory(inventory_id):
|
||
"""
|
||
Обработать инвентаризацию:
|
||
- Для недостач (разница < 0): создать WriteOffDocument (черновик) с позициями
|
||
- Для излишков (разница > 0): создать IncomingDocument (черновик) с позициями
|
||
- Обновить статус inventory и lines
|
||
- НЕ проводить документы сразу - они остаются в статусе 'draft'
|
||
|
||
Args:
|
||
inventory_id: ID объекта Inventory
|
||
|
||
Returns:
|
||
dict: {
|
||
'inventory': Inventory,
|
||
'processed_lines': int,
|
||
'writeoff_document': WriteOffDocument или None,
|
||
'incoming_document': IncomingDocument или None,
|
||
'errors': [...]
|
||
}
|
||
"""
|
||
inventory = Inventory.objects.get(id=inventory_id)
|
||
# Получаем все строки инвентаризации для сохранения snapshot
|
||
all_lines = InventoryLine.objects.filter(inventory=inventory)
|
||
# Получаем только необработанные строки для обработки
|
||
lines = all_lines.filter(processed=False)
|
||
|
||
errors = []
|
||
writeoff_document = None
|
||
incoming_document = None
|
||
|
||
# Сохраняем snapshot-значения для ВСЕХ строк ПЕРЕД обработкой
|
||
# Это нужно чтобы зафиксировать состояние на момент подсчета
|
||
from inventory.models import Stock
|
||
for line in all_lines:
|
||
try:
|
||
stock, _ = Stock.objects.get_or_create(
|
||
product=line.product,
|
||
warehouse=inventory.warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
# Пересчитываем разницу перед сохранением snapshot
|
||
# чтобы убедиться что она актуальна
|
||
current_difference = (line.quantity_fact + stock.quantity_reserved) - stock.quantity_available
|
||
|
||
# Сохраняем snapshot-значения на момент завершения
|
||
line.snapshot_quantity_available = stock.quantity_available
|
||
line.snapshot_quantity_reserved = stock.quantity_reserved
|
||
line.snapshot_quantity_system = stock.quantity_free
|
||
line.snapshot_difference = current_difference
|
||
line.save(update_fields=[
|
||
'snapshot_quantity_available',
|
||
'snapshot_quantity_reserved',
|
||
'snapshot_quantity_system',
|
||
'snapshot_difference'
|
||
])
|
||
except Exception as e:
|
||
errors.append({
|
||
'line': line,
|
||
'error': f'Ошибка сохранения snapshot: {str(e)}'
|
||
})
|
||
|
||
# Собираем недостачи и излишки
|
||
deficit_lines = []
|
||
surplus_lines = []
|
||
|
||
try:
|
||
for line in lines:
|
||
try:
|
||
if line.difference < 0:
|
||
# Недостача
|
||
deficit_lines.append(line)
|
||
elif line.difference > 0:
|
||
# Излишек
|
||
surplus_lines.append(line)
|
||
|
||
# Отмечаем строку как обработанную
|
||
line.processed = True
|
||
line.save(update_fields=['processed'])
|
||
|
||
except Exception as e:
|
||
errors.append({
|
||
'line': line,
|
||
'error': str(e)
|
||
})
|
||
|
||
# Создаем WriteOffDocument для недостач (если есть)
|
||
if deficit_lines:
|
||
writeoff_document = InventoryProcessor._create_writeoff_document(
|
||
inventory, deficit_lines
|
||
)
|
||
|
||
# Создаем IncomingDocument для излишков (если есть)
|
||
if surplus_lines:
|
||
incoming_document = InventoryProcessor._create_incoming_document(
|
||
inventory, surplus_lines
|
||
)
|
||
|
||
# Обновляем статус инвентаризации
|
||
inventory.status = 'completed'
|
||
inventory.save(update_fields=['status'])
|
||
|
||
except Exception as e:
|
||
errors.append({
|
||
'inventory': inventory,
|
||
'error': str(e)
|
||
})
|
||
|
||
return {
|
||
'inventory': inventory,
|
||
'processed_lines': lines.count(),
|
||
'writeoff_document': writeoff_document,
|
||
'incoming_document': incoming_document,
|
||
'errors': errors
|
||
}
|
||
|
||
@staticmethod
|
||
def _create_writeoff_document(inventory, deficit_lines):
|
||
"""
|
||
Создать документ списания (WriteOffDocument) для недостач при инвентаризации.
|
||
Документ создается в статусе 'draft' и не проводится сразу.
|
||
|
||
Args:
|
||
inventory: объект Inventory
|
||
deficit_lines: список InventoryLine с negative difference
|
||
|
||
Returns:
|
||
WriteOffDocument
|
||
"""
|
||
if not deficit_lines:
|
||
return None
|
||
|
||
# Создаем документ списания (черновик)
|
||
writeoff_document = WriteOffDocumentService.create_document(
|
||
warehouse=inventory.warehouse,
|
||
date=inventory.date.date(),
|
||
notes=f'Списание по результатам инвентаризации #{inventory.id}',
|
||
created_by=None # Можно добавить пользователя если передавать в process_inventory
|
||
)
|
||
|
||
# Связываем документ с инвентаризацией
|
||
writeoff_document.inventory = inventory
|
||
writeoff_document.save(update_fields=['inventory'])
|
||
|
||
# Добавляем позиции в документ
|
||
for line in deficit_lines:
|
||
quantity_to_writeoff = abs(line.difference)
|
||
WriteOffDocumentService.add_item(
|
||
document=writeoff_document,
|
||
product=line.product,
|
||
quantity=quantity_to_writeoff,
|
||
reason='inventory',
|
||
notes=f'Инвентаризация #{inventory.id}, строка #{line.id}'
|
||
)
|
||
|
||
return writeoff_document
|
||
|
||
@staticmethod
|
||
def _create_incoming_document(inventory, surplus_lines):
|
||
"""
|
||
Создать документ поступления (IncomingDocument) для излишков при инвентаризации.
|
||
Документ создается в статусе 'draft' и не проводится сразу.
|
||
|
||
Args:
|
||
inventory: объект Inventory
|
||
surplus_lines: список InventoryLine с positive difference
|
||
|
||
Returns:
|
||
IncomingDocument
|
||
"""
|
||
if not surplus_lines:
|
||
return None
|
||
|
||
# Создаем документ поступления (черновик) с типом 'inventory'
|
||
incoming_document = IncomingDocumentService.create_document(
|
||
warehouse=inventory.warehouse,
|
||
date=inventory.date.date(),
|
||
receipt_type='inventory',
|
||
supplier_name=None,
|
||
notes=f'Оприходование по результатам инвентаризации #{inventory.id}',
|
||
created_by=None # Можно добавить пользователя если передавать в process_inventory
|
||
)
|
||
|
||
# Связываем документ с инвентаризацией
|
||
incoming_document.inventory = inventory
|
||
incoming_document.save(update_fields=['inventory'])
|
||
|
||
# Добавляем позиции в документ
|
||
for line in surplus_lines:
|
||
quantity_surplus = line.difference
|
||
|
||
# Получаем последнюю known cost_price
|
||
cost_price = InventoryProcessor._get_last_cost_price(
|
||
line.product,
|
||
inventory.warehouse
|
||
)
|
||
|
||
IncomingDocumentService.add_item(
|
||
document=incoming_document,
|
||
product=line.product,
|
||
quantity=quantity_surplus,
|
||
cost_price=cost_price,
|
||
notes=f'Инвентаризация #{inventory.id}, строка #{line.id}'
|
||
)
|
||
|
||
return incoming_document
|
||
|
||
@staticmethod
|
||
def _get_last_cost_price(product, warehouse):
|
||
"""
|
||
Получить последнюю известную закупочную цену товара на складе.
|
||
Используется для создания новой партии при излишке.
|
||
|
||
Порядок поиска:
|
||
1. Последняя активная партия на этом складе
|
||
2. Последняя активная партия на любом складе
|
||
3. cost_price из карточки Product (если есть)
|
||
4. Дефолт 0 (если ничего не найдено)
|
||
|
||
Args:
|
||
product: объект Product
|
||
warehouse: объект Warehouse
|
||
|
||
Returns:
|
||
Decimal - закупочная цена
|
||
"""
|
||
from inventory.models import StockBatch
|
||
|
||
# Вариант 1: последняя партия на этом складе
|
||
last_batch = StockBatch.objects.filter(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
is_active=True
|
||
).order_by('-created_at').first()
|
||
|
||
if last_batch:
|
||
return last_batch.cost_price
|
||
|
||
# Вариант 2: последняя партия на любом складе
|
||
last_batch_any = StockBatch.objects.filter(
|
||
product=product,
|
||
is_active=True
|
||
).order_by('-created_at').first()
|
||
|
||
if last_batch_any:
|
||
return last_batch_any.cost_price
|
||
|
||
# Вариант 3: cost_price из карточки товара
|
||
if product.cost_price:
|
||
return product.cost_price
|
||
|
||
# Вариант 4: ноль (не должно быть)
|
||
return Decimal('0')
|
||
|
||
@staticmethod
|
||
def create_inventory_lines_from_current_stock(inventory):
|
||
"""
|
||
Автоматически создать InventoryLine для всех товаров на складе.
|
||
Используется для удобства: оператор может сразу начать вводить фактические
|
||
количества, имея под рукой системные остатки.
|
||
|
||
Args:
|
||
inventory: объект Inventory
|
||
"""
|
||
from inventory.models import StockBatch
|
||
|
||
# Получаем все товары, которые есть на этом складе
|
||
batches = StockBatch.objects.filter(
|
||
warehouse=inventory.warehouse,
|
||
is_active=True
|
||
).values('product').distinct()
|
||
|
||
for batch_dict in batches:
|
||
product = batch_dict['product']
|
||
|
||
# Рассчитываем системный остаток
|
||
quantity_system = StockBatchManager.get_total_stock(product, inventory.warehouse)
|
||
|
||
# Создаем строку инвентаризации (факт будет заполнен оператором)
|
||
InventoryLine.objects.get_or_create(
|
||
inventory=inventory,
|
||
product_id=product,
|
||
defaults={
|
||
'quantity_system': quantity_system,
|
||
'quantity_fact': 0, # Оператор должен заполнить
|
||
}
|
||
)
|
||
|
||
@staticmethod
|
||
def get_inventory_report(inventory):
|
||
"""
|
||
Получить отчет по инвентаризации.
|
||
|
||
Args:
|
||
inventory: объект Inventory
|
||
|
||
Returns:
|
||
dict: {
|
||
'inventory': Inventory,
|
||
'total_lines': int,
|
||
'total_deficit': Decimal,
|
||
'total_surplus': Decimal,
|
||
'lines': [...]
|
||
}
|
||
"""
|
||
lines = InventoryLine.objects.filter(inventory=inventory).select_related('product')
|
||
|
||
total_deficit = Decimal('0')
|
||
total_surplus = Decimal('0')
|
||
lines_data = []
|
||
|
||
for line in lines:
|
||
if line.difference < 0:
|
||
total_deficit += abs(line.difference)
|
||
elif line.difference > 0:
|
||
total_surplus += line.difference
|
||
|
||
lines_data.append({
|
||
'line': line,
|
||
'system_value': line.quantity_system * line.product.cost_price,
|
||
'fact_value': line.quantity_fact * line.product.cost_price,
|
||
'value_difference': (line.quantity_fact - line.quantity_system) * line.product.cost_price,
|
||
})
|
||
|
||
return {
|
||
'inventory': inventory,
|
||
'total_lines': lines.count(),
|
||
'total_deficit': total_deficit,
|
||
'total_surplus': total_surplus,
|
||
'lines': lines_data
|
||
}
|