1136 lines
39 KiB
Python
1136 lines
39 KiB
Python
"""Автоматический прогресс секретных навыков по системным данным."""
|
||
|
||
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)
|