Merge branch 'feature/requests-employee-action-automation' into develop

Добавлена автоматизация создания кадровых событий из заявок:
- Сигналы в employees/signals.py для авто-создания EmployeeAction
- Celery tasks для отложенных событий (vacation, sick_leave)
- Management команда migrate_requests_to_actions
- 13 тестов покрытия функционала
- Документация в docs/completed/REQUESTS_CELERY_AUTOMATION.md
This commit is contained in:
lizerk 2026-03-18 09:42:48 +03:00
commit 26941f29ee
11 changed files with 1317 additions and 0 deletions

View File

@ -1,5 +1,9 @@
# users/signals.py
import logging
import os
from datetime import datetime
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.db.models import Q
@ -10,6 +14,25 @@ from .constants import ACTION_HIRED
from .models import Employee, EmployeeAction, Department
logger = logging.getLogger(__name__)
# ============================================================
# Автоматизация создания кадровых событий из заявок
# ============================================================
# Немедленные события (создаются сразу при одобрении)
IMMEDIATE_ACTION_MAPPING = {
"transfer": "transferred", # Перевод → Переведен
"dismissal": "dismissed", # Увольнение → Уволен
}
# Отложенные события (создаются по Celery в date_from)
SCHEDULED_ACTION_MAPPING = {
"vacation": "on_leave", # Отпуск → В отпуске
"sick_leave": "on_sick_leave", # Больничный → На больничном
}
@receiver(post_save, sender=Employee)
def create_hired_action(sender, instance: Employee, created, **kwargs):
@ -60,3 +83,174 @@ def create_main_department_chat(sender, instance, created, **kwargs):
except ImportError:
# communications app не установлен - пропускаем
pass
# ============================================================
# Обработчики заявок (requests_app)
# ============================================================
@receiver(pre_save, sender='requests_app.Request')
def track_request_status_change(sender, instance, **kwargs):
"""Сохраняем старый статус перед обновлением для отслеживания изменений."""
if instance.pk:
try:
Request = sender
old_instance = Request.objects.get(pk=instance.pk)
instance._old_status = old_instance.status
except sender.DoesNotExist:
instance._old_status = None
@receiver(post_save, sender='requests_app.Request')
def create_action_on_request_approval(sender, instance, created, **kwargs):
"""
Автоматически создает EmployeeAction при одобрении заявок.
Две стратегии:
1. НЕМЕДЛЕННО (transfer, dismissal) - сразу при одобрении
2. ОТЛОЖЕННО (vacation, sick_leave) - через Celery в date_from
"""
# Пропускаем если это новая заявка
if created:
return
# Получаем enum из requests_app
try:
from requests_app.enums import RequestStatus
except ImportError:
logger.error("Cannot import RequestStatus from requests_app")
return
# Проверяем изменение статуса
old_status = getattr(instance, '_old_status', None)
if old_status == RequestStatus.APPROVED:
return # Уже была одобрена
if instance.status != RequestStatus.APPROVED:
return # Не одобрение
# === СТРАТЕГИЯ 1: Немедленное создание ===
if instance.type in IMMEDIATE_ACTION_MAPPING:
_create_immediate_action(instance)
# === СТРАТЕГИЯ 2: Отложенное создание через Celery ===
elif instance.type in SCHEDULED_ACTION_MAPPING:
_schedule_delayed_action(instance)
else:
logger.debug(
f"Request #{instance.id} approved but type '{instance.type}' "
f"doesn't create EmployeeAction"
)
def _create_immediate_action(request):
"""Создать событие немедленно (для увольнения, перевода)."""
action_type = IMMEDIATE_ACTION_MAPPING.get(request.type)
if not action_type:
return
# Проверяем дубли
if EmployeeAction.objects.filter(
extra__request_id=request.id,
action=action_type
).exists():
logger.warning(
f"EmployeeAction already exists for Request #{request.id}"
)
return
# Создаем событие
action_date = request.date_from or request.decided_at or timezone.now()
action_comment = f"Заявление #{request.id}"
if request.comment:
action_comment += f": {request.comment[:200]}"
try:
action = EmployeeAction.objects.create(
employee=request.employee,
action=action_type,
date=action_date,
comment=action_comment,
extra={
'request_id': request.id,
'approved_by': (
request.approver.id if request.approver else None
),
'immediate': True
}
)
# Применяем эффекты (деактивация, LDAP sync)
_apply_action_effects(action)
logger.info(
f"Created immediate EmployeeAction #{action.id} ({action_type}) "
f"from Request #{request.id}"
)
except Exception as e:
logger.error(
f"Failed to create EmployeeAction for Request #{request.id}: {e}",
exc_info=True
)
def _schedule_delayed_action(request):
"""Запланировать создание события через Celery."""
try:
from requests_app.tasks import create_scheduled_action
except ImportError:
logger.error("Cannot import create_scheduled_action task")
return
if not request.date_from:
logger.warning(
f"Request #{request.id} type '{request.type}' needs date_from "
f"for scheduling"
)
return
# Если date_from уже прошла или сегодня - создаем сразу
today = timezone.now().date()
if request.date_from <= today:
logger.info(
f"Request #{request.id} date_from is today or past, "
f"creating action immediately"
)
# Вызываем task синхронно
create_scheduled_action.apply(args=[request.id])
return
# Иначе планируем на date_from в 00:00
eta = timezone.make_aware(
datetime.combine(request.date_from, datetime.min.time())
)
try:
task = create_scheduled_action.apply_async(
args=[request.id],
eta=eta
)
logger.info(
f"Scheduled EmployeeAction creation for Request #{request.id} "
f"at {eta} (task_id={task.id})"
)
except Exception as e:
logger.error(
f"Failed to schedule action for Request #{request.id}: {e}",
exc_info=True
)
def _apply_action_effects(action):
"""Применяет эффекты кадрового события (как в EmployeeActionViewSet)."""
try:
from api.v1.employees.views.actions import EmployeeActionViewSet
viewset = EmployeeActionViewSet()
viewset._apply_effects(action)
except Exception as e:
logger.error(
f"Failed to apply effects for EmployeeAction #{action.id}: {e}",
exc_info=True
)

