Corp/backend/employees/services/secret_skills.py
2026-09-07 19:43:48 +03:00

1136 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Автоматический прогресс секретных навыков по системным данным."""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Iterable
from django.db import transaction
from django.utils import timezone
from employees.constants import (
ACTION_DISMISSED,
ACTION_HIRED,
ACTION_ON_DAY_OFF,
ACTION_ON_LEAVE,
ACTION_ON_MATERNITY,
ACTION_ON_SICK_LEAVE,
ACTION_REHIRED,
ACTION_REMOTE,
ACTION_WORKING,
)
from employees.models import Employee, EmployeeAction, EmployeeSkillProgress, Skill
from employees.services.secret_skill_settings import get_secret_skills_start_date
from employees.services.skill_progress import SKILL_RANKS, rarity_payload
RANK_CODES = tuple(rank["code"] for rank in SKILL_RANKS)
RANK_LABELS = {rank["code"]: rank["label"] for rank in SKILL_RANKS}
RANK_INDEX = {code: index for index, code in enumerate(RANK_CODES)}
PUNCTUALITY_REQUIREMENTS = {
"bronze": {"best_streak": 20},
"silver": {"best_streak": 45},
"gold": {"best_streak": 90},
"ruby": {"best_streak": 180},
"diamond": {"best_streak": 365},
}
SELF_ORGANIZATION_REQUIREMENTS = {
"bronze": {
"clean_days": 60,
"best_clean_streak": 20,
"off_schedule_days": 5,
"overtime_days": 10,
},
"silver": {
"clean_days": 120,
"best_clean_streak": 45,
"off_schedule_days": 10,
"overtime_days": 25,
},
"gold": {
"clean_days": 240,
"best_clean_streak": 90,
"off_schedule_days": 20,
"overtime_days": 50,
},
"ruby": {
"clean_days": 480,
"best_clean_streak": 180,
"off_schedule_days": 35,
"overtime_days": 90,
},
"diamond": {
"clean_days": 800,
"best_clean_streak": 365,
"off_schedule_days": 50,
"overtime_days": 150,
},
}
RELIABILITY_REQUIREMENTS = {
"bronze": {"worked_days": 60},
"silver": {"worked_days": 150},
"gold": {"worked_days": 300},
"ruby": {"worked_days": 600},
"diamond": {"worked_days": 1000},
}
RESILIENCE_REQUIREMENTS = {
"bronze": {"best_resilience_streak": 180},
"silver": {"best_resilience_streak": 365},
"gold": {"best_resilience_streak": 730},
"ruby": {"best_resilience_streak": 1095},
"diamond": {"best_resilience_streak": 1825},
}
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": "Чистые рабочие дни",
"best_clean_streak": "Лучшая чистая серия",
"off_schedule_days": "Выходы в нерабочий день",
"overtime_days": "Дни с переработкой от 1 часа",
"worked_days": "Дни без отпуска и отгула",
"best_resilience_streak": "Лучшая серия между больничными",
"confirmed_tasks": "Подтверждённые задачи",
"on_time_percent": "Завершено вовремя, %",
"best_on_time_streak": "Лучшая серия своевременных задач",
}
ANTI_STREAK_LABELS = {
Skill.ProgressionType.ATTENDANCE_PUNCTUALITY: "Опоздания подряд",
Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION: "Проблемные дни подряд",
Skill.ProgressionType.ATTENDANCE_RELIABILITY: "Отпуска и отгулы за 120 дней",
Skill.ProgressionType.PERSONNEL_RESILIENCE: "Больничные за 180 дней",
Skill.ProgressionType.TASK_EXECUTION: "Нарушения за 60 дней",
}
@dataclass(frozen=True)
class SecretSkillDefinition:
system_code: str
name: str
progression_type: str
requirements: dict[str, dict[str, int]]
anti_streak_limit: int
recovery_streak_target: int
SECRET_SKILL_DEFINITIONS = (
SecretSkillDefinition(
system_code="attendance_punctuality",
name="Пунктуальность",
progression_type=Skill.ProgressionType.ATTENDANCE_PUNCTUALITY,
requirements=PUNCTUALITY_REQUIREMENTS,
anti_streak_limit=3,
recovery_streak_target=20,
),
SecretSkillDefinition(
system_code="attendance_self_organization",
name="Самоорганизация",
progression_type=Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION,
requirements=SELF_ORGANIZATION_REQUIREMENTS,
anti_streak_limit=5,
recovery_streak_target=30,
),
SecretSkillDefinition(
system_code="attendance_reliability",
name="Надёжность",
progression_type=Skill.ProgressionType.ATTENDANCE_RELIABILITY,
requirements=RELIABILITY_REQUIREMENTS,
anti_streak_limit=3,
recovery_streak_target=60,
),
SecretSkillDefinition(
system_code="personnel_resilience",
name="Стойкость",
progression_type=Skill.ProgressionType.PERSONNEL_RESILIENCE,
requirements=RESILIENCE_REQUIREMENTS,
anti_streak_limit=2,
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: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:
return bool(
record.arrival_time
or record.departure_time
or (record.work_hours is not None and record.work_hours > 0)
)
def _rank_metrics(metrics: dict[str, object], definition: SecretSkillDefinition):
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
return {"best_streak": metrics["best_streak"]}
if (
definition.progression_type
== Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION
):
return {
"clean_days": metrics["clean_days"],
"best_clean_streak": metrics["best_clean_streak"],
"off_schedule_days": metrics["off_schedule_days"],
"overtime_days": metrics["overtime_days"],
}
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"],
}
def _action_date(action: EmployeeAction) -> date | None:
value = action.date
if not value:
return None
if isinstance(value, datetime):
if timezone.is_aware(value):
return timezone.localtime(value).date()
return value.date()
return value
def _is_gamified_action(action: EmployeeAction) -> bool:
return not (
isinstance(action.extra, dict)
and action.extra.get("exclude_from_secret_skills") is True
)
def _initial_secret_state(
definition: SecretSkillDefinition,
*,
rolling: bool = False,
) -> dict[str, object]:
state: dict[str, object] = {
"earned_rank": "none",
"effective_rank": "none",
"anti_streak": 0,
"anti_streak_limit": definition.anti_streak_limit,
"recovery_streak": 0,
"recovery_streak_target": definition.recovery_streak_target,
"penalty_levels": 0,
"last_demotion_date": "",
"last_recovery_date": "",
}
if rolling:
state["_rolling_dates"] = deque()
return state
def _prune_rolling_dates(
dates: deque[date],
current_date: date,
window_days: int,
) -> None:
minimum_date = current_date - timedelta(days=window_days - 1)
while dates and dates[0] < minimum_date:
dates.popleft()
def _register_rolling_violation(
state: dict[str, object],
definition: SecretSkillDefinition,
metrics: dict[str, object],
*,
violation_date: date,
window_days: int,
) -> None:
_refresh_secret_state(state, definition, metrics)
earned_rank_index = RANK_INDEX[str(state["earned_rank"])]
dates = state["_rolling_dates"]
if earned_rank_index == 0:
dates.clear()
state["anti_streak"] = 0
state["recovery_streak"] = 0
return
_prune_rolling_dates(dates, violation_date, window_days)
dates.append(violation_date)
state["anti_streak"] = len(dates)
state["recovery_streak"] = 0
penalty_levels = int(state["penalty_levels"])
if (
earned_rank_index > 0
and penalty_levels < earned_rank_index
and len(dates) >= definition.anti_streak_limit
):
state["penalty_levels"] = penalty_levels + 1
state["anti_streak"] = 0
state["last_demotion_date"] = violation_date.isoformat()
dates.clear()
_refresh_secret_state(state, definition, metrics)
def _advance_rolling_recovery(
state: dict[str, object],
definition: SecretSkillDefinition,
metrics: dict[str, object],
*,
record_date: date,
) -> None:
_refresh_secret_state(state, definition, metrics)
if int(state["penalty_levels"]) <= 0:
state["recovery_streak"] = 0
return
state["recovery_streak"] = int(state["recovery_streak"]) + 1
if int(state["recovery_streak"]) >= definition.recovery_streak_target:
state["penalty_levels"] = int(state["penalty_levels"]) - 1
state["recovery_streak"] = 0
state["last_recovery_date"] = record_date.isoformat()
_refresh_secret_state(state, definition, metrics)
def _refresh_secret_state(
state: dict[str, int | str],
definition: SecretSkillDefinition,
metrics: dict[str, object],
) -> None:
earned_rank = _candidate_rank(
_rank_metrics(metrics, definition),
definition.requirements,
)
earned_rank_index = RANK_INDEX[earned_rank]
penalty_levels = min(int(state["penalty_levels"]), earned_rank_index)
state["penalty_levels"] = penalty_levels
state["earned_rank"] = earned_rank
state["effective_rank"] = RANK_CODES[max(earned_rank_index - penalty_levels, 0)]
def _advance_secret_state(
state: dict[str, int | str],
definition: SecretSkillDefinition,
metrics: dict[str, object],
*,
is_bad: bool,
record_date,
) -> None:
_refresh_secret_state(state, definition, metrics)
earned_rank_index = RANK_INDEX[str(state["earned_rank"])]
if earned_rank_index == 0:
state.update(
anti_streak=0,
recovery_streak=0,
penalty_levels=0,
effective_rank="none",
)
return
penalty_levels = int(state["penalty_levels"])
if is_bad:
state["recovery_streak"] = 0
if penalty_levels < earned_rank_index:
state["anti_streak"] = int(state["anti_streak"]) + 1
if int(state["anti_streak"]) >= definition.anti_streak_limit:
state["penalty_levels"] = penalty_levels + 1
state["anti_streak"] = 0
state["last_demotion_date"] = record_date.isoformat()
else:
state["anti_streak"] = 0
else:
state["anti_streak"] = 0
if penalty_levels > 0:
state["recovery_streak"] = int(state["recovery_streak"]) + 1
if int(state["recovery_streak"]) >= definition.recovery_streak_target:
state["penalty_levels"] = penalty_levels - 1
state["recovery_streak"] = 0
state["last_recovery_date"] = record_date.isoformat()
else:
state["recovery_streak"] = 0
_refresh_secret_state(state, definition, metrics)
def calculate_attendance_secret_metrics( # noqa: C901
records: Iterable,
) -> dict[str, object]:
"""Рассчитать показатели навыков, основанных только на посещаемости."""
punctuality_streak = 0
best_punctuality_streak = 0
clean_streak = 0
best_clean_streak = 0
clean_days = 0
off_schedule_days = 0
overtime_days = 0
last_evaluated_date = ""
skill_states = {
definition.system_code: _initial_secret_state(definition)
for definition in ATTENDANCE_SECRET_SKILL_DEFINITIONS
}
def current_metrics():
return {
"current_streak": punctuality_streak,
"best_streak": best_punctuality_streak,
"current_clean_streak": clean_streak,
"best_clean_streak": best_clean_streak,
"clean_days": clean_days,
"off_schedule_days": off_schedule_days,
"overtime_days": overtime_days,
"last_evaluated_date": last_evaluated_date,
}
def refresh_paused_states():
metrics = current_metrics()
for definition in ATTENDANCE_SECRET_SKILL_DEFINITIONS:
_refresh_secret_state(
skill_states[definition.system_code],
definition,
metrics,
)
for record in sorted(records, key=lambda item: (item.date, item.id or 0)):
if record.technical_issues:
continue
has_work = _has_work(record)
if record.is_overtime and has_work and float(record.overtime_hours or 0) >= 1:
overtime_days += 1
if not record.effective_is_workday:
if (
not record.is_workday
and record.personnel_status == "normal"
and record.is_overtime
and has_work
):
off_schedule_days += 1
refresh_paused_states()
continue
if record.personnel_status == ACTION_REMOTE:
refresh_paused_states()
continue
last_evaluated_date = record.date.isoformat()
if record.is_absent or not has_work:
punctuality_streak = 0
clean_streak = 0
metrics = current_metrics()
for definition in ATTENDANCE_SECRET_SKILL_DEFINITIONS:
_advance_secret_state(
skill_states[definition.system_code],
definition,
metrics,
is_bad=True,
record_date=record.date,
)
continue
if record.is_late:
punctuality_streak = 0
else:
punctuality_streak += 1
best_punctuality_streak = max(
best_punctuality_streak,
punctuality_streak,
)
is_clean = not any(
(
record.is_late,
record.is_early_leave,
record.is_underwork,
record.is_absent,
)
)
if is_clean:
clean_days += 1
clean_streak += 1
best_clean_streak = max(best_clean_streak, clean_streak)
else:
clean_streak = 0
metrics = current_metrics()
for definition in ATTENDANCE_SECRET_SKILL_DEFINITIONS:
is_bad = (
bool(record.is_late)
if definition.progression_type
== Skill.ProgressionType.ATTENDANCE_PUNCTUALITY
else not is_clean
)
_advance_secret_state(
skill_states[definition.system_code],
definition,
metrics,
is_bad=is_bad,
record_date=record.date,
)
return {**current_metrics(), "skill_states": skill_states}
def _employment_period(
employee: Employee,
actions: list[EmployeeAction],
reference_date: date,
) -> tuple[date, date] | None:
created_date = timezone.localtime(employee.created_at).date()
segment_start = created_date
segment_end: date | None = None
is_employed = True
for action in actions:
action_date = _action_date(action)
if action_date is None or action_date > reference_date:
continue
if action.action in {ACTION_HIRED, ACTION_REHIRED}:
segment_start = action_date
segment_end = None
is_employed = True
elif action.action in {ACTION_REMOTE, ACTION_WORKING}:
if not is_employed:
segment_start = action_date
segment_end = None
is_employed = True
elif action.action == ACTION_DISMISSED and is_employed:
segment_end = action_date
is_employed = False
if segment_start > reference_date:
return None
return (
segment_start,
reference_date if is_employed else segment_end or reference_date,
)
def _action_period(
action: EmployeeAction,
*,
start_date: date,
end_date: date,
) -> tuple[date, date] | None:
action_date = _action_date(action)
if action_date is None or action_date > end_date:
return None
period_start = max(action_date, start_date)
period_end = min(action.date_to or action_date, end_date)
if period_end < period_start:
return None
return period_start, period_end
def _empty_personnel_metrics(skill_states) -> dict[str, object]:
for state in skill_states.values():
state.pop("_rolling_dates", None)
state["recent_violation_dates"] = []
return {
"worked_days": 0,
"current_reliability_streak": 0,
"best_reliability_streak": 0,
"current_resilience_streak": 0,
"best_resilience_streak": 0,
"leave_or_day_off_episodes": 0,
"sick_leave_episodes": 0,
"personnel_last_evaluated_date": "",
"skill_states": skill_states,
}
def calculate_personnel_secret_metrics( # noqa: C901
employee: Employee,
actions: Iterable[EmployeeAction],
*,
reference_date: date | None = None,
since: date | None = None,
) -> dict[str, object]:
"""Рассчитать кадровые навыки в пределах достоверного периода."""
reference_date = reference_date or timezone.localdate()
sorted_actions = sorted(
(action for action in actions if _is_gamified_action(action)),
key=lambda item: (_action_date(item) or date.min, item.id or 0),
)
definitions_by_type = {
definition.progression_type: definition
for definition in PERSONNEL_SECRET_SKILL_DEFINITIONS
}
reliability_definition = definitions_by_type[
Skill.ProgressionType.ATTENDANCE_RELIABILITY
]
resilience_definition = definitions_by_type[
Skill.ProgressionType.PERSONNEL_RESILIENCE
]
skill_states = {
reliability_definition.system_code: _initial_secret_state(
reliability_definition,
rolling=True,
),
resilience_definition.system_code: _initial_secret_state(
resilience_definition,
),
}
employment_period = _employment_period(employee, sorted_actions, reference_date)
if employment_period is None:
return _empty_personnel_metrics(skill_states)
start_date, end_date = employment_period
if since is not None:
start_date = max(start_date, since)
if start_date > end_date:
return _empty_personnel_metrics(skill_states)
reliability_blocked_dates: set[date] = set()
reliability_paused_dates: set[date] = set()
resilience_blocked_dates: set[date] = set()
resilience_paused_dates: set[date] = set()
reliability_episode_dates: set[date] = set()
sick_episode_dates: set[date] = set()
for action in sorted_actions:
if action.action not in {
ACTION_ON_LEAVE,
ACTION_ON_DAY_OFF,
ACTION_ON_SICK_LEAVE,
ACTION_ON_MATERNITY,
}:
continue
period = _action_period(action, start_date=start_date, end_date=end_date)
if period is None:
continue
period_start, period_end = period
period_dates = {
period_start + timedelta(days=offset)
for offset in range((period_end - period_start).days + 1)
}
if action.action in {ACTION_ON_LEAVE, ACTION_ON_DAY_OFF}:
reliability_blocked_dates.update(period_dates)
resilience_paused_dates.update(period_dates)
reliability_episode_dates.add(period_start)
elif action.action == ACTION_ON_SICK_LEAVE:
reliability_paused_dates.update(period_dates)
resilience_blocked_dates.update(period_dates)
sick_episode_dates.add(period_start)
else:
reliability_paused_dates.update(period_dates)
resilience_paused_dates.update(period_dates)
worked_days = 0
reliability_streak = 0
best_reliability_streak = 0
recovery_days_since_last_sick = 0
resilience_streak = 0
best_resilience_streak = 0
def current_metrics():
return {
"worked_days": worked_days,
"current_reliability_streak": reliability_streak,
"best_reliability_streak": best_reliability_streak,
"current_resilience_streak": resilience_streak,
"best_resilience_streak": best_resilience_streak,
"leave_or_day_off_episodes": len(reliability_episode_dates),
"sick_leave_episodes": len(sick_episode_dates),
"personnel_last_evaluated_date": end_date.isoformat(),
}
current_date = start_date
while current_date <= end_date:
reliability_state = skill_states[reliability_definition.system_code]
rolling_dates = reliability_state["_rolling_dates"]
_prune_rolling_dates(rolling_dates, current_date, 120)
reliability_state["anti_streak"] = len(rolling_dates)
if current_date in reliability_episode_dates:
reliability_streak = 0
_register_rolling_violation(
skill_states[reliability_definition.system_code],
reliability_definition,
current_metrics(),
violation_date=current_date,
window_days=120,
)
if current_date in sick_episode_dates:
resilience_streak = 0
recovery_days_since_last_sick = 0
if current_date not in reliability_blocked_dates | reliability_paused_dates:
worked_days += 1
reliability_streak += 1
best_reliability_streak = max(
best_reliability_streak,
reliability_streak,
)
_advance_rolling_recovery(
skill_states[reliability_definition.system_code],
reliability_definition,
current_metrics(),
record_date=current_date,
)
else:
_refresh_secret_state(
skill_states[reliability_definition.system_code],
reliability_definition,
current_metrics(),
)
if current_date not in resilience_blocked_dates | resilience_paused_dates:
recovery_days_since_last_sick += 1
resilience_streak += 1
best_resilience_streak = max(best_resilience_streak, resilience_streak)
current_date += timedelta(days=1)
reliability_state = skill_states[reliability_definition.system_code]
rolling_dates = reliability_state.pop("_rolling_dates")
_prune_rolling_dates(rolling_dates, end_date, 120)
reliability_state["anti_streak"] = len(rolling_dates)
reliability_state["recent_violation_dates"] = [
violation_date.isoformat() for violation_date in rolling_dates
]
resilience_state = skill_states[resilience_definition.system_code]
recent_sick_dates = sorted(
sick_date
for sick_date in sick_episode_dates
if sick_date >= end_date - timedelta(days=179)
)
_refresh_secret_state(
resilience_state,
resilience_definition,
current_metrics(),
)
earned_rank_index = RANK_INDEX[str(resilience_state["earned_rank"])]
sick_count = len(recent_sick_dates)
if sick_count < 2:
base_penalty_levels = 0
elif sick_count == 2:
base_penalty_levels = 1
elif sick_count == 3:
base_penalty_levels = 2
else:
base_penalty_levels = earned_rank_index
recovered_levels = recovery_days_since_last_sick // 90
penalty_levels = max(
min(base_penalty_levels, earned_rank_index) - recovered_levels,
0,
)
resilience_state.update(
anti_streak=sick_count,
penalty_levels=penalty_levels,
effective_rank=RANK_CODES[max(earned_rank_index - penalty_levels, 0)],
recovery_streak=(recovery_days_since_last_sick % 90 if penalty_levels else 0),
last_demotion_date=(
recent_sick_dates[-1].isoformat() if penalty_levels else ""
),
last_recovery_date=(end_date.isoformat() if recovered_levels else ""),
recent_violation_dates=[item.isoformat() for item in recent_sick_dates],
)
return {**current_metrics(), "skill_states": skill_states}
def _candidate_rank(
metrics: dict[str, int | str],
requirements: dict[str, dict[str, int]],
) -> str:
candidate = "none"
for rank_code in RANK_CODES[1:]:
rank_requirements = requirements[rank_code]
if all(
int(metrics.get(metric, 0) or 0) >= target
for metric, target in rank_requirements.items()
):
candidate = rank_code
else:
break
return candidate
def _definition_for_skill(skill: Skill) -> SecretSkillDefinition | None:
return next(
(
definition
for definition in SECRET_SKILL_DEFINITIONS
if definition.system_code == skill.system_code
),
None,
)
def _metrics_for_definition(
definition: SecretSkillDefinition,
metrics: dict[str, object],
) -> dict[str, object]:
skill_state = dict(metrics["skill_states"][definition.system_code])
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
return {
"current_streak": metrics["current_streak"],
"best_streak": metrics["best_streak"],
"last_evaluated_date": metrics["last_evaluated_date"],
**skill_state,
}
if (
definition.progression_type
== Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION
):
return {
"current_clean_streak": metrics["current_clean_streak"],
"best_clean_streak": metrics["best_clean_streak"],
"clean_days": metrics["clean_days"],
"off_schedule_days": metrics["off_schedule_days"],
"overtime_days": metrics["overtime_days"],
"last_evaluated_date": metrics["last_evaluated_date"],
**skill_state,
}
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_RELIABILITY:
return {
"worked_days": metrics["worked_days"],
"current_reliability_streak": metrics["current_reliability_streak"],
"best_reliability_streak": metrics["best_reliability_streak"],
"leave_or_day_off_episodes": metrics["leave_or_day_off_episodes"],
"last_evaluated_date": metrics["personnel_last_evaluated_date"],
**skill_state,
}
return {
"current_resilience_streak": metrics["current_resilience_streak"],
"best_resilience_streak": metrics["best_resilience_streak"],
"sick_leave_episodes": metrics["sick_leave_episodes"],
"last_evaluated_date": metrics["personnel_last_evaluated_date"],
**skill_state,
}
@transaction.atomic
def recalculate_attendance_secret_skills( # noqa: C901
employee_id: int,
*,
since: date | None = None,
) -> list[str]:
"""Пересчитать и при необходимости открыть системные навыки сотрудника."""
from attendance.models import AttendanceRecord
employee = Employee.objects.select_for_update().filter(pk=employee_id).first()
if employee is None:
return []
since = since or get_secret_skills_start_date()
records_queryset = AttendanceRecord.objects.filter(employee_id=employee_id)
if since is not None:
records_queryset = records_queryset.filter(date__gte=since)
records = list(records_queryset.order_by("date", "id"))
actions = list(
EmployeeAction.objects.filter(employee_id=employee_id).order_by("date", "id")
)
attendance_metrics = calculate_attendance_secret_metrics(records)
personnel_metrics = calculate_personnel_secret_metrics(
employee,
actions,
since=since,
)
metrics = {
**attendance_metrics,
**personnel_metrics,
"skill_states": {
**attendance_metrics["skill_states"],
**personnel_metrics["skill_states"],
},
}
changed_skills: list[str] = []
now = timezone.now()
skills = {
skill.system_code: skill
for skill in Skill.objects.filter(
system_code__in=[
definition.system_code
for definition in ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS
],
is_secret=True,
)
}
for definition in ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS:
skill = skills.get(definition.system_code)
if skill is None:
continue
definition_metrics = _metrics_for_definition(definition, metrics)
calculated_rank = str(definition_metrics["effective_rank"])
progress = EmployeeSkillProgress.objects.filter(
employee=employee,
skill=skill,
).first()
previous_rank = progress.special_rank if progress else "none"
if calculated_rank == "none":
if progress:
was_active = progress.is_active
rank_history = dict(
progress.progress_data.get("rank_achieved_at") or {}
)
rank_losses = list(progress.progress_data.get("rank_losses") or [])
if previous_rank != "none":
rank_losses.append(
{
"from": previous_rank,
"to": "none",
"at": now.isoformat(),
}
)
progress.special_rank = "none"
progress.is_active = False
progress.experience = 0
progress.progress_data = {
**definition_metrics,
"rank_achieved_at": rank_history,
"rank_losses": rank_losses[-20:],
"recalculated_at": now.isoformat(),
}
progress.save(
update_fields=[
"special_rank",
"is_active",
"experience",
"progress_data",
"updated_at",
]
)
if was_active:
employee.skills.remove(skill)
if previous_rank != "none" or was_active:
changed_skills.append(skill.name)
continue
employee.skills.add(skill)
progress = EmployeeSkillProgress.objects.select_for_update().get(
employee=employee,
skill=skill,
)
rank_history = dict(progress.progress_data.get("rank_achieved_at") or {})
if RANK_INDEX[calculated_rank] > RANK_INDEX[previous_rank]:
rank_history[calculated_rank] = now.isoformat()
rank_losses = list(progress.progress_data.get("rank_losses") or [])
if 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 = 0
progress.is_active = True
progress.created_by = None
progress.progress_data = {
**definition_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",
]
)
progress.experience_events.filter(revoked_at__isnull=True).update(
revoked_at=now,
)
if calculated_rank != previous_rank:
changed_skills.append(skill.name)
return changed_skills
def secret_skill_progress_payload(
skill: Skill,
progress: EmployeeSkillProgress | None,
) -> dict:
definition = _definition_for_skill(skill)
if definition is None:
return {}
rank_code = progress.special_rank if progress else "none"
metrics = dict(progress.progress_data or {}) if progress else {}
rank_index = RANK_INDEX.get(rank_code, 0)
next_rank = RANK_CODES[rank_index + 1] if rank_index + 1 < len(RANK_CODES) else None
penalty_levels = int(metrics.get("penalty_levels", 0) or 0)
if penalty_levels > 0 and next_rank:
next_requirements = {
"recovery_streak": definition.recovery_streak_target,
}
requirement_payload = [
{
"key": "recovery_streak",
"label": "Новая положительная серия",
"current": int(metrics.get("recovery_streak", 0) or 0),
"target": definition.recovery_streak_target,
"complete": int(metrics.get("recovery_streak", 0) or 0)
>= definition.recovery_streak_target,
}
]
else:
next_requirements = definition.requirements.get(next_rank or "", {})
requirement_payload = [
{
"key": metric,
"label": REQUIREMENT_LABELS[metric],
"current": int(metrics.get(metric, 0) or 0),
"target": target,
"complete": int(metrics.get(metric, 0) or 0) >= target,
}
for metric, target in next_requirements.items()
]
if requirement_payload:
progress_percent = round(
min(
min(item["current"] / max(item["target"], 1), 1)
for item in requirement_payload
)
* 100
)
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} {recovery_unit}"
)
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
progress_value = int(metrics.get("best_streak", 0) or 0)
progress_label = (
recovery_label if penalty_levels else f"Лучшая серия: {progress_value} дн."
)
elif (
definition.progression_type
== Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION
):
progress_value = int(metrics.get("clean_days", 0) or 0)
completed = sum(item["complete"] for item in requirement_payload)
progress_label = (
recovery_label
if penalty_levels
else f"До следующего ранга: {completed}/{len(requirement_payload)} условий"
if requirement_payload
else "Все условия выполнены"
)
elif definition.progression_type == Skill.ProgressionType.ATTENDANCE_RELIABILITY:
progress_value = int(metrics.get("worked_days", 0) or 0)
progress_label = (
recovery_label
if penalty_levels
else f"Без отпуска и отгула: {progress_value} дн."
)
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 = (
int(metrics.get(next(iter(next_requirements), ""), 0) or 0)
if next_requirements
else 0
)
return {
"rank": rank_code,
"rank_display": RANK_LABELS.get(rank_code, "Без ранга"),
"rank_threshold": 0,
"next_rank": next_rank,
"next_rank_display": RANK_LABELS.get(next_rank) if next_rank else None,
"next_rank_experience": primary_target,
"experience_to_next_rank": (
max(int(primary_target or 0) - primary_current, 0)
if primary_target is not None
else 0
),
"rank_progress_percent": progress_percent,
"progress_value": progress_value,
"progress_label": progress_label,
"progress_data": metrics,
"next_rank_requirements": requirement_payload,
"anti_streak": int(metrics.get("anti_streak", 0) or 0),
"anti_streak_label": ANTI_STREAK_LABELS[definition.progression_type],
"anti_streak_limit": definition.anti_streak_limit,
"anti_streak_remaining": max(
definition.anti_streak_limit - int(metrics.get("anti_streak", 0) or 0),
0,
),
"recovery_streak": int(metrics.get("recovery_streak", 0) or 0),
"recovery_streak_target": definition.recovery_streak_target,
"penalty_levels": penalty_levels,
}
def skill_rarity_payload(skill: Skill, confirmed_holders: int) -> dict:
if skill.is_secret:
return {
"confirmed_holders": max(int(confirmed_holders or 0), 0),
"rarity": "secret",
"rarity_display": "Секретный",
}
return rarity_payload(confirmed_holders)