feat: Реализовать систему поступления товаров с партиями (IncomingBatch)
Основные изменения: - Создана модель IncomingBatch для группировки товаров по документам - Каждое поступление (Incoming) связано с одной батчем поступления - Автоматическое создание StockBatch для каждого товара в приходе - Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД - Обновлены все представления (views) для работы с новой архитектурой - Добавлены детальные страницы просмотра партий поступлений - Обновлены шаблоны для отображения информации о партиях и их товарах - Исправлена логика сигналов для создания StockBatch при приходе товара - Обновлены формы для работы с новой структурой IncomingBatch Архитектура FIFO: - IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX) - Incoming: товар в партии поступления - StockBatch: одна партия товара на складе (создается для каждого товара) Это позволяет системе правильно применять FIFO при продаже товаров. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
72
myproject/inventory/views/__init__.py
Normal file
72
myproject/inventory/views/__init__.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Inventory Views Package
|
||||
|
||||
Организация views по модулям:
|
||||
- warehouse.py: Управление складами
|
||||
- incoming.py: Управление приходами товара
|
||||
- sale.py: Управление продажами
|
||||
- inventory_ops.py: Инвентаризация и её строки
|
||||
- writeoff.py: Списания товара
|
||||
- transfer.py: Перемещения между складами
|
||||
- reservation.py: Резервирования товара
|
||||
- stock.py: Справочник остатков (view-only)
|
||||
- batch.py: Справочник партий товара (view-only)
|
||||
- allocation.py: Распределение продаж по партиям (view-only)
|
||||
- movements.py: Журнал складских операций (view-only)
|
||||
"""
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView
|
||||
from .incoming import IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView
|
||||
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
|
||||
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
|
||||
from .inventory_ops import (
|
||||
InventoryListView, InventoryCreateView, InventoryDetailView,
|
||||
InventoryLineCreateBulkView
|
||||
)
|
||||
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView
|
||||
from .transfer import TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView
|
||||
from .reservation import ReservationListView, ReservationCreateView, ReservationUpdateView
|
||||
from .stock import StockListView, StockDetailView
|
||||
from .allocation import SaleBatchAllocationListView
|
||||
from .movements import StockMovementListView
|
||||
|
||||
|
||||
@login_required
|
||||
def inventory_home(request):
|
||||
"""
|
||||
Главная страница Склада для управления инвентаризацией
|
||||
"""
|
||||
return render(request, 'inventory/home.html')
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Home
|
||||
'inventory_home',
|
||||
# Warehouse
|
||||
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView',
|
||||
# Incoming
|
||||
'IncomingListView', 'IncomingCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
|
||||
# IncomingBatch
|
||||
'IncomingBatchListView', 'IncomingBatchDetailView',
|
||||
# Sale
|
||||
'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView',
|
||||
# Inventory
|
||||
'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView',
|
||||
# WriteOff
|
||||
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
|
||||
# Transfer
|
||||
'TransferListView', 'TransferCreateView', 'TransferUpdateView', 'TransferDeleteView',
|
||||
# Reservation
|
||||
'ReservationListView', 'ReservationCreateView', 'ReservationUpdateView',
|
||||
# Stock
|
||||
'StockListView', 'StockDetailView',
|
||||
# StockBatch
|
||||
'StockBatchListView', 'StockBatchDetailView',
|
||||
# Allocation
|
||||
'SaleBatchAllocationListView',
|
||||
# Movement
|
||||
'StockMovementListView',
|
||||
]
|
||||
25
myproject/inventory/views/allocation.py
Normal file
25
myproject/inventory/views/allocation.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
SaleBatchAllocation (Распределение продаж по партиям) views - READ ONLY
|
||||
GROUP 3: LOW PRIORITY - Аудит и трассировка FIFO
|
||||
"""
|
||||
from django.views.generic import ListView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from ..models import SaleBatchAllocation
|
||||
|
||||
|
||||
class SaleBatchAllocationListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Полный список всех распределений продаж по партиям.
|
||||
Используется для аудита и понимания как применялся FIFO.
|
||||
"""
|
||||
model = SaleBatchAllocation
|
||||
template_name = 'inventory/allocation/allocation_list.html'
|
||||
context_object_name = 'allocations'
|
||||
paginate_by = 30
|
||||
|
||||
def get_queryset(self):
|
||||
return SaleBatchAllocation.objects.select_related(
|
||||
'sale', 'sale__product',
|
||||
'batch', 'batch__product'
|
||||
).order_by('-sale__date')
|
||||
83
myproject/inventory/views/batch.py
Normal file
83
myproject/inventory/views/batch.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Batch views - READ ONLY
|
||||
- IncomingBatch (Партии поступлений)
|
||||
- StockBatch (Партии товара на складе)
|
||||
"""
|
||||
from django.views.generic import ListView, DetailView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from ..models import IncomingBatch, Incoming, StockBatch, SaleBatchAllocation, WriteOff
|
||||
|
||||
|
||||
class IncomingBatchListView(LoginRequiredMixin, ListView):
|
||||
"""Список всех партий поступлений товара"""
|
||||
model = IncomingBatch
|
||||
template_name = 'inventory/incoming_batch/batch_list.html'
|
||||
context_object_name = 'batches'
|
||||
paginate_by = 30
|
||||
|
||||
def get_queryset(self):
|
||||
return IncomingBatch.objects.all().select_related('warehouse').order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Добавляем количество товаров в каждую партию
|
||||
for batch in context['batches']:
|
||||
batch.items_count = batch.items.count()
|
||||
batch.total_quantity = sum(item.quantity for item in batch.items.all())
|
||||
return context
|
||||
|
||||
|
||||
class IncomingBatchDetailView(LoginRequiredMixin, DetailView):
|
||||
"""Детальная информация по партии поступления"""
|
||||
model = IncomingBatch
|
||||
template_name = 'inventory/incoming_batch/batch_detail.html'
|
||||
context_object_name = 'batch'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
batch = self.get_object()
|
||||
|
||||
# Товары в этой партии
|
||||
context['items'] = batch.items.all().select_related('product', 'stock_batch')
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class StockBatchListView(LoginRequiredMixin, ListView):
|
||||
"""Список всех партий товара на складах"""
|
||||
model = StockBatch
|
||||
template_name = 'inventory/batch/batch_list.html'
|
||||
context_object_name = 'batches'
|
||||
paginate_by = 30
|
||||
|
||||
def get_queryset(self):
|
||||
return StockBatch.objects.filter(
|
||||
is_active=True
|
||||
).select_related('product', 'warehouse').order_by('-created_at')
|
||||
|
||||
|
||||
class StockBatchDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Детальная информация по партии товара.
|
||||
Показывает историю операций с данной партией (продажи, списания, перемещения).
|
||||
"""
|
||||
model = StockBatch
|
||||
template_name = 'inventory/batch/batch_detail.html'
|
||||
context_object_name = 'batch'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
batch = self.get_object()
|
||||
|
||||
# История продаж из этой партии
|
||||
context['sales'] = SaleBatchAllocation.objects.filter(
|
||||
batch=batch
|
||||
).select_related('sale', 'sale__product')
|
||||
|
||||
# История списаний из этой партии
|
||||
context['writeoffs'] = WriteOff.objects.filter(
|
||||
batch=batch
|
||||
).order_by('-date')
|
||||
|
||||
return context
|
||||
239
myproject/inventory/views/incoming.py
Normal file
239
myproject/inventory/views/incoming.py
Normal file
@@ -0,0 +1,239 @@
|
||||
# -*- 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
|
||||
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
|
||||
from ..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 = IncomingForm
|
||||
template_name = 'inventory/incoming/incoming_form.html'
|
||||
success_url = reverse_lazy('inventory:incoming-list')
|
||||
|
||||
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(is_active=True).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(is_active=True).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
|
||||
|
||||
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(is_active=True).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,
|
||||
supplier_name=supplier_name,
|
||||
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(is_active=True).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(is_active=True).order_by('name')
|
||||
context = {
|
||||
'form': form,
|
||||
'products': products,
|
||||
'products_json': request.POST.get('products_json', '[]'), # Сохраняем товары
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
def _parse_products_from_post(self, 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
|
||||
102
myproject/inventory/views/inventory_ops.py
Normal file
102
myproject/inventory/views/inventory_ops.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.shortcuts import render, redirect
|
||||
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
|
||||
from ..models import Inventory, InventoryLine
|
||||
from ..forms import InventoryForm, InventoryLineForm
|
||||
|
||||
|
||||
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'
|
||||
success_url = reverse_lazy('inventory:inventory-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.status = 'processing'
|
||||
messages.success(
|
||||
self.request,
|
||||
f'Инвентаризация склада "{form.instance.warehouse.name}" начата.'
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class InventoryDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Детальный просмотр инвентаризации с её строками.
|
||||
Позволяет добавлять строки и заполнять фактические количества.
|
||||
"""
|
||||
model = Inventory
|
||||
template_name = 'inventory/inventory/inventory_detail.html'
|
||||
context_object_name = 'inventory'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Получаем все строки этой инвентаризации
|
||||
context['lines'] = InventoryLine.objects.filter(
|
||||
inventory=self.object
|
||||
).select_related('product')
|
||||
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)
|
||||
24
myproject/inventory/views/movements.py
Normal file
24
myproject/inventory/views/movements.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
StockMovement (Журнал всех складских операций) views - READ ONLY
|
||||
GROUP 3: LOW PRIORITY - Аудит логирование
|
||||
"""
|
||||
from django.views.generic import ListView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from ..models import StockMovement
|
||||
|
||||
|
||||
class StockMovementListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Полный журнал всех складских операций (приход, продажа, списание, корректировка).
|
||||
Используется для аудита и контроля.
|
||||
"""
|
||||
model = StockMovement
|
||||
template_name = 'inventory/movements/movement_list.html'
|
||||
context_object_name = 'movements'
|
||||
paginate_by = 50
|
||||
|
||||
def get_queryset(self):
|
||||
return StockMovement.objects.select_related(
|
||||
'product', 'order'
|
||||
).order_by('-created_at')
|
||||
46
myproject/inventory/views/reservation.py
Normal file
46
myproject/inventory/views/reservation.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Reservation (Резервирование товара) views
|
||||
GROUP 2: MEDIUM PRIORITY
|
||||
"""
|
||||
from django.views.generic import ListView, CreateView, UpdateView
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from ..models import Reservation
|
||||
from ..forms import ReservationForm
|
||||
|
||||
|
||||
class ReservationListView(LoginRequiredMixin, ListView):
|
||||
model = Reservation
|
||||
template_name = 'inventory/reservation/reservation_list.html'
|
||||
context_object_name = 'reservations'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
return Reservation.objects.filter(
|
||||
status='reserved'
|
||||
).select_related('product', 'warehouse', 'order_item').order_by('-reserved_at')
|
||||
|
||||
|
||||
class ReservationCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Reservation
|
||||
form_class = ReservationForm
|
||||
template_name = 'inventory/reservation/reservation_form.html'
|
||||
success_url = reverse_lazy('inventory:reservation-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.status = 'reserved'
|
||||
messages.success(self.request, f'Товар успешно зарезервирован.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class ReservationUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = Reservation
|
||||
fields = ['status']
|
||||
template_name = 'inventory/reservation/reservation_update.html'
|
||||
success_url = reverse_lazy('inventory:reservation-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Статус резервирования обновлен.')
|
||||
return super().form_valid(form)
|
||||
103
myproject/inventory/views/sale.py
Normal file
103
myproject/inventory/views/sale.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.shortcuts import render
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from ..models import Sale, SaleBatchAllocation
|
||||
from ..forms import SaleForm
|
||||
|
||||
|
||||
class SaleListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Список всех продаж товара (истории реализации)
|
||||
"""
|
||||
model = Sale
|
||||
template_name = 'inventory/sale/sale_list.html'
|
||||
context_object_name = 'sales'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Sale.objects.select_related('product', 'warehouse', 'order').order_by('-date')
|
||||
|
||||
# Фильтры (если переданы)
|
||||
product_id = self.request.GET.get('product')
|
||||
warehouse_id = self.request.GET.get('warehouse')
|
||||
processed = self.request.GET.get('processed')
|
||||
|
||||
if product_id:
|
||||
queryset = queryset.filter(product_id=product_id)
|
||||
if warehouse_id:
|
||||
queryset = queryset.filter(warehouse_id=warehouse_id)
|
||||
if processed:
|
||||
queryset = queryset.filter(processed=processed == 'true')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class SaleCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Регистрация новой продажи товара.
|
||||
После сохранения автоматически применяется FIFO (через сигнал).
|
||||
"""
|
||||
model = Sale
|
||||
form_class = SaleForm
|
||||
template_name = 'inventory/sale/sale_form.html'
|
||||
success_url = reverse_lazy('inventory:sale-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(
|
||||
self.request,
|
||||
f'Продажа товара "{form.instance.product.name}" ({form.instance.quantity} шт) успешно зарегистрирована.'
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class SaleDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Просмотр деталей продажи с распределением по партиям.
|
||||
Показывает SaleBatchAllocation для данной продажи.
|
||||
"""
|
||||
model = Sale
|
||||
template_name = 'inventory/sale/sale_detail.html'
|
||||
context_object_name = 'sale'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Получаем все распределения этой продажи по партиям
|
||||
context['allocations'] = SaleBatchAllocation.objects.filter(
|
||||
sale=self.object
|
||||
).select_related('batch', 'batch__product')
|
||||
return context
|
||||
|
||||
|
||||
class SaleUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Редактирование продажи (только если ещё не обработана).
|
||||
Обработанные продажи редактировать нельзя.
|
||||
"""
|
||||
model = Sale
|
||||
form_class = SaleForm
|
||||
template_name = 'inventory/sale/sale_form.html'
|
||||
success_url = reverse_lazy('inventory:sale-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Продажа товара обновлена.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class SaleDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""
|
||||
Отмена/удаление продажи товара.
|
||||
"""
|
||||
model = Sale
|
||||
template_name = 'inventory/sale/sale_confirm_delete.html'
|
||||
success_url = reverse_lazy('inventory:sale-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
sale = self.get_object()
|
||||
messages.success(
|
||||
self.request,
|
||||
f'Продажа товара "{sale.product.name}" отменена.'
|
||||
)
|
||||
return super().form_valid(form)
|
||||
27
myproject/inventory/views/stock.py
Normal file
27
myproject/inventory/views/stock.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Stock (Остатки товаров) views - READ ONLY
|
||||
GROUP 3: LOW PRIORITY - Справочник состояния
|
||||
"""
|
||||
from django.views.generic import ListView, DetailView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from ..models import Stock
|
||||
|
||||
|
||||
class StockListView(LoginRequiredMixin, ListView):
|
||||
"""Список всех остатков товаров на всех складах"""
|
||||
model = Stock
|
||||
template_name = 'inventory/stock/stock_list.html'
|
||||
context_object_name = 'stocks'
|
||||
paginate_by = 30
|
||||
|
||||
def get_queryset(self):
|
||||
# Показываем все остатки, включая нулевые (для полной видимости)
|
||||
return Stock.objects.select_related('product', 'warehouse').order_by('warehouse', 'product')
|
||||
|
||||
|
||||
class StockDetailView(LoginRequiredMixin, DetailView):
|
||||
"""Детальная информация по остаткам конкретного товара"""
|
||||
model = Stock
|
||||
template_name = 'inventory/stock/stock_detail.html'
|
||||
context_object_name = 'stock'
|
||||
60
myproject/inventory/views/transfer.py
Normal file
60
myproject/inventory/views/transfer.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Transfer (Перемещение товара между складами) views
|
||||
GROUP 2: MEDIUM PRIORITY
|
||||
"""
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from ..models import Transfer
|
||||
from ..forms import TransferForm
|
||||
|
||||
|
||||
class TransferListView(LoginRequiredMixin, ListView):
|
||||
model = Transfer
|
||||
template_name = 'inventory/transfer/transfer_list.html'
|
||||
context_object_name = 'transfers'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
return Transfer.objects.select_related(
|
||||
'batch', 'batch__product',
|
||||
'from_warehouse', 'to_warehouse'
|
||||
).order_by('-date')
|
||||
|
||||
|
||||
class TransferCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Transfer
|
||||
form_class = TransferForm
|
||||
template_name = 'inventory/transfer/transfer_form.html'
|
||||
success_url = reverse_lazy('inventory:transfer-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(
|
||||
self.request,
|
||||
f'Перемещение товара "{form.instance.batch.product.name}" успешно зарегистрировано.'
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class TransferUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = Transfer
|
||||
form_class = TransferForm
|
||||
template_name = 'inventory/transfer/transfer_form.html'
|
||||
success_url = reverse_lazy('inventory:transfer-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Перемещение товара обновлено.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class TransferDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = Transfer
|
||||
template_name = 'inventory/transfer/transfer_confirm_delete.html'
|
||||
success_url = reverse_lazy('inventory:transfer-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
transfer = self.get_object()
|
||||
messages.success(self.request, f'Перемещение товара отменено.')
|
||||
return super().form_valid(form)
|
||||
66
myproject/inventory/views/warehouse.py
Normal file
66
myproject/inventory/views/warehouse.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.shortcuts import render
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from ..models import Warehouse
|
||||
from ..forms import WarehouseForm
|
||||
|
||||
|
||||
class WarehouseListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Список всех складов тенанта
|
||||
"""
|
||||
model = Warehouse
|
||||
template_name = 'inventory/warehouse/warehouse_list.html'
|
||||
context_object_name = 'warehouses'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
return Warehouse.objects.filter(is_active=True).order_by('name')
|
||||
|
||||
|
||||
class WarehouseCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Создание нового склада
|
||||
"""
|
||||
model = Warehouse
|
||||
form_class = WarehouseForm
|
||||
template_name = 'inventory/warehouse/warehouse_form.html'
|
||||
success_url = reverse_lazy('inventory:warehouse-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Склад "{form.instance.name}" успешно создан.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class WarehouseUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Редактирование склада
|
||||
"""
|
||||
model = Warehouse
|
||||
form_class = WarehouseForm
|
||||
template_name = 'inventory/warehouse/warehouse_form.html'
|
||||
success_url = reverse_lazy('inventory:warehouse-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Склад "{form.instance.name}" успешно обновлён.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class WarehouseDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""
|
||||
Удаление склада (мягкое удаление - деактивация)
|
||||
"""
|
||||
model = Warehouse
|
||||
template_name = 'inventory/warehouse/warehouse_confirm_delete.html'
|
||||
success_url = reverse_lazy('inventory:warehouse-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
# Мягкое удаление - просто деактивируем
|
||||
warehouse = self.get_object()
|
||||
warehouse.is_active = False
|
||||
warehouse.save()
|
||||
messages.success(self.request, f'Склад "{warehouse.name}" деактивирован.')
|
||||
return super().form_valid(form)
|
||||
54
myproject/inventory/views/writeoff.py
Normal file
54
myproject/inventory/views/writeoff.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WriteOff (Списание товара) views
|
||||
GROUP 2: MEDIUM PRIORITY
|
||||
"""
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from ..models import WriteOff
|
||||
from ..forms import WriteOffForm
|
||||
|
||||
|
||||
class WriteOffListView(LoginRequiredMixin, ListView):
|
||||
model = WriteOff
|
||||
template_name = 'inventory/writeoff/writeoff_list.html'
|
||||
context_object_name = 'writeoffs'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
return WriteOff.objects.select_related('batch', 'batch__product').order_by('-date')
|
||||
|
||||
|
||||
class WriteOffCreateView(LoginRequiredMixin, CreateView):
|
||||
model = WriteOff
|
||||
form_class = WriteOffForm
|
||||
template_name = 'inventory/writeoff/writeoff_form.html'
|
||||
success_url = reverse_lazy('inventory:writeoff-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Списание товара успешно создано.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class WriteOffUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = WriteOff
|
||||
form_class = WriteOffForm
|
||||
template_name = 'inventory/writeoff/writeoff_form.html'
|
||||
success_url = reverse_lazy('inventory:writeoff-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Списание товара обновлено.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class WriteOffDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = WriteOff
|
||||
template_name = 'inventory/writeoff/writeoff_confirm_delete.html'
|
||||
success_url = reverse_lazy('inventory:writeoff-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
writeoff = self.get_object()
|
||||
messages.success(self.request, f'Списание товара отменено.')
|
||||
return super().form_valid(form)
|
||||
Reference in New Issue
Block a user