View File

@ -3,6 +3,7 @@
"""
import os
from celery import Celery
from celery.schedules import crontab
# Устанавливаем настройки Django по умолчанию
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
@ -17,6 +18,14 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
# Автоматически находит tasks.py в каждом приложении
app.autodiscover_tasks()
# Periodic tasks (Celery Beat)
app.conf.beat_schedule = {
'cleanup-missed-returns': {
'task': 'requests_app.tasks.cleanup_missed_returns',
'schedule': crontab(hour=0, minute=5), # Каждый день в 00:05
},
}
@app.task(bind=True, ignore_result=True)
def debug_task(self):

View File

@ -0,0 +1 @@
default_app_config = 'requests_app.apps.RequestsAppConfig'

View File

@ -8,4 +8,5 @@ class RequestsAppConfig(AppConfig):
def ready(self):
# Подключаем модуль уведомлений (signals регистрируются автоматически)
import requests_app.notifications.signals # noqa
# import requests_app.signals # MOVED: Автоматическое создание EmployeeAction теперь в employees/signals.py
import requests_app.rules # django-rules: регистрация предикатов и правил доступа

View File

@ -0,0 +1 @@
# Management commands package

View File

@ -0,0 +1 @@
# Management commands

View File

@ -0,0 +1,183 @@
"""
Management команда для миграции существующих заявок в кадровые события.
Использование:
python manage.py migrate_requests_to_actions [--dry-run]
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from requests_app.models import Request
from requests_app.enums import RequestStatus
from employees.models import EmployeeAction
from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING
class Command(BaseCommand):
help = 'Создает EmployeeAction для всех одобренных заявок, у которых их еще нет'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Показать что будет создано, но не создавать',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('=== DRY RUN MODE ==='))
# Объединяем все маппинги
all_mappings = {**IMMEDIATE_ACTION_MAPPING, **SCHEDULED_ACTION_MAPPING}
# Находим все одобренные заявки с типами, которые требуют EmployeeAction
approved_requests = Request.objects.filter(
status=RequestStatus.APPROVED,
type__in=all_mappings.keys()
).select_related('employee', 'approver').order_by('created_at')
total_requests = approved_requests.count()
self.stdout.write(
f'Найдено {total_requests} одобренных заявок типов: {list(all_mappings.keys())}'
)
created_count = 0
skipped_count = 0
errors = []
for request in approved_requests:
action_type = all_mappings.get(request.type)
if not action_type:
continue
# Проверяем, существует ли уже событие для этой заявки
existing = EmployeeAction.objects.filter(
extra__request_id=request.id,
action=action_type
).exists()
if existing:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f' SKIP: Request #{request.id} ({request.type}) - '
f'EmployeeAction уже существует'
)
)
continue
# Определяем дату события
action_date = request.date_from or request.decided_at or request.created_at or timezone.now()
# Формируем комментарий
action_comment = f"Заявление #{request.id}"
if request.comment:
action_comment += f": {request.comment[:200]}"
# Готовим extra данные
extra_data = {
'request_id': request.id,
'approved_by': request.approver.id if request.approver else None,
'migrated': True, # Помечаем что создано миграцией
'migration_date': timezone.now().isoformat()
}
if dry_run:
# action_date может быть date или datetime
date_str = action_date.date() if hasattr(action_date, 'date') else action_date
self.stdout.write(
self.style.SUCCESS(
f' WOULD CREATE: Request #{request.id} ({request.type}) → '
f'EmployeeAction ({action_type}) for {request.employee} '
f'on {date_str}'
)
)
created_count += 1
else:
try:
with transaction.atomic():
action = EmployeeAction.objects.create(
employee=request.employee,
action=action_type,
date=action_date,
comment=action_comment,
extra=extra_data
)
# Применяем эффекты (деактивация, LDAP sync)
self._apply_effects(action)
created_count += 1
self.stdout.write(
self.style.SUCCESS(
f' ✓ Created: EmployeeAction #{action.id} ({action_type}) '
f'from Request #{request.id} for {request.employee}'
)
)
except Exception as e:
errors.append((request.id, str(e)))
self.stdout.write(
self.style.ERROR(
f' ✗ Failed: Request #{request.id} - {e}'
)
)
# Итоговая статистика
self.stdout.write('\n' + '='*70)
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - изменения не применены'))
self.stdout.write(
self.style.SUCCESS(
f'Обработано заявок: {total_requests}'
)
)
if created_count:
verb = 'Будет создано' if dry_run else 'Создано'
self.stdout.write(
self.style.SUCCESS(
f'{verb} событий: {created_count}'
)
)
if skipped_count:
self.stdout.write(
self.style.WARNING(
f'Пропущено (уже существуют): {skipped_count}'
)
)
if errors:
self.stdout.write(
self.style.ERROR(
f'\nОшибки ({len(errors)}):'
)
)
for req_id, error in errors:
self.stdout.write(f' Request #{req_id}: {error}')
if not dry_run and created_count > 0:
self.stdout.write(
self.style.SUCCESS(
'\n✓ Миграция завершена успешно!'
)
)
def _apply_effects(self, action):
"""Применяет эффекты кадрового события (как в EmployeeActionViewSet)."""
try:
from api.v1.employees.views.actions import EmployeeActionViewSet
viewset = EmployeeActionViewSet()
viewset._apply_effects(action)
except Exception as e:
# Не критично если эффекты не применились
self.stdout.write(
self.style.WARNING(
f' Warning: Could not apply effects for action #{action.id}: {e}'
)
)

