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