- Автоматическое проведение документов списания и оприходования после завершения инвентаризации - Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation - Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available - Переименование поля 'По факту' в 'Подсчитано (факт, свободные)' - Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации - Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением - Центральное выравнивание значений в столбцах таблицы - Автоматическое выделение текста при фокусе на поле ввода количества - Исправление форматирования разницы (убраны лишние нули) - Изменение статуса 'Не обработана' на 'Не проведено' - Добавление номера документа для инвентаризаций (INV-XXXXXX) - Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem) - Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
324 lines
12 KiB
Python
324 lines
12 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)
|
||
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
|
||
}
|