Files
octopus/myproject/inventory/views/inventory_ops.py
Andrey Smakotin c476eafd4a Добавлено сохранение snapshot-значений для проведенных инвентаризаций
- Добавлены поля snapshot_* в модель InventoryLine для фиксации значений на момент завершения
- Обновлен InventoryProcessor для сохранения snapshot перед обработкой
- Обновлен InventoryDetailView для отображения snapshot-значений в проведенных инвентаризациях
- Добавлена миграция 0018 для новых полей
- Теперь в проведенных инвентаризациях отображаются оригинальные значения и правильная разница, а не текущие скорректированные остатки
2025-12-22 13:43:35 +03:00

665 lines
31 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'
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)