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