Files
octopus/myproject/inventory/services/inventory_processor.py
Andrey Smakotin c476eafd4a Добавлено сохранение snapshot-значений для проведенных инвентаризаций
- Добавлены поля snapshot_* в модель InventoryLine для фиксации значений на момент завершения
- Обновлен InventoryProcessor для сохранения snapshot перед обработкой
- Обновлен InventoryDetailView для отображения snapshot-значений в проведенных инвентаризациях
- Добавлена миграция 0018 для новых полей
- Теперь в проведенных инвентаризациях отображаются оригинальные значения и правильная разница, а не текущие скорректированные остатки
2025-12-22 13:43:35 +03:00

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Процессор для обработки инвентаризации.
Основной функционал:
- Обработка расхождений между фактом и системой
- Автоматическое создание 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
}