Files
octopus/myproject/inventory/services/inventory_processor.py
Andrey Smakotin a8ba5ce780 Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения UI
- Автоматическое проведение документов списания и оприходования после завершения инвентаризации
- Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation
- Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available
- Переименование поля 'По факту' в 'Подсчитано (факт, свободные)'
- Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации
- Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением
- Центральное выравнивание значений в столбцах таблицы
- Автоматическое выделение текста при фокусе на поле ввода количества
- Исправление форматирования разницы (убраны лишние нули)
- Изменение статуса 'Не обработана' на 'Не проведено'
- Добавление номера документа для инвентаризаций (INV-XXXXXX)
- Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem)
- Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
2025-12-21 23:59:02 +03:00

324 lines
12 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)
lines = InventoryLine.objects.filter(inventory=inventory, processed=False)
errors = []
writeoff_document = None
incoming_document = None
# Собираем недостачи и излишки
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
}