feat: add task execution secret skill
This commit is contained in:
parent
d36996b4e3
commit
65c51b64e3
@ -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(
|
||||
|
||||
45
backend/employees/migrations/0060_task_execution_skill.py
Normal file
45
backend/employees/migrations/0060_task_execution_skill.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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 = (
|
||||
|
||||
402
backend/employees/services/task_execution_skill.py
Normal file
402
backend/employees/services/task_execution_skill.py
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
139
backend/tasks/migrations/0041_taskexecutioncycle.py
Normal file
139
backend/tasks/migrations/0041_taskexecutioncycle.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
0
backend/tests/tasks/__init__.py
Normal file
0
backend/tests/tasks/__init__.py
Normal file
176
backend/tests/tasks/test_task_execution_skill.py
Normal file
176
backend/tests/tasks/test_task_execution_skill.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user