357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Ранги, редкость и журнал опыта навыков сотрудников."""
|
||
|
||
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
|