453 lines
15 KiB
Python
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,
|
|
}
|