refactor(procurement): approval logic now fully driven by ApprovalRoute table (priority+min_amount), removed hardcoded steps, all workflow is DB-driven; tests and migrations updated
This commit is contained in:
parent
c61b62969a
commit
2031872461
@ -1,8 +1,4 @@
|
||||
"""
|
||||
ViewSets для API модуля закупок.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
"""ViewSets для API модуля закупок."""
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
@ -15,7 +11,6 @@ from employees.models import Department
|
||||
from procurement.constants import ApprovalStatus, ProcurementStatus, EquipmentStatus
|
||||
from procurement.models import (
|
||||
Approval,
|
||||
Budget,
|
||||
Equipment,
|
||||
EquipmentCategory,
|
||||
EquipmentTransferLog,
|
||||
@ -26,17 +21,14 @@ from procurement.models import (
|
||||
)
|
||||
from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from procurement.services import QRCodeGenerator
|
||||
from procurement.services import ProcurementApprovalResolver, QRCodeGenerator
|
||||
from .permissions import (
|
||||
CanApproveProcurementRequest,
|
||||
CanManageBudget,
|
||||
CanManageEquipment,
|
||||
CanManageProcurementRequest,
|
||||
CanManageSupplier,
|
||||
)
|
||||
from .serializers import (
|
||||
BudgetSerializer,
|
||||
BudgetDetailSerializer,
|
||||
EquipmentCategorySerializer,
|
||||
EquipmentDetailSerializer,
|
||||
EquipmentListSerializer,
|
||||
@ -47,7 +39,6 @@ from .serializers import (
|
||||
ProcurementRequestListSerializer,
|
||||
SupplierSerializer,
|
||||
)
|
||||
from notifications.signals import notify
|
||||
|
||||
|
||||
class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
@ -148,65 +139,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return queryset.distinct()
|
||||
|
||||
def _check_budget_alert(self, procurement_request):
|
||||
"""Проверяет бюджет и отправляет алерт если низкий остаток."""
|
||||
department = procurement_request.department
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=department,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
except Budget.DoesNotExist:
|
||||
return # Нет бюджета - не отправляем алерт
|
||||
|
||||
# Пороговые значения для алертов
|
||||
LOW_BUDGET_THRESHOLD = Decimal('0.20') # 20% остатка
|
||||
CRITICAL_BUDGET_THRESHOLD = Decimal('0.10') # 10% остатка
|
||||
|
||||
utilization = budget.utilization_percentage / 100
|
||||
remaining_ratio = 1 - utilization
|
||||
|
||||
# Получаем руководителя отдела
|
||||
dept_head = department.head
|
||||
if not dept_head:
|
||||
return
|
||||
|
||||
if remaining_ratio <= CRITICAL_BUDGET_THRESHOLD:
|
||||
# Критический уровень - менее 10%
|
||||
notify.send(
|
||||
sender=None,
|
||||
recipient=dept_head,
|
||||
verb='budget_critical',
|
||||
description=(
|
||||
f'Бюджет отдела "{department.name}" почти исчерпан! '
|
||||
f'Остаток: {budget.remaining_amount}₽ '
|
||||
f'({remaining_ratio*100:.1f}%)'
|
||||
),
|
||||
action_url='/procurement',
|
||||
data={'title': '⚠️ Критически низкий бюджет!'},
|
||||
)
|
||||
elif remaining_ratio <= LOW_BUDGET_THRESHOLD:
|
||||
# Низкий уровень - менее 20%
|
||||
notify.send(
|
||||
sender=None,
|
||||
recipient=dept_head,
|
||||
verb='budget_low',
|
||||
description=(
|
||||
f'Бюджет отдела "{department.name}" снижается. '
|
||||
f'Остаток: {budget.remaining_amount}₽ '
|
||||
f'({remaining_ratio*100:.1f}%)'
|
||||
),
|
||||
action_url='/procurement',
|
||||
data={'title': 'Низкий остаток бюджета'},
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def submit(self, request, pk=None):
|
||||
"""Отправить заявку на согласование."""
|
||||
from django.db import transaction
|
||||
|
||||
procurement_request = self.get_object()
|
||||
|
||||
# Проверки
|
||||
@ -228,57 +165,51 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Проверка бюджета
|
||||
available, remaining = procurement_request.check_budget_available()
|
||||
if not available:
|
||||
required_priorities = (
|
||||
procurement_request.get_required_approval_priorities()
|
||||
)
|
||||
if not required_priorities:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Недостаточно бюджета',
|
||||
'remaining': float(remaining),
|
||||
'required': float(procurement_request.total_cost),
|
||||
'error': (
|
||||
'Для этой суммы заявки не настроены '
|
||||
'маршруты согласования'
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Меняем статус
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
resolved_approvals, missing_priorities = (
|
||||
ProcurementApprovalResolver.resolve_required_approvers(
|
||||
procurement_request
|
||||
)
|
||||
)
|
||||
if missing_priorities:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Не настроены согласующие для обязательных этапов',
|
||||
'missing_priorities': missing_priorities,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Создаем записи согласований
|
||||
required_approvals = procurement_request.get_required_approvals()
|
||||
from procurement.constants import ApprovalRole
|
||||
with transaction.atomic():
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.submitted_at = timezone.now()
|
||||
procurement_request.save(
|
||||
update_fields=['status', 'submitted_at', 'updated_at']
|
||||
)
|
||||
|
||||
created_approvals = []
|
||||
for role in required_approvals:
|
||||
# Определяем согласующего
|
||||
approver = None
|
||||
if role == ApprovalRole.DEPARTMENT_HEAD:
|
||||
approver = procurement_request.department.head
|
||||
elif role == ApprovalRole.FINANCE_MANAGER:
|
||||
# Найдем первого пользователя с правом на бюджеты
|
||||
from employees.models import Employee
|
||||
from django.db.models import Q
|
||||
approver = Employee.objects.filter(
|
||||
is_active=True
|
||||
).filter(
|
||||
Q(groups__permissions__codename='change_budget') |
|
||||
Q(user_permissions__codename='change_budget')
|
||||
).distinct().first()
|
||||
elif role == ApprovalRole.DIRECTOR:
|
||||
# Найдем директора (суперпользователя)
|
||||
from employees.models import Employee
|
||||
approver = Employee.objects.filter(
|
||||
is_superuser=True
|
||||
).first()
|
||||
|
||||
if approver:
|
||||
approval = Approval.objects.create(
|
||||
for priority, approver, step_name in resolved_approvals:
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=approver,
|
||||
role=role,
|
||||
priority=priority,
|
||||
step_name=step_name,
|
||||
status=ApprovalStatus.PENDING
|
||||
)
|
||||
created_approvals.append(approval)
|
||||
|
||||
procurement_request.refresh_from_db()
|
||||
|
||||
# Уведомления согласующим отправит сигнал post_save(Approval, created=True)
|
||||
|
||||
@ -294,10 +225,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
self.check_object_permissions(request, procurement_request)
|
||||
|
||||
# Находим согласование текущего пользователя
|
||||
approval = procurement_request.approvals.filter(
|
||||
approver=request.user,
|
||||
status=ApprovalStatus.PENDING
|
||||
).first()
|
||||
approval = ProcurementApprovalResolver.get_available_approval(
|
||||
request.user,
|
||||
procurement_request,
|
||||
)
|
||||
|
||||
if not approval:
|
||||
return Response(
|
||||
@ -321,8 +252,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
procurement_request.status = ProcurementStatus.APPROVED
|
||||
procurement_request.save()
|
||||
|
||||
# Проверяем бюджет и отправляем алерт если низкий
|
||||
self._check_budget_alert(procurement_request)
|
||||
procurement_request.refresh_from_db()
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
@ -336,10 +266,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
self.check_object_permissions(request, procurement_request)
|
||||
|
||||
# Находим согласование текущего пользователя
|
||||
approval = procurement_request.approvals.filter(
|
||||
approver=request.user,
|
||||
status=ApprovalStatus.PENDING
|
||||
).first()
|
||||
approval = ProcurementApprovalResolver.get_available_approval(
|
||||
request.user,
|
||||
procurement_request,
|
||||
)
|
||||
|
||||
if not approval:
|
||||
return Response(
|
||||
@ -356,6 +286,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
procurement_request.status = ProcurementStatus.REJECTED
|
||||
procurement_request.save()
|
||||
|
||||
procurement_request.refresh_from_db()
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -374,10 +306,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
def pending_approvals(self, request):
|
||||
"""Получить заявки, ожидающие согласования текущим польз."""
|
||||
# Находим заявки где есть pending approval для текущего юзера
|
||||
queryset = self.get_queryset().filter(
|
||||
base_queryset = self.get_queryset().filter(
|
||||
approvals__approver=request.user,
|
||||
approvals__status=ApprovalStatus.PENDING
|
||||
)
|
||||
).distinct()
|
||||
available_ids = [
|
||||
request_obj.id
|
||||
for request_obj in base_queryset
|
||||
if ProcurementApprovalResolver.user_can_approve(request.user, request_obj)
|
||||
]
|
||||
queryset = base_queryset.filter(id__in=available_ids)
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
@ -479,52 +417,12 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
procurement_request.status = ProcurementStatus.COMPLETED
|
||||
procurement_request.completed_at = timezone.now()
|
||||
procurement_request.save()
|
||||
|
||||
# Списываем бюджет отдела
|
||||
self._deduct_budget(procurement_request)
|
||||
# Сигнал post_save(ProcurementRequest) с COMPLETED отправит уведомления
|
||||
# requestor'у и согласующим
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
def _deduct_budget(self, procurement_request):
|
||||
"""Списывает сумму заявки из бюджета отдела."""
|
||||
department = procurement_request.department
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
# Используем фактическую сумму если есть, иначе общую стоимость позиций
|
||||
amount = (
|
||||
procurement_request.actual_cost or
|
||||
procurement_request.total_cost
|
||||
)
|
||||
if not amount or amount <= 0:
|
||||
return # Нечего списывать
|
||||
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=department,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
except Budget.DoesNotExist:
|
||||
# Нет бюджета - создаём с нулём, чтобы зафиксировать траты
|
||||
budget = Budget.objects.create(
|
||||
department=department,
|
||||
year=now.year,
|
||||
quarter=quarter,
|
||||
allocated_amount=Decimal('0'),
|
||||
spent_amount=Decimal('0')
|
||||
)
|
||||
|
||||
# Увеличиваем потраченную сумму
|
||||
budget.spent_amount = (budget.spent_amount or Decimal('0')) + amount
|
||||
budget.save()
|
||||
|
||||
# Проверяем и отправляем алерт если бюджет низкий
|
||||
self._check_budget_alert(procurement_request)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def cancel(self, request, pk=None):
|
||||
"""Отменить заявку."""
|
||||
@ -1371,95 +1269,6 @@ class MaintenanceRecordViewSet(viewsets.ModelViewSet):
|
||||
serializer.save(performed_by=self.request.user)
|
||||
|
||||
|
||||
class BudgetViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet для бюджетов."""
|
||||
|
||||
queryset = Budget.objects.select_related('department')
|
||||
serializer_class = BudgetSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ['department', 'year', 'quarter']
|
||||
ordering_fields = ['year', 'quarter']
|
||||
ordering = ['-year', '-quarter']
|
||||
|
||||
def get_permissions(self):
|
||||
"""Права доступа."""
|
||||
if self.action == 'my_department':
|
||||
# my_department доступен любому авторизованному пользователю
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
permission_classes = [CanManageBudget]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Фильтровать бюджеты."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if user.is_superuser or user.is_staff:
|
||||
return queryset
|
||||
|
||||
# Руководители видят бюджеты своих отделов
|
||||
if user.headed_departments.exists():
|
||||
return queryset.filter(
|
||||
department__in=user.headed_departments.all()
|
||||
)
|
||||
|
||||
return queryset.none()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def current_quarter(self, request):
|
||||
"""Получить бюджеты текущего квартала."""
|
||||
from django.utils import timezone
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
queryset = self.get_queryset().filter(
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='my-department')
|
||||
def my_department(self, request):
|
||||
"""Получить бюджет текущего квартала для отдела пользователя."""
|
||||
from django.utils import timezone
|
||||
|
||||
user = request.user
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
# Получаем отдел пользователя
|
||||
user_departments = user.departments.all()
|
||||
if not user_departments.exists():
|
||||
return Response(
|
||||
{'detail': 'Вы не состоите ни в одном отделе.'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Берём первый отдел (основной)
|
||||
department = user_departments.first()
|
||||
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=department,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
except Budget.DoesNotExist:
|
||||
msg = (
|
||||
f'Бюджет для {department.name} '
|
||||
f'на Q{quarter} {now.year} не найден.'
|
||||
)
|
||||
return Response(
|
||||
{'detail': msg},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
serializer = BudgetDetailSerializer(budget)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class SupplierViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet для поставщиков."""
|
||||
|
||||
@ -1584,26 +1393,11 @@ class ProcurementStatsViewSet(viewsets.ViewSet):
|
||||
spent = requests.filter(
|
||||
status=ProcurementStatus.COMPLETED
|
||||
).aggregate(total=Sum('actual_cost'))['total'] or 0
|
||||
|
||||
# Получаем текущий бюджет
|
||||
from django.utils import timezone
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=dept,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
utilization = float(budget.utilization_percentage)
|
||||
except Budget.DoesNotExist:
|
||||
utilization = 0.0
|
||||
|
||||
result.append({
|
||||
'department': {'id': dept.id, 'name': dept.name},
|
||||
'total_requests': total,
|
||||
'total_spent': str(spent),
|
||||
'budget_utilization': utilization,
|
||||
})
|
||||
|
||||
return Response(result)
|
||||
|
||||
@ -5,6 +5,7 @@ from django.utils.html import format_html
|
||||
|
||||
from .models import (
|
||||
Approval,
|
||||
ApprovalRoute,
|
||||
Budget,
|
||||
Equipment,
|
||||
EquipmentCategory,
|
||||
@ -159,6 +160,15 @@ class ProcurementItemAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ["created_at", "total_price"]
|
||||
|
||||
|
||||
@admin.register(ApprovalRoute)
|
||||
class ApprovalRouteAdmin(admin.ModelAdmin):
|
||||
"""Маршруты согласования закупок."""
|
||||
|
||||
list_display = ["priority", "min_amount", "name", "resolver_type", "employee"]
|
||||
ordering = ["priority"]
|
||||
autocomplete_fields = ["employee"]
|
||||
|
||||
|
||||
@admin.register(Approval)
|
||||
class ApprovalAdmin(admin.ModelAdmin):
|
||||
"""Админка для согласований."""
|
||||
@ -167,11 +177,12 @@ class ApprovalAdmin(admin.ModelAdmin):
|
||||
"id",
|
||||
"request",
|
||||
"approver",
|
||||
"role",
|
||||
"priority",
|
||||
"step_name",
|
||||
"status_badge",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["status", "role", "created_at"]
|
||||
list_filter = ["status", "priority", "created_at"]
|
||||
search_fields = [
|
||||
"request__title",
|
||||
"approver__username",
|
||||
|
||||
@ -25,11 +25,10 @@ class UrgencyLevel(models.TextChoices):
|
||||
CRITICAL = 'critical', 'Критическая'
|
||||
|
||||
|
||||
class ApprovalRole(models.TextChoices):
|
||||
"""Роль согласующего в процессе одобрения."""
|
||||
DEPARTMENT_HEAD = 'department_head', 'Руководитель отдела'
|
||||
FINANCE_MANAGER = 'finance_manager', 'Финансовый менеджер'
|
||||
DIRECTOR = 'director', 'Генеральный директор'
|
||||
def get_default_approval_step_name(priority: int, resolver_type: str | None = None) -> str:
|
||||
if resolver_type == 'department_head':
|
||||
return 'Руководитель отдела'
|
||||
return f'Этап {priority}'
|
||||
|
||||
|
||||
class ApprovalStatus(models.TextChoices):
|
||||
@ -56,9 +55,3 @@ class MaintenanceType(models.TextChoices):
|
||||
REPAIR = 'repair', 'Ремонт'
|
||||
UPGRADE = 'upgrade', 'Модернизация'
|
||||
CLEANING = 'cleaning', 'Чистка'
|
||||
|
||||
|
||||
# Пороги для согласования по сумме (в рублях)
|
||||
APPROVAL_THRESHOLD_LOW = 10_000 # До 10k - только руководитель отдела
|
||||
# До 50k - + фин. менеджер, выше 50k - + директор
|
||||
APPROVAL_THRESHOLD_HIGH = 50_000
|
||||
|
||||
28
backend/procurement/migrations/0005_add_approval_route.py
Normal file
28
backend/procurement/migrations/0005_add_approval_route.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.4 on 2026-03-27 23:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('procurement', '0004_add_executor_and_started_at'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApprovalRoute',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('department_head', 'Руководитель отдела'), ('finance_manager', 'Финансовый менеджер'), ('director', 'Генеральный директор')], max_length=20, unique=True, verbose_name='Роль')),
|
||||
('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='approval_routes', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Маршрут согласования',
|
||||
'verbose_name_plural': 'Маршруты согласования',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.4 on 2026-03-27 23:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_default_priorities(apps, schema_editor):
|
||||
Approval = apps.get_model('procurement', 'Approval')
|
||||
ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
|
||||
|
||||
priorities = {
|
||||
'department_head': 10,
|
||||
'finance_manager': 20,
|
||||
'director': 30,
|
||||
}
|
||||
|
||||
for role, priority in priorities.items():
|
||||
Approval.objects.filter(role=role).update(priority=priority)
|
||||
ApprovalRoute.objects.filter(role=role).update(priority=priority)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('procurement', '0005_add_approval_route'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='approval',
|
||||
options={'ordering': ['priority', 'created_at', 'id'], 'verbose_name': 'Согласование', 'verbose_name_plural': 'Согласования'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='approvalroute',
|
||||
options={'ordering': ['priority', 'role', 'id'], 'verbose_name': 'Маршрут согласования', 'verbose_name_plural': 'Маршруты согласования'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='approval',
|
||||
name='priority',
|
||||
field=models.PositiveSmallIntegerField(db_index=True, default=100, verbose_name='Приоритет'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='approvalroute',
|
||||
name='priority',
|
||||
field=models.PositiveSmallIntegerField(db_index=True, default=100, help_text='Чем меньше число, тем раньше этап в цепочке согласования.', verbose_name='Приоритет'),
|
||||
),
|
||||
migrations.RunPython(set_default_priorities, migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.4 on 2026-03-28 00:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_route_roles_to_resolver_types(apps, schema_editor):
|
||||
ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
|
||||
ApprovalRoute.objects.filter(role='department_head').update(
|
||||
resolver_type='department_head',
|
||||
employee=None,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('procurement', '0006_add_approval_priorities'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='approvalroute',
|
||||
options={'ordering': ['priority', 'id'], 'verbose_name': 'Маршрут согласования', 'verbose_name_plural': 'Маршруты согласования'},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='approval',
|
||||
unique_together={('request', 'priority')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='approvalroute',
|
||||
name='resolver_type',
|
||||
field=models.CharField(choices=[('department_head', 'Руководитель отдела'), ('fixed_employee', 'Конкретный сотрудник')], default='fixed_employee', max_length=20, verbose_name='Тип резолва'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='approval',
|
||||
name='priority',
|
||||
field=models.PositiveSmallIntegerField(db_index=True, verbose_name='Приоритет'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='approvalroute',
|
||||
name='employee',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='approval_routes', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='approvalroute',
|
||||
name='priority',
|
||||
field=models.PositiveSmallIntegerField(db_index=True, help_text='Чем меньше число, тем раньше этап в цепочке согласования.', unique=True, verbose_name='Приоритет'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_route_roles_to_resolver_types,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='approvalroute',
|
||||
name='role',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='approval',
|
||||
name='role',
|
||||
),
|
||||
]
|
||||
47
backend/procurement/migrations/0008_add_step_names.py
Normal file
47
backend/procurement/migrations/0008_add_step_names.py
Normal file
@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.4 on 2026-03-28 00:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def populate_step_names(apps, schema_editor):
|
||||
Approval = apps.get_model('procurement', 'Approval')
|
||||
ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
|
||||
|
||||
default_names = {
|
||||
10: 'Руководитель отдела',
|
||||
20: 'Финансовый менеджер',
|
||||
30: 'Генеральный директор',
|
||||
}
|
||||
|
||||
route_names = {
|
||||
route.priority: (route.name or default_names.get(route.priority, f'Этап {route.priority}'))
|
||||
for route in ApprovalRoute.objects.all()
|
||||
}
|
||||
|
||||
for approval in Approval.objects.all():
|
||||
approval.step_name = route_names.get(
|
||||
approval.priority,
|
||||
default_names.get(approval.priority, f'Этап {approval.priority}'),
|
||||
)
|
||||
approval.save(update_fields=['step_name'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('procurement', '0007_replace_roles_with_priority_steps'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='approval',
|
||||
name='step_name',
|
||||
field=models.CharField(blank=True, max_length=150, verbose_name='Название этапа'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='approvalroute',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, help_text='Необязательно. Если заполнено, будет показано в интерфейсе.', max_length=150, verbose_name='Название этапа'),
|
||||
),
|
||||
migrations.RunPython(populate_step_names, migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.4 on 2026-03-28 00:35
|
||||
|
||||
import django.core.validators
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_legacy_min_amounts(apps, schema_editor):
|
||||
ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
|
||||
|
||||
ApprovalRoute.objects.filter(
|
||||
priority=20,
|
||||
min_amount__isnull=True,
|
||||
).update(min_amount=Decimal('10000.00'))
|
||||
ApprovalRoute.objects.filter(
|
||||
priority=30,
|
||||
min_amount__isnull=True,
|
||||
).update(min_amount=Decimal('50000.00'))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('procurement', '0008_add_step_names'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='approvalroute',
|
||||
name='min_amount',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, help_text='Пусто = этап обязателен для любой суммы. Иначе этап включается при сумме не меньше указанной.', max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='Минимальная сумма заявки'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_legacy_min_amounts,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -16,6 +16,7 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
@ -23,14 +24,12 @@ from django.utils import timezone
|
||||
from employees.models import Department
|
||||
|
||||
from .constants import (
|
||||
APPROVAL_THRESHOLD_HIGH,
|
||||
APPROVAL_THRESHOLD_LOW,
|
||||
ApprovalRole,
|
||||
ApprovalStatus,
|
||||
EquipmentStatus,
|
||||
MaintenanceType,
|
||||
ProcurementStatus,
|
||||
UrgencyLevel,
|
||||
get_default_approval_step_name,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@ -132,50 +131,15 @@ class ProcurementRequest(models.Model):
|
||||
def __str__(self):
|
||||
return f"#{self.pk} {self.title}"
|
||||
|
||||
def get_required_approvals(self):
|
||||
"""Возвращает список необходимых ролей для согласования.
|
||||
|
||||
Логика:
|
||||
- < 10,000₽: только руководитель отдела
|
||||
- 10,000 - 100,000₽: руководитель + финансовый менеджер
|
||||
- > 100,000₽: руководитель + финансы + директор
|
||||
"""
|
||||
cost = self.total_cost
|
||||
|
||||
if cost < APPROVAL_THRESHOLD_LOW:
|
||||
return [ApprovalRole.DEPARTMENT_HEAD]
|
||||
elif cost < APPROVAL_THRESHOLD_HIGH:
|
||||
return [
|
||||
ApprovalRole.DEPARTMENT_HEAD,
|
||||
ApprovalRole.FINANCE_MANAGER
|
||||
]
|
||||
else:
|
||||
return [
|
||||
ApprovalRole.DEPARTMENT_HEAD,
|
||||
ApprovalRole.FINANCE_MANAGER,
|
||||
ApprovalRole.DIRECTOR
|
||||
]
|
||||
|
||||
def check_budget_available(self):
|
||||
"""Проверяет наличие бюджета в отделе.
|
||||
|
||||
Returns:
|
||||
tuple: (bool, Decimal) - (доступно ли, остаток бюджета)
|
||||
"""
|
||||
# Определяем текущий квартал
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=self.department,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
remaining = budget.remaining_amount
|
||||
return (remaining >= self.total_cost, remaining)
|
||||
except Budget.DoesNotExist:
|
||||
return (False, Decimal('0'))
|
||||
def get_required_approval_priorities(self):
|
||||
"""Возвращает приоритеты обязательных этапов из таблицы маршрутов."""
|
||||
return list(
|
||||
self.get_required_approval_routes().values_list('priority', flat=True)
|
||||
)
|
||||
|
||||
def get_required_approval_routes(self):
|
||||
"""Возвращает queryset обязательных этапов из таблицы маршрутов."""
|
||||
return ApprovalRoute.get_applicable_routes(self.total_cost)
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
@ -265,6 +229,74 @@ class ProcurementItem(models.Model):
|
||||
return self.estimated_unit_price * self.quantity
|
||||
|
||||
|
||||
class ApprovalRoute(models.Model):
|
||||
"""Правило получения согласующего для этапа с заданным приоритетом."""
|
||||
|
||||
class ResolverType(models.TextChoices):
|
||||
DEPARTMENT_HEAD = 'department_head', 'Руководитель отдела'
|
||||
FIXED_EMPLOYEE = 'fixed_employee', 'Конкретный сотрудник'
|
||||
|
||||
priority = models.PositiveSmallIntegerField(
|
||||
'Приоритет',
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text='Чем меньше число, тем раньше этап в цепочке согласования.',
|
||||
)
|
||||
min_amount = models.DecimalField(
|
||||
'Минимальная сумма заявки',
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0.00'))],
|
||||
help_text='Пусто = этап обязателен для любой суммы. Иначе этап включается при сумме не меньше указанной.',
|
||||
)
|
||||
name = models.CharField(
|
||||
'Название этапа',
|
||||
max_length=150,
|
||||
blank=True,
|
||||
help_text='Необязательно. Если заполнено, будет показано в интерфейсе.',
|
||||
)
|
||||
resolver_type = models.CharField(
|
||||
'Тип резолва',
|
||||
max_length=20,
|
||||
choices=ResolverType.choices,
|
||||
default=ResolverType.FIXED_EMPLOYEE,
|
||||
)
|
||||
employee = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='approval_routes',
|
||||
verbose_name='Согласующий',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Маршрут согласования'
|
||||
verbose_name_plural = 'Маршруты согласования'
|
||||
ordering = ['priority', 'id']
|
||||
|
||||
@classmethod
|
||||
def get_applicable_routes(cls, total_cost):
|
||||
return cls.objects.filter(
|
||||
models.Q(min_amount__isnull=True) | models.Q(min_amount__lte=total_cost)
|
||||
).order_by('priority', 'id')
|
||||
|
||||
def __str__(self):
|
||||
threshold = '' if self.min_amount is None else f' от {self.min_amount}'
|
||||
if self.resolver_type == self.ResolverType.DEPARTMENT_HEAD:
|
||||
return f"Этап {self.priority}{threshold} → руководитель отдела"
|
||||
return f"Этап {self.priority}{threshold} → {self.employee}"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.resolver_type == self.ResolverType.FIXED_EMPLOYEE and not self.employee_id:
|
||||
raise ValidationError({'employee': 'Для статического этапа укажите сотрудника.'})
|
||||
if self.resolver_type == self.ResolverType.DEPARTMENT_HEAD and self.employee_id:
|
||||
raise ValidationError({'employee': 'Для этапа руководителя отдела сотрудник не указывается.'})
|
||||
|
||||
|
||||
class Approval(models.Model):
|
||||
"""Запись о согласовании заявки."""
|
||||
|
||||
@ -280,11 +312,14 @@ class Approval(models.Model):
|
||||
related_name='procurement_approvals',
|
||||
verbose_name='Согласующий'
|
||||
)
|
||||
role = models.CharField(
|
||||
'Роль',
|
||||
max_length=20,
|
||||
choices=ApprovalRole.choices,
|
||||
help_text='Роль согласующего в процессе'
|
||||
priority = models.PositiveSmallIntegerField(
|
||||
'Приоритет',
|
||||
db_index=True,
|
||||
)
|
||||
step_name = models.CharField(
|
||||
'Название этапа',
|
||||
max_length=150,
|
||||
blank=True,
|
||||
)
|
||||
status = models.CharField(
|
||||
'Статус',
|
||||
@ -302,14 +337,27 @@ class Approval(models.Model):
|
||||
class Meta:
|
||||
verbose_name = 'Согласование'
|
||||
verbose_name_plural = 'Согласования'
|
||||
ordering = ['created_at']
|
||||
unique_together = [('request', 'role')]
|
||||
ordering = ['priority', 'created_at', 'id']
|
||||
unique_together = [('request', 'priority')]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.get_role_display()} - "
|
||||
f"{self.get_status_display()}"
|
||||
)
|
||||
return f"Этап {self.priority} - {self.get_status_display()}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.step_name:
|
||||
route = ApprovalRoute.objects.filter(priority=self.priority).only(
|
||||
'name',
|
||||
'resolver_type',
|
||||
).first()
|
||||
if route and route.name:
|
||||
self.step_name = route.name
|
||||
else:
|
||||
resolver_type = route.resolver_type if route else None
|
||||
self.step_name = get_default_approval_step_name(
|
||||
self.priority,
|
||||
resolver_type=resolver_type,
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class EquipmentCategory(models.Model):
|
||||
|
||||
@ -11,6 +11,11 @@ from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from procurement.constants import (
|
||||
ApprovalStatus,
|
||||
get_default_approval_step_name,
|
||||
)
|
||||
|
||||
|
||||
class InventoryNumberGenerator:
|
||||
"""Генератор инвентарных номеров."""
|
||||
@ -144,6 +149,87 @@ class QRCodeGenerator:
|
||||
return os.path.join(cls.QR_CODE_DIR, f'{inventory_number}.png')
|
||||
|
||||
|
||||
class ProcurementApprovalResolver:
|
||||
"""Определяет согласующих для заявки на закупку.
|
||||
|
||||
Состав этапов и их порядок берутся из таблицы ApprovalRoute.
|
||||
Для этапа типа department_head согласующий определяется через Department.head.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def resolve_approval_step(cls, procurement_request, route):
|
||||
from .models import ApprovalRoute
|
||||
|
||||
if route is None:
|
||||
return None
|
||||
|
||||
if not isinstance(route, ApprovalRoute):
|
||||
route = ApprovalRoute.objects.select_related('employee').filter(
|
||||
priority=route,
|
||||
).first()
|
||||
if route is None:
|
||||
return None
|
||||
|
||||
step_name = route.name or get_default_approval_step_name(
|
||||
route.priority,
|
||||
resolver_type=route.resolver_type,
|
||||
)
|
||||
|
||||
if route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD:
|
||||
head = procurement_request.department.head
|
||||
if head and head.is_active:
|
||||
return route.priority, head, step_name
|
||||
return None
|
||||
|
||||
if route.employee and route.employee.is_active:
|
||||
return route.priority, route.employee, step_name
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def resolve_approver(cls, procurement_request, priority: int):
|
||||
step = cls.resolve_approval_step(procurement_request, priority)
|
||||
if step is None:
|
||||
return None
|
||||
return step[1]
|
||||
|
||||
@classmethod
|
||||
def resolve_required_approvers(cls, procurement_request):
|
||||
resolved, missing = [], []
|
||||
for route in procurement_request.get_required_approval_routes():
|
||||
step = cls.resolve_approval_step(procurement_request, route)
|
||||
if step:
|
||||
resolved.append(step)
|
||||
else:
|
||||
missing.append(route.priority)
|
||||
resolved.sort(key=lambda item: item[0])
|
||||
return resolved, missing
|
||||
|
||||
@classmethod
|
||||
def get_available_approval(cls, user, procurement_request):
|
||||
if not user or not user.is_authenticated:
|
||||
return None
|
||||
|
||||
pending = procurement_request.approvals.filter(
|
||||
status=ApprovalStatus.PENDING,
|
||||
).order_by('priority', 'created_at', 'id')
|
||||
first_pending = pending.first()
|
||||
if first_pending is None:
|
||||
return None
|
||||
|
||||
return pending.filter(
|
||||
approver=user,
|
||||
priority=first_pending.priority,
|
||||
).first()
|
||||
|
||||
@classmethod
|
||||
def user_can_approve(cls, user, procurement_request) -> bool:
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return cls.get_available_approval(user, procurement_request) is not None
|
||||
|
||||
|
||||
class EquipmentService:
|
||||
"""Сервис для операций с оборудованием."""
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ from notifications.models import (
|
||||
NotificationCategory,
|
||||
NotificationType,
|
||||
)
|
||||
from procurement.constants import ApprovalRole, ApprovalStatus, ProcurementStatus
|
||||
from procurement.constants import ApprovalStatus, ProcurementStatus
|
||||
from procurement.models import (
|
||||
Approval,
|
||||
Budget,
|
||||
@ -160,7 +160,7 @@ class TestProcurementSignals:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=department_with_head.head,
|
||||
role=ApprovalRole.DEPARTMENT_HEAD,
|
||||
priority=10,
|
||||
)
|
||||
|
||||
# Отправляем на согласование
|
||||
@ -241,7 +241,7 @@ class TestApprovalNotifications:
|
||||
approval = Approval.objects.create(
|
||||
request=request,
|
||||
approver=department_with_head.head,
|
||||
role=ApprovalRole.DEPARTMENT_HEAD,
|
||||
priority=10,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
@ -273,7 +273,7 @@ class TestApprovalNotifications:
|
||||
approval = Approval.objects.create(
|
||||
request=request,
|
||||
approver=department_with_head.head,
|
||||
role=ApprovalRole.DEPARTMENT_HEAD,
|
||||
priority=10,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ class TestRequestLifecycle:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.dept_head,
|
||||
role='department_head',
|
||||
priority=10,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
)
|
||||
|
||||
@ -189,7 +189,7 @@ class TestRequestLifecycle:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.dept_head,
|
||||
role='department_head',
|
||||
priority=10,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
)
|
||||
|
||||
@ -258,7 +258,7 @@ class TestRequestLifecycle:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.dept_head,
|
||||
role='department_head',
|
||||
priority=10,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
|
||||
@ -10,12 +10,12 @@ from django.contrib.auth import get_user_model
|
||||
|
||||
from employees.models import Department
|
||||
from procurement.constants import (
|
||||
ApprovalRole,
|
||||
EquipmentStatus,
|
||||
ProcurementStatus,
|
||||
UrgencyLevel,
|
||||
)
|
||||
from procurement.models import (
|
||||
ApprovalRoute,
|
||||
Budget,
|
||||
Equipment,
|
||||
EquipmentCategory,
|
||||
@ -26,6 +26,10 @@ from procurement.models import (
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
HEAD_PRIORITY = 1
|
||||
FINANCE_PRIORITY = 2
|
||||
DIRECTOR_PRIORITY = 3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def department(db):
|
||||
@ -65,6 +69,26 @@ def budget(db, department):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approval_routes(db, user):
|
||||
ApprovalRoute.objects.create(
|
||||
priority=HEAD_PRIORITY,
|
||||
resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD,
|
||||
)
|
||||
ApprovalRoute.objects.create(
|
||||
priority=FINANCE_PRIORITY,
|
||||
min_amount=Decimal('10000.00'),
|
||||
resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE,
|
||||
employee=user,
|
||||
)
|
||||
ApprovalRoute.objects.create(
|
||||
priority=DIRECTOR_PRIORITY,
|
||||
min_amount=Decimal('50000.00'),
|
||||
resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE,
|
||||
employee=user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProcurementRequest:
|
||||
"""Тесты модели ProcurementRequest."""
|
||||
@ -85,19 +109,26 @@ class TestProcurementRequest:
|
||||
assert request.is_editable is True
|
||||
assert request.items_count == 0
|
||||
|
||||
def test_get_required_approvals_low(self, department, user):
|
||||
def test_get_required_approval_priorities_low(self, department, user, approval_routes):
|
||||
"""Тест получения уровней согласования для малой суммы."""
|
||||
request = ProcurementRequest.objects.create(
|
||||
title="Канцтовары",
|
||||
department=department,
|
||||
requestor=user,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
request=request,
|
||||
name='Бумага',
|
||||
quantity=1,
|
||||
unit='шт',
|
||||
estimated_unit_price=Decimal('5000.00'),
|
||||
)
|
||||
|
||||
approvals = request.get_required_approvals()
|
||||
approvals = request.get_required_approval_priorities()
|
||||
assert len(approvals) == 1
|
||||
assert ApprovalRole.DEPARTMENT_HEAD in approvals
|
||||
assert HEAD_PRIORITY in approvals
|
||||
|
||||
def test_get_required_approvals_medium(self, department, user):
|
||||
def test_get_required_approval_priorities_medium(self, department, user, approval_routes):
|
||||
"""Тест получения уровней согласования для средней суммы (10k-50k)."""
|
||||
request = ProcurementRequest.objects.create(
|
||||
title="Оргтехника",
|
||||
@ -113,12 +144,12 @@ class TestProcurementRequest:
|
||||
estimated_unit_price=Decimal('30000.00'),
|
||||
)
|
||||
|
||||
approvals = request.get_required_approvals()
|
||||
approvals = request.get_required_approval_priorities()
|
||||
assert len(approvals) == 2
|
||||
assert ApprovalRole.DEPARTMENT_HEAD in approvals
|
||||
assert ApprovalRole.FINANCE_MANAGER in approvals
|
||||
assert HEAD_PRIORITY in approvals
|
||||
assert FINANCE_PRIORITY in approvals
|
||||
|
||||
def test_get_required_approvals_high(self, department, user):
|
||||
def test_get_required_approval_priorities_high(self, department, user, approval_routes):
|
||||
"""Тест получения уровней согласования для большой суммы (>50k)."""
|
||||
request = ProcurementRequest.objects.create(
|
||||
title="Серверное оборудование",
|
||||
@ -134,23 +165,11 @@ class TestProcurementRequest:
|
||||
estimated_unit_price=Decimal('500000.00'),
|
||||
)
|
||||
|
||||
approvals = request.get_required_approvals()
|
||||
approvals = request.get_required_approval_priorities()
|
||||
assert len(approvals) == 3
|
||||
assert ApprovalRole.DEPARTMENT_HEAD in approvals
|
||||
assert ApprovalRole.FINANCE_MANAGER in approvals
|
||||
assert ApprovalRole.DIRECTOR in approvals
|
||||
|
||||
def test_check_budget_available(self, department, user, budget):
|
||||
"""Тест проверки доступности бюджета."""
|
||||
request = ProcurementRequest.objects.create(
|
||||
title="Покупка",
|
||||
department=department,
|
||||
requestor=user,
|
||||
)
|
||||
|
||||
available, remaining = request.check_budget_available()
|
||||
assert available is True
|
||||
assert remaining == Decimal("70000.00")
|
||||
assert HEAD_PRIORITY in approvals
|
||||
assert FINANCE_PRIORITY in approvals
|
||||
assert DIRECTOR_PRIORITY in approvals
|
||||
|
||||
def test_is_editable_draft(self, department, user):
|
||||
"""Тест: черновик можно редактировать."""
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from notifications.models import NotificationCategory, NotificationType
|
||||
from procurement.constants import (
|
||||
ApprovalStatus,
|
||||
ProcurementStatus,
|
||||
@ -14,11 +14,16 @@ from procurement.constants import (
|
||||
)
|
||||
from procurement.models import (
|
||||
Approval,
|
||||
ApprovalRoute,
|
||||
Budget,
|
||||
ProcurementItem,
|
||||
ProcurementRequest,
|
||||
)
|
||||
|
||||
HEAD_PRIORITY = 1
|
||||
FINANCE_PRIORITY = 2
|
||||
DIRECTOR_PRIORITY = 3
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProcurementWorkflow:
|
||||
@ -29,43 +34,6 @@ class TestProcurementWorkflow:
|
||||
self, api_client, department_factory, user_factory, link_factory
|
||||
):
|
||||
"""Подготовка данных для тестов."""
|
||||
# Создаем категорию и типы уведомлений для закупок
|
||||
category, _ = NotificationCategory.objects.get_or_create(
|
||||
code='procurement',
|
||||
defaults={
|
||||
'name': 'Закупки',
|
||||
'icon': 'bi-cart',
|
||||
'color': 'success',
|
||||
'order': 10,
|
||||
}
|
||||
)
|
||||
|
||||
# Создаем типы уведомлений
|
||||
notification_types = [
|
||||
'procurement_new_request',
|
||||
'procurement_pending_approval',
|
||||
'procurement_approved',
|
||||
'procurement_rejected',
|
||||
'procurement_completed',
|
||||
'procurement_stage_approved',
|
||||
]
|
||||
|
||||
for type_code in notification_types:
|
||||
NotificationType.objects.get_or_create(
|
||||
code=type_code,
|
||||
defaults={
|
||||
'category': category,
|
||||
'name': type_code.replace('_', ' ').title(),
|
||||
'description': f'Уведомление {type_code}',
|
||||
'priority': 'normal',
|
||||
'default_channels': {
|
||||
'web': True,
|
||||
'email': False,
|
||||
'telegram': False
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Создаем отдел
|
||||
self.department = department_factory(name="IT отдел")
|
||||
|
||||
@ -101,6 +69,23 @@ class TestProcurementWorkflow:
|
||||
superuser=True,
|
||||
)
|
||||
|
||||
ApprovalRoute.objects.create(
|
||||
priority=HEAD_PRIORITY,
|
||||
resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD,
|
||||
)
|
||||
ApprovalRoute.objects.create(
|
||||
priority=FINANCE_PRIORITY,
|
||||
min_amount=10000,
|
||||
resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE,
|
||||
employee=self.finance,
|
||||
)
|
||||
ApprovalRoute.objects.create(
|
||||
priority=DIRECTOR_PRIORITY,
|
||||
min_amount=50000,
|
||||
resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE,
|
||||
employee=self.director,
|
||||
)
|
||||
|
||||
# Создаем бюджет
|
||||
Budget.objects.create(
|
||||
department=self.department,
|
||||
@ -132,7 +117,7 @@ class TestProcurementWorkflow:
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
'/api/procurement/requests/',
|
||||
reverse('api:v1:procurement:procurementrequest-list'),
|
||||
data,
|
||||
format='json'
|
||||
)
|
||||
@ -170,11 +155,13 @@ class TestProcurementWorkflow:
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=self.requestor)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/submit/'
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
|
||||
response = self.client.post(submit_url)
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
print(f"Error response: {response.data}")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@ -199,20 +186,24 @@ class TestProcurementWorkflow:
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=self.requestor)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/submit/'
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
|
||||
response = self.client.post(submit_url)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'позиц' in response.data['error'].lower()
|
||||
|
||||
def test_cannot_submit_without_budget(self):
|
||||
"""Тест: нельзя отправить заявку если нет бюджета."""
|
||||
# Создаем заявку на сумму больше бюджета
|
||||
def test_submit_fails_when_required_authority_missing(self):
|
||||
"""Тест: нельзя отправить заявку без настроенного обязательного согласующего."""
|
||||
self.director.is_active = False
|
||||
self.director.save(update_fields=['is_active'])
|
||||
|
||||
request = ProcurementRequest.objects.create(
|
||||
title='Дорогая заявка',
|
||||
description='Превышает бюджет',
|
||||
description='Нет директора для согласования',
|
||||
department=self.department,
|
||||
requestor=self.requestor,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
@ -227,13 +218,16 @@ class TestProcurementWorkflow:
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=self.requestor)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/submit/'
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
|
||||
response = self.client.post(submit_url)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'бюджет' in response.data['error'].lower()
|
||||
assert 'согласующ' in response.data['error'].lower()
|
||||
assert DIRECTOR_PRIORITY in response.data['missing_priorities']
|
||||
|
||||
def test_approve_by_department_head(self):
|
||||
"""Тест одобрения заявки руководителем отдела."""
|
||||
@ -258,14 +252,18 @@ class TestProcurementWorkflow:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.dept_head,
|
||||
role='department_head',
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=self.dept_head)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/approve/',
|
||||
approve_url,
|
||||
{'comment': 'Одобряю'},
|
||||
)
|
||||
|
||||
@ -294,14 +292,18 @@ class TestProcurementWorkflow:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.dept_head,
|
||||
role='department_head',
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=self.dept_head)
|
||||
reject_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-reject',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/reject/',
|
||||
reject_url,
|
||||
{'comment': 'Не обоснована необходимость'},
|
||||
)
|
||||
|
||||
@ -339,20 +341,24 @@ class TestProcurementWorkflow:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.dept_head,
|
||||
role='department_head',
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.finance,
|
||||
role='finance_manager',
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
# 1. Одобряет руководитель отдела
|
||||
self.client.force_authenticate(user=self.dept_head)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/approve/',
|
||||
approve_url,
|
||||
{'comment': 'От отдела одобряю'},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@ -364,7 +370,7 @@ class TestProcurementWorkflow:
|
||||
# 2. Одобряет финансовый менеджер
|
||||
self.client.force_authenticate(user=self.finance)
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/approve/',
|
||||
approve_url,
|
||||
{'comment': 'От финансов одобряю'},
|
||||
)
|
||||
print(f"DEBUG 2: Response status: {response.status_code}")
|
||||
@ -390,17 +396,19 @@ class TestProcurementWorkflow:
|
||||
Approval.objects.create(
|
||||
request=request,
|
||||
approver=self.dept_head,
|
||||
role='department_head',
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
# Пытаемся одобрить от лица другого пользователя
|
||||
self.client.force_authenticate(user=self.finance)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/requests/{request.id}/approve/'
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
|
||||
response = self.client.post(approve_url)
|
||||
|
||||
# Должна быть ошибка - 404 или 403/400
|
||||
# 404 если заявка не в queryset, 403/400 если нет прав
|
||||
assert response.status_code in [
|
||||
@ -423,9 +431,13 @@ class TestProcurementWorkflow:
|
||||
assert request.is_editable is False
|
||||
|
||||
self.client.force_authenticate(user=self.requestor)
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': request.id},
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f'/api/procurement/requests/{request.id}/',
|
||||
detail_url,
|
||||
{'title': 'Новое название'},
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user