View File

@ -0,0 +1,270 @@
"""
Celery tasks для автоматизации кадровых событий из заявок.
"""
import logging
from datetime import date, timedelta
from celery import shared_task
from django.utils import timezone
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def create_scheduled_action(self, request_id):
"""
Создать кадровое событие для заявки по расписанию.
Вызывается в date_from для vacation/sick_leave.
Args:
request_id: ID заявки
"""
from .models import Request
from .enums import RequestStatus
from employees.models import EmployeeAction
try:
request = Request.objects.get(id=request_id)
# Проверяем, что заявка все еще одобрена
if request.status != RequestStatus.APPROVED:
logger.info(
f"Request #{request_id} is no longer approved "
f"(status={request.status}), skipping action creation"
)
return
# Определяем тип события
action_mapping = {
'vacation': 'on_leave',
'sick_leave': 'on_sick_leave',
}
action_type = action_mapping.get(request.type)
if not action_type:
logger.warning(
f"Request #{request_id} has type '{request.type}' "
f"which doesn't map to scheduled action"
)
return
# Проверяем, что событие еще не создано
if EmployeeAction.objects.filter(
extra__request_id=request_id,
action=action_type
).exists():
logger.info(
f"Action already exists for Request #{request_id}, skipping"
)
return
# Создаем событие
action_comment = f"Заявление #{request.id}"
if request.comment:
action_comment += f": {request.comment[:200]}"
action = EmployeeAction.objects.create(
employee=request.employee,
action=action_type,
date=timezone.now(),
comment=action_comment,
extra={
'request_id': request.id,
'approved_by': (
request.approver.id if request.approver else None
),
'scheduled': True
}
)
logger.info(
f"Created scheduled EmployeeAction #{action.id} ({action_type}) "
f"for Request #{request_id}"
)
# Планируем автовозврат на date_to + 1 день
if request.date_to:
schedule_auto_return.apply_async(
args=[request_id],
eta=timezone.make_aware(
timezone.datetime.combine(
request.date_to + timedelta(days=1),
timezone.datetime.min.time()
)
)
)
return f"Created action {action.id}"
except Request.DoesNotExist:
logger.error(f"Request #{request_id} not found")
raise
except Exception as e:
logger.error(
f"Failed to create scheduled action for Request #{request_id}: {e}",
exc_info=True
)
# Retry через 5 минут
raise self.retry(exc=e, countdown=300)
@shared_task(bind=True, max_retries=3)
def schedule_auto_return(self, request_id):
"""
Автоматически создать событие возврата из отпуска/больничного.
Вызывается в date_to + 1 день.
Args:
request_id: ID заявки
"""
from .models import Request
from .enums import RequestStatus
from employees.models import EmployeeAction
try:
request = Request.objects.get(id=request_id)
# Проверяем, что заявка все еще одобрена
if request.status != RequestStatus.APPROVED:
logger.info(
f"Request #{request_id} is no longer approved, "
f"skipping auto-return"
)
return
# Определяем тип возврата
return_mapping = {
'vacation': 'returned_from_leave',
'sick_leave': 'returned_from_leave',
}
return_action = return_mapping.get(request.type)
if not return_action:
logger.warning(
f"Request #{request_id} type '{request.type}' "
f"doesn't have return action"
)
return
# Проверяем, что событие возврата еще не создано
if EmployeeAction.objects.filter(
extra__request_id=request_id,
action=return_action
).exists():
logger.info(
f"Return action already exists for Request #{request_id}"
)
return
# Создаем событие возврата
return_comment = (
f"Автоматически: окончание "
f"{request.get_type_display().lower()} "
f"(заявка #{request.id})"
)
action = EmployeeAction.objects.create(
employee=request.employee,
action=return_action,
date=timezone.now(),
comment=return_comment,
extra={
'request_id': request.id,
'auto_return': True
}
)
logger.info(
f"Created auto-return EmployeeAction #{action.id} "
f"({return_action}) for Request #{request_id}"
)
return f"Created return action {action.id}"
except Request.DoesNotExist:
logger.error(f"Request #{request_id} not found for auto-return")
raise
except Exception as e:
logger.error(
f"Failed to create auto-return for Request #{request_id}: {e}",
exc_info=True
)
raise self.retry(exc=e, countdown=300)
@shared_task
def cleanup_missed_returns():
"""
Periodic task: проверяет заявки, для которых date_to прошел,
но событие возврата не создано.
Запускается ежедневно в 00:05.
"""
from .models import Request
from .enums import RequestStatus
from employees.models import EmployeeAction
logger.info("Running cleanup_missed_returns task")
today = date.today()
yesterday = today - timedelta(days=1)
# Ищем одобренные заявки, которые закончились вчера или раньше
ended_requests = Request.objects.filter(
status=RequestStatus.APPROVED,
type__in=['vacation', 'sick_leave'],
date_to__lt=today,
date_to__gte=yesterday - timedelta(days=7) # За последнюю неделю
)
created_count = 0
for req in ended_requests:
# Определяем тип возврата
return_action = {
'vacation': 'returned_from_leave',
'sick_leave': 'returned_from_leave',
}.get(req.type)
if not return_action:
continue
# Проверяем, что события возврата нет
if EmployeeAction.objects.filter(
extra__request_id=req.id,
action=return_action
).exists():
continue
# Создаем пропущенное событие возврата
try:
action = EmployeeAction.objects.create(
employee=req.employee,
action=return_action,
date=req.date_to + timedelta(days=1),
comment=(
f"Автоматически восстановлено: окончание "
f"{req.get_type_display().lower()} (заявка #{req.id})"
),
extra={
'request_id': req.id,
'auto_return': True,
'cleanup': True
}
)
created_count += 1
logger.info(
f"Cleanup: created return action #{action.id} "
f"for Request #{req.id}"
)
except Exception as e:
logger.error(
f"Cleanup: failed to create return for Request #{req.id}: {e}"
)
logger.info(
f"cleanup_missed_returns completed: {created_count} actions created"
)
return created_count

