Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-26 15:47:56 +03:00
commit 7eff2d020c
2 changed files with 286 additions and 0 deletions

View 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)}"
)

View 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