Merge branch 'develop'
This commit is contained in:
commit
7eff2d020c
217
backend/employees/management/commands/secret_skills_report.py
Normal file
217
backend/employees/management/commands/secret_skills_report.py
Normal file
@ -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)}"
|
||||
)
|
||||
69
backend/tests/employees/test_secret_skills_report_command.py
Normal file
69
backend/tests/employees/test_secret_skills_report_command.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user