"""Ранги, редкость и журнал опыта навыков сотрудников.""" 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 TASK_SKILL_EXPERIENCE = 25 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( employee_progresses__is_active=True, employee_progresses__experience__gte=100, 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: return ( EmployeeSkillProgress.objects.filter( skill=skill, is_active=True, experience__gte=100, employee__is_active=True, employee__is_hidden_in_ui=False, employee__skills=skill, ) .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]: 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: """Начисляет опыт исполнителю и участникам завершённой задачи. Баллы получают только сотрудники, у которых навык уже добавлен в профиль. Идемпотентный ключ исключает повторное начисление после повторного save(). """ 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 employee_ids = set(task.participants.values_list("id", flat=True)) if task.assignee_id: employee_ids.add(task.assignee_id) if not employee_ids: return 0 awarded = 0 for skill in task.skills.all(): recipients = Employee.objects.filter( id__in=employee_ids, is_active=True, skills=skill, ).distinct() for employee in recipients: _, _, created = record_skill_experience( employee=employee, skill=skill, points=TASK_SKILL_EXPERIENCE, source_type=SkillExperienceEvent.Source.TASK, actor=task.created_by, source_id=str(task.id), reason=f"Завершена задача «{task.title}»", idempotency_key=( f"task:{task.id}:employee:{employee.id}:skill:{skill.id}" ), metadata={"task_id": task.id, "task_title": task.title}, ) 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