View File

@ -0,0 +1,41 @@
"""
Conftest для тестов requests_app - импортирует общие фикстуры.
"""
import pytest
from django.conf import settings
@pytest.fixture
def user_factory():
"""Фабрика для создания пользователей."""
from employees.models import Employee
def _make(
email: str = None,
*,
first_name: str = "Test",
last_name: str = "User",
active: bool = True,
**extra
):
import uuid
if not email:
email = f"test+{uuid.uuid4().hex[:8]}@example.com"
# Генерируем уникальные phone_number и username
unique_id = uuid.uuid4().hex[:8]
if 'phone_number' not in extra:
extra['phone_number'] = f"+7900{unique_id}"
if 'username' not in extra:
extra['username'] = f"user_{unique_id}"
user = Employee.objects.create(
email=email,
first_name=first_name,
last_name=last_name,
is_active=active,
**extra
)
return user
return _make

View File

@ -0,0 +1,298 @@
"""
Тесты для автоматического создания EmployeeAction из заявок (signals).
"""
import pytest
from datetime import date, timedelta
from unittest.mock import patch, MagicMock
from django.utils import timezone
from requests_app.models import Request
from requests_app.enums import RequestStatus
from employees.models import EmployeeAction
from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING
@pytest.mark.django_db
class TestRequestSignals:
"""Тесты сигналов создания EmployeeAction из Request."""
def test_immediate_action_dismissal(self, user_factory):
"""Увольнение создаёт EmployeeAction немедленно при одобрении."""
employee = user_factory()
approver = user_factory()
# Создаём заявку на увольнение
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
date_from=date.today(),
comment='Увольнение по собственному желанию'
)
# Проверяем что события ещё нет
assert not EmployeeAction.objects.filter(
extra__request_id=request.id
).exists()
# Одобряем заявку
request.status = RequestStatus.APPROVED
request.approver = approver
request.decided_at = timezone.now()
request.save()
# Проверяем что событие создалось
action = EmployeeAction.objects.get(
employee=employee,
action='dismissed',
extra__request_id=request.id
)
assert action.action == 'dismissed'
assert action.extra['request_id'] == request.id
assert action.extra['approved_by'] == approver.id
assert action.extra['immediate'] is True
assert 'Заявление #' in action.comment
def test_immediate_action_transfer(self, user_factory):
"""Перевод создаёт EmployeeAction немедленно при одобрении."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='transfer',
status=RequestStatus.PENDING,
date_from=date.today(),
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Проверяем событие
action = EmployeeAction.objects.get(
employee=employee,
action='transferred',
extra__request_id=request.id
)
assert action.action == 'transferred'
assert action.extra['immediate'] is True
@patch('employees.signals._schedule_delayed_action')
def test_scheduled_action_vacation(self, mock_schedule, user_factory):
"""Отпуск запускает планирование через Celery."""
employee = user_factory()
approver = user_factory()
future_date = date.today() + timedelta(days=7)
request = Request.objects.create(
employee=employee,
type='vacation',
status=RequestStatus.PENDING,
date_from=future_date,
date_to=future_date + timedelta(days=10),
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Проверяем что вызвалась функция планирования
mock_schedule.assert_called_once()
called_request = mock_schedule.call_args[0][0]
assert called_request.id == request.id
@patch('employees.signals._schedule_delayed_action')
def test_scheduled_action_sick_leave(self, mock_schedule, user_factory):
"""Больничный запускает планирование через Celery."""
employee = user_factory()
approver = user_factory()
future_date = date.today() + timedelta(days=3)
request = Request.objects.create(
employee=employee,
type='sick_leave',
status=RequestStatus.PENDING,
date_from=future_date,
)
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
mock_schedule.assert_called_once()
def test_no_duplicate_actions(self, user_factory):
"""Повторное сохранение одобренной заявки не создаёт дубликаты."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
date_from=date.today(),
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Первое событие создалось
assert EmployeeAction.objects.filter(
employee=employee,
action='dismissed',
extra__request_id=request.id
).count() == 1
# Меняем комментарий и сохраняем снова
request.comment = 'Обновлённый комментарий'
request.save()
# Дубликат не создался
assert EmployeeAction.objects.filter(
employee=employee,
action='dismissed',
extra__request_id=request.id
).count() == 1
def test_signal_not_fired_on_creation(self, user_factory):
"""Сигнал не срабатывает при создании (только при одобрении)."""
employee = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
)
# События нет
assert not EmployeeAction.objects.filter(
extra__request_id=request.id
).exists()
def test_signal_not_fired_on_rejection(self, user_factory):
"""Сигнал не срабатывает при отклонении."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
)
# Отклоняем
request.status = RequestStatus.REJECTED
request.approver = approver
request.save()
# События нет
assert not EmployeeAction.objects.filter(
extra__request_id=request.id
).exists()
def test_unknown_request_type_no_action(self, user_factory):
"""Неизвестный тип заявки не создаёт события."""
employee = user_factory()
approver = user_factory()
# Используем тип, который не в маппингах
request = Request.objects.create(
employee=employee,
type='other', # Не в IMMEDIATE/SCHEDULED маппингах
status=RequestStatus.PENDING,
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# События нет
assert not EmployeeAction.objects.filter(
employee=employee,
extra__request_id=request.id
).exists()
def test_action_date_from_request_date_from(self, user_factory):
"""Дата события берётся из date_from заявки."""
employee = user_factory()
approver = user_factory()
specific_date = date(2026, 6, 15)
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
date_from=specific_date,
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
action = EmployeeAction.objects.get(
employee=employee,
action='dismissed',
extra__request_id=request.id
)
# Проверяем дату (может быть datetime, поэтому берём .date())
action_date = action.date.date() if hasattr(action.date, 'date') else action.date
assert action_date == specific_date
@patch('employees.signals._apply_action_effects')
def test_effects_applied_for_immediate_actions(self, mock_apply, user_factory):
"""Эффекты применяются для немедленных действий."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Проверяем что _apply_action_effects была вызвана
mock_apply.assert_called_once()
created_action = mock_apply.call_args[0][0]
assert created_action.action == 'dismissed'
@pytest.mark.django_db
class TestRequestMappings:
"""Тесты корректности маппингов типов заявок."""
def test_immediate_mapping_complete(self):
"""Проверка что все немедленные маппинги правильные."""
assert IMMEDIATE_ACTION_MAPPING == {
'transfer': 'transferred',
'dismissal': 'dismissed',
}
def test_scheduled_mapping_complete(self):
"""Проверка что все отложенные маппинги правильные."""
assert SCHEDULED_ACTION_MAPPING == {
'vacation': 'on_leave',
'sick_leave': 'on_sick_leave',
}
def test_no_overlap_in_mappings(self):
"""Проверка что маппинги не пересекаются."""
immediate_keys = set(IMMEDIATE_ACTION_MAPPING.keys())
scheduled_keys = set(SCHEDULED_ACTION_MAPPING.keys())
assert immediate_keys.isdisjoint(scheduled_keys), \
"Request types должны быть либо immediate, либо scheduled, но не оба"

View File

@ -0,0 +1,318 @@
# Автоматизация кадровых событий через Celery
**Дата**: 17 марта 2026 г.
**Статус**: ✅ Реализовано
## Что сделано
Реализована **полная автоматизация** создания кадровых событий из заявок с использованием **Celery + Redis**.
## Архитектура
### 3 стратегии обработки заявок:
```
┌─────────────────────────────────────────────┐
│ 1. НЕМЕДЛЕННЫЕ (dismissal, transfer) │
│ ↓ approve() → Signal → EmployeeAction │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 2. ОТЛОЖЕННЫЕ (vacation, sick_leave) │
│ ↓ approve() → Signal → Celery Task │
│ ↓ date_from → EmployeeAction │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 3. АВТОВОЗВРАТ (vacation, sick_leave) │
│ ↓ date_to + 1 день → Celery Task │
│ ↓ EmployeeAction (returned_from_leave) │
└─────────────────────────────────────────────┘
```
---
## Файлы
### 1. `requests_app/tasks.py` - Celery задачи
**Задачи**:
- `create_scheduled_action()` - создает событие в date_from
- `schedule_auto_return()` - создает событие возврата в date_to + 1
- `cleanup_missed_returns()` - periodic task для восстановления пропущенных
### 2. `requests_app/signals.py` - обработчик одобрения
**Логика**:
- Отслеживает переход в APPROVED
- Немедленные типы → создает сразу
- Отложенные типы → планирует через Celery
### 3. `eusrr_backend/celery.py` - Celery beat schedule
**Periodic tasks**:
- `cleanup-missed-returns` - каждый день в 00:05
---
## Таблица автоматизации
| Заявка | Событие начала | Когда | Событие возврата | Когда |
|--------|----------------|-------|------------------|-------|
| **dismissal** | `dismissed` | ✅ Сразу при approve | - | - |
| **transfer** | `transferred` | ✅ Сразу при approve | - | - |
| **vacation** | `on_leave` | 🕐 В `date_from` | `returned_from_leave` | 🕐 `date_to` + 1 день |
| **sick_leave** | `on_sick_leave` | 🕐 В `date_from` | `returned_from_leave` | 🕐 `date_to` + 1 день |
| **day_off** | - | ❌ Не создается | - | - |
| **other** | - | ❌ Не создается | - | - |
---
## Примеры работы
### Пример 1: Увольнение (немедленное)
```python
# 17 марта - создаем заявку
request = Request.objects.create(
employee=user,
type='dismissal',
date_from='2026-04-01'
)
# 17 марта - HR одобряет
request.approve(hr_manager)
# ✅ СРАЗУ создано EmployeeAction(action='dismissed')
# ✅ employee.is_active = False
# ✅ LDAP sync выполнен
```
### Пример 2: Отпуск (отложенное)
```python
# 17 марта - создаем заявку на отпуск 1-14 апреля
request = Request.objects.create(
employee=user,
type='vacation',
date_from='2026-04-01',
date_to='2026-04-14'
)
# 17 марта - HR одобряет
request.approve(hr_manager)
# ✅ Celery task запланирован на 01.04.2026 00:00
# 1 апреля 00:00 - Celery автоматически:
# ✅ Создает EmployeeAction(action='on_leave')
# ✅ Планирует автовозврат на 15.04.2026 00:00
# 15 апреля 00:00 - Celery автоматически:
# ✅ Создает EmployeeAction(action='returned_from_leave')
```
### Пример 3: Отпуск начинается сегодня
```python
# 17 марта - создаем заявку с date_from=сегодня
request = Request.objects.create(
employee=user,
type='vacation',
date_from='2026-03-17', # Сегодня!
date_to='2026-03-24'
)
# 17 марта - HR одобряет
request.approve(hr_manager)
# ✅ Событие создается СРАЗУ (не через Celery)
# ✅ Автовозврат запланирован на 25.03.2026
```
---
## Защита от ошибок
### 1. **Idempotency** (защита от дублей)
```python
# Проверка перед созданием
if EmployeeAction.objects.filter(
extra__request_id=request_id,
action=action_type
).exists():
return # Уже создано
```
### 2. **Retry логика**
```python
@shared_task(bind=True, max_retries=3)
def create_scheduled_action(self, request_id):
try:
# ... создание
except Exception as e:
# Retry через 5 минут
raise self.retry(exc=e, countdown=300)
```
### 3. **Cleanup task** (восстановление пропущенных)
```python
# Каждый день в 00:05 проверяет:
# - Заявки, которые закончились, но нет события возврата
# - Создает пропущенные события
```
### 4. **Отмена заявки**
```python
# Если заявку отменили до date_from:
# - Celery task выполнится
# - Проверит статус: if status != APPROVED: return
# - Событие НЕ будет создано
```
---
## Метаданные в extra
Каждое созданное событие содержит:
```python
# Немедленное событие
extra = {
'request_id': 123,
'approved_by': 5,
'immediate': True
}
# Отложенное событие
extra = {
'request_id': 123,
'approved_by': 5,
'scheduled': True
}
# Автовозврат
extra = {
'request_id': 123,
'auto_return': True
}
# Восстановленное cleanup'ом
extra = {
'request_id': 123,
'auto_return': True,
'cleanup': True
}
```
---
## Запуск
### Celery Worker
```bash
cd backend
celery -A eusrr_backend worker -l info
```
### Celery Beat (для periodic tasks)
```bash
celery -A eusrr_backend beat -l info
```
### Одной командой (для разработки)
```bash
celery -A eusrr_backend worker -B -l info
```
---
## Мониторинг
### Flower (Celery monitoring)
```bash
celery -A eusrr_backend flower
# http://localhost:5555
```
### Проверка задач
```python
from celery import current_app
# Посмотреть запланированные задачи
current_app.control.inspect().scheduled()
# Посмотреть активные задачи
current_app.control.inspect().active()
```
---
## Расширение
### Добавить новый тип (например, декрет)
**1. Обновить маппинг**:
```python
# requests_app/signals.py
SCHEDULED_ACTION_MAPPING = {
"vacation": "on_leave",
"sick_leave": "on_sick_leave",
"maternity": "on_maternity", # ← Добавить
}
```
**2. Обновить tasks.py**:
```python
# В create_scheduled_action
action_mapping = {
'vacation': 'on_leave',
'sick_leave': 'on_sick_leave',
'maternity': 'on_maternity', # ← Добавить
}
# В schedule_auto_return
return_mapping = {
'vacation': 'returned_from_leave',
'sick_leave': 'returned_from_leave',
'maternity': 'returned_from_maternity', # ← Добавить
}
```
**3. Добавить тип в RequestType**:
```python
# requests_app/enums.py
class RequestType(TextChoices):
# ...
MATERNITY = "maternity", "Декрет"
```
**Всё!** Автоматизация заработает для нового типа.
---
## Логирование
Все действия логируются:
```
INFO: Scheduled EmployeeAction creation for Request #123 at 2026-04-01 00:00:00
INFO: Created scheduled EmployeeAction #456 (on_leave) for Request #123
INFO: Created auto-return EmployeeAction #457 (returned_from_leave) for Request #123
INFO: cleanup_missed_returns completed: 2 actions created
```
---
## Преимущества решения
✅ **Автоматизация** - все работает без ручного вмешательства
✅ **Гибкость** - можно отменить заявку до date_from
✅ **Надежность** - retry + cleanup task
✅ **Масштабируемость** - Celery + Redis
✅ **Audittrail** - все события с метаданными
✅ **Расширяемость** - легко добавить новые типы
✅ **Event-driven** - декаплинг через сигналы
---
**Автор**: GitHub Copilot
**Дата**: 17 марта 2026 г.