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:
2025-12-26 17:33:00 +03:00
parent d2384394c8
commit c9ff778630
16 changed files with 30 additions and 1590 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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')