# -*- 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' 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() # Автоматически проставляем роль пользователя, который создает инвентаризацию # Если у пользователя есть роль в тенанте - проставляем её, иначе оставляем NULL try: if hasattr(self.request.user, 'tenant_role'): user_role = getattr(self.request.user, 'tenant_role', None) if user_role and user_role.is_active: form.instance.conducted_by = user_role except (AttributeError, Exception): # Если у пользователя нет роли (суперюзер/админ без роли) - оставляем NULL pass messages.success( self.request, f'Инвентаризация склада "{form.instance.warehouse.name}" начата.' ) return super().form_valid(form) def get_success_url(self): """Перенаправляем на страницу редактирования созданной инвентаризации""" return reverse_lazy('inventory:inventory-detail', kwargs={'pk': self.object.pk}) 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: # Для завершенных инвентаризаций используем snapshot-значения if self.object.status == 'completed': # Используем snapshot-значения если они есть (для новых записей) if line.snapshot_quantity_available is not None: line.quantity_available = line.snapshot_quantity_available line.quantity_reserved = line.snapshot_quantity_reserved line.quantity_system = line.snapshot_quantity_system # Используем snapshot_difference, если он есть, иначе пересчитываем if line.snapshot_difference is not None: line.difference = line.snapshot_difference else: # Fallback: пересчитываем из snapshot-значений line.difference = (line.quantity_fact + line.snapshot_quantity_reserved) - line.snapshot_quantity_available else: # Fallback для старых записей без snapshot # Используем текущие значения Stock, но это не идеально # так как остатки уже могли измениться после завершения инвентаризации stock = existing_stocks.get(line.product_id) if not stock: stock, _ = Stock.objects.get_or_create( product=line.product, warehouse=warehouse ) stock.refresh_from_batches() line.quantity_reserved = stock.quantity_reserved line.quantity_available = stock.quantity_available # Для старых записей используем сохраненное quantity_system из модели # и пересчитываем разницу line.difference = (line.quantity_fact + line.quantity_reserved) - line.quantity_available else: # Для незавершенных инвентаризаций используем актуальные значения stock = existing_stocks.get(line.product_id) if not stock: stock, _ = Stock.objects.get_or_create( product=line.product, warehouse=warehouse ) stock.refresh_from_batches() # Используем актуальное свободное количество из Stock line.quantity_system = stock.quantity_free 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) class InventoryDeleteView(LoginRequiredMixin, View): """ Удаление инвентаризации. Можно удалять только инвентаризации со статусом 'draft' или 'processing'. Завершенные инвентаризации удалять нельзя, так как документы уже проведены. """ @method_decorator(require_http_methods(["POST"])) @transaction.atomic def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) def post(self, request, pk): try: inventory = get_object_or_404(Inventory, pk=pk) # Проверка статуса - завершенные нельзя удалять if inventory.status == 'completed': messages.error( request, 'Нельзя удалить завершенную инвентаризацию. Документы уже проведены.' ) return redirect('inventory:inventory-detail', pk=pk) # Проверка связанных документов writeoff_docs = inventory.writeoff_documents.all() incoming_docs = inventory.incoming_documents.all() # Проверяем, есть ли проведенные документы if writeoff_docs.filter(status='confirmed').exists(): messages.error( request, 'Нельзя удалить инвентаризацию с проведенными документами списания.' ) return redirect('inventory:inventory-detail', pk=pk) if incoming_docs.filter(status='confirmed').exists(): messages.error( request, 'Нельзя удалить инвентаризацию с проведенными документами поступления.' ) return redirect('inventory:inventory-detail', pk=pk) # Сохраняем информацию для сообщения warehouse_name = inventory.warehouse.name document_number = inventory.document_number or f"#{inventory.id}" # Удаляем документы-черновики вместе с инвентаризацией writeoff_docs.filter(status='draft').delete() incoming_docs.filter(status='draft').delete() # Удаляем инвентаризацию (InventoryLine удалятся автоматически через CASCADE) inventory.delete() messages.success( request, f'Инвентаризация {document_number} склада "{warehouse_name}" успешно удалена.' ) return redirect('inventory:inventory-list') except Exception as e: messages.error(request, f'Ошибка при удалении инвентаризации: {str(e)}') return redirect('inventory:inventory-detail', pk=pk)