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:
lizerk 2026-03-30 02:16:29 +03:00
parent c61b62969a
commit 2031872461
14 changed files with 618 additions and 432 deletions

View File

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

View File

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

View File

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

View 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': 'Маршруты согласования',
},
),
]

View File

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

View File

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

View 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),
]

View File

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

View File

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

View File

@ -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:
"""Сервис для операций с оборудованием."""

View File

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

View File

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

View File

@ -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):
"""Тест: черновик можно редактировать."""

View File

@ -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': 'Новое название'},
)