diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py index 423cf29..76beddb 100644 --- a/myproject/inventory/urls.py +++ b/myproject/inventory/urls.py @@ -28,6 +28,12 @@ from .views import ( ) # Showcase views from .views.showcase import ShowcaseListView, ShowcaseCreateView, ShowcaseUpdateView, ShowcaseDeleteView, SetDefaultShowcaseView +# WriteOff Document views +from .views.writeoff_document import ( + WriteOffDocumentListView, WriteOffDocumentCreateView, WriteOffDocumentDetailView, + WriteOffDocumentAddItemView, WriteOffDocumentUpdateItemView, WriteOffDocumentRemoveItemView, + WriteOffDocumentConfirmView, WriteOffDocumentCancelView +) # Debug views from .views.debug_views import debug_inventory_page from . import views @@ -68,12 +74,22 @@ urlpatterns = [ path('inventory-ops//', InventoryDetailView.as_view(), name='inventory-detail'), path('inventory-ops//lines/add/', InventoryLineCreateBulkView.as_view(), name='inventory-lines-add'), - # ==================== WRITEOFF ==================== + # ==================== WRITEOFF (одиночные записи) ==================== path('writeoffs/', WriteOffListView.as_view(), name='writeoff-list'), path('writeoffs/create/', WriteOffCreateView.as_view(), name='writeoff-create'), path('writeoffs//edit/', WriteOffUpdateView.as_view(), name='writeoff-update'), path('writeoffs//delete/', WriteOffDeleteView.as_view(), name='writeoff-delete'), + # ==================== WRITEOFF DOCUMENT (документы списания) ==================== + path('writeoff-documents/', WriteOffDocumentListView.as_view(), name='writeoff-document-list'), + path('writeoff-documents/create/', WriteOffDocumentCreateView.as_view(), name='writeoff-document-create'), + path('writeoff-documents//', WriteOffDocumentDetailView.as_view(), name='writeoff-document-detail'), + path('writeoff-documents//add-item/', WriteOffDocumentAddItemView.as_view(), name='writeoff-document-add-item'), + path('writeoff-documents//update-item//', WriteOffDocumentUpdateItemView.as_view(), name='writeoff-document-update-item'), + path('writeoff-documents//remove-item//', WriteOffDocumentRemoveItemView.as_view(), name='writeoff-document-remove-item'), + path('writeoff-documents//confirm/', WriteOffDocumentConfirmView.as_view(), name='writeoff-document-confirm'), + path('writeoff-documents//cancel/', WriteOffDocumentCancelView.as_view(), name='writeoff-document-cancel'), + # ==================== TRANSFER ==================== path('transfers/', TransferListView.as_view(), name='transfer-list'), path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения diff --git a/myproject/inventory/views/writeoff_document.py b/myproject/inventory/views/writeoff_document.py new file mode 100644 index 0000000..bab5ead --- /dev/null +++ b/myproject/inventory/views/writeoff_document.py @@ -0,0 +1,208 @@ +""" +Views для работы с документами списания (WriteOffDocument). +""" + +from django.views.generic import ListView, CreateView, DetailView, View +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse +from django.shortcuts import get_object_or_404, redirect +from django.contrib import messages +from django.http import JsonResponse +from django.db import transaction +from django.core.exceptions import ValidationError + +from inventory.models import WriteOffDocument, WriteOffDocumentItem +from inventory.forms import WriteOffDocumentForm, WriteOffDocumentItemForm +from inventory.services.writeoff_document_service import WriteOffDocumentService + + +class WriteOffDocumentListView(LoginRequiredMixin, ListView): + """Список документов списания""" + model = WriteOffDocument + template_name = 'inventory/writeoff_document/list.html' + context_object_name = 'documents' + paginate_by = 20 + + def get_queryset(self): + return WriteOffDocument.objects.select_related( + 'warehouse', 'created_by', 'confirmed_by' + ).prefetch_related('items').order_by('-date', '-created_at') + + +class WriteOffDocumentCreateView(LoginRequiredMixin, CreateView): + """Создание документа списания""" + model = WriteOffDocument + form_class = WriteOffDocumentForm + template_name = 'inventory/writeoff_document/form.html' + + def form_valid(self, form): + document = WriteOffDocumentService.create_document( + warehouse=form.cleaned_data['warehouse'], + date=form.cleaned_data['date'], + notes=form.cleaned_data.get('notes'), + created_by=self.request.user + ) + messages.success(self.request, f'Документ {document.document_number} создан') + return redirect('inventory:writeoff-document-detail', pk=document.pk) + + +class WriteOffDocumentDetailView(LoginRequiredMixin, DetailView): + """Детальный просмотр документа списания""" + model = WriteOffDocument + template_name = 'inventory/writeoff_document/detail.html' + context_object_name = 'document' + + def get_queryset(self): + return WriteOffDocument.objects.select_related( + 'warehouse', 'created_by', 'confirmed_by' + ).prefetch_related('items__product', 'items__reservation') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['item_form'] = WriteOffDocumentItemForm(document=self.object) + return context + + +class WriteOffDocumentAddItemView(LoginRequiredMixin, View): + """Добавление позиции в документ списания""" + + @transaction.atomic + def post(self, request, pk): + document = get_object_or_404(WriteOffDocument, pk=pk) + form = WriteOffDocumentItemForm(request.POST, document=document) + + if form.is_valid(): + try: + item = WriteOffDocumentService.add_item( + document=document, + product=form.cleaned_data['product'], + quantity=form.cleaned_data['quantity'], + reason=form.cleaned_data['reason'], + notes=form.cleaned_data.get('notes') + ) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'item_id': item.id, + 'message': f'Добавлено: {item.product.name}' + }) + + messages.success(request, f'Добавлено: {item.product.name}') + + except ValidationError as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'error': str(e)}, status=400) + messages.error(request, str(e)) + else: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + errors = '; '.join([f"{k}: {v[0]}" for k, v in form.errors.items()]) + return JsonResponse({'success': False, 'error': errors}, status=400) + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f'{field}: {error}') + + return redirect('inventory:writeoff-document-detail', pk=pk) + + +class WriteOffDocumentUpdateItemView(LoginRequiredMixin, View): + """Обновление позиции документа списания""" + + @transaction.atomic + def post(self, request, pk, item_pk): + document = get_object_or_404(WriteOffDocument, pk=pk) + item = get_object_or_404(WriteOffDocumentItem, pk=item_pk, document=document) + + try: + quantity = request.POST.get('quantity') + reason = request.POST.get('reason') + notes = request.POST.get('notes') + + WriteOffDocumentService.update_item( + item, + quantity=quantity, + reason=reason, + notes=notes + ) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Обновлено: {item.product.name}' + }) + + messages.success(request, f'Обновлено: {item.product.name}') + + except ValidationError as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'error': str(e)}, status=400) + messages.error(request, str(e)) + + return redirect('inventory:writeoff-document-detail', pk=pk) + + +class WriteOffDocumentRemoveItemView(LoginRequiredMixin, View): + """Удаление позиции из документа списания""" + + @transaction.atomic + def post(self, request, pk, item_pk): + document = get_object_or_404(WriteOffDocument, pk=pk) + item = get_object_or_404(WriteOffDocumentItem, pk=item_pk, document=document) + + try: + product_name = item.product.name + WriteOffDocumentService.remove_item(item) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Удалено: {product_name}' + }) + + messages.success(request, f'Удалено: {product_name}') + + except ValidationError as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'error': str(e)}, status=400) + messages.error(request, str(e)) + + return redirect('inventory:writeoff-document-detail', pk=pk) + + +class WriteOffDocumentConfirmView(LoginRequiredMixin, View): + """Проведение документа списания""" + + @transaction.atomic + def post(self, request, pk): + document = get_object_or_404(WriteOffDocument, pk=pk) + + try: + result = WriteOffDocumentService.confirm_document( + document, + confirmed_by=request.user + ) + messages.success( + request, + f'Документ {document.document_number} проведён. ' + f'Списано {result["total_quantity"]} шт на сумму {result["total_cost"]:.2f}' + ) + except ValidationError as e: + messages.error(request, str(e)) + + return redirect('inventory:writeoff-document-detail', pk=pk) + + +class WriteOffDocumentCancelView(LoginRequiredMixin, View): + """Отмена документа списания""" + + @transaction.atomic + def post(self, request, pk): + document = get_object_or_404(WriteOffDocument, pk=pk) + + try: + WriteOffDocumentService.cancel_document(document) + messages.success(request, f'Документ {document.document_number} отменён') + except ValidationError as e: + messages.error(request, str(e)) + + return redirect('inventory:writeoff-document-list')