refactor: мигрировать на новую систему документов поступления
Удалена старая одноэтапная система incoming и оставлена только новая двухэтапная система IncomingDocument (черновик → проведение). Изменения: - URL структура изменена с /incoming-documents/ на /incoming/ - URL names: incoming-document-* → incoming-* - Удалены старые views, forms, templates для Incoming/IncomingBatch - Обновлена навигация и все ссылки в шаблонах - Модели IncomingBatch/Incoming сохранены как внутренняя архитектура Удалено ~1590 строк кода: - inventory/views/incoming.py (389 строк) - inventory/forms.py (206 строк старых форм) - inventory/admin.py (56 строк) - 4 шаблона incoming/*.html (895 строк) Обновлено: - inventory/urls.py - новая URL структура - inventory/views/incoming_document.py - обновлены redirects - Все шаблоны с ссылками на incoming 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,6 @@ from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
|
||||
from .incoming import IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView
|
||||
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
|
||||
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
|
||||
from .inventory_ops import (
|
||||
@@ -58,8 +57,6 @@ __all__ = [
|
||||
'inventory_home',
|
||||
# Warehouse
|
||||
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
|
||||
# Incoming
|
||||
'IncomingListView', 'IncomingCreateView', 'IncomingAdjustmentCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
|
||||
# IncomingBatch
|
||||
'IncomingBatchListView', 'IncomingBatchDetailView',
|
||||
# Sale
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
# -*- 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)
|
||||
@@ -45,7 +45,7 @@ class IncomingDocumentCreateView(LoginRequiredMixin, CreateView):
|
||||
created_by=self.request.user
|
||||
)
|
||||
messages.success(self.request, f'Документ {document.document_number} создан')
|
||||
return redirect('inventory:incoming-document-detail', pk=document.pk)
|
||||
return redirect('inventory:incoming-detail', pk=document.pk)
|
||||
|
||||
|
||||
class IncomingDocumentDetailView(LoginRequiredMixin, DetailView):
|
||||
@@ -110,7 +110,7 @@ class IncomingDocumentAddItemView(LoginRequiredMixin, View):
|
||||
for error in errors:
|
||||
messages.error(request, f'{field}: {error}')
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
return redirect('inventory:incoming-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentUpdateItemView(LoginRequiredMixin, View):
|
||||
@@ -146,7 +146,7 @@ class IncomingDocumentUpdateItemView(LoginRequiredMixin, View):
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
return redirect('inventory:incoming-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
|
||||
@@ -174,7 +174,7 @@ class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
return redirect('inventory:incoming-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentConfirmView(LoginRequiredMixin, View):
|
||||
@@ -197,7 +197,7 @@ class IncomingDocumentConfirmView(LoginRequiredMixin, View):
|
||||
except ValidationError as e:
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
return redirect('inventory:incoming-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentCancelView(LoginRequiredMixin, View):
|
||||
@@ -213,5 +213,5 @@ class IncomingDocumentCancelView(LoginRequiredMixin, View):
|
||||
except ValidationError as e:
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-list')
|
||||
return redirect('inventory:incoming-list')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user