feat: add task execution secret skill

This commit is contained in:
lizerk 2026-08-26 11:20:06 +03:00
parent d36996b4e3
commit 65c51b64e3
14 changed files with 1161 additions and 17 deletions

View File

@ -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(

View File

@ -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,
),
]

View File

@ -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)

View File

@ -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 = (

View File

@ -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,
}

View File

@ -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

View File

@ -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"]

View File

@ -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",
),
],
},
),
]

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

View File

@ -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