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

357 lines
12 KiB
Python
Raw 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 datetime import timedelta
from django.db import transaction
from django.db.models import Count, Q, Sum
from django.utils import timezone
from employees.models import (
Department,
Employee,
EmployeeDepartment,
EmployeeSkillProgress,
RoleAssignment,
Skill,
SkillExperienceEvent,
)
SKILL_RANKS = (
{"code": "none", "label": "Без ранга", "threshold": 0},
{"code": "bronze", "label": "Бронза", "threshold": 100},
{"code": "silver", "label": "Серебро", "threshold": 300},
{"code": "gold", "label": "Золото", "threshold": 800},
{"code": "ruby", "label": "Рубин", "threshold": 1800},
{"code": "diamond", "label": "Бриллиант", "threshold": 4000},
)
ENDORSEMENT_COOLDOWN_DAYS = 90
RARITY_CONFIRMATION_EXPERIENCE = 50
TASK_ASSIGNEE_SKILL_EXPERIENCE = 30
TASK_PARTICIPANT_SKILL_EXPERIENCE = 15
def skill_queryset_with_rarity(queryset=None):
queryset = queryset if queryset is not None else Skill.objects.all()
return queryset.annotate(
confirmed_holders=Count(
"employee_progresses",
filter=(
Q(is_secret=True)
| Q(employee_progresses__experience__gte=RARITY_CONFIRMATION_EXPERIENCE)
)
& Q(
employee_progresses__is_active=True,
employee_progresses__employee__is_active=True,
employee_progresses__employee__is_hidden_in_ui=False,
),
distinct=True,
)
)
def rank_payload(experience: int) -> dict:
experience = max(int(experience or 0), 0)
current = SKILL_RANKS[0]
next_rank = None
for index, rank in enumerate(SKILL_RANKS):
if experience >= rank["threshold"]:
current = rank
next_rank = SKILL_RANKS[index + 1] if index + 1 < len(SKILL_RANKS) else None
else:
break
current_threshold = int(current["threshold"])
next_threshold = int(next_rank["threshold"]) if next_rank else None
if next_threshold is None:
percent = 100
remaining = 0
else:
span = max(next_threshold - current_threshold, 1)
percent = round(min(max((experience - current_threshold) / span, 0), 1) * 100)
remaining = max(next_threshold - experience, 0)
return {
"rank": current["code"],
"rank_display": current["label"],
"rank_threshold": current_threshold,
"next_rank": next_rank["code"] if next_rank else None,
"next_rank_display": next_rank["label"] if next_rank else None,
"next_rank_experience": next_threshold,
"experience_to_next_rank": remaining,
"rank_progress_percent": percent,
}
def rarity_payload(confirmed_holders: int) -> dict:
count = max(int(confirmed_holders or 0), 0)
if count == 0:
code, label = "unrated", "Не определена"
elif count == 1:
code, label = "legendary", "Легендарный"
elif count <= 3:
code, label = "epic", "Эпический"
elif count <= 9:
code, label = "rare", "Редкий"
elif count <= 24:
code, label = "uncommon", "Необычный"
else:
code, label = "common", "Обычный"
return {
"confirmed_holders": count,
"rarity": code,
"rarity_display": label,
}
def confirmed_holder_count(skill: Skill) -> int:
filters = {
"skill": skill,
"is_active": True,
"employee__is_active": True,
"employee__is_hidden_in_ui": False,
"employee__skills": skill,
}
if not skill.is_secret:
filters["experience__gte"] = RARITY_CONFIRMATION_EXPERIENCE
return EmployeeSkillProgress.objects.filter(**filters).distinct().count()
def ensure_skill_progress(
employee: Employee,
skill: Skill,
*,
created_by: Employee | None = None,
) -> EmployeeSkillProgress:
progress, created = EmployeeSkillProgress.objects.get_or_create(
employee=employee,
skill=skill,
defaults={"created_by": created_by, "is_active": True},
)
update_fields = []
if not progress.is_active:
progress.is_active = True
update_fields.append("is_active")
if created_by and progress.created_by_id is None:
progress.created_by = created_by
update_fields.append("created_by")
if update_fields:
update_fields.append("updated_at")
progress.save(update_fields=update_fields)
if created:
employee.skills.add(skill)
return progress
@transaction.atomic
def record_skill_experience(
*,
employee: Employee,
skill: Skill,
points: int,
source_type: str,
actor: Employee | None,
reason: str,
source_id: str = "",
idempotency_key: str = "",
metadata: dict | None = None,
) -> tuple[EmployeeSkillProgress, SkillExperienceEvent, bool]:
if skill.is_secret:
raise ValueError("Секретные навыки изменяются только системными правилами")
points = int(points)
if points <= 0:
raise ValueError("Баллы опыта должны быть положительным числом")
progress = ensure_skill_progress(employee, skill, created_by=actor)
progress = EmployeeSkillProgress.objects.select_for_update().get(pk=progress.pk)
existing = None
if idempotency_key:
existing = SkillExperienceEvent.objects.filter(
idempotency_key=idempotency_key
).first()
if existing:
if existing.revoked_at is not None:
existing.revoked_at = None
existing.revoked_by = None
existing.save(update_fields=["revoked_at", "revoked_by"])
progress.experience += existing.points
progress.save(update_fields=["experience", "updated_at"])
return progress, existing, False
event = SkillExperienceEvent.objects.create(
progress=progress,
points=points,
source_type=source_type,
source_id=str(source_id or ""),
actor=actor,
reason=reason,
metadata=metadata or {},
idempotency_key=idempotency_key,
)
progress.experience += points
progress.save(update_fields=["experience", "updated_at"])
return progress, event, True
@transaction.atomic
def revoke_skill_experience_event(
event: SkillExperienceEvent,
*,
actor: Employee | None = None,
) -> EmployeeSkillProgress:
event = SkillExperienceEvent.objects.select_for_update().get(pk=event.pk)
progress = EmployeeSkillProgress.objects.select_for_update().get(
pk=event.progress_id
)
if event.revoked_at is not None:
return progress
event.revoked_at = timezone.now()
event.revoked_by = actor
event.save(update_fields=["revoked_at", "revoked_by"])
progress.experience = max(progress.experience - event.points, 0)
progress.save(update_fields=["experience", "updated_at"])
return progress
def recalculate_skill_progress(progress: EmployeeSkillProgress) -> int:
total = (
progress.experience_events.filter(revoked_at__isnull=True).aggregate(
total=Sum("points")
)["total"]
or 0
)
if progress.experience != total:
progress.experience = total
progress.save(update_fields=["experience", "updated_at"])
return total
def endorsement_weight(actor: Employee, target: Employee) -> tuple[int, str]:
target_department_ids = EmployeeDepartment.objects.filter(
employee=target,
is_active=True,
).values_list("department_id", flat=True)
if Department.objects.filter(
id__in=target_department_ids,
head=actor,
).exists():
return 15, "Руководитель отдела"
if RoleAssignment.objects.filter(
employee=actor,
role__department_id__in=target_department_ids,
is_active=True,
).exists():
return 10, "Роль в отделе"
return 5, "Коллега"
def endorsement_available_at(
*,
progress: EmployeeSkillProgress,
actor: Employee,
):
latest = (
progress.experience_events.filter(
source_type=SkillExperienceEvent.Source.ENDORSEMENT,
actor=actor,
revoked_at__isnull=True,
)
.order_by("-created_at")
.first()
)
if latest is None:
return None
available_at = latest.created_at + timedelta(days=ENDORSEMENT_COOLDOWN_DAYS)
return available_at if available_at > timezone.now() else None
def award_task_skill_experience(task_id: int) -> int:
"""Начисляет опыт по снимку ролей на момент завершения задачи.
Баллы получают только сотрудники, у которых навык уже добавлен в профиль.
Исполнитель получает больше участника. Создатель задачи, автоматически
добавленный в participants, не получает опыт, если не является исполнителем.
Время завершения в ключе сохраняет отдельную историю каждого повторного
завершения, а повторный вызов для одного снимка остаётся идемпотентным.
"""
from tasks.models import Task
task = (
Task.objects.select_related("assignee")
.prefetch_related("participants", "skills")
.filter(pk=task_id, completed_at__isnull=False)
.first()
)
if task is None:
return 0
reward_roles = {
employee_id: "participant"
for employee_id in task.participants.values_list("id", flat=True)
if employee_id not in {task.created_by_id, task.assignee_id}
}
if task.assignee_id:
reward_roles[task.assignee_id] = "assignee"
if not reward_roles:
return 0
completion_key = task.completed_at.isoformat()
awarded = 0
for skill in task.skills.all():
if skill.is_secret:
continue
recipients = Employee.objects.filter(
id__in=reward_roles,
is_active=True,
skills=skill,
).distinct()
for employee in recipients:
reward_role = reward_roles[employee.id]
is_assignee = reward_role == "assignee"
points = (
TASK_ASSIGNEE_SKILL_EXPERIENCE
if is_assignee
else TASK_PARTICIPANT_SKILL_EXPERIENCE
)
role_display = "Исполнитель" if is_assignee else "Участник"
_, _, created = record_skill_experience(
employee=employee,
skill=skill,
points=points,
source_type=SkillExperienceEvent.Source.TASK,
actor=task.created_by,
source_id=str(task.id),
reason=f"Завершена задача «{task.title}» — {role_display.lower()}",
idempotency_key=(
f"task:{task.id}:completed:{completion_key}:"
f"employee:{employee.id}:skill:{skill.id}"
),
metadata={
"task_id": task.id,
"task_title": task.title,
"completed_at": completion_key,
"reward_role": reward_role,
"reward_role_display": role_display,
"points": points,
},
)
awarded += int(created)
return awarded
def revoke_task_skill_experience(task_id: int, *, actor: Employee | None = None) -> int:
events = SkillExperienceEvent.objects.filter(
source_type=SkillExperienceEvent.Source.TASK,
source_id=str(task_id),
revoked_at__isnull=True,
)
revoked = 0
for event in events:
revoke_skill_experience_event(event, actor=actor)
revoked += 1
return revoked