Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения UI

- Автоматическое проведение документов списания и оприходования после завершения инвентаризации
- Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation
- Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available
- Переименование поля 'По факту' в 'Подсчитано (факт, свободные)'
- Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации
- Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением
- Центральное выравнивание значений в столбцах таблицы
- Автоматическое выделение текста при фокусе на поле ввода количества
- Исправление форматирования разницы (убраны лишние нули)
- Изменение статуса 'Не обработана' на 'Не проведено'
- Добавление номера документа для инвентаризаций (INV-XXXXXX)
- Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem)
- Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
This commit is contained in:
2025-12-21 23:59:02 +03:00
parent bb821f9ef4
commit a8ba5ce780
16 changed files with 1619 additions and 194 deletions

View File

@@ -2,8 +2,8 @@
Процессор для обработки инвентаризации.
Основной функционал:
- Обработка расхождений между фактом и системой
- Автоматическое создание WriteOff для недостач (по FIFO)
- Автоматическое создание Incoming для излишков
- Автоматическое создание WriteOffDocument для недостач (черновик)
- Автоматическое создание IncomingDocument для излишков (черновик)
"""
from decimal import Decimal
@@ -11,11 +11,11 @@ from django.db import transaction
from django.utils import timezone
from inventory.models import (
Inventory, InventoryLine, WriteOff, Incoming, IncomingBatch,
StockBatch, Stock
Inventory, InventoryLine, StockBatch, Stock
)
from inventory.services.batch_manager import StockBatchManager
from inventory.utils import generate_incoming_document_number
from inventory.services.writeoff_document_service import WriteOffDocumentService
from inventory.services.incoming_document_service import IncomingDocumentService
class InventoryProcessor:
@@ -28,9 +28,10 @@ class InventoryProcessor:
def process_inventory(inventory_id):
"""
Обработать инвентаризацию:
- Для недостач (разница < 0): создать WriteOff по FIFO
- Для излишков (разница > 0): создать Incoming с новой партией
- Для недостач (разница < 0): создать WriteOffDocument (черновик) с позициями
- Для излишков (разница > 0): создать IncomingDocument (черновик) с позициями
- Обновить статус inventory и lines
- НЕ проводить документы сразу - они остаются в статусе 'draft'
Args:
inventory_id: ID объекта Inventory
@@ -39,34 +40,31 @@ class InventoryProcessor:
dict: {
'inventory': Inventory,
'processed_lines': int,
'writeoffs_created': int,
'incomings_created': 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)
writeoffs_created = 0
incomings_created = 0
errors = []
writeoff_document = None
incoming_document = None
# Собираем недостачи и излишки
deficit_lines = []
surplus_lines = []
try:
for line in lines:
try:
if line.difference < 0:
# Недостача: списать по FIFO
InventoryProcessor._create_writeoff_for_deficit(
inventory, line
)
writeoffs_created += 1
# Недостача
deficit_lines.append(line)
elif line.difference > 0:
# Излишек: создать новую партию
InventoryProcessor._create_incoming_for_surplus(
inventory, line
)
incomings_created += 1
# Излишек
surplus_lines.append(line)
# Отмечаем строку как обработанную
line.processed = True
@@ -78,6 +76,18 @@ class InventoryProcessor:
'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'])
@@ -91,78 +101,101 @@ class InventoryProcessor:
return {
'inventory': inventory,
'processed_lines': lines.count(),
'writeoffs_created': writeoffs_created,
'incomings_created': incomings_created,
'writeoff_document': writeoff_document,
'incoming_document': incoming_document,
'errors': errors
}
@staticmethod
def _create_writeoff_for_deficit(inventory, line):
def _create_writeoff_document(inventory, deficit_lines):
"""
Создать операцию WriteOff для недостачи при инвентаризации.
Списывается по FIFO из старейших партий.
Создать документ списания (WriteOffDocument) для недостач при инвентаризации.
Документ создается в статусе 'draft' и не проводится сразу.
Args:
inventory: объект Inventory
line: объект InventoryLine с negative difference
"""
quantity_to_writeoff = abs(line.difference)
deficit_lines: список InventoryLine с negative difference
# Списываем по FIFO
allocations = StockBatchManager.write_off_by_fifo(
line.product,
inventory.warehouse,
quantity_to_writeoff
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 для каждой партии
for batch, qty_allocated in allocations:
WriteOff.objects.create(
batch=batch,
quantity=qty_allocated,
# Связываем документ с инвентаризацией
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',
cost_price=batch.cost_price,
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
notes=f'Инвентаризация #{inventory.id}, строка #{line.id}'
)
return writeoff_document
@staticmethod
def _create_incoming_for_surplus(inventory, line):
def _create_incoming_document(inventory, surplus_lines):
"""
Создать операцию Incoming для излишка при инвентаризации.
Новая партия создается с последней известной cost_price товара.
Создать документ поступления (IncomingDocument) для излишков при инвентаризации.
Документ создается в статусе 'draft' и не проводится сразу.
Args:
inventory: объект Inventory
line: объект InventoryLine с positive difference
surplus_lines: список InventoryLine с positive difference
Returns:
IncomingDocument
"""
quantity_surplus = line.difference
if not surplus_lines:
return None
# Получаем последнюю known cost_price
cost_price = InventoryProcessor._get_last_cost_price(
line.product,
inventory.warehouse
)
# Генерируем номер документа для поступления
document_number = generate_incoming_document_number()
# Создаем IncomingBatch с типом 'inventory'
incoming_batch = IncomingBatch.objects.create(
# Создаем документ поступления (черновик) с типом 'inventory'
incoming_document = IncomingDocumentService.create_document(
warehouse=inventory.warehouse,
document_number=document_number,
date=inventory.date.date(),
receipt_type='inventory',
notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}'
supplier_name=None,
notes=f'Оприходование по результатам инвентаризации #{inventory.id}',
created_by=None # Можно добавить пользователя если передавать в process_inventory
)
# Создаем документ Incoming
# Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
Incoming.objects.create(
batch=incoming_batch,
product=line.product,
quantity=quantity_surplus,
cost_price=cost_price,
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
)
# Связываем документ с инвентаризацией
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):