From 51b15aa650fc773e9c88804e5b4efd2f3a2e2165 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 26 Aug 2026 15:45:31 +0300 Subject: [PATCH] feat: add secret skills leaderboard report --- .../commands/secret_skills_report.py | 217 ++++++++++++++++++ .../test_secret_skills_report_command.py | 69 ++++++ 2 files changed, 286 insertions(+) create mode 100644 backend/employees/management/commands/secret_skills_report.py create mode 100644 backend/tests/employees/test_secret_skills_report_command.py diff --git a/backend/employees/management/commands/secret_skills_report.py b/backend/employees/management/commands/secret_skills_report.py new file mode 100644 index 00000000..ca00ef06 --- /dev/null +++ b/backend/employees/management/commands/secret_skills_report.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.utils.dateparse import parse_date + +from attendance.models import AttendanceRecord +from employees.models import Employee, EmployeeAction +from employees.services.secret_skills import ( + ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS, + RANK_INDEX, + RANK_LABELS, + REQUIREMENT_LABELS, + TASK_SECRET_SKILL_DEFINITIONS, + SecretSkillDefinition, + _metrics_for_definition, + _rank_metrics, + calculate_attendance_secret_metrics, + calculate_personnel_secret_metrics, +) +from employees.services.task_execution_skill import calculate_task_execution_metrics + + +@dataclass(frozen=True) +class SkillReportRow: + employee: Employee + definition: SecretSkillDefinition + metrics: dict[str, object] + + @property + def rank(self) -> str: + return str(self.metrics["effective_rank"]) + + @property + def bronze_progress(self) -> float: + values = _rank_metrics(self.metrics, self.definition) + requirements = self.definition.requirements["bronze"] + return min( + float(values.get(metric, 0) or 0) / target + for metric, target in requirements.items() + ) + + @property + def rank_progress(self) -> float: + values = _rank_metrics(self.metrics, self.definition) + return sum(float(value or 0) for value in values.values()) + + +def _employee_metrics( + employee: Employee, + *, + since: date | None, +) -> dict[str, dict[str, object]]: + records = AttendanceRecord.objects.filter(employee=employee) + if since is not None: + records = records.filter(date__gte=since) + attendance_metrics = calculate_attendance_secret_metrics(records) + personnel_metrics = calculate_personnel_secret_metrics( + employee, + EmployeeAction.objects.filter(employee=employee), + since=since, + ) + combined_metrics = { + **attendance_metrics, + **personnel_metrics, + "skill_states": { + **attendance_metrics["skill_states"], + **personnel_metrics["skill_states"], + }, + } + result = { + definition.system_code: _metrics_for_definition( + definition, + combined_metrics, + ) + for definition in ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS + } + task_definition = TASK_SECRET_SKILL_DEFINITIONS[0] + result[task_definition.system_code] = calculate_task_execution_metrics( + employee.id, + since=since, + ) + return result + + +def _metric_summary(row: SkillReportRow) -> str: + metrics = row.metrics + code = row.definition.system_code + if code == "attendance_punctuality": + return f"лучшая серия: {metrics.get('best_streak', 0)} дн." + if 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 code == "attendance_reliability": + return f"без отпуска и отгула: {metrics.get('worked_days', 0)} дн." + if 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)}" + ) + + +def _bronze_gap_summary(row: SkillReportRow) -> str: + values = _rank_metrics(row.metrics, row.definition) + requirements = row.definition.requirements["bronze"] + parts = [ + f"{REQUIREMENT_LABELS.get(metric, metric)}: " + f"{values.get(metric, 0) or 0}/{target}" + for metric, target in requirements.items() + ] + penalty_levels = int(row.metrics.get("penalty_levels", 0) or 0) + if penalty_levels: + parts.append(f"штрафных уровней: {penalty_levels}") + return "; ".join(parts) + + +def _parse_since(value: str | None) -> date | None: + since = parse_date(value) if value else None + if value and since is None: + raise CommandError("Параметр --since должен быть датой в формате YYYY-MM-DD.") + if since is not None and since > timezone.localdate(): + raise CommandError("Параметр --since не может быть датой из будущего.") + return since + + +class Command(BaseCommand): + help = "Показать лидеров и ближайших кандидатов по секретным навыкам" + + def add_arguments(self, parser): + parser.add_argument( + "--since", + type=str, + help="Учитывать события начиная с даты в формате YYYY-MM-DD", + ) + parser.add_argument( + "--top", + type=int, + default=3, + help="Количество сотрудников в рейтинге (по умолчанию 3)", + ) + + def handle(self, *args, **options): + since = _parse_since(options.get("since")) + top = options.get("top", 3) + if top < 1: + raise CommandError("Параметр --top должен быть больше нуля.") + + definitions = ( + ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS + + TASK_SECRET_SKILL_DEFINITIONS + ) + rows_by_skill = {definition.system_code: [] for definition in definitions} + employees = Employee.objects.order_by("id") + for employee in employees: + metrics_by_skill = _employee_metrics(employee, since=since) + for definition in definitions: + rows_by_skill[definition.system_code].append( + SkillReportRow( + employee=employee, + definition=definition, + metrics=metrics_by_skill[definition.system_code], + ) + ) + + period_label = since.isoformat() if since else "за всё время" + self.stdout.write(f"Секретные навыки — расчёт {period_label}") + self.stdout.write(f"Проверено сотрудников: {employees.count()}") + + for definition in definitions: + rows = rows_by_skill[definition.system_code] + holders = [row for row in rows if row.rank != "none"] + holders.sort( + key=lambda row: ( + RANK_INDEX[row.rank], + row.rank_progress, + -row.employee.id, + ), + reverse=True, + ) + self.stdout.write(f"\n{definition.name}") + if holders: + self.stdout.write(f"Обладателей: {len(holders)}. Топ-{top}:") + for index, row in enumerate(holders[:top], start=1): + self.stdout.write( + f"{index}. #{row.employee.id} {row.employee} — " + f"{RANK_LABELS[row.rank]}; {_metric_summary(row)}" + ) + continue + + self.stdout.write("Навык пока никто не получил.") + nearest = sorted( + rows, + key=lambda row: ( + row.bronze_progress, + row.rank_progress, + -row.employee.id, + ), + reverse=True, + )[:top] + self.stdout.write(f"Ближе всего к получению, топ-{top}:") + for index, row in enumerate(nearest, start=1): + percent = min(int(row.bronze_progress * 100), 100) + self.stdout.write( + f"{index}. #{row.employee.id} {row.employee} — {percent}%; " + f"{_bronze_gap_summary(row)}" + ) diff --git a/backend/tests/employees/test_secret_skills_report_command.py b/backend/tests/employees/test_secret_skills_report_command.py new file mode 100644 index 00000000..a80c0f27 --- /dev/null +++ b/backend/tests/employees/test_secret_skills_report_command.py @@ -0,0 +1,69 @@ +from datetime import date, timedelta +from io import StringIO + +import pytest +from django.core.management import call_command + +from attendance.models import AttendanceAnalysisRun, AttendanceRecord + +pytestmark = pytest.mark.django_db + + +def test_report_lists_holders_and_nearest_candidates(user_factory): + holder = user_factory(first_name="Иван", last_name="Лидер") + candidate = user_factory(first_name="Пётр", last_name="Кандидат") + start = date(2026, 1, 1) + run = AttendanceAnalysisRun.objects.create( + employee=holder, + period_start=start, + period_end=start + timedelta(days=19), + status=AttendanceAnalysisRun.STATUS_SUCCESS, + ) + for offset in range(20): + AttendanceRecord.objects.create( + analysis_run=run, + employee=holder, + date=start + timedelta(days=offset), + arrival_time="08:00", + departure_time="17:00", + work_hours=9, + expected_hours=9, + is_workday=True, + effective_is_workday=True, + personnel_status="normal", + ) + candidate_run = AttendanceAnalysisRun.objects.create( + employee=candidate, + period_start=start, + period_end=start + timedelta(days=14), + status=AttendanceAnalysisRun.STATUS_SUCCESS, + ) + for offset in range(15): + AttendanceRecord.objects.create( + analysis_run=candidate_run, + employee=candidate, + date=start + timedelta(days=offset), + arrival_time="08:00", + departure_time="17:00", + work_hours=9, + expected_hours=9, + is_workday=True, + effective_is_workday=True, + personnel_status="normal", + ) + + output = StringIO() + call_command( + "secret_skills_report", + since=start.isoformat(), + top=3, + stdout=output, + ) + + result = output.getvalue() + assert "Пунктуальность" in result + assert "Обладателей: 1. Топ-3" in result + assert "#1 Лидер Иван — Бронза" in result + assert "Самоорганизация" in result + assert "Навык пока никто не получил." in result + assert "Ближе всего к получению, топ-3" in result