- Добавлено свойство can_edit в модель Incoming - Добавлена проверка в IncomingUpdateView для запрета редактирования обработанных приходов - Скрыта кнопка редактирования в списке приходов для обработанных записей - Добавлено предупреждение в форму редактирования Это предотвращает проблемы с целостностью данных при FIFO-списаниях, когда партия уже может быть использована в продажах.
390 lines
18 KiB
Python
390 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
||
import logging
|
||
from django.shortcuts import render, redirect
|
||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, View
|
||
from django.urls import reverse_lazy
|
||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||
from django.contrib import messages
|
||
from django.http import JsonResponse, Http404
|
||
from django.views.decorators.http import require_http_methods
|
||
from django.utils.decorators import method_decorator
|
||
from django.db import IntegrityError, transaction
|
||
from ..models import Incoming, IncomingBatch, Warehouse
|
||
from ..forms import IncomingForm, IncomingLineForm, IncomingModelForm
|
||
from inventory.utils import generate_incoming_document_number
|
||
from products.models import Product
|
||
|
||
file_logger = logging.getLogger('incoming_sequence_file')
|
||
|
||
|
||
class IncomingListView(LoginRequiredMixin, ListView):
|
||
"""
|
||
Список всех приходов товара (истории поступлений)
|
||
"""
|
||
model = Incoming
|
||
template_name = 'inventory/incoming/incoming_list.html'
|
||
context_object_name = 'incomings'
|
||
paginate_by = 20
|
||
|
||
def get_queryset(self):
|
||
queryset = Incoming.objects.select_related('product', 'batch', 'batch__warehouse').order_by('-created_at')
|
||
|
||
# Фильтры (если переданы)
|
||
product_id = self.request.GET.get('product')
|
||
warehouse_id = self.request.GET.get('warehouse')
|
||
|
||
if product_id:
|
||
queryset = queryset.filter(product_id=product_id)
|
||
if warehouse_id:
|
||
queryset = queryset.filter(batch__warehouse_id=warehouse_id)
|
||
|
||
return queryset
|
||
|
||
|
||
class IncomingUpdateView(LoginRequiredMixin, UpdateView):
|
||
"""
|
||
Редактирование поступления (только если ещё не обработано).
|
||
Обработанные приходы редактировать нельзя.
|
||
"""
|
||
model = Incoming
|
||
form_class = IncomingModelForm
|
||
template_name = 'inventory/incoming/incoming_form.html'
|
||
success_url = reverse_lazy('inventory:incoming-list')
|
||
|
||
def dispatch(self, request, *args, **kwargs):
|
||
"""Проверяем, можно ли редактировать приход"""
|
||
obj = self.get_object()
|
||
if not obj.can_edit:
|
||
messages.error(
|
||
request,
|
||
f'Нельзя редактировать приход товара "{obj.product.name}", '
|
||
f'так как для него уже создана складская партия. '
|
||
f'Редактирование возможно только до обработки прихода.'
|
||
)
|
||
return redirect('inventory:incoming-list')
|
||
return super().dispatch(request, *args, **kwargs)
|
||
|
||
def form_valid(self, form):
|
||
messages.success(self.request, f'Приход товара обновлён.')
|
||
return super().form_valid(form)
|
||
|
||
|
||
class IncomingDeleteView(LoginRequiredMixin, DeleteView):
|
||
"""
|
||
Отмена/удаление поступления товара.
|
||
"""
|
||
model = Incoming
|
||
template_name = 'inventory/incoming/incoming_confirm_delete.html'
|
||
success_url = reverse_lazy('inventory:incoming-list')
|
||
|
||
def form_valid(self, form):
|
||
incoming = self.get_object()
|
||
messages.success(
|
||
self.request,
|
||
f'Приход товара "{incoming.product.name}" отменён.'
|
||
)
|
||
return super().form_valid(form)
|
||
|
||
|
||
class IncomingCreateView(LoginRequiredMixin, View):
|
||
"""
|
||
Создание поступлений товара на склад.
|
||
Позволяет добавить один или несколько товаров в одной форме
|
||
с одинаковым номером документа и складом.
|
||
|
||
По умолчанию показывается одна пустая строка товара,
|
||
но пользователь может добавить неограниченное количество товаров.
|
||
"""
|
||
template_name = 'inventory/incoming/incoming_bulk_form.html'
|
||
|
||
def get(self, request):
|
||
"""Отображение формы ввода товаров."""
|
||
form = IncomingForm()
|
||
# Django-tenants автоматически фильтрует по текущей схеме
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
|
||
# Генерируем номер документа автоматически
|
||
generated_document_number = generate_incoming_document_number()
|
||
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'generated_document_number': generated_document_number,
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
def post(self, request):
|
||
"""Обработка формы ввода товаров."""
|
||
form = IncomingForm(request.POST)
|
||
|
||
if not form.is_valid():
|
||
# Django-tenants автоматически фильтрует по текущей схеме
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'errors': form.errors,
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
# Получаем данные header
|
||
warehouse = form.cleaned_data['warehouse']
|
||
# Оставляем пустой document_number как есть - модель будет генерировать при сохранении
|
||
document_number = form.cleaned_data.get('document_number', '').strip() or None
|
||
|
||
receipt_type = form.cleaned_data.get('receipt_type', 'supplier')
|
||
supplier_name = form.cleaned_data.get('supplier_name', '')
|
||
header_notes = form.cleaned_data.get('notes', '')
|
||
|
||
# Получаем данные товаров из POST
|
||
products_data = self._parse_products_from_post(request.POST)
|
||
|
||
if not products_data:
|
||
messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.')
|
||
# Django-tenants автоматически фильтрует по текущей схеме
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
# Генерируем номер партии один раз (если не указан)
|
||
if not document_number:
|
||
document_number = generate_incoming_document_number()
|
||
|
||
file_logger.info(f"--- POST started | batch_doc_number={document_number} | items_count={len(products_data)}")
|
||
|
||
try:
|
||
# Используем транзакцию для атомарности: либо все товары, либо ничего
|
||
with transaction.atomic():
|
||
# 1. Создаем партию (содержит номер документа и метаданные)
|
||
batch = IncomingBatch.objects.create(
|
||
warehouse=warehouse,
|
||
document_number=document_number,
|
||
receipt_type=receipt_type,
|
||
supplier_name=supplier_name if receipt_type == 'supplier' else '',
|
||
notes=header_notes
|
||
)
|
||
file_logger.info(f" ✓ Created batch: {document_number}")
|
||
|
||
# 2. Создаем товары в этой партии
|
||
created_count = 0
|
||
for product_data in products_data:
|
||
incoming = Incoming.objects.create(
|
||
batch=batch,
|
||
product_id=product_data['product_id'],
|
||
quantity=product_data['quantity'],
|
||
cost_price=product_data['cost_price'],
|
||
)
|
||
created_count += 1
|
||
file_logger.info(f" ✓ Item: {incoming.product.name} ({incoming.quantity} шт)")
|
||
|
||
file_logger.info(f"✓ SUCCESS: Batch {document_number} with {created_count} items")
|
||
messages.success(
|
||
request,
|
||
f'✓ Успешно создана партия "{document_number}" с {created_count} товарами.'
|
||
)
|
||
return redirect('inventory:incoming-list')
|
||
|
||
except IntegrityError as e:
|
||
# Ошибка дублирования номера (обычно при вводе вручную существующего номера)
|
||
file_logger.error(f"✗ IntegrityError: {str(e)} | doc_number={document_number}")
|
||
if 'document_number' in str(e):
|
||
error_msg = (
|
||
f'❌ Номер документа "{document_number}" уже существует в системе. '
|
||
f'Пожалуйста, оставьте поле пустым для автогенерации свободного номера. '
|
||
f'Данные, которые вы вводили, сохранены ниже.'
|
||
)
|
||
messages.error(request, error_msg)
|
||
else:
|
||
messages.error(request, f'Ошибка при создании партии: {str(e)}')
|
||
|
||
# Восстанавливаем данные на форме
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'products_json': request.POST.get('products_json', '[]'),
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
except Exception as e:
|
||
messages.error(
|
||
request,
|
||
f'❌ Ошибка при создании приходов: {str(e)}'
|
||
)
|
||
# Django-tenants автоматически фильтрует по текущей схеме
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'products_json': request.POST.get('products_json', '[]'), # Сохраняем товары
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
@staticmethod
|
||
def _parse_products_from_post(post_data):
|
||
"""
|
||
Парсит данные товаров из POST данных.
|
||
Ожидается formato:
|
||
product_ids: [1, 2, 3]
|
||
quantities: [100, 50, 30]
|
||
cost_prices: [50, 30, 20]
|
||
"""
|
||
products_data = []
|
||
|
||
# Получаем JSON данные из hidden input (если используется)
|
||
import json
|
||
|
||
products_json = post_data.get('products_json', '[]')
|
||
try:
|
||
products_list = json.loads(products_json)
|
||
for item in products_list:
|
||
if item.get('product_id') and item.get('quantity') and item.get('cost_price'):
|
||
products_data.append({
|
||
'product_id': int(item['product_id']),
|
||
'quantity': float(item['quantity']),
|
||
'cost_price': float(item['cost_price']),
|
||
})
|
||
except (json.JSONDecodeError, ValueError):
|
||
pass
|
||
|
||
return products_data
|
||
|
||
|
||
class IncomingAdjustmentCreateView(LoginRequiredMixin, View):
|
||
"""
|
||
Создание оприходования товара на склад (без инвентаризации).
|
||
Аналогично IncomingCreateView, но с типом 'adjustment' и без поля поставщика.
|
||
"""
|
||
template_name = 'inventory/incoming/incoming_bulk_form.html'
|
||
|
||
def get(self, request):
|
||
"""Отображение формы ввода товаров."""
|
||
form = IncomingForm(initial={'receipt_type': 'adjustment'})
|
||
# Django-tenants автоматически фильтрует по текущей схеме
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
|
||
# Генерируем номер документа автоматически
|
||
generated_document_number = generate_incoming_document_number()
|
||
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'generated_document_number': generated_document_number,
|
||
'is_adjustment': True, # Флаг для шаблона, чтобы скрыть supplier_name
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
def post(self, request):
|
||
"""Обработка формы ввода товаров."""
|
||
# Устанавливаем receipt_type в 'adjustment'
|
||
post_data = request.POST.copy()
|
||
post_data['receipt_type'] = 'adjustment'
|
||
form = IncomingForm(post_data)
|
||
|
||
if not form.is_valid():
|
||
# Django-tenants автоматически фильтрует по текущей схеме
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'errors': form.errors,
|
||
'is_adjustment': True,
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
# Получаем данные header
|
||
warehouse = form.cleaned_data['warehouse']
|
||
document_number = form.cleaned_data.get('document_number', '').strip() or None
|
||
|
||
receipt_type = 'adjustment' # Всегда adjustment для этого view
|
||
header_notes = form.cleaned_data.get('notes', '')
|
||
|
||
# Получаем данные товаров из POST
|
||
products_data = IncomingCreateView._parse_products_from_post(request.POST)
|
||
|
||
if not products_data:
|
||
messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.')
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'is_adjustment': True,
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
# Генерируем номер партии один раз (если не указан)
|
||
if not document_number:
|
||
document_number = generate_incoming_document_number()
|
||
|
||
file_logger.info(f"--- POST started (adjustment) | batch_doc_number={document_number} | items_count={len(products_data)}")
|
||
|
||
try:
|
||
# Используем транзакцию для атомарности: либо все товары, либо ничего
|
||
with transaction.atomic():
|
||
# 1. Создаем партию (содержит номер документа и метаданные)
|
||
batch = IncomingBatch.objects.create(
|
||
warehouse=warehouse,
|
||
document_number=document_number,
|
||
receipt_type=receipt_type,
|
||
supplier_name='', # Не заполняем для adjustment
|
||
notes=header_notes
|
||
)
|
||
file_logger.info(f" ✓ Created batch: {document_number}")
|
||
|
||
# 2. Создаем товары в этой партии
|
||
created_count = 0
|
||
for product_data in products_data:
|
||
incoming = Incoming.objects.create(
|
||
batch=batch,
|
||
product_id=product_data['product_id'],
|
||
quantity=product_data['quantity'],
|
||
cost_price=product_data['cost_price'],
|
||
)
|
||
created_count += 1
|
||
file_logger.info(f" ✓ Item: {incoming.product.name} ({incoming.quantity} шт)")
|
||
|
||
file_logger.info(f"✓ SUCCESS: Batch {document_number} with {created_count} items")
|
||
messages.success(
|
||
request,
|
||
f'✓ Успешно создано оприходование "{document_number}" с {created_count} товарами.'
|
||
)
|
||
return redirect('inventory:incoming-list')
|
||
|
||
except IntegrityError as e:
|
||
file_logger.error(f"✗ IntegrityError: {str(e)} | doc_number={document_number}")
|
||
if 'document_number' in str(e):
|
||
error_msg = (
|
||
f'❌ Номер документа "{document_number}" уже существует в системе. '
|
||
f'Пожалуйста, оставьте поле пустым для автогенерации свободного номера. '
|
||
f'Данные, которые вы вводили, сохранены ниже.'
|
||
)
|
||
messages.error(request, error_msg)
|
||
else:
|
||
messages.error(request, f'Ошибка при создании оприходования: {str(e)}')
|
||
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'products_json': request.POST.get('products_json', '[]'),
|
||
'is_adjustment': True,
|
||
}
|
||
return render(request, self.template_name, context)
|
||
|
||
except Exception as e:
|
||
messages.error(
|
||
request,
|
||
f'❌ Ошибка при создании оприходования: {str(e)}'
|
||
)
|
||
products = Product.objects.filter(status='active').order_by('name')
|
||
context = {
|
||
'form': form,
|
||
'products': products,
|
||
'products_json': request.POST.get('products_json', '[]'),
|
||
'is_adjustment': True,
|
||
}
|
||
return render(request, self.template_name, context)
|