253 lines
8.3 KiB
Python
253 lines
8.3 KiB
Python
"""
|
|
Celery tasks для автоматизации кадровых событий из заявок.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, time
|
|
|
|
from celery import shared_task
|
|
from django.db.models import Q
|
|
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 для типов кадрового отсутствия.
|
|
|
|
Args:
|
|
request_id: ID заявки
|
|
"""
|
|
from .models import Request
|
|
from .enums import RequestStatus
|
|
from employees.constants import (
|
|
ACTION_ON_DAY_OFF,
|
|
ACTION_ON_LEAVE,
|
|
ACTION_ON_MATERNITY,
|
|
ACTION_ON_SICK_LEAVE,
|
|
)
|
|
from employees.services.request_actions import (
|
|
build_request_action_comment,
|
|
create_request_action,
|
|
)
|
|
|
|
try:
|
|
request = Request.objects.get(id=request_id)
|
|
|
|
# Проверяем, что заявка все еще одобрена
|
|
if request.status != RequestStatus.APPROVED:
|
|
logger.info(
|
|
"Scheduled employee action creation skipped because request is not approved",
|
|
extra={
|
|
"event": "request.scheduled_action.skipped",
|
|
"business_request_id": request_id,
|
|
"request_status": request.status,
|
|
"reason": "request_not_approved",
|
|
},
|
|
)
|
|
return
|
|
|
|
# Определяем тип события
|
|
action_mapping = {
|
|
"short_day_off": ACTION_ON_DAY_OFF,
|
|
"vacation": ACTION_ON_LEAVE,
|
|
"sick_leave": ACTION_ON_SICK_LEAVE,
|
|
"day_off": ACTION_ON_DAY_OFF,
|
|
"maternity": ACTION_ON_MATERNITY,
|
|
}
|
|
|
|
action_type = action_mapping.get(request.type)
|
|
if not action_type:
|
|
logger.warning(
|
|
"Scheduled employee action creation skipped because request type is unsupported",
|
|
extra={
|
|
"event": "request.scheduled_action.skipped",
|
|
"business_request_id": request_id,
|
|
"request_type": request.type,
|
|
"reason": "request_type_not_mapped",
|
|
},
|
|
)
|
|
return
|
|
|
|
is_partial_day_off = request.type == "short_day_off"
|
|
extra = {
|
|
"approved_by": (request.approver.id if request.approver else None),
|
|
"scheduled": True,
|
|
}
|
|
if is_partial_day_off:
|
|
extra.update(
|
|
{
|
|
"partial_day": True,
|
|
"starts_at": request.starts_at.isoformat(),
|
|
"ends_at": request.ends_at.isoformat(),
|
|
"duration_minutes": request.duration_minutes,
|
|
}
|
|
)
|
|
|
|
action, created = create_request_action(
|
|
request=request,
|
|
action_type=action_type,
|
|
action_date=request.starts_at if is_partial_day_off else request.date_from,
|
|
date_to=request.date_to,
|
|
comment=build_request_action_comment(request),
|
|
extra=extra,
|
|
)
|
|
if not created:
|
|
logger.info(
|
|
"Scheduled employee action creation skipped because action exists",
|
|
extra={
|
|
"event": "request.scheduled_action.skipped",
|
|
"business_request_id": request_id,
|
|
"action_type": action_type,
|
|
"reason": "action_exists",
|
|
},
|
|
)
|
|
return
|
|
|
|
logger.info(
|
|
"Scheduled employee action created",
|
|
extra={
|
|
"event": "request.scheduled_action.created",
|
|
"business_request_id": request_id,
|
|
"employee_action_id": action.id,
|
|
"action_type": action_type,
|
|
},
|
|
)
|
|
|
|
return f"Created action {action.id}"
|
|
|
|
except Request.DoesNotExist:
|
|
logger.error(
|
|
"Scheduled employee action request not found",
|
|
extra={
|
|
"event": "request.scheduled_action.failed",
|
|
"business_request_id": request_id,
|
|
"error_type": "Request.DoesNotExist",
|
|
},
|
|
)
|
|
raise
|
|
except Exception as e:
|
|
logger.exception(
|
|
"Scheduled employee action creation failed",
|
|
extra={
|
|
"event": "request.scheduled_action.failed",
|
|
"business_request_id": request_id,
|
|
"error_type": type(e).__name__,
|
|
},
|
|
)
|
|
# Retry через 5 минут
|
|
raise self.retry(exc=e, countdown=300)
|
|
|
|
|
|
@shared_task(bind=True, max_retries=3)
|
|
def schedule_auto_return(self, request_id):
|
|
"""
|
|
Legacy no-op для старых очередей Celery.
|
|
|
|
Возвраты больше не закрывают временные кадровые состояния: период
|
|
хранится в EmployeeAction.date_to. Старые запланированные задачи должны
|
|
спокойно завершаться и не создавать дублирующих событий.
|
|
"""
|
|
from .models import Request
|
|
|
|
try:
|
|
request = Request.objects.get(id=request_id)
|
|
logger.info(
|
|
"Legacy auto-return task skipped",
|
|
extra={
|
|
"event": "request.auto_return.skipped",
|
|
"business_request_id": request_id,
|
|
"interval_ends_at": request.date_to,
|
|
"reason": "feature_disabled",
|
|
},
|
|
)
|
|
return "Auto-return disabled"
|
|
|
|
except Request.DoesNotExist:
|
|
logger.error(
|
|
"Legacy auto-return request not found",
|
|
extra={
|
|
"event": "request.auto_return.failed",
|
|
"business_request_id": request_id,
|
|
"error_type": "Request.DoesNotExist",
|
|
},
|
|
)
|
|
raise
|
|
except Exception as e:
|
|
logger.exception(
|
|
"Legacy auto-return task failed",
|
|
extra={
|
|
"event": "request.auto_return.failed",
|
|
"business_request_id": request_id,
|
|
"error_type": type(e).__name__,
|
|
},
|
|
)
|
|
raise self.retry(exc=e, countdown=300)
|
|
|
|
|
|
@shared_task
|
|
def cleanup_missed_returns():
|
|
"""
|
|
Legacy no-op: возвратные события больше не создаются автоматически.
|
|
"""
|
|
logger.info(
|
|
"Missed auto-return cleanup skipped",
|
|
extra={
|
|
"event": "request.auto_return_cleanup.skipped",
|
|
"reason": "feature_disabled",
|
|
},
|
|
)
|
|
return 0
|
|
|
|
|
|
@shared_task
|
|
def process_due_personnel_actions():
|
|
"""Apply account effects for dismissal actions that are due today.
|
|
|
|
Future dismissal actions are created from approved requests immediately, but
|
|
account and department deactivation must happen only when their effective
|
|
date arrives. This task also repairs missed runs by processing all due
|
|
dismissals.
|
|
"""
|
|
from api.v1.employees.views.actions import EmployeeActionViewSet
|
|
from employees.constants import ACTION_DISMISSED
|
|
from employees.models import EmployeeAction
|
|
|
|
today = timezone.localdate()
|
|
end_of_today = timezone.make_aware(datetime.combine(today, time.max))
|
|
|
|
dismissal_employee_ids = (
|
|
EmployeeAction.objects.filter(
|
|
action=ACTION_DISMISSED,
|
|
date__lte=end_of_today,
|
|
)
|
|
.filter(
|
|
Q(employee__is_active=True)
|
|
| Q(employee__departments_links__is_active=True)
|
|
| Q(employee__role_assignments__is_active=True)
|
|
| Q(employee__headed_departments__isnull=False)
|
|
)
|
|
.values_list("employee_id", flat=True)
|
|
)
|
|
|
|
employee_ids = sorted(set(dismissal_employee_ids))
|
|
viewset = EmployeeActionViewSet()
|
|
processed = 0
|
|
for employee_id in employee_ids:
|
|
viewset._sync_employee_account_state_by_id(employee_id)
|
|
processed += 1
|
|
|
|
logger.info(
|
|
"Due dismissal actions processed",
|
|
extra={
|
|
"event": "employee.dismissal_actions.processed",
|
|
"employee_count": processed,
|
|
"effective_date": today,
|
|
},
|
|
)
|
|
return processed
|