From 65c51b64e3798aef0a1dc8f27e1b9c08e054188a Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 26 Aug 2026 11:20:06 +0300 Subject: [PATCH] feat: add task execution secret skill --- .../commands/recalculate_secret_skills.py | 151 ++++++- .../migrations/0060_task_execution_skill.py | 45 ++ backend/employees/models.py | 4 + backend/employees/services/secret_skills.py | 73 +++- .../services/task_execution_skill.py | 402 ++++++++++++++++++ backend/eusrr_backend/settings.py | 4 + backend/tasks/admin.py | 22 + .../migrations/0041_taskexecutioncycle.py | 139 ++++++ backend/tasks/models.py | 63 +++ backend/tasks/signals.py | 26 +- backend/tasks/tasks.py | 9 + .../test_recalculate_secret_skills_command.py | 64 +++ backend/tests/tasks/__init__.py | 0 .../tests/tasks/test_task_execution_skill.py | 176 ++++++++ 14 files changed, 1161 insertions(+), 17 deletions(-) create mode 100644 backend/employees/migrations/0060_task_execution_skill.py create mode 100644 backend/employees/services/task_execution_skill.py create mode 100644 backend/tasks/migrations/0041_taskexecutioncycle.py create mode 100644 backend/tests/employees/test_recalculate_secret_skills_command.py create mode 100644 backend/tests/tasks/__init__.py create mode 100644 backend/tests/tasks/test_task_execution_skill.py diff --git a/backend/employees/management/commands/recalculate_secret_skills.py b/backend/employees/management/commands/recalculate_secret_skills.py index ec08664f..49d4f3be 100644 --- a/backend/employees/management/commands/recalculate_secret_skills.py +++ b/backend/employees/management/commands/recalculate_secret_skills.py @@ -1,7 +1,73 @@ -from django.core.management.base import BaseCommand +from collections import Counter, defaultdict -from employees.models import Employee +from django.core.management.base import BaseCommand +from django.db import transaction + +from employees.models import Employee, EmployeeSkillProgress from employees.services.secret_skills import recalculate_attendance_secret_skills +from employees.services.task_execution_skill import refresh_task_execution_skills + + +RANK_LABELS = { + "none": "без ранга", + "bronze": "бронза", + "silver": "серебро", + "gold": "золото", + "ruby": "рубин", + "diamond": "бриллиант", +} + + +def _progress_snapshot(employee_ids): + return { + (progress.employee_id, progress.skill_id): { + "rank": progress.special_rank, + "active": progress.is_active, + "skill_name": progress.skill.name, + "system_code": progress.skill.system_code, + "progress_data": dict(progress.progress_data or {}), + } + for progress in EmployeeSkillProgress.objects.filter( + employee_id__in=employee_ids, + skill__is_secret=True, + ).select_related("skill") + } + + +def _change_kind(before, after): + if not before["active"] and after["active"]: + return "получит" + if before["active"] and not after["active"]: + return "потеряет" + rank_order = ["none", "bronze", "silver", "gold", "ruby", "diamond"] + before_index = rank_order.index(before["rank"]) + after_index = rank_order.index(after["rank"]) + return "повысит" if after_index > before_index else "понизит" + + +def _metric_summary(state): + metrics = state["progress_data"] + system_code = state["system_code"] + if system_code == "attendance_punctuality": + return f"лучшая серия {metrics.get('best_streak', 0)} дн." + if system_code == "attendance_self_organization": + return ( + f"чистых дней {metrics.get('clean_days', 0)}, " + f"выходов {metrics.get('off_schedule_days', 0)}, " + f"переработок {metrics.get('overtime_days', 0)}" + ) + if system_code == "attendance_reliability": + return f"без отпуска и отгула {metrics.get('worked_days', 0)} дн." + if system_code == "personnel_resilience": + return ( + "лучшая серия без больничного " + f"{metrics.get('best_resilience_streak', 0)} дн." + ) + return ( + f"подтверждено {metrics.get('confirmed_tasks', 0)} задач, " + f"вовремя {metrics.get('on_time_percent', 0)}%, " + f"серия {metrics.get('best_on_time_streak', 0)}" + ) class Command(BaseCommand): @@ -9,9 +75,74 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--employee-id", type=int) + parser.add_argument( + "--dry-run", + action="store_true", + help="Показать изменения без записи в базу", + ) + + def _recalculate(self, employee_ids, employee_id=None): + processed = 0 + rank_changes = 0 + task_result = refresh_task_execution_skills(employee_id=employee_id) + rank_changes += task_result["rank_changes"] + for current_employee_id in employee_ids: + changes = recalculate_attendance_secret_skills(current_employee_id) + processed += 1 + rank_changes += len(changes) + return processed, rank_changes + + def _print_dry_run(self, employee_ids, before, after, processed): + employees = { + employee.id: str(employee) + for employee in Employee.objects.filter(id__in=employee_ids) + } + empty_state = { + "rank": "none", + "active": False, + "skill_name": "", + "system_code": "", + "progress_data": {}, + } + changes = [] + summary = defaultdict(Counter) + for key in sorted(set(before) | set(after)): + old = before.get(key, empty_state) + new = after.get(key, empty_state) + if old["rank"] == new["rank"] and old["active"] == new["active"]: + continue + kind = _change_kind(old, new) + state = new if new["skill_name"] else old + summary[state["skill_name"]][kind] += 1 + changes.append((key[0], kind, old, new, state)) + + self.stdout.write(self.style.WARNING("СУХОЙ ПРОГОН — изменения не сохранены")) + self.stdout.write(f"Проверено сотрудников: {processed}") + if not changes: + self.stdout.write("Изменений навыков и рангов нет.") + return + + self.stdout.write("\nСводка:") + for skill_name in sorted(summary): + counts = summary[skill_name] + self.stdout.write( + f"- {skill_name}: получат {counts['получит']}, " + f"повысят {counts['повысит']}, понизят {counts['понизит']}, " + f"потеряют {counts['потеряет']}" + ) + + self.stdout.write("\nСотрудники:") + for current_employee_id, kind, old, new, state in changes: + self.stdout.write( + f"- #{current_employee_id} {employees.get(current_employee_id, '—')} — " + f"{state['skill_name']}: {kind} " + f"({RANK_LABELS[old['rank']]} → {RANK_LABELS[new['rank']]}); " + f"{_metric_summary(state)}" + ) def handle(self, *args, **options): employee_id = options.get("employee_id") + dry_run = options.get("dry_run", False) if employee_id: employee_ids = [employee_id] else: @@ -19,12 +150,16 @@ class Command(BaseCommand): Employee.objects.order_by("id").values_list("id", flat=True) ) - processed = 0 - rank_changes = 0 - for current_employee_id in employee_ids: - changes = recalculate_attendance_secret_skills(current_employee_id) - processed += 1 - rank_changes += len(changes) + if dry_run: + with transaction.atomic(): + before = _progress_snapshot(employee_ids) + processed, _ = self._recalculate(employee_ids, employee_id) + after = _progress_snapshot(employee_ids) + transaction.set_rollback(True) + self._print_dry_run(employee_ids, before, after, processed) + return + + processed, rank_changes = self._recalculate(employee_ids, employee_id) self.stdout.write( self.style.SUCCESS( diff --git a/backend/employees/migrations/0060_task_execution_skill.py b/backend/employees/migrations/0060_task_execution_skill.py new file mode 100644 index 00000000..91b3ecb7 --- /dev/null +++ b/backend/employees/migrations/0060_task_execution_skill.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.4 on 2026-08-26 + +from django.db import migrations, models + + +def create_task_execution_skill(apps, schema_editor): + Skill = apps.get_model("employees", "Skill") + skill, _ = Skill.objects.get_or_create(name="Исполнительность") + skill.is_secret = True + skill.system_code = "task_execution" + skill.progression_type = "task_execution" + skill.save(update_fields=["is_secret", "system_code", "progression_type"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0059_reliability_and_resilience_skills"), + ] + + operations = [ + migrations.AlterField( + model_name="skill", + name="progression_type", + field=models.CharField( + choices=[ + ("standard", "Обычный опыт"), + ("attendance_punctuality", "Посещаемость: пунктуальность"), + ( + "attendance_self_organization", + "Посещаемость: самоорганизация", + ), + ("attendance_reliability", "Посещаемость: надёжность"), + ("personnel_resilience", "Кадровые события: стойкость"), + ("task_execution", "Задачи: исполнительность"), + ], + default="standard", + max_length=48, + verbose_name="Механика прогресса", + ), + ), + migrations.RunPython( + create_task_execution_skill, + migrations.RunPython.noop, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index 3dfe2285..861c6d62 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -710,6 +710,10 @@ class Skill(models.Model): "personnel_resilience", "Кадровые события: стойкость", ) + TASK_EXECUTION = ( + "task_execution", + "Задачи: исполнительность", + ) name = models.CharField(max_length=100, unique=True) is_secret = models.BooleanField("Секретный навык", default=False) diff --git a/backend/employees/services/secret_skills.py b/backend/employees/services/secret_skills.py index e1efd192..afd6504a 100644 --- a/backend/employees/services/secret_skills.py +++ b/backend/employees/services/secret_skills.py @@ -85,6 +85,34 @@ RESILIENCE_REQUIREMENTS = { "diamond": {"best_resilience_streak": 800}, } +TASK_EXECUTION_REQUIREMENTS = { + "bronze": { + "confirmed_tasks": 10, + "on_time_percent": 80, + "best_on_time_streak": 5, + }, + "silver": { + "confirmed_tasks": 30, + "on_time_percent": 85, + "best_on_time_streak": 10, + }, + "gold": { + "confirmed_tasks": 75, + "on_time_percent": 90, + "best_on_time_streak": 20, + }, + "ruby": { + "confirmed_tasks": 150, + "on_time_percent": 92, + "best_on_time_streak": 35, + }, + "diamond": { + "confirmed_tasks": 300, + "on_time_percent": 95, + "best_on_time_streak": 60, + }, +} + REQUIREMENT_LABELS = { "best_streak": "Лучшая серия без опозданий", "clean_days": "Чистые рабочие дни", @@ -93,6 +121,9 @@ REQUIREMENT_LABELS = { "overtime_days": "Дни с переработкой от 1 часа", "worked_days": "Дни без отпуска и отгула", "best_resilience_streak": "Лучшая серия между больничными", + "confirmed_tasks": "Подтверждённые задачи", + "on_time_percent": "Завершено вовремя, %", + "best_on_time_streak": "Лучшая серия своевременных задач", } ANTI_STREAK_LABELS = { @@ -100,6 +131,7 @@ ANTI_STREAK_LABELS = { Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION: "Проблемные дни подряд", Skill.ProgressionType.ATTENDANCE_RELIABILITY: "Отпуска и отгулы за 120 дней", Skill.ProgressionType.PERSONNEL_RESILIENCE: "Больничные за 120 дней", + Skill.ProgressionType.TASK_EXECUTION: "Нарушения за 60 дней", } @@ -146,10 +178,22 @@ SECRET_SKILL_DEFINITIONS = ( anti_streak_limit=3, recovery_streak_target=90, ), + SecretSkillDefinition( + system_code="task_execution", + name="Исполнительность", + progression_type=Skill.ProgressionType.TASK_EXECUTION, + requirements=TASK_EXECUTION_REQUIREMENTS, + anti_streak_limit=3, + recovery_streak_target=10, + ), ) ATTENDANCE_SECRET_SKILL_DEFINITIONS = SECRET_SKILL_DEFINITIONS[:2] -PERSONNEL_SECRET_SKILL_DEFINITIONS = SECRET_SKILL_DEFINITIONS[2:] +PERSONNEL_SECRET_SKILL_DEFINITIONS = SECRET_SKILL_DEFINITIONS[2:4] +TASK_SECRET_SKILL_DEFINITIONS = SECRET_SKILL_DEFINITIONS[4:] +ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS = ( + ATTENDANCE_SECRET_SKILL_DEFINITIONS + PERSONNEL_SECRET_SKILL_DEFINITIONS +) def _has_work(record) -> bool: @@ -175,6 +219,12 @@ def _rank_metrics(metrics: dict[str, object], definition: SecretSkillDefinition) } if definition.progression_type == Skill.ProgressionType.ATTENDANCE_RELIABILITY: return {"worked_days": metrics["worked_days"]} + if definition.progression_type == Skill.ProgressionType.TASK_EXECUTION: + return { + "confirmed_tasks": metrics["confirmed_tasks"], + "on_time_percent": metrics["on_time_percent"], + "best_on_time_streak": metrics["best_on_time_streak"], + } return { "best_resilience_streak": metrics["best_resilience_streak"], } @@ -786,12 +836,13 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]: # noqa skill.system_code: skill for skill in Skill.objects.filter( system_code__in=[ - definition.system_code for definition in SECRET_SKILL_DEFINITIONS + definition.system_code + for definition in ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS ], is_secret=True, ) } - for definition in SECRET_SKILL_DEFINITIONS: + for definition in ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS: skill = skills.get(definition.system_code) if skill is None: continue @@ -940,9 +991,14 @@ def secret_skill_progress_payload( else: progress_percent = 100 + recovery_unit = ( + "задач" + if definition.progression_type == Skill.ProgressionType.TASK_EXECUTION + else "дн." + ) recovery_label = ( f"Восстановление ранга: {metrics.get('recovery_streak', 0)}/" - f"{definition.recovery_streak_target} дн." + f"{definition.recovery_streak_target} {recovery_unit}" ) if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY: progress_value = int(metrics.get("best_streak", 0) or 0) @@ -969,13 +1025,20 @@ def secret_skill_progress_payload( if penalty_levels else f"Без отпуска и отгула: {progress_value} дн." ) - else: + elif definition.progression_type == Skill.ProgressionType.PERSONNEL_RESILIENCE: progress_value = int(metrics.get("best_resilience_streak", 0) or 0) progress_label = ( recovery_label if penalty_levels else f"Лучшая серия без больничного: {progress_value} дн." ) + else: + progress_value = int(metrics.get("confirmed_tasks", 0) or 0) + progress_label = ( + recovery_label + if penalty_levels + else f"Подтверждено задач: {progress_value}" + ) primary_target = next(iter(next_requirements.values()), None) primary_current = ( diff --git a/backend/employees/services/task_execution_skill.py b/backend/employees/services/task_execution_skill.py new file mode 100644 index 00000000..c74af01e --- /dev/null +++ b/backend/employees/services/task_execution_skill.py @@ -0,0 +1,402 @@ +"""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_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) -> 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) + 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): + 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 + 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 calculate_task_execution_metrics(employee_id: int, *, today: 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: + if cycle.status == TaskExecutionCycle.Status.CONFIRMED: + events.append( + (_local_date(cycle.completed_at_snapshot), "confirmed", cycle.id) + ) + elif cycle.status == TaskExecutionCycle.Status.LATE: + late_tasks += 1 + events.append((_local_date(cycle.completed_at_snapshot), "late", cycle.id)) + elif cycle.status == TaskExecutionCycle.Status.REOPENED: + reopened_tasks += 1 + violation_at = cycle.reopened_at or cycle.completed_at_snapshot + events.append((_local_date(violation_at), "reopened", cycle.id)) + + overdue_events = _open_overdue_events(employee_id, today) + 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) + 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, + ).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) -> list[str]: + 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) + 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, +) -> dict[str, int]: + """Reconcile missed transitions, mature pending cycles and recalculate users.""" + + 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 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) + + changed_cycles = 0 + employee_ids: set[int] = set() + cycles = TaskExecutionCycle.objects.filter( + status__in=[ + TaskExecutionCycle.Status.PENDING, + TaskExecutionCycle.Status.CONFIRMED, + ] + ).select_related("task") + 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) + 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)) + rank_changes = 0 + for employee_id in employee_ids: + rank_changes += len(recalculate_task_execution_skill(employee_id)) + return { + "captured_cycles": len(missing_task_ids), + "changed_cycles": changed_cycles, + "processed_employees": len(employee_ids), + "rank_changes": rank_changes, + } diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 84c161f2..30035a0b 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -827,6 +827,10 @@ CELERY_BEAT_SCHEDULE = { "task": "tasks.dispatch_scheduled_automations", "schedule": 60.0, }, + "tasks-refresh-execution-skills": { + "task": "tasks.refresh_task_execution_skills", + "schedule": crontab(hour=0, minute=20), + }, "process-ldap-sync-queue": { "task": "employees.tasks.process_ldap_queue", "schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 3c340d9f..48db471c 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -15,6 +15,7 @@ from .models import ( TaskColumnRow, TaskCover, TaskExternalLink, + TaskExecutionCycle, TaskLabel, TaskLinkedObject, TaskUserSettings, @@ -223,6 +224,27 @@ class TaskActivityAdmin(admin.ModelAdmin): search_fields = ["task__title", "actor__email", "object_id"] +@admin.register(TaskExecutionCycle) +class TaskExecutionCycleAdmin(admin.ModelAdmin): + list_display = [ + "task_title", + "employee", + "due_date_snapshot", + "completed_at_snapshot", + "status", + "protection_until", + ] + list_filter = ["status", "is_self_assigned", "completed_at_snapshot"] + search_fields = ["task_title", "employee__email", "cycle_key"] + readonly_fields = [ + "task_id_snapshot", + "cycle_key", + "completed_at_snapshot", + "created_at", + "updated_at", + ] + + @admin.register(TaskUserSettings) class TaskUserSettingsAdmin(admin.ModelAdmin): list_display = ["user", "default_board", "default_group", "updated_at"] diff --git a/backend/tasks/migrations/0041_taskexecutioncycle.py b/backend/tasks/migrations/0041_taskexecutioncycle.py new file mode 100644 index 00000000..f4111bd4 --- /dev/null +++ b/backend/tasks/migrations/0041_taskexecutioncycle.py @@ -0,0 +1,139 @@ +# Generated by Django 5.2.4 on 2026-08-26 07:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0040_task_skills"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TaskExecutionCycle", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "task_id_snapshot", + models.PositiveBigIntegerField(verbose_name="ID задачи"), + ), + ( + "cycle_key", + models.CharField( + max_length=180, + unique=True, + verbose_name="Ключ цикла", + ), + ), + ( + "task_title", + models.CharField(max_length=255, verbose_name="Название задачи"), + ), + ( + "due_date_snapshot", + models.DateField( + null=True, verbose_name="Срок на момент завершения" + ), + ), + ( + "completed_at_snapshot", + models.DateTimeField(verbose_name="Время завершения"), + ), + ( + "protection_until", + models.DateTimeField(verbose_name="Подтвердить после"), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Ожидает подтверждения"), + ("confirmed", "Подтверждено"), + ("late", "Завершено после срока"), + ("reopened", "Возвращено в работу"), + ("excluded", "Не участвует в расчёте"), + ], + default="pending", + max_length=16, + verbose_name="Состояние", + ), + ), + ( + "is_self_assigned", + models.BooleanField(default=False, verbose_name="Самоназначение"), + ), + ( + "exclusion_reason", + models.CharField( + blank=True, + max_length=80, + verbose_name="Причина исключения", + ), + ), + ( + "reopened_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Возвращено в работу", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "employee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="task_execution_cycles", + to=settings.AUTH_USER_MODEL, + verbose_name="Исполнитель на момент завершения", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="execution_cycles", + to="tasks.task", + verbose_name="Задача", + ), + ), + ], + options={ + "verbose_name": "Цикл исполнения задачи", + "verbose_name_plural": "Циклы исполнения задач", + "ordering": ["completed_at_snapshot", "id"], + "indexes": [ + models.Index( + fields=["employee", "status", "completed_at_snapshot"], + name="tasks_taske_employe_e1d1f9_idx", + ), + models.Index( + fields=["task", "status"], + name="tasks_taske_task_id_d09903_idx", + ), + ], + }, + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 2baa3f5f..6beb6a3e 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -449,6 +449,69 @@ class Task(models.Model): super().save(*args, **kwargs) +class TaskExecutionCycle(models.Model): + """Immutable role/deadline snapshot for one task completion cycle. + + The task may be reopened, reassigned or deleted later. Secret skill + progress must still belong to the employee who was the assignee when the + task entered the final column, so the mutable task itself is not a safe + source of historical truth. + """ + + class Status(models.TextChoices): + PENDING = "pending", "Ожидает подтверждения" + CONFIRMED = "confirmed", "Подтверждено" + LATE = "late", "Завершено после срока" + REOPENED = "reopened", "Возвращено в работу" + EXCLUDED = "excluded", "Не участвует в расчёте" + + task = models.ForeignKey( + Task, + on_delete=models.SET_NULL, + related_name="execution_cycles", + null=True, + blank=True, + verbose_name="Задача", + ) + task_id_snapshot = models.PositiveBigIntegerField("ID задачи") + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="task_execution_cycles", + null=True, + blank=True, + verbose_name="Исполнитель на момент завершения", + ) + cycle_key = models.CharField("Ключ цикла", max_length=180, unique=True) + task_title = models.CharField("Название задачи", max_length=255) + due_date_snapshot = models.DateField("Срок на момент завершения", null=True) + completed_at_snapshot = models.DateTimeField("Время завершения") + protection_until = models.DateTimeField("Подтвердить после") + status = models.CharField( + "Состояние", + max_length=16, + choices=Status.choices, + default=Status.PENDING, + ) + is_self_assigned = models.BooleanField("Самоназначение", default=False) + exclusion_reason = models.CharField("Причина исключения", max_length=80, blank=True) + reopened_at = models.DateTimeField("Возвращено в работу", null=True, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Цикл исполнения задачи" + verbose_name_plural = "Циклы исполнения задач" + ordering = ["completed_at_snapshot", "id"] + indexes = [ + models.Index(fields=["employee", "status", "completed_at_snapshot"]), + models.Index(fields=["task", "status"]), + ] + + def __str__(self): + return f"{self.task_title}: {self.get_status_display()}" + + class TaskAutomation(models.Model): name = models.CharField("Название", max_length=160) description = models.TextField("Описание", blank=True) diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index 03bc5375..72340485 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -78,11 +78,18 @@ def ensure_task_core_participants(sender, instance, **kwargs): def remember_task_completion_state(sender, instance, **kwargs): if not instance.pk: instance._skill_was_completed = False + instance._skill_previous_completed_at = None return - instance._skill_was_completed = sender.objects.filter( - pk=instance.pk, - completed_at__isnull=False, - ).exists() + previous_completed_at = ( + sender.objects.filter(pk=instance.pk) + .values_list( + "completed_at", + flat=True, + ) + .first() + ) + instance._skill_previous_completed_at = previous_completed_at + instance._skill_was_completed = previous_completed_at is not None @receiver(post_save, sender=Task) @@ -94,17 +101,28 @@ def sync_task_skill_experience(sender, instance, **kwargs): task_id = instance.id actor = getattr(instance, "_skill_completion_actor", None) + previous_completed_at = getattr( + instance, + "_skill_previous_completed_at", + None, + ) def sync_after_commit(): from employees.services.skill_progress import ( award_task_skill_experience, revoke_task_skill_experience, ) + from employees.services.task_execution_skill import ( + capture_task_execution_cycle, + reopen_task_execution_cycle, + ) if is_completed: award_task_skill_experience(task_id) + capture_task_execution_cycle(task_id) else: revoke_task_skill_experience(task_id, actor=actor) + reopen_task_execution_cycle(task_id, previous_completed_at) transaction.on_commit(sync_after_commit) diff --git a/backend/tasks/tasks.py b/backend/tasks/tasks.py index 2b79136f..4645cbe1 100644 --- a/backend/tasks/tasks.py +++ b/backend/tasks/tasks.py @@ -20,3 +20,12 @@ def process_task_automation_event_task(activity_id): @shared_task(name="tasks.dispatch_scheduled_automations") def dispatch_scheduled_automations_task(): return dispatch_scheduled_automations() + + +@shared_task(name="tasks.refresh_task_execution_skills") +def refresh_task_execution_skills_task(): + from employees.services.task_execution_skill import ( + refresh_task_execution_skills, + ) + + return refresh_task_execution_skills() diff --git a/backend/tests/employees/test_recalculate_secret_skills_command.py b/backend/tests/employees/test_recalculate_secret_skills_command.py new file mode 100644 index 00000000..869135eb --- /dev/null +++ b/backend/tests/employees/test_recalculate_secret_skills_command.py @@ -0,0 +1,64 @@ +from datetime import timedelta +from io import StringIO + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from employees.models import EmployeeSkillProgress, Skill +from tasks.models import TaskExecutionCycle + +pytestmark = pytest.mark.django_db + + +def _confirmed_cycles(employee, amount=10): + for index in range(amount): + completed_at = timezone.now() - timedelta(days=20 - index) + TaskExecutionCycle.objects.create( + task_id_snapshot=20000 + index, + employee=employee, + cycle_key=f"command-test:{employee.id}:{index}", + task_title=f"Командная задача {index}", + due_date_snapshot=timezone.localdate(completed_at), + completed_at_snapshot=completed_at, + protection_until=completed_at + timedelta(days=7), + status=TaskExecutionCycle.Status.CONFIRMED, + ) + + +def test_dry_run_reports_skill_and_rolls_back(user_factory): + employee = user_factory(first_name="Гарик", last_name="Мусик") + _confirmed_cycles(employee) + skill = Skill.objects.get(system_code="task_execution") + output = StringIO() + + call_command( + "recalculate_secret_skills", + employee_id=employee.id, + dry_run=True, + stdout=output, + ) + + result = output.getvalue() + assert "СУХОЙ ПРОГОН" in result + assert "Исполнительность: получат 1" in result + assert f"#{employee.id} Мусик Гарик" in result + assert "без ранга → бронза" in result + assert not EmployeeSkillProgress.objects.filter( + employee=employee, + skill=skill, + ).exists() + assert not employee.skills.filter(pk=skill.pk).exists() + + +def test_regular_run_persists_the_same_calculation(user_factory): + employee = user_factory() + _confirmed_cycles(employee) + skill = Skill.objects.get(system_code="task_execution") + + call_command("recalculate_secret_skills", employee_id=employee.id) + + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.is_active is True + assert employee.skills.filter(pk=skill.pk).exists() diff --git a/backend/tests/tasks/__init__.py b/backend/tests/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/tasks/test_task_execution_skill.py b/backend/tests/tasks/test_task_execution_skill.py new file mode 100644 index 00000000..f57295c4 --- /dev/null +++ b/backend/tests/tasks/test_task_execution_skill.py @@ -0,0 +1,176 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from employees.models import EmployeeSkillProgress, Skill +from employees.services.task_execution_skill import ( + capture_task_execution_cycle, + recalculate_task_execution_skill, + refresh_task_execution_skills, + reopen_task_execution_cycle, +) +from tasks.models import Task, TaskBoard, TaskColumn, TaskExecutionCycle + +pytestmark = pytest.mark.django_db + + +def _board_columns(creator): + board = TaskBoard.objects.create(name="Исполнительность", created_by=creator) + source = TaskColumn.objects.create(board=board, name="В работе", position=1000) + done = TaskColumn.objects.create( + board=board, + name="Готово", + position=2000, + is_done=True, + ) + return board, source, done + + +def _cycle(employee, index, status, *, days_ago): + completed_at = timezone.now() - timedelta(days=days_ago, minutes=index) + return TaskExecutionCycle.objects.create( + task_id_snapshot=10000 + index, + employee=employee, + cycle_key=f"test-execution:{employee.id}:{index}:{status}", + task_title=f"Задача {index}", + due_date_snapshot=timezone.localdate(completed_at), + completed_at_snapshot=completed_at, + protection_until=completed_at + timedelta(days=7), + status=status, + reopened_at=( + completed_at if status == TaskExecutionCycle.Status.REOPENED else None + ), + ) + + +def test_completion_snapshot_keeps_original_assignee(user_factory): + creator = user_factory() + assignee = user_factory() + replacement = user_factory() + board, source, done = _board_columns(creator) + task = Task.objects.create( + board=board, + column=done, + title="Закрытая задача", + created_by=creator, + assignee=assignee, + due_date=timezone.localdate() + timedelta(days=1), + ) + + cycle = capture_task_execution_cycle(task.id) + assert cycle.status == TaskExecutionCycle.Status.PENDING + assert cycle.employee_id == assignee.id + + task.assignee = replacement + task.save(update_fields=["assignee", "updated_at"]) + cycle.refresh_from_db() + assert cycle.employee_id == assignee.id + + previous_completed_at = task.completed_at + task.column = source + task.save(update_fields=["column", "completed_at", "updated_at"]) + reopen_task_execution_cycle(task.id, previous_completed_at) + cycle.refresh_from_db() + assert cycle.status == TaskExecutionCycle.Status.REOPENED + assert cycle.employee_id == assignee.id + + +def test_self_assigned_and_unscheduled_tasks_are_excluded(user_factory): + employee = user_factory() + board, _, done = _board_columns(employee) + self_assigned = Task.objects.create( + board=board, + column=done, + title="Сам себе исполнитель", + created_by=employee, + assignee=employee, + due_date=timezone.localdate(), + ) + no_due_date = Task.objects.create( + board=board, + column=done, + title="Без срока", + created_by=user_factory(), + assignee=employee, + ) + + self_cycle = capture_task_execution_cycle(self_assigned.id) + no_due_cycle = capture_task_execution_cycle(no_due_date.id) + + assert self_cycle.status == TaskExecutionCycle.Status.EXCLUDED + assert self_cycle.exclusion_reason == "self_assigned" + assert no_due_cycle.status == TaskExecutionCycle.Status.EXCLUDED + assert no_due_cycle.exclusion_reason == "missing_due_date" + + +def test_ten_confirmed_tasks_unlock_bronze(user_factory): + employee = user_factory() + for index in range(10): + _cycle( + employee, + index, + TaskExecutionCycle.Status.CONFIRMED, + days_ago=20 - index, + ) + + assert recalculate_task_execution_skill(employee.id) == ["Исполнительность"] + skill = Skill.objects.get(system_code="task_execution") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.progress_data["confirmed_tasks"] == 10 + assert progress.progress_data["on_time_percent"] == 100 + assert progress.progress_data["best_on_time_streak"] == 10 + assert employee.skills.filter(pk=skill.pk).exists() + + +def test_three_recent_violations_demote_silver_to_bronze(user_factory): + employee = user_factory() + for index in range(30): + _cycle( + employee, + index, + TaskExecutionCycle.Status.CONFIRMED, + days_ago=50 - index, + ) + recalculate_task_execution_skill(employee.id) + skill = Skill.objects.get(system_code="task_execution") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "silver" + + for index in range(30, 33): + _cycle( + employee, + index, + TaskExecutionCycle.Status.REOPENED, + days_ago=3 - (index - 30), + ) + recalculate_task_execution_skill(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.progress_data["violations_last_60"] == 3 + assert progress.progress_data["penalty_levels"] == 1 + + +def test_refresh_captures_old_completion_and_confirms_after_protection(user_factory): + creator = user_factory() + assignee = user_factory() + board, _, done = _board_columns(creator) + task = Task.objects.create( + board=board, + column=done, + title="Старая завершённая задача", + created_by=creator, + assignee=assignee, + due_date=timezone.localdate() - timedelta(days=8), + ) + old_completion = timezone.now() - timedelta(days=8) + TaskExecutionCycle.objects.filter(task=task).delete() + Task.objects.filter(pk=task.pk).update(completed_at=old_completion) + + result = refresh_task_execution_skills(employee_id=assignee.id) + + cycle = TaskExecutionCycle.objects.get(task=task) + assert result["captured_cycles"] == 1 + assert cycle.completed_at_snapshot == old_completion + assert cycle.status == TaskExecutionCycle.Status.CONFIRMED