Files
octopus/myproject/inventory/views/inventory_ops.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

559 lines
25 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.
# -*- coding: utf-8 -*-
from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import ListView, CreateView, DetailView, View, FormView
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.http import HttpResponseRedirect, JsonResponse
from django.db import transaction
from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from decimal import Decimal
import json
from ..models import Inventory, InventoryLine, Stock
from ..forms import InventoryForm, InventoryLineForm
from ..services.inventory_processor import InventoryProcessor
from ..services.writeoff_document_service import WriteOffDocumentService
from ..services.incoming_document_service import IncomingDocumentService
from django.core.exceptions import ValidationError
from products.models import Product, ProductCategory, ProductTag
class InventoryListView(LoginRequiredMixin, ListView):
"""
Список всех инвентаризаций по складам
"""
model = Inventory
template_name = 'inventory/inventory/inventory_list.html'
context_object_name = 'inventories'
paginate_by = 20
def get_queryset(self):
queryset = Inventory.objects.select_related('warehouse').order_by('-date')
# Фильтры (если переданы)
warehouse_id = self.request.GET.get('warehouse')
status = self.request.GET.get('status')
if warehouse_id:
queryset = queryset.filter(warehouse_id=warehouse_id)
if status:
queryset = queryset.filter(status=status)
return queryset
class InventoryCreateView(LoginRequiredMixin, CreateView):
"""
Начало новой инвентаризации по конкретному складу.
Переводит инвентаризацию в статус 'processing'.
"""
model = Inventory
form_class = InventoryForm
template_name = 'inventory/inventory/inventory_form.html'
success_url = reverse_lazy('inventory:inventory-list')
def form_valid(self, form):
from inventory.utils.document_generator import generate_inventory_document_number
form.instance.status = 'processing'
form.instance.document_number = generate_inventory_document_number()
messages.success(
self.request,
f'Инвентаризация склада "{form.instance.warehouse.name}" начата.'
)
return super().form_valid(form)
class InventoryDetailView(LoginRequiredMixin, DetailView):
"""
Детальный просмотр инвентаризации с её строками.
Позволяет добавлять строки и заполнять фактические количества.
"""
model = Inventory
template_name = 'inventory/inventory/inventory_detail.html'
context_object_name = 'inventory'
def get_queryset(self):
"""Оптимизация: предзагружаем warehouse"""
return Inventory.objects.select_related('warehouse')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
from inventory.models import Stock, StockBatch, Reservation
from django.db.models import Sum, Q
from decimal import Decimal
# Получаем все строки этой инвентаризации с оптимизацией
lines = InventoryLine.objects.filter(
inventory=self.object
).select_related('product').order_by('product__name')
if not lines.exists():
context['lines'] = []
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
if self.object.status == 'completed':
context['writeoff_document'] = self.object.writeoff_documents.filter(status='draft').first()
context['incoming_document'] = self.object.incoming_documents.filter(status='draft').first()
return context
# Получаем все product_id из строк
product_ids = [line.product_id for line in lines]
warehouse = self.object.warehouse
# Получаем или создаем все Stock объекты одним запросом
stocks = Stock.objects.filter(
product_id__in=product_ids,
warehouse=warehouse
)
existing_stocks = {stock.product_id: stock for stock in stocks}
# Создаем недостающие Stock объекты
missing_product_ids = set(product_ids) - set(existing_stocks.keys())
if missing_product_ids:
new_stocks = [
Stock(product_id=pid, warehouse=warehouse)
for pid in missing_product_ids
]
Stock.objects.bulk_create(new_stocks)
# Перезагружаем все Stock объекты
stocks = Stock.objects.filter(
product_id__in=product_ids,
warehouse=warehouse
)
existing_stocks = {stock.product_id: stock for stock in stocks}
# Bulk запрос для получения всех StockBatch данных
stock_batches_qs = StockBatch.objects.filter(
product_id__in=product_ids,
warehouse=warehouse,
is_active=True
).values('product_id').annotate(
total_qty=Sum('quantity')
)
stock_batches_dict = {
item['product_id']: item['total_qty'] or Decimal('0')
for item in stock_batches_qs
}
# Bulk запрос для получения всех Reservation данных
reservations_qs = Reservation.objects.filter(
product_id__in=product_ids,
warehouse=warehouse,
status='reserved'
).values('product_id').annotate(
total_reserved=Sum('quantity')
)
reservations_dict = {
item['product_id']: item['total_reserved'] or Decimal('0')
for item in reservations_qs
}
# Обновляем все Stock объекты bulk update
stocks_to_update = []
for stock in stocks:
product_id = stock.product_id
stock.quantity_available = stock_batches_dict.get(product_id, Decimal('0'))
stock.quantity_reserved = reservations_dict.get(product_id, Decimal('0'))
stocks_to_update.append(stock)
if stocks_to_update:
Stock.objects.bulk_update(
stocks_to_update,
['quantity_available', 'quantity_reserved', 'updated_at'],
batch_size=100
)
# Добавляем quantity_reserved и обновляем quantity_system для каждой строки
lines_with_reserved = []
for line in lines:
stock = existing_stocks.get(line.product_id)
if not stock:
# Fallback на старый способ, если что-то пошло не так
stock, _ = Stock.objects.get_or_create(
product=line.product,
warehouse=warehouse
)
stock.refresh_from_batches()
# Для незавершенных инвентаризаций обновляем quantity_system динамически
if self.object.status != 'completed':
# Используем актуальное свободное количество из Stock
line.quantity_system = stock.quantity_free
# Добавляем quantity_reserved и quantity_available
line.quantity_reserved = stock.quantity_reserved
line.quantity_available = stock.quantity_available
# Пересчитываем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available
line.difference = (line.quantity_fact + line.quantity_reserved) - line.quantity_available
lines_with_reserved.append(line)
context['lines'] = lines_with_reserved
# Получаем категории и теги для компонента поиска товаров
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
# Получаем созданные документы (если инвентаризация завершена)
if self.object.status == 'completed':
context['writeoff_document'] = self.object.writeoff_documents.first()
context['incoming_document'] = self.object.incoming_documents.first()
return context
class InventoryLineCreateBulkView(LoginRequiredMixin, View):
"""
Форма для массового внесения результатов инвентаризации.
Позволяет заполнить результаты пересчета для всех товаров на складе.
"""
template_name = 'inventory/inventory/inventory_line_bulk_form.html'
def get_context_data(self, inventory_id, **kwargs):
inventory = Inventory.objects.get(pk=inventory_id)
return {
'inventory': inventory,
'products': inventory.warehouse.stock_batches.values_list(
'product', flat=True
).distinct()
}
def get(self, request, pk):
inventory = Inventory.objects.get(pk=pk)
context = {
'inventory': inventory,
'lines': InventoryLine.objects.filter(inventory=inventory).select_related('product')
}
return render(request, self.template_name, context)
def post(self, request, pk):
inventory = Inventory.objects.get(pk=pk)
# Здесь будет логика обработки массового ввода данных
# TODO: Реализовать обработку формы с множественными строками
messages.success(request, 'Результаты инвентаризации добавлены.')
return redirect('inventory:inventory-detail', pk=pk)
class InventoryLineAddView(LoginRequiredMixin, View):
"""
AJAX view для добавления строки инвентаризации.
Принимает product_id через POST, создает InventoryLine с quantity_system = quantity_free.
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
return JsonResponse({
'success': False,
'error': 'Нельзя добавлять строки в завершенную инвентаризацию'
}, status=400)
# Получаем product_id из POST
product_id = request.POST.get('product_id')
if not product_id:
return JsonResponse({
'success': False,
'error': 'Не указан product_id'
}, status=400)
product = get_object_or_404(Product, id=product_id)
# Проверяем, нет ли уже такой строки
existing_line = InventoryLine.objects.filter(
inventory=inventory,
product=product
).first()
if existing_line:
return JsonResponse({
'success': False,
'error': f'Товар "{product.name}" уже добавлен в инвентаризацию'
}, status=400)
# Получаем свободное количество (quantity_free)
stock, _ = Stock.objects.get_or_create(
product=product,
warehouse=inventory.warehouse
)
stock.refresh_from_batches() # Обновить из партий
quantity_system = stock.quantity_free # Свободное незарезервированное количество
quantity_reserved = stock.quantity_reserved
quantity_available = stock.quantity_available
# Создаем строку инвентаризации
# Передаем quantity_reserved и quantity_available для корректного расчета разницы в save()
line = InventoryLine(
inventory=inventory,
product=product,
quantity_system=quantity_system,
quantity_fact=Decimal('0'), # Оператор заполнит позже
processed=False
)
line.save(quantity_reserved=quantity_reserved, quantity_available=quantity_available)
return JsonResponse({
'success': True,
'line': {
'id': line.id,
'product_id': product.id,
'product_name': product.name,
'quantity_system': str(line.quantity_system),
'quantity_reserved': str(quantity_reserved),
'quantity_available': str(quantity_available),
'quantity_fact': str(line.quantity_fact),
'difference': str(line.difference),
'processed': line.processed
}
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class InventoryLineUpdateView(LoginRequiredMixin, View):
"""
AJAX view для обновления quantity_fact строки инвентаризации.
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id, line_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
return JsonResponse({
'success': False,
'error': 'Нельзя редактировать строки завершенной инвентаризации'
}, status=400)
line = get_object_or_404(InventoryLine, id=line_id, inventory=inventory)
# Проверяем что строка не обработана
if line.processed:
return JsonResponse({
'success': False,
'error': 'Нельзя редактировать обработанную строку'
}, status=400)
# Получаем quantity_fact из POST
quantity_fact_str = request.POST.get('quantity_fact')
if quantity_fact_str is None:
return JsonResponse({
'success': False,
'error': 'Не указано quantity_fact'
}, status=400)
try:
quantity_fact = Decimal(str(quantity_fact_str))
if quantity_fact < 0:
return JsonResponse({
'success': False,
'error': 'Количество не может быть отрицательным'
}, status=400)
except (ValueError, TypeError):
return JsonResponse({
'success': False,
'error': 'Некорректное значение quantity_fact'
}, status=400)
# Обновляем quantity_fact
line.quantity_fact = quantity_fact
# Получаем актуальные данные из Stock для расчета разницы
stock, _ = Stock.objects.get_or_create(
product=line.product,
warehouse=inventory.warehouse
)
stock.refresh_from_batches()
# Для незавершенных инвентаризаций обновляем quantity_system динамически
if inventory.status != 'completed':
line.quantity_system = stock.quantity_free
# Пересчитываем разницу по новой формуле: (quantity_fact + quantity_reserved) - quantity_available
line.difference = (line.quantity_fact + stock.quantity_reserved) - stock.quantity_available
# Сохраняем с передачей quantity_reserved и quantity_available для корректного расчета в save()
line.save(quantity_reserved=stock.quantity_reserved, quantity_available=stock.quantity_available)
return JsonResponse({
'success': True,
'line': {
'id': line.id,
'product_id': line.product.id,
'product_name': line.product.name,
'quantity_system': str(line.quantity_system),
'quantity_reserved': str(stock.quantity_reserved),
'quantity_available': str(stock.quantity_available),
'quantity_fact': str(line.quantity_fact),
'difference': str(line.difference),
'processed': line.processed
}
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class InventoryLineDeleteView(LoginRequiredMixin, View):
"""
AJAX view для удаления строки инвентаризации.
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id, line_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
return JsonResponse({
'success': False,
'error': 'Нельзя удалять строки завершенной инвентаризации'
}, status=400)
line = get_object_or_404(InventoryLine, id=line_id, inventory=inventory)
# Проверяем что строка не обработана
if line.processed:
return JsonResponse({
'success': False,
'error': 'Нельзя удалять обработанную строку'
}, status=400)
product_name = line.product.name
line.delete()
return JsonResponse({
'success': True,
'message': f'Строка для товара "{product_name}" удалена'
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
class InventoryCompleteView(LoginRequiredMixin, View):
"""
View для завершения инвентаризации.
Создает документы списания и оприходования (черновики).
"""
@method_decorator(require_http_methods(["POST"]))
@transaction.atomic
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, inventory_id):
try:
inventory = get_object_or_404(Inventory, id=inventory_id)
# Проверяем что инвентаризация не завершена
if inventory.status == 'completed':
messages.warning(request, 'Инвентаризация уже завершена.')
return redirect('inventory:inventory-detail', pk=inventory_id)
# Проверяем что есть строки
lines_count = InventoryLine.objects.filter(inventory=inventory).count()
if lines_count == 0:
messages.error(request, 'Нельзя завершить инвентаризацию без строк.')
return redirect('inventory:inventory-detail', pk=inventory_id)
# Обрабатываем инвентаризацию (создает документы-черновики)
result = InventoryProcessor.process_inventory(inventory_id)
# Автоматически проводим созданные документы
writeoff_confirmed = False
incoming_confirmed = False
confirmation_errors = []
if result['writeoff_document'] and result['writeoff_document'].status == 'draft':
try:
WriteOffDocumentService.confirm_document(
result['writeoff_document'],
confirmed_by=request.user
)
writeoff_confirmed = True
except ValidationError as e:
confirmation_errors.append(f'Ошибка проведения документа списания: {str(e)}')
if result['incoming_document'] and result['incoming_document'].status == 'draft':
try:
IncomingDocumentService.confirm_document(
result['incoming_document'],
confirmed_by=request.user
)
incoming_confirmed = True
except ValidationError as e:
confirmation_errors.append(f'Ошибка проведения документа оприходования: {str(e)}')
# Формируем сообщение
msg_parts = [f'Инвентаризация завершена. Обработано строк: {result["processed_lines"]}.']
if result['writeoff_document']:
if writeoff_confirmed:
msg_parts.append(
f'Документ списания {result["writeoff_document"].document_number} создан и автоматически проведен.'
)
else:
msg_parts.append(
f'Документ списания: {result["writeoff_document"].document_number}.'
)
if result['incoming_document']:
if incoming_confirmed:
msg_parts.append(
f'Документ оприходования {result["incoming_document"].document_number} создан и автоматически проведен.'
)
else:
msg_parts.append(
f'Документ оприходования: {result["incoming_document"].document_number}.'
)
if result['errors']:
msg_parts.append(f'Ошибок при обработке: {len(result["errors"])}.')
if confirmation_errors:
for error in confirmation_errors:
messages.error(request, error)
messages.success(request, ' '.join(msg_parts))
return redirect('inventory:inventory-detail', pk=inventory_id)
except Exception as e:
messages.error(request, f'Ошибка при завершении инвентаризации: {str(e)}')
return redirect('inventory:inventory-detail', pk=inventory_id)