Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения 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

@@ -24,7 +24,8 @@ from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchLis
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
from .inventory_ops import (
InventoryListView, InventoryCreateView, InventoryDetailView,
InventoryLineCreateBulkView
InventoryLineCreateBulkView, InventoryLineAddView, InventoryLineUpdateView,
InventoryLineDeleteView, InventoryCompleteView
)
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView
from .writeoff_document import (
@@ -65,6 +66,8 @@ __all__ = [
'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView',
# Inventory
'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView',
'InventoryLineAddView', 'InventoryLineUpdateView', 'InventoryLineDeleteView',
'InventoryCompleteView',
# WriteOff
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
# WriteOffDocument

View File

@@ -5,7 +5,7 @@
from django.contrib.auth.decorators import login_required, user_passes_test
from django.shortcuts import render
from django.db.models import Q, Sum, Count
from inventory.models import StockBatch, Stock, Reservation, Sale, SaleBatchAllocation
from inventory.models import StockBatch, Stock, Reservation, Sale, SaleBatchAllocation, WriteOff, WriteOffDocument, WriteOffDocumentItem
from orders.models import Order
from products.models import Product
from inventory.models import Warehouse
@@ -38,6 +38,12 @@ def debug_inventory_page(request):
allocations = SaleBatchAllocation.objects.select_related(
'sale__product', 'batch'
).order_by('-id')
# Все списания: из продаж (WriteOff) и из документов списания (WriteOffDocumentItem)
writeoffs = WriteOff.objects.select_related('batch__product', 'batch__warehouse').order_by('-date')
writeoff_documents = WriteOffDocument.objects.select_related('warehouse').order_by('-date')
writeoff_document_items = WriteOffDocumentItem.objects.select_related(
'product', 'document__warehouse'
).order_by('-id')
orders = Order.objects.prefetch_related('items').order_by('-created_at')
# Применяем фильтры
@@ -48,6 +54,8 @@ def debug_inventory_page(request):
reservations = reservations.filter(product_id=product_id)
sales = sales.filter(product_id=product_id)
allocations = allocations.filter(sale__product_id=product_id)
writeoffs = writeoffs.filter(batch__product_id=product_id)
writeoff_document_items = writeoff_document_items.filter(product_id=product_id)
orders = orders.filter(items__product_id=product_id).distinct()
else:
product = None
@@ -85,6 +93,9 @@ def debug_inventory_page(request):
stocks = stocks.filter(warehouse_id=warehouse_id)
reservations = reservations.filter(warehouse_id=warehouse_id)
sales = sales.filter(warehouse_id=warehouse_id)
writeoffs = writeoffs.filter(batch__warehouse_id=warehouse_id)
writeoff_documents = writeoff_documents.filter(warehouse_id=warehouse_id)
writeoff_document_items = writeoff_document_items.filter(document__warehouse_id=warehouse_id)
else:
warehouse = None
@@ -94,6 +105,9 @@ def debug_inventory_page(request):
reservations = reservations[:100]
sales = sales[:100]
allocations = allocations[:100]
writeoffs = writeoffs[:100]
writeoff_documents = writeoff_documents[:50]
writeoff_document_items = writeoff_document_items[:100]
orders = orders[:50]
# Списки для фильтров
@@ -106,6 +120,9 @@ def debug_inventory_page(request):
'reservations': reservations,
'sales': sales,
'allocations': allocations,
'writeoffs': writeoffs,
'writeoff_documents': writeoff_documents,
'writeoff_document_items': writeoff_document_items,
'orders': orders,
'products': products,
'warehouses': warehouses,

View File

@@ -1,12 +1,23 @@
# -*- coding: utf-8 -*-
from django.shortcuts import render, redirect
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
from ..models import Inventory, InventoryLine
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):
@@ -44,7 +55,10 @@ class InventoryCreateView(LoginRequiredMixin, CreateView):
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}" начата.'
@@ -60,13 +74,135 @@ 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)
# Получаем все строки этой инвентаризации
context['lines'] = InventoryLine.objects.filter(
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')
).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
@@ -100,3 +236,323 @@ class InventoryLineCreateBulkView(LoginRequiredMixin, View):
# 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)