feat: автоматическое создание EmployeeAction из заявок
- Добавлены сигналы в employees/signals.py для автоматизации создания кадровых событий - Реализованы стратегии: немедленные (transfer, dismissal) и отложенные (vacation, sick_leave) - Добавлены Celery tasks для отложенного создания событий и авто-возврата - Настроен periodic task для очистки пропущенных возвратов - Архитектура: employees (ядро) обрабатывает события из requests_app (модуль)
This commit is contained in:
parent
3dc8541508
commit
c5a8455421
@ -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
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -0,0 +1 @@
|
||||
default_app_config = 'requests_app.apps.RequestsAppConfig'
|
||||
@ -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: регистрация предикатов и правил доступа
|
||||
|
||||
270
backend/requests_app/tasks.py
Normal file
270
backend/requests_app/tasks.py
Normal 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
|
||||
318
docs/completed/REQUESTS_CELERY_AUTOMATION.md
Normal file
318
docs/completed/REQUESTS_CELERY_AUTOMATION.md
Normal 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 г.
|
||||
Loading…
x
Reference in New Issue
Block a user