Corp/backend/employees/services/task_execution_skill.py
2026-09-07 19:43:48 +03:00

453 lines
15 KiB
Python

"""System-owned progress for the secret skill ``Исполнительность``."""
from __future__ import annotations
from datetime import date, timedelta
from django.db import transaction
from django.db.models import Exists, F, OuterRef
from django.utils import timezone
from employees.models import Employee, EmployeeSkillProgress, Skill
from employees.services.secret_skill_settings import get_secret_skills_start_date
from employees.services.secret_skills import (
RANK_CODES,
RANK_INDEX,
TASK_SECRET_SKILL_DEFINITIONS,
)
from tasks.models import Task, TaskExecutionCycle
PROTECTION_DAYS = 7
OVERDUE_GRACE_BUSINESS_DAYS = 3
VIOLATION_WINDOW_DAYS = 60
def _local_date(value) -> date:
if timezone.is_aware(value):
return timezone.localtime(value).date()
return value.date()
def _add_business_days(value: date, amount: int) -> date:
current = value
added = 0
while added < amount:
current += timedelta(days=1)
if current.weekday() < 5:
added += 1
return current
def _cycle_key(task_id: int, completed_at) -> str:
return f"task:{task_id}:completed:{completed_at.isoformat()}"
@transaction.atomic
def capture_task_execution_cycle(
task_id: int,
*,
since: date | None = None,
) -> TaskExecutionCycle | None:
"""Create the immutable completion snapshot if the task is currently done."""
task = (
Task.objects.select_related("assignee")
.filter(pk=task_id, completed_at__isnull=False)
.first()
)
if task is None:
return None
completed_at = task.completed_at
protection_until = completed_at + timedelta(days=PROTECTION_DAYS)
is_self_assigned = bool(task.assignee_id and task.assignee_id == task.created_by_id)
exclusion_reason = ""
if task.assignee_id is None:
exclusion_reason = "missing_assignee"
elif task.due_date is None:
exclusion_reason = "missing_due_date"
elif is_self_assigned:
exclusion_reason = "self_assigned"
elif not task.assignee.is_active:
exclusion_reason = "inactive_assignee"
if exclusion_reason:
status = TaskExecutionCycle.Status.EXCLUDED
elif _local_date(completed_at) > task.due_date:
status = TaskExecutionCycle.Status.LATE
elif protection_until <= timezone.now():
status = TaskExecutionCycle.Status.CONFIRMED
else:
status = TaskExecutionCycle.Status.PENDING
cycle, _ = TaskExecutionCycle.objects.get_or_create(
cycle_key=_cycle_key(task.id, completed_at),
defaults={
"task": task,
"task_id_snapshot": task.id,
"employee_id": task.assignee_id,
"task_title": task.title,
"due_date_snapshot": task.due_date,
"completed_at_snapshot": completed_at,
"protection_until": protection_until,
"status": status,
"is_self_assigned": is_self_assigned,
"exclusion_reason": exclusion_reason,
},
)
if cycle.employee_id:
recalculate_task_execution_skill(cycle.employee_id, since=since)
return cycle
@transaction.atomic
def reopen_task_execution_cycle(
task_id: int,
completed_at,
) -> TaskExecutionCycle | None:
"""Turn the matching positive/pending completion into one violation."""
if completed_at is None:
return None
cycle = (
TaskExecutionCycle.objects.select_for_update()
.filter(cycle_key=_cycle_key(task_id, completed_at))
.first()
)
if cycle is None or cycle.status == TaskExecutionCycle.Status.EXCLUDED:
return cycle
cycle.status = TaskExecutionCycle.Status.REOPENED
cycle.reopened_at = timezone.now()
cycle.save(update_fields=["status", "reopened_at", "updated_at"])
if cycle.employee_id:
recalculate_task_execution_skill(cycle.employee_id)
return cycle
def _open_overdue_events(
employee_id: int,
today: date,
*,
since: date | None = None,
):
tasks = (
Task.objects.filter(
assignee_id=employee_id,
completed_at__isnull=True,
due_date__isnull=False,
)
.exclude(created_by_id=F("assignee_id"))
.prefetch_related("execution_cycles")
)
events = []
for task in tasks:
violation_date = _add_business_days(
task.due_date,
OVERDUE_GRACE_BUSINESS_DAYS,
)
if violation_date > today:
continue
if since is not None and violation_date < since:
continue
latest_cycle = max(
task.execution_cycles.all(),
key=lambda item: (item.completed_at_snapshot, item.id),
default=None,
)
if (
latest_cycle
and latest_cycle.due_date_snapshot == task.due_date
and latest_cycle.status
in {
TaskExecutionCycle.Status.LATE,
TaskExecutionCycle.Status.REOPENED,
}
):
continue
events.append((violation_date, "overdue", task.id))
return events
def _cycle_event(cycle: TaskExecutionCycle) -> tuple[date, str, int] | None:
if cycle.status == TaskExecutionCycle.Status.CONFIRMED:
return _local_date(cycle.completed_at_snapshot), "confirmed", cycle.id
if cycle.status == TaskExecutionCycle.Status.LATE:
return _local_date(cycle.completed_at_snapshot), "late", cycle.id
if cycle.status == TaskExecutionCycle.Status.REOPENED:
violation_at = cycle.reopened_at or cycle.completed_at_snapshot
return _local_date(violation_at), "reopened", cycle.id
return None
def calculate_task_execution_metrics(
employee_id: int,
*,
today: date | None = None,
since: date | None = None,
):
today = today or timezone.localdate()
cycles = list(
TaskExecutionCycle.objects.filter(employee_id=employee_id)
.exclude(
status__in=[
TaskExecutionCycle.Status.PENDING,
TaskExecutionCycle.Status.EXCLUDED,
]
)
.order_by("completed_at_snapshot", "id")
)
events = []
late_tasks = 0
reopened_tasks = 0
for cycle in cycles:
event = _cycle_event(cycle)
if event is None or (since is not None and event[0] < since):
continue
events.append(event)
late_tasks += event[1] == "late"
reopened_tasks += event[1] == "reopened"
overdue_events = _open_overdue_events(employee_id, today, since=since)
events.extend(overdue_events)
events.sort(key=lambda item: (item[0], str(item[2])))
confirmed_tasks = 0
current_on_time_streak = 0
best_on_time_streak = 0
for _, event_type, _ in events:
if event_type == "confirmed":
confirmed_tasks += 1
current_on_time_streak += 1
best_on_time_streak = max(
best_on_time_streak,
current_on_time_streak,
)
else:
current_on_time_streak = 0
attempts = len(events)
on_time_percent = round(confirmed_tasks / attempts * 100) if attempts else 0
violation_cutoff = today - timedelta(days=VIOLATION_WINDOW_DAYS)
if since is not None:
violation_cutoff = max(violation_cutoff, since)
violations_last_60 = sum(
event_type != "confirmed" and event_date >= violation_cutoff
for event_date, event_type, _ in events
)
base_penalty_levels = violations_last_60 // 3
recovered_levels = current_on_time_streak // 10
penalty_levels = max(base_penalty_levels - recovered_levels, 0)
definition = TASK_SECRET_SKILL_DEFINITIONS[0]
rank_metrics = {
"confirmed_tasks": confirmed_tasks,
"on_time_percent": on_time_percent,
"best_on_time_streak": best_on_time_streak,
}
earned_rank = "none"
for rank_code in RANK_CODES[1:]:
requirements = definition.requirements[rank_code]
if all(rank_metrics[key] >= target for key, target in requirements.items()):
earned_rank = rank_code
else:
break
effective_index = max(RANK_INDEX[earned_rank] - penalty_levels, 0)
effective_rank = RANK_CODES[effective_index]
return {
**rank_metrics,
"attempted_tasks": attempts,
"current_on_time_streak": current_on_time_streak,
"late_tasks": late_tasks,
"reopened_tasks": reopened_tasks,
"open_overdue_tasks": len(overdue_events),
"pending_tasks": TaskExecutionCycle.objects.filter(
employee_id=employee_id,
status=TaskExecutionCycle.Status.PENDING,
**(
{"completed_at_snapshot__date__gte": since} if since is not None else {}
),
).count(),
"violations_last_60": violations_last_60,
"anti_streak": violations_last_60 % 3,
"anti_streak_limit": 3,
"recovery_streak": current_on_time_streak % 10 if penalty_levels else 0,
"recovery_streak_target": 10,
"penalty_levels": penalty_levels,
"earned_rank": earned_rank,
"effective_rank": effective_rank,
"last_evaluated_date": today.isoformat(),
}
@transaction.atomic
def recalculate_task_execution_skill(
employee_id: int,
*,
since: date | None = None,
) -> list[str]:
since = since or get_secret_skills_start_date()
employee = Employee.objects.select_for_update().filter(pk=employee_id).first()
if employee is None:
return []
skill = Skill.objects.filter(
system_code="task_execution",
is_secret=True,
).first()
if skill is None:
return []
metrics = calculate_task_execution_metrics(employee_id, since=since)
calculated_rank = metrics["effective_rank"]
progress = EmployeeSkillProgress.objects.filter(
employee=employee,
skill=skill,
).first()
previous_rank = progress.special_rank if progress else "none"
previous_active = progress.is_active if progress else False
if progress is None and calculated_rank == "none":
return []
progress, _ = EmployeeSkillProgress.objects.get_or_create(
employee=employee,
skill=skill,
defaults={"is_active": calculated_rank != "none"},
)
now = timezone.now()
rank_history = dict(progress.progress_data.get("rank_achieved_at") or {})
rank_losses = list(progress.progress_data.get("rank_losses") or [])
if RANK_INDEX[calculated_rank] > RANK_INDEX[previous_rank]:
rank_history[calculated_rank] = now.isoformat()
elif RANK_INDEX[calculated_rank] < RANK_INDEX[previous_rank]:
rank_losses.append(
{"from": previous_rank, "to": calculated_rank, "at": now.isoformat()}
)
progress.special_rank = calculated_rank
progress.experience = metrics["confirmed_tasks"]
progress.is_active = calculated_rank != "none"
progress.created_by = None
progress.progress_data = {
**metrics,
"rank_achieved_at": rank_history,
"rank_losses": rank_losses[-20:],
"recalculated_at": now.isoformat(),
}
progress.save(
update_fields=[
"special_rank",
"experience",
"is_active",
"created_by",
"progress_data",
"updated_at",
]
)
if calculated_rank == "none":
employee.skills.remove(skill)
else:
employee.skills.add(skill)
changed = calculated_rank != previous_rank or progress.is_active != previous_active
return [skill.name] if changed else []
def refresh_task_execution_skills( # noqa: C901
employee_id: int | None = None,
*,
since: date | None = None,
) -> dict[str, int]:
"""Reconcile missed transitions, mature pending cycles and recalculate users."""
since = since or get_secret_skills_start_date()
now = timezone.now()
existing_cycle = TaskExecutionCycle.objects.filter(
task_id=OuterRef("pk"),
completed_at_snapshot=OuterRef("completed_at"),
)
completed_tasks = Task.objects.filter(completed_at__isnull=False)
if since is not None:
completed_tasks = completed_tasks.filter(completed_at__date__gte=since)
if employee_id is not None:
completed_tasks = completed_tasks.filter(assignee_id=employee_id)
missing_task_ids = list(
completed_tasks.annotate(has_cycle=Exists(existing_cycle))
.filter(has_cycle=False)
.values_list("id", flat=True)
)
for task_id in missing_task_ids:
capture_task_execution_cycle(task_id, since=since)
changed_cycles = 0
employee_ids: set[int] = set()
cycles = TaskExecutionCycle.objects.filter(
status__in=[
TaskExecutionCycle.Status.PENDING,
TaskExecutionCycle.Status.CONFIRMED,
]
).select_related("task")
if since is not None:
cycles = cycles.filter(completed_at_snapshot__date__gte=since)
if employee_id is not None:
cycles = cycles.filter(employee_id=employee_id)
for cycle in cycles:
task = cycle.task
if task is None:
if cycle.status == TaskExecutionCycle.Status.PENDING:
cycle.status = TaskExecutionCycle.Status.EXCLUDED
cycle.exclusion_reason = "task_deleted_during_protection"
else:
continue
elif task.completed_at != cycle.completed_at_snapshot:
cycle.status = TaskExecutionCycle.Status.REOPENED
cycle.reopened_at = now
elif (
cycle.status == TaskExecutionCycle.Status.PENDING
and cycle.protection_until <= now
):
cycle.status = TaskExecutionCycle.Status.CONFIRMED
else:
continue
cycle.save(
update_fields=[
"status",
"exclusion_reason",
"reopened_at",
"updated_at",
]
)
changed_cycles += 1
if cycle.employee_id:
employee_ids.add(cycle.employee_id)
cycle_employees = TaskExecutionCycle.objects.exclude(employee_id=None)
if since is not None:
cycle_employees = cycle_employees.filter(completed_at_snapshot__date__gte=since)
open_tasks = Task.objects.filter(
completed_at__isnull=True,
assignee__isnull=False,
due_date__isnull=False,
)
if employee_id is not None:
cycle_employees = cycle_employees.filter(employee_id=employee_id)
open_tasks = open_tasks.filter(assignee_id=employee_id)
employee_ids.update(cycle_employees.values_list("employee_id", flat=True))
employee_ids.update(open_tasks.values_list("assignee_id", flat=True))
if employee_id is not None:
employee_ids.add(employee_id)
elif since is not None:
employee_ids.update(
EmployeeSkillProgress.objects.filter(
skill__system_code="task_execution",
).values_list("employee_id", flat=True)
)
rank_changes = 0
for employee_id in employee_ids:
rank_changes += len(recalculate_task_execution_skill(employee_id, since=since))
return {
"captured_cycles": len(missing_task_ids),
"changed_cycles": changed_cycles,
"processed_employees": len(employee_ids),
"rank_changes": rank_changes,
}