diff --git a/backend/api/v1/attendance/endpoints/records.py b/backend/api/v1/attendance/endpoints/records.py index 73fe859a..bf99f8a7 100644 --- a/backend/api/v1/attendance/endpoints/records.py +++ b/backend/api/v1/attendance/endpoints/records.py @@ -1,5 +1,6 @@ import logging +from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status @@ -137,6 +138,15 @@ class AttendanceRecordDetailAPIView(APIView): ] ) + def recalculate_secret_skills(): + from employees.services.secret_skills import ( + recalculate_attendance_secret_skills, + ) + + recalculate_attendance_secret_skills(record.employee_id) + + transaction.on_commit(recalculate_secret_skills) + record = ( _records_visible_to(request.user) .select_related("employee", "analysis_run") diff --git a/backend/api/v1/employees/serializers/__init__.py b/backend/api/v1/employees/serializers/__init__.py index f33da6e3..2e94a14c 100644 --- a/backend/api/v1/employees/serializers/__init__.py +++ b/backend/api/v1/employees/serializers/__init__.py @@ -20,7 +20,9 @@ from .shared import ( EmployeeActionSerializer, PositionBriefSerializer, PositionSerializer, + SkillExperienceEventSerializer, SkillSerializer, + serialize_employee_skills, ) __all__ = [ @@ -42,6 +44,8 @@ __all__ = [ "SetMemberRoleInput", # shared "SkillSerializer", + "SkillExperienceEventSerializer", + "serialize_employee_skills", "PositionBriefSerializer", "PositionSerializer", "EmployeeActionSerializer", diff --git a/backend/api/v1/employees/serializers/employee_serializers/detail.py b/backend/api/v1/employees/serializers/employee_serializers/detail.py index 6845197d..315e33db 100644 --- a/backend/api/v1/employees/serializers/employee_serializers/detail.py +++ b/backend/api/v1/employees/serializers/employee_serializers/detail.py @@ -13,7 +13,7 @@ from employees.models import EmployeeDepartment, Position, Skill from ..shared import ( EmployeeActionSerializer, PositionBriefSerializer, - SkillSerializer, + serialize_employee_skills, ) from .common import ( EmployeeAttendanceStatusSerializer, @@ -40,9 +40,12 @@ class EmployeeSerializer(serializers.ModelSerializer): username = serializers.CharField(read_only=True, allow_blank=True) last_activity_at = serializers.SerializerMethodField() - skills = SkillSerializer(many=True, read_only=True) + skills = serializers.SerializerMethodField() skills_ids = serializers.PrimaryKeyRelatedField( - queryset=Skill.objects.all(), many=True, write_only=True, required=False + queryset=Skill.objects.filter(is_secret=False), + many=True, + write_only=True, + required=False, ) position = PositionBriefSerializer(read_only=True) @@ -123,6 +126,9 @@ class EmployeeSerializer(serializers.ModelSerializer): return None return value + def get_skills(self, obj): + return serialize_employee_skills(obj, context=self.context) + def validate_attendance_aliases(self, value): result = [] for alias in value or []: @@ -139,7 +145,8 @@ class EmployeeSerializer(serializers.ModelSerializer): instance = super().update(instance, validated_data) if skills is not None: - instance.skills.set(skills) + secret_skills = list(instance.skills.filter(is_secret=True)) + instance.skills.set([*skills, *secret_skills]) return instance @extend_schema_field(EmployeeDepartmentSerializer(many=True)) diff --git a/backend/api/v1/employees/serializers/employee_serializers/listing.py b/backend/api/v1/employees/serializers/employee_serializers/listing.py index b5605ba8..826914e5 100644 --- a/backend/api/v1/employees/serializers/employee_serializers/listing.py +++ b/backend/api/v1/employees/serializers/employee_serializers/listing.py @@ -8,7 +8,7 @@ from employees.models import EmployeeDepartment from ..shared import ( PositionBriefSerializer, - SkillSerializer, + serialize_employee_skills, ) from .common import ( EmployeeAttendanceStatusSerializer, @@ -33,7 +33,7 @@ class EmployeeListSerializer(serializers.ModelSerializer): position = PositionBriefSerializer(read_only=True) departments = serializers.SerializerMethodField() full_name = serializers.SerializerMethodField() - skills = SkillSerializer(many=True, read_only=True) + skills = serializers.SerializerMethodField() department_relation = serializers.SerializerMethodField() personnel_state = serializers.SerializerMethodField(read_only=True) attendance_status = serializers.SerializerMethodField(read_only=True) @@ -65,6 +65,9 @@ class EmployeeListSerializer(serializers.ModelSerializer): def get_personnel_state(self, obj): return _employee_personnel_state_payload(obj) + def get_skills(self, obj): + return serialize_employee_skills(obj, context=self.context) + @extend_schema_field(EmployeeAttendanceStatusSerializer(allow_null=True)) def get_attendance_status(self, obj): return _employee_attendance_status_payload(obj) diff --git a/backend/api/v1/employees/serializers/shared.py b/backend/api/v1/employees/serializers/shared.py index 8ad54e61..6ff8c5b0 100644 --- a/backend/api/v1/employees/serializers/shared.py +++ b/backend/api/v1/employees/serializers/shared.py @@ -1,11 +1,26 @@ """Общие (shared) сериализаторы: Skill, Position, EmployeeAction.""" -from drf_spectacular.utils import extend_schema_field from django.contrib.auth.models import Group -from employees.constants import ACTION_CHOICES, TEMPORARY_START_ACTIONS -from employees.models import EmployeeAction, Position, Skill +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from employees.constants import ACTION_CHOICES, TEMPORARY_START_ACTIONS +from employees.models import ( + EmployeeAction, + EmployeeSkillProgress, + Position, + Skill, + SkillExperienceEvent, +) +from employees.services.secret_skills import ( + secret_skill_progress_payload, + skill_rarity_payload, +) +from employees.services.skill_progress import ( + confirmed_holder_count, + rank_payload, +) + class GroupVerboseSerializer(serializers.Serializer): id = serializers.IntegerField() @@ -25,9 +40,190 @@ class EmployeeActionHistorySerializer(serializers.Serializer): class SkillSerializer(serializers.ModelSerializer): + experience = serializers.SerializerMethodField() + rank = serializers.SerializerMethodField() + rank_display = serializers.SerializerMethodField() + next_rank = serializers.SerializerMethodField() + next_rank_display = serializers.SerializerMethodField() + next_rank_experience = serializers.SerializerMethodField() + experience_to_next_rank = serializers.SerializerMethodField() + rank_progress_percent = serializers.SerializerMethodField() + confirmed_holders = serializers.SerializerMethodField() + rarity = serializers.SerializerMethodField() + rarity_display = serializers.SerializerMethodField() + progress_value = serializers.SerializerMethodField() + progress_label = serializers.SerializerMethodField() + progress_data = serializers.SerializerMethodField() + next_rank_requirements = serializers.SerializerMethodField() + anti_streak = serializers.SerializerMethodField() + anti_streak_label = serializers.SerializerMethodField() + anti_streak_limit = serializers.SerializerMethodField() + anti_streak_remaining = serializers.SerializerMethodField() + recovery_streak = serializers.SerializerMethodField() + recovery_streak_target = serializers.SerializerMethodField() + penalty_levels = serializers.SerializerMethodField() + class Meta: model = Skill - fields = ["id", "name"] + fields = [ + "id", + "name", + "is_secret", + "progression_type", + "experience", + "rank", + "rank_display", + "next_rank", + "next_rank_display", + "next_rank_experience", + "experience_to_next_rank", + "rank_progress_percent", + "confirmed_holders", + "rarity", + "rarity_display", + "progress_value", + "progress_label", + "progress_data", + "next_rank_requirements", + "anti_streak", + "anti_streak_label", + "anti_streak_limit", + "anti_streak_remaining", + "recovery_streak", + "recovery_streak_target", + "penalty_levels", + ] + read_only_fields = ["is_secret", "progression_type"] + + def _progress(self, obj): + progress_by_skill = self.context.get("skill_progress_by_skill", {}) + return progress_by_skill.get(obj.id) + + def _rank(self, obj): + progress = self._progress(obj) + if obj.is_secret: + return secret_skill_progress_payload(obj, progress) + return rank_payload(getattr(progress, "experience", 0)) + + def _rarity(self, obj): + count = getattr(obj, "confirmed_holders", None) + if count is None: + count = confirmed_holder_count(obj) + return skill_rarity_payload(obj, count) + + def get_experience(self, obj): + return getattr(self._progress(obj), "experience", 0) + + def get_rank(self, obj): + return self._rank(obj)["rank"] + + def get_rank_display(self, obj): + return self._rank(obj)["rank_display"] + + def get_next_rank(self, obj): + return self._rank(obj)["next_rank"] + + def get_next_rank_display(self, obj): + return self._rank(obj)["next_rank_display"] + + def get_next_rank_experience(self, obj): + return self._rank(obj)["next_rank_experience"] + + def get_experience_to_next_rank(self, obj): + return self._rank(obj)["experience_to_next_rank"] + + def get_rank_progress_percent(self, obj): + return self._rank(obj)["rank_progress_percent"] + + def get_confirmed_holders(self, obj): + return self._rarity(obj)["confirmed_holders"] + + def get_rarity(self, obj): + return self._rarity(obj)["rarity"] + + def get_rarity_display(self, obj): + return self._rarity(obj)["rarity_display"] + + def get_progress_value(self, obj): + return self._rank(obj).get("progress_value") + + def get_progress_label(self, obj): + return self._rank(obj).get("progress_label", "") + + def get_progress_data(self, obj): + return self._rank(obj).get("progress_data", {}) + + def get_next_rank_requirements(self, obj): + return self._rank(obj).get("next_rank_requirements", []) + + def get_anti_streak(self, obj): + return self._rank(obj).get("anti_streak", 0) + + def get_anti_streak_label(self, obj): + return self._rank(obj).get("anti_streak_label", "Антисерия") + + def get_anti_streak_limit(self, obj): + return self._rank(obj).get("anti_streak_limit", 0) + + def get_anti_streak_remaining(self, obj): + return self._rank(obj).get("anti_streak_remaining", 0) + + def get_recovery_streak(self, obj): + return self._rank(obj).get("recovery_streak", 0) + + def get_recovery_streak_target(self, obj): + return self._rank(obj).get("recovery_streak_target", 0) + + def get_penalty_levels(self, obj): + return self._rank(obj).get("penalty_levels", 0) + + +class SkillExperienceEventSerializer(serializers.ModelSerializer): + source_display = serializers.CharField( + source="get_source_type_display", + read_only=True, + ) + actor_name = serializers.SerializerMethodField() + + class Meta: + model = SkillExperienceEvent + fields = [ + "id", + "points", + "source_type", + "source_display", + "source_id", + "actor", + "actor_name", + "reason", + "metadata", + "revoked_at", + "created_at", + ] + + def get_actor_name(self, obj): + if obj.actor is None: + return None + return obj.actor.get_full_name() or obj.actor.email + + +def serialize_employee_skills(employee, *, context=None): + progress_items = getattr(employee, "_prefetched_skill_progresses", None) + if progress_items is None: + progress_items = EmployeeSkillProgress.objects.filter( + employee=employee, + is_active=True, + ) + progress_by_skill = { + progress.skill_id: progress for progress in progress_items if progress.is_active + } + serializer_context = dict(context or {}) + serializer_context["skill_progress_by_skill"] = progress_by_skill + return SkillSerializer( + employee.skills.all(), + many=True, + context=serializer_context, + ).data class PositionBriefSerializer(serializers.ModelSerializer): @@ -83,8 +279,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): raise serializers.ValidationError( { "date_to": ( - "Дата окончания доступна только для временных " - "кадровых событий." + "Дата окончания доступна только для временных кадровых событий." ) } ) @@ -102,8 +297,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): raise serializers.ValidationError( { "date_to": ( - "Дата окончания обязательна для временных " - "кадровых событий." + "Дата окончания обязательна для временных кадровых событий." ) } ) @@ -168,18 +362,12 @@ class EmployeeActionSerializer(serializers.ModelSerializer): { "history_id": cur.history_id, "history_type": cur.history_type, - "history_type_display": history_type_labels.get( - cur.history_type - ), + "history_type_display": history_type_labels.get(cur.history_type), "history_date": cur.history_date.isoformat(), - "history_date_display": cur.history_date.strftime( - "%d.%m.%Y %H:%M" - ), + "history_date_display": cur.history_date.strftime("%d.%m.%Y %H:%M"), "history_user": getattr(cur.history_user, "email", None), "action": getattr(cur, "action", None), - "action_display": action_labels.get( - getattr(cur, "action", None) - ), + "action_display": action_labels.get(getattr(cur, "action", None)), "changes": changes, } ) diff --git a/backend/api/v1/employees/views/employee_mixins/persistence.py b/backend/api/v1/employees/views/employee_mixins/persistence.py index 0b4c246b..344526a2 100644 --- a/backend/api/v1/employees/views/employee_mixins/persistence.py +++ b/backend/api/v1/employees/views/employee_mixins/persistence.py @@ -5,6 +5,7 @@ import logging from django.utils.crypto import get_random_string from common.emails import send_templated_mail +from employees.models import Skill logger = logging.getLogger(__name__) @@ -20,7 +21,9 @@ class EmployeePersistenceMixin: skills_ids = self.request.data.get("skills_ids", []) if skills_ids: - instance.skills.set(skills_ids) + instance.skills.set( + Skill.objects.filter(pk__in=skills_ids, is_secret=False) + ) def perform_update(self, serializer): """Update an employee and notify them when their email changes.""" diff --git a/backend/api/v1/employees/views/employee_mixins/profile.py b/backend/api/v1/employees/views/employee_mixins/profile.py index 7553a5f9..9fc13754 100644 --- a/backend/api/v1/employees/views/employee_mixins/profile.py +++ b/backend/api/v1/employees/views/employee_mixins/profile.py @@ -4,7 +4,29 @@ from django.db.models import Prefetch from rest_framework.decorators import action from rest_framework.response import Response -from employees.models import Employee, EmployeeAction, EmployeeDepartment, Skill +from employees.models import ( + Employee, + EmployeeAction, + EmployeeDepartment, + EmployeeSkillProgress, + Skill, + SkillExperienceEvent, +) +from employees.services.skill_progress import ( + endorsement_available_at, + endorsement_weight, + ensure_skill_progress, + confirmed_holder_count, + rank_payload, + record_skill_experience, + skill_queryset_with_rarity, +) +from employees.services.secret_skills import ( + secret_skill_progress_payload, + skill_rarity_payload, +) +from api.v1.employees.serializers import SkillExperienceEventSerializer +from rest_framework import status class EmployeeProfileMixin: @@ -18,7 +40,15 @@ class EmployeeProfileMixin: instance = ( self._with_last_activity(Employee.objects.select_related("position")) .prefetch_related( - "skills", + Prefetch( + "skills", + queryset=skill_queryset_with_rarity().order_by("name"), + ), + Prefetch( + "skill_progresses", + queryset=EmployeeSkillProgress.objects.select_related("skill"), + to_attr="_prefetched_skill_progresses", + ), Prefetch( "departments_links", queryset=EmployeeDepartment.objects.filter( @@ -75,9 +105,152 @@ class EmployeeProfileMixin: else: skill, _ = Skill.objects.get_or_create(name=skill_name) + if skill.is_secret: + return Response( + {"detail": "Секретный навык открывается только системным правилом."}, + status=status.HTTP_400_BAD_REQUEST, + ) + instance.skills.add(skill) + progress = ensure_skill_progress( + instance, + skill, + created_by=request.user, + ) + if progress.created_by_id is None: + progress.created_by = request.user + progress.save(update_fields=["created_by", "updated_at"]) return Response(self.get_serializer(instance).data, status=200) + @action( + detail=True, + methods=["get"], + url_path=r"skills/(?P\d+)/progress", + ) + def skill_progress(self, request, pk=None, skill_id=None): + employee = self.get_object() + try: + skill = skill_queryset_with_rarity().get(pk=int(skill_id)) + except (TypeError, ValueError, Skill.DoesNotExist): + return Response({"detail": "Навык не найден"}, status=404) + if not employee.skills.filter(pk=skill.pk).exists(): + return Response( + {"detail": "Навык не добавлен сотруднику"}, + status=404, + ) + + progress = ensure_skill_progress(employee, skill) + events = progress.experience_events.select_related("actor").order_by( + "-created_at", + "-id", + )[:50] + rank = ( + secret_skill_progress_payload(skill, progress) + if skill.is_secret + else rank_payload(progress.experience) + ) + holder_count = getattr(skill, "confirmed_holders", None) + if holder_count is None: + holder_count = confirmed_holder_count(skill) + rarity = skill_rarity_payload(skill, holder_count) + available_at = None + if not skill.is_secret and request.user.id != employee.id: + available_at = endorsement_available_at( + progress=progress, + actor=request.user, + ) + return Response( + { + "employee_id": employee.id, + "skill_id": skill.id, + "skill_name": skill.name, + "is_secret": skill.is_secret, + "progression_type": skill.progression_type, + "experience": progress.experience, + **rank, + **rarity, + "can_endorse": ( + not skill.is_secret + and request.user.id != employee.id + and available_at is None + ), + "endorsement_available_at": available_at, + "events": SkillExperienceEventSerializer(events, many=True).data, + } + ) + + @action( + detail=True, + methods=["post"], + url_path=r"skills/(?P\d+)/endorse", + ) + def endorse_skill(self, request, pk=None, skill_id=None): + employee = self.get_object() + if employee.id == request.user.id: + return Response( + {"detail": "Нельзя подтверждать собственный навык."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + skill = Skill.objects.get(pk=int(skill_id)) + except (TypeError, ValueError, Skill.DoesNotExist): + return Response({"detail": "Навык не найден"}, status=404) + if not employee.skills.filter(pk=skill.pk).exists(): + return Response( + {"detail": "Навык не добавлен сотруднику"}, + status=404, + ) + if skill.is_secret: + return Response( + {"detail": "Секретные навыки нельзя подтверждать вручную."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + reason = str(request.data.get("reason") or "").strip() + if len(reason) < 8: + return Response( + {"reason": ["Опишите, где сотрудник применил навык."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + progress = ensure_skill_progress(employee, skill) + available_at = endorsement_available_at( + progress=progress, + actor=request.user, + ) + if available_at is not None: + return Response( + { + "detail": "Этот навык уже подтверждался вами недавно.", + "available_at": available_at, + }, + status=status.HTTP_409_CONFLICT, + ) + + points, role_label = endorsement_weight(request.user, employee) + progress, event, _ = record_skill_experience( + employee=employee, + skill=skill, + points=points, + source_type=SkillExperienceEvent.Source.ENDORSEMENT, + actor=request.user, + source_id=str(request.user.id), + reason=reason, + metadata={"endorser_role": role_label}, + ) + rank = rank_payload(progress.experience) + return Response( + { + "ok": True, + "points": points, + "endorser_role": role_label, + "experience": progress.experience, + **rank, + "event": SkillExperienceEventSerializer(event).data, + }, + status=status.HTTP_201_CREATED, + ) + def get_serializer_context(self): context = super().get_serializer_context() if getattr(self, "action", None) in {"retrieve", "me"}: diff --git a/backend/api/v1/employees/views/employee_mixins/query.py b/backend/api/v1/employees/views/employee_mixins/query.py index cd582065..f55cad56 100644 --- a/backend/api/v1/employees/views/employee_mixins/query.py +++ b/backend/api/v1/employees/views/employee_mixins/query.py @@ -13,8 +13,10 @@ from employees.models import ( Employee, EmployeeAction, EmployeeDepartment, + EmployeeSkillProgress, RoleAssignment, ) +from employees.services.skill_progress import skill_queryset_with_rarity from employees.utils import _to_bool @@ -70,7 +72,15 @@ class EmployeeQueryMixin: return ( Employee.objects.select_related("position") .prefetch_related( - "skills", + Prefetch( + "skills", + queryset=skill_queryset_with_rarity().order_by("name"), + ), + Prefetch( + "skill_progresses", + queryset=EmployeeSkillProgress.objects.select_related("skill"), + to_attr="_prefetched_skill_progresses", + ), active_departments, Prefetch( "actions", diff --git a/backend/api/v1/employees/views/skills.py b/backend/api/v1/employees/views/skills.py index bf042923..9aa792ea 100644 --- a/backend/api/v1/employees/views/skills.py +++ b/backend/api/v1/employees/views/skills.py @@ -3,7 +3,8 @@ from __future__ import annotations from django.db import transaction -from employees.models import Skill +from employees.models import EmployeeSkillProgress, Skill +from employees.services.skill_progress import skill_queryset_with_rarity from rest_framework import filters, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated @@ -26,7 +27,7 @@ class SkillViewSet(viewsets.ModelViewSet): - POST /skills/merge """ - queryset = Skill.objects.all().order_by("name") + queryset = skill_queryset_with_rarity().order_by("name") serializer_class = SkillSerializer filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["name"] @@ -37,6 +38,12 @@ class SkillViewSet(viewsets.ModelViewSet): "merge": "employees.change_skill", } + def get_queryset(self): + queryset = super().get_queryset() + if self.action in {"list", "create", "bulk_create"}: + return queryset.filter(is_secret=False) + return queryset + def get_permissions(self): if self.action in ("create", "bulk_create", "list", "retrieve"): return [IsAuthenticated()] @@ -66,9 +73,7 @@ class SkillViewSet(viewsets.ModelViewSet): cleaned.append(s) existing = set( - Skill.objects.filter(name__in=cleaned).values_list( - "name", flat=True - ) + Skill.objects.filter(name__in=cleaned).values_list("name", flat=True) ) to_create = [Skill(name=n) for n in cleaned if n not in existing] Skill.objects.bulk_create(to_create, ignore_conflicts=True) @@ -97,9 +102,7 @@ class SkillViewSet(viewsets.ModelViewSet): delete_source = bool(request.data.get("delete_source", True)) if not sid or not tid: - return Response( - {"detail": "source_id и target_id обязательны"}, status=400 - ) + return Response({"detail": "source_id и target_id обязательны"}, status=400) try: sid = int(sid) tid = int(tid) @@ -119,12 +122,39 @@ class SkillViewSet(viewsets.ModelViewSet): target = Skill.objects.get(pk=tid) except Skill.DoesNotExist: return Response({"detail": "Skill не найден"}, status=404) + if source.is_secret or target.is_secret: + return Response( + {"detail": "Секретные навыки нельзя объединять вручную"}, + status=400, + ) moved_count = 0 if reassign: qs = Employee.objects.filter(skills=source).only("id").distinct() for emp in qs: emp.skills.add(target) + source_progress = EmployeeSkillProgress.objects.filter( + employee=emp, + skill=source, + ).first() + target_progress = EmployeeSkillProgress.objects.filter( + employee=emp, + skill=target, + ).first() + if source_progress and target_progress: + source_progress.experience_events.update(progress=target_progress) + target_progress.experience += source_progress.experience + target_progress.is_active = True + target_progress.save( + update_fields=["experience", "is_active", "updated_at"] + ) + source_progress.delete() + elif source_progress: + source_progress.skill = target + source_progress.is_active = True + source_progress.save( + update_fields=["skill", "is_active", "updated_at"] + ) emp.skills.remove(source) moved_count += 1 diff --git a/backend/api/v1/tasks/serializers/tasks.py b/backend/api/v1/tasks/serializers/tasks.py index 12cd39fb..74c591f4 100644 --- a/backend/api/v1/tasks/serializers/tasks.py +++ b/backend/api/v1/tasks/serializers/tasks.py @@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from api.v1.employees.serializers import EmployeeBriefSerializer +from api.v1.employees.serializers import EmployeeBriefSerializer, SkillSerializer from communications.comments_helpers import get_comment_count from tasks.access import ( user_can_access_task_board, @@ -15,6 +15,7 @@ from tasks.models import ( TaskLabel, TaskLinkedObjectKind, ) +from employees.models import Skill from .linked import TaskCoverSerializer from .structure import TaskLabelSerializer @@ -52,6 +53,14 @@ class TaskSerializer(serializers.ModelSerializer): required=False, write_only=True, ) + skills = SkillSerializer(many=True, read_only=True) + skill_ids = serializers.PrimaryKeyRelatedField( + source="skills", + queryset=Skill.objects.filter(is_secret=False), + many=True, + required=False, + write_only=True, + ) priority_display = serializers.CharField( source="get_priority_display", read_only=True, @@ -100,6 +109,8 @@ class TaskSerializer(serializers.ModelSerializer): "participant_ids", "labels", "label_ids", + "skills", + "skill_ids", "priority", "priority_display", "due_date", @@ -348,11 +359,7 @@ class TaskSerializer(serializers.ModelSerializer): self.instance.assignee_id if self.instance is not None else None ) board_changed = bool( - board - and ( - self.instance is None - or self.instance.board_id != board.id - ) + board and (self.instance is None or self.instance.board_id != board.id) ) assignee = attrs.get("assignee") if "assignee" in attrs and assignee is not None: @@ -431,6 +438,7 @@ class TaskSerializer(serializers.ModelSerializer): def create(self, validated_data): labels = validated_data.pop("labels", []) + skills = validated_data.pop("skills", []) participants = list(validated_data.pop("participants", [])) if "position" not in validated_data: column = validated_data["column"] @@ -450,11 +458,14 @@ class TaskSerializer(serializers.ModelSerializer): task.participants.set(participant_ids) if labels: task.labels.set(labels) + if skills: + task.skills.set(skills) return task def update(self, instance, validated_data): previous_assignee_id = instance.assignee_id labels = validated_data.pop("labels", None) + skills = validated_data.pop("skills", None) participants = validated_data.pop("participants", None) task = super().update(instance, validated_data) if participants is not None: @@ -476,4 +487,6 @@ class TaskSerializer(serializers.ModelSerializer): ) if labels is not None: task.labels.set(labels) + if skills is not None: + task.skills.set(skills) return task diff --git a/backend/api/v1/tasks/viewsets/boards.py b/backend/api/v1/tasks/viewsets/boards.py index 7946a16b..45d8255b 100644 --- a/backend/api/v1/tasks/viewsets/boards.py +++ b/backend/api/v1/tasks/viewsets/boards.py @@ -19,6 +19,7 @@ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from communications.models import Message +from employees.services.skill_progress import skill_queryset_with_rarity from tasks.access import task_board_access_q from tasks.models import ( Task, @@ -165,6 +166,10 @@ class TaskBoardViewSet(TaskBoardActionsMixin, viewsets.ModelViewSet): .prefetch_related( "participants", "labels", + Prefetch( + "skills", + queryset=skill_queryset_with_rarity(), + ), Prefetch( "linked_objects", queryset=TaskLinkedObject.objects.only( diff --git a/backend/api/v1/tasks/viewsets/tasks.py b/backend/api/v1/tasks/viewsets/tasks.py index 795eb95f..35b772fe 100644 --- a/backend/api/v1/tasks/viewsets/tasks.py +++ b/backend/api/v1/tasks/viewsets/tasks.py @@ -81,6 +81,7 @@ class TaskViewSet( "due_date": instance.due_date.isoformat() if instance.due_date else None, "row_id": instance.row_id, "label_ids": sorted(instance.labels.values_list("id", flat=True)), + "skill_ids": sorted(instance.skills.values_list("id", flat=True)), "participant_ids": sorted( instance.participants.values_list("id", flat=True) ), @@ -94,6 +95,7 @@ class TaskViewSet( "due_date": task.due_date.isoformat() if task.due_date else None, "row_id": task.row_id, "label_ids": sorted(task.labels.values_list("id", flat=True)), + "skill_ids": sorted(task.skills.values_list("id", flat=True)), "participant_ids": sorted(task.participants.values_list("id", flat=True)), } changed_fields = [ @@ -136,6 +138,7 @@ class TaskViewSet( task.column = column task.row = row task.position = position + task._skill_completion_actor = actor task.save( update_fields=["column", "row", "position", "completed_at", "updated_at"] ) diff --git a/backend/attendance/services.py b/backend/attendance/services.py index 6bd5456c..9e836469 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -220,6 +220,15 @@ def save_logstorm_attendance_result( defaults={"analysis_run": run, **record_defaults}, ) + def recalculate_secret_skills(): + from employees.services.secret_skills import ( + recalculate_attendance_secret_skills, + ) + + recalculate_attendance_secret_skills(employee.id) + + transaction.on_commit(recalculate_secret_skills) + return run diff --git a/backend/employees/admin.py b/backend/employees/admin.py index f83eb418..b0bd6a17 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -18,8 +18,10 @@ from .models import ( Department, DepartmentRole, EmployeeDepartment, + EmployeeSkillProgress, Position, Skill, + SkillExperienceEvent, LdapSyncState, ) @@ -1903,10 +1905,53 @@ class EmployeeDepartmentAdmin(admin.ModelAdmin): @admin.register(Skill) class SkillAdmin(admin.ModelAdmin): - list_display = ("name",) + list_display = ("name", "is_secret", "progression_type", "system_code") + list_filter = ("is_secret", "progression_type") search_fields = ("name",) +@admin.register(EmployeeSkillProgress) +class EmployeeSkillProgressAdmin(admin.ModelAdmin): + list_display = ( + "employee", + "skill", + "experience", + "special_rank", + "is_active", + "updated_at", + ) + list_filter = ("is_active", "special_rank", "skill") + search_fields = ( + "employee__email", + "employee__first_name", + "employee__last_name", + "skill__name", + ) + autocomplete_fields = ("employee", "skill", "created_by") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(SkillExperienceEvent) +class SkillExperienceEventAdmin(admin.ModelAdmin): + list_display = ( + "progress", + "points", + "source_type", + "actor", + "created_at", + "revoked_at", + ) + list_filter = ("source_type", "revoked_at") + search_fields = ( + "progress__employee__email", + "progress__skill__name", + "reason", + "idempotency_key", + ) + autocomplete_fields = ("progress", "actor", "revoked_by") + readonly_fields = ("created_at",) + + # Полезно иметь Permission в админке @admin.register(Permission) class PermissionAdmin(admin.ModelAdmin): diff --git a/backend/employees/management/commands/recalculate_secret_skills.py b/backend/employees/management/commands/recalculate_secret_skills.py new file mode 100644 index 00000000..49d4f3be --- /dev/null +++ b/backend/employees/management/commands/recalculate_secret_skills.py @@ -0,0 +1,169 @@ +from collections import Counter, defaultdict + +from django.core.management.base import BaseCommand +from django.db import transaction + +from employees.models import Employee, EmployeeSkillProgress +from employees.services.secret_skills import recalculate_attendance_secret_skills +from employees.services.task_execution_skill import refresh_task_execution_skills + + +RANK_LABELS = { + "none": "без ранга", + "bronze": "бронза", + "silver": "серебро", + "gold": "золото", + "ruby": "рубин", + "diamond": "бриллиант", +} + + +def _progress_snapshot(employee_ids): + return { + (progress.employee_id, progress.skill_id): { + "rank": progress.special_rank, + "active": progress.is_active, + "skill_name": progress.skill.name, + "system_code": progress.skill.system_code, + "progress_data": dict(progress.progress_data or {}), + } + for progress in EmployeeSkillProgress.objects.filter( + employee_id__in=employee_ids, + skill__is_secret=True, + ).select_related("skill") + } + + +def _change_kind(before, after): + if not before["active"] and after["active"]: + return "получит" + if before["active"] and not after["active"]: + return "потеряет" + rank_order = ["none", "bronze", "silver", "gold", "ruby", "diamond"] + before_index = rank_order.index(before["rank"]) + after_index = rank_order.index(after["rank"]) + return "повысит" if after_index > before_index else "понизит" + + +def _metric_summary(state): + metrics = state["progress_data"] + system_code = state["system_code"] + if system_code == "attendance_punctuality": + return f"лучшая серия {metrics.get('best_streak', 0)} дн." + if system_code == "attendance_self_organization": + return ( + f"чистых дней {metrics.get('clean_days', 0)}, " + f"выходов {metrics.get('off_schedule_days', 0)}, " + f"переработок {metrics.get('overtime_days', 0)}" + ) + if system_code == "attendance_reliability": + return f"без отпуска и отгула {metrics.get('worked_days', 0)} дн." + if system_code == "personnel_resilience": + return ( + "лучшая серия без больничного " + f"{metrics.get('best_resilience_streak', 0)} дн." + ) + return ( + f"подтверждено {metrics.get('confirmed_tasks', 0)} задач, " + f"вовремя {metrics.get('on_time_percent', 0)}%, " + f"серия {metrics.get('best_on_time_streak', 0)}" + ) + + +class Command(BaseCommand): + help = "Пересчитать системные секретные навыки сотрудников" + + def add_arguments(self, parser): + parser.add_argument("--employee-id", type=int) + parser.add_argument( + "--dry-run", + action="store_true", + help="Показать изменения без записи в базу", + ) + + def _recalculate(self, employee_ids, employee_id=None): + processed = 0 + rank_changes = 0 + task_result = refresh_task_execution_skills(employee_id=employee_id) + rank_changes += task_result["rank_changes"] + for current_employee_id in employee_ids: + changes = recalculate_attendance_secret_skills(current_employee_id) + processed += 1 + rank_changes += len(changes) + return processed, rank_changes + + def _print_dry_run(self, employee_ids, before, after, processed): + employees = { + employee.id: str(employee) + for employee in Employee.objects.filter(id__in=employee_ids) + } + empty_state = { + "rank": "none", + "active": False, + "skill_name": "", + "system_code": "", + "progress_data": {}, + } + changes = [] + summary = defaultdict(Counter) + for key in sorted(set(before) | set(after)): + old = before.get(key, empty_state) + new = after.get(key, empty_state) + if old["rank"] == new["rank"] and old["active"] == new["active"]: + continue + kind = _change_kind(old, new) + state = new if new["skill_name"] else old + summary[state["skill_name"]][kind] += 1 + changes.append((key[0], kind, old, new, state)) + + self.stdout.write(self.style.WARNING("СУХОЙ ПРОГОН — изменения не сохранены")) + self.stdout.write(f"Проверено сотрудников: {processed}") + if not changes: + self.stdout.write("Изменений навыков и рангов нет.") + return + + self.stdout.write("\nСводка:") + for skill_name in sorted(summary): + counts = summary[skill_name] + self.stdout.write( + f"- {skill_name}: получат {counts['получит']}, " + f"повысят {counts['повысит']}, понизят {counts['понизит']}, " + f"потеряют {counts['потеряет']}" + ) + + self.stdout.write("\nСотрудники:") + for current_employee_id, kind, old, new, state in changes: + self.stdout.write( + f"- #{current_employee_id} {employees.get(current_employee_id, '—')} — " + f"{state['skill_name']}: {kind} " + f"({RANK_LABELS[old['rank']]} → {RANK_LABELS[new['rank']]}); " + f"{_metric_summary(state)}" + ) + + def handle(self, *args, **options): + employee_id = options.get("employee_id") + dry_run = options.get("dry_run", False) + if employee_id: + employee_ids = [employee_id] + else: + employee_ids = list( + Employee.objects.order_by("id").values_list("id", flat=True) + ) + + if dry_run: + with transaction.atomic(): + before = _progress_snapshot(employee_ids) + processed, _ = self._recalculate(employee_ids, employee_id) + after = _progress_snapshot(employee_ids) + transaction.set_rollback(True) + self._print_dry_run(employee_ids, before, after, processed) + return + + processed, rank_changes = self._recalculate(employee_ids, employee_id) + + self.stdout.write( + self.style.SUCCESS( + "Пересчитано сотрудников: " + f"{processed}; изменений рангов и навыков: {rank_changes}" + ) + ) diff --git a/backend/employees/migrations/0057_employeeskillprogress_skillexperienceevent_and_more.py b/backend/employees/migrations/0057_employeeskillprogress_skillexperienceevent_and_more.py new file mode 100644 index 00000000..ac003401 --- /dev/null +++ b/backend/employees/migrations/0057_employeeskillprogress_skillexperienceevent_and_more.py @@ -0,0 +1,248 @@ +# Generated by Django 5.2.4 on 2026-08-25 10:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def create_existing_skill_progresses(apps, schema_editor): + Employee = apps.get_model("employees", "Employee") + EmployeeSkillProgress = apps.get_model( + "employees", + "EmployeeSkillProgress", + ) + through = Employee.skills.through + employee_field = next( + field + for field in through._meta.fields + if field.remote_field and field.remote_field.model == Employee + ) + Skill = apps.get_model("employees", "Skill") + skill_field = next( + field + for field in through._meta.fields + if field.remote_field and field.remote_field.model == Skill + ) + rows = through.objects.values_list( + employee_field.attname, + skill_field.attname, + ).iterator(chunk_size=1000) + EmployeeSkillProgress.objects.bulk_create( + [ + EmployeeSkillProgress( + employee_id=employee_id, + skill_id=skill_id, + is_active=True, + ) + for employee_id, skill_id in rows + ], + batch_size=1000, + ignore_conflicts=True, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0056_employee_hidden_in_ui"), + ] + + operations = [ + migrations.CreateModel( + name="EmployeeSkillProgress", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "experience", + models.PositiveIntegerField(default=0, verbose_name="Опыт"), + ), + ( + "is_active", + models.BooleanField( + default=True, verbose_name="Показывается в профиле" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_skill_progresses", + to=settings.AUTH_USER_MODEL, + verbose_name="Добавил навык", + ), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="skill_progresses", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "skill", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="employee_progresses", + to="employees.skill", + verbose_name="Навык", + ), + ), + ], + options={ + "verbose_name": "Прогресс навыка сотрудника", + "verbose_name_plural": "Прогресс навыков сотрудников", + }, + ), + migrations.CreateModel( + name="SkillExperienceEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("points", models.PositiveIntegerField(verbose_name="Баллы опыта")), + ( + "source_type", + models.CharField( + choices=[ + ("endorsement", "Подтверждение сотрудником"), + ("task", "Завершённая задача"), + ("training", "Обучение"), + ("certification", "Аттестация"), + ("manual", "Ручное начисление"), + ], + max_length=24, + verbose_name="Источник", + ), + ), + ( + "source_id", + models.CharField( + blank=True, max_length=100, verbose_name="ID источника" + ), + ), + ("reason", models.TextField(blank=True, verbose_name="Причина")), + ( + "metadata", + models.JSONField( + blank=True, default=dict, verbose_name="Дополнительные данные" + ), + ), + ( + "idempotency_key", + models.CharField( + blank=True, + default="", + max_length=180, + verbose_name="Ключ идемпотентности", + ), + ), + ( + "revoked_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Отозвано" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "actor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="skill_experience_events_given", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор начисления", + ), + ), + ( + "progress", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="experience_events", + to="employees.employeeskillprogress", + verbose_name="Прогресс навыка", + ), + ), + ( + "revoked_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="skill_experience_events_revoked", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто отозвал", + ), + ), + ], + options={ + "verbose_name": "Начисление опыта навыка", + "verbose_name_plural": "Начисления опыта навыков", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.AddIndex( + model_name="employeeskillprogress", + index=models.Index( + fields=["skill", "is_active", "experience"], + name="employees_e_skill_i_94f7eb_idx", + ), + ), + migrations.AddIndex( + model_name="employeeskillprogress", + index=models.Index( + fields=["employee", "is_active"], name="employees_e_employe_0f2ad0_idx" + ), + ), + migrations.AddConstraint( + model_name="employeeskillprogress", + constraint=models.UniqueConstraint( + fields=("employee", "skill"), name="uniq_employee_skill_progress" + ), + ), + migrations.AddIndex( + model_name="skillexperienceevent", + index=models.Index( + fields=["progress", "source_type", "revoked_at"], + name="employees_s_progres_195da6_idx", + ), + ), + migrations.AddIndex( + model_name="skillexperienceevent", + index=models.Index( + fields=["actor", "source_type", "created_at"], + name="employees_s_actor_i_cf6da7_idx", + ), + ), + migrations.AddConstraint( + model_name="skillexperienceevent", + constraint=models.UniqueConstraint( + condition=models.Q(("idempotency_key", ""), _negated=True), + fields=("idempotency_key",), + name="uniq_skill_xp_idempotency_key", + ), + ), + migrations.RunPython( + create_existing_skill_progresses, + migrations.RunPython.noop, + ), + ] diff --git a/backend/employees/migrations/0058_secret_skill_progress.py b/backend/employees/migrations/0058_secret_skill_progress.py new file mode 100644 index 00000000..02dcabfb --- /dev/null +++ b/backend/employees/migrations/0058_secret_skill_progress.py @@ -0,0 +1,92 @@ +# Generated by Django 5.2.4 on 2026-08-25 12:51 + +from django.db import migrations, models + + +def create_secret_attendance_skills(apps, schema_editor): + Skill = apps.get_model("employees", "Skill") + definitions = ( + ( + "attendance_punctuality", + "Пунктуальность", + "attendance_punctuality", + ), + ( + "attendance_self_organization", + "Самоорганизация", + "attendance_self_organization", + ), + ) + for system_code, name, progression_type in definitions: + skill, _ = Skill.objects.get_or_create(name=name) + skill.is_secret = True + skill.system_code = system_code + skill.progression_type = progression_type + skill.save(update_fields=["is_secret", "system_code", "progression_type"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0057_employeeskillprogress_skillexperienceevent_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="employeeskillprogress", + name="progress_data", + field=models.JSONField( + blank=True, default=dict, verbose_name="Данные системного прогресса" + ), + ), + migrations.AddField( + model_name="employeeskillprogress", + name="special_rank", + field=models.CharField( + choices=[ + ("none", "Без ранга"), + ("bronze", "Бронза"), + ("silver", "Серебро"), + ("gold", "Золото"), + ("ruby", "Рубин"), + ("diamond", "Бриллиант"), + ], + default="none", + max_length=16, + verbose_name="Ранг системного навыка", + ), + ), + migrations.AddField( + model_name="skill", + name="is_secret", + field=models.BooleanField(default=False, verbose_name="Секретный навык"), + ), + migrations.AddField( + model_name="skill", + name="progression_type", + field=models.CharField( + choices=[ + ("standard", "Обычный опыт"), + ("attendance_punctuality", "Посещаемость: пунктуальность"), + ("attendance_self_organization", "Посещаемость: самоорганизация"), + ], + default="standard", + max_length=48, + verbose_name="Механика прогресса", + ), + ), + migrations.AddField( + model_name="skill", + name="system_code", + field=models.CharField( + blank=True, + max_length=64, + null=True, + unique=True, + verbose_name="Системный код", + ), + ), + migrations.RunPython( + create_secret_attendance_skills, + migrations.RunPython.noop, + ), + ] diff --git a/backend/employees/migrations/0059_reliability_and_resilience_skills.py b/backend/employees/migrations/0059_reliability_and_resilience_skills.py new file mode 100644 index 00000000..a4526016 --- /dev/null +++ b/backend/employees/migrations/0059_reliability_and_resilience_skills.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.4 on 2026-08-26 + +from django.db import migrations, models + + +def create_reliability_and_resilience_skills(apps, schema_editor): + Skill = apps.get_model("employees", "Skill") + definitions = ( + ( + "attendance_reliability", + "Надёжность", + "attendance_reliability", + ), + ( + "personnel_resilience", + "Стойкость", + "personnel_resilience", + ), + ) + for system_code, name, progression_type in definitions: + skill, _ = Skill.objects.get_or_create(name=name) + skill.is_secret = True + skill.system_code = system_code + skill.progression_type = progression_type + skill.save(update_fields=["is_secret", "system_code", "progression_type"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0058_secret_skill_progress"), + ] + + operations = [ + migrations.AlterField( + model_name="skill", + name="progression_type", + field=models.CharField( + choices=[ + ("standard", "Обычный опыт"), + ("attendance_punctuality", "Посещаемость: пунктуальность"), + ( + "attendance_self_organization", + "Посещаемость: самоорганизация", + ), + ("attendance_reliability", "Посещаемость: надёжность"), + ("personnel_resilience", "Кадровые события: стойкость"), + ], + default="standard", + max_length=48, + verbose_name="Механика прогресса", + ), + ), + migrations.RunPython( + create_reliability_and_resilience_skills, + migrations.RunPython.noop, + ), + ] diff --git a/backend/employees/migrations/0060_task_execution_skill.py b/backend/employees/migrations/0060_task_execution_skill.py new file mode 100644 index 00000000..91b3ecb7 --- /dev/null +++ b/backend/employees/migrations/0060_task_execution_skill.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.4 on 2026-08-26 + +from django.db import migrations, models + + +def create_task_execution_skill(apps, schema_editor): + Skill = apps.get_model("employees", "Skill") + skill, _ = Skill.objects.get_or_create(name="Исполнительность") + skill.is_secret = True + skill.system_code = "task_execution" + skill.progression_type = "task_execution" + skill.save(update_fields=["is_secret", "system_code", "progression_type"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0059_reliability_and_resilience_skills"), + ] + + operations = [ + migrations.AlterField( + model_name="skill", + name="progression_type", + field=models.CharField( + choices=[ + ("standard", "Обычный опыт"), + ("attendance_punctuality", "Посещаемость: пунктуальность"), + ( + "attendance_self_organization", + "Посещаемость: самоорганизация", + ), + ("attendance_reliability", "Посещаемость: надёжность"), + ("personnel_resilience", "Кадровые события: стойкость"), + ("task_execution", "Задачи: исполнительность"), + ], + default="standard", + max_length=48, + verbose_name="Механика прогресса", + ), + ), + migrations.RunPython( + create_task_execution_skill, + migrations.RunPython.noop, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index af27072f..861c6d62 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -34,14 +34,8 @@ class DateRangeMixin(models.Model): def clean(self): super().clean() if hasattr(self, "date_from") and hasattr(self, "date_to"): - if ( - self.date_from - and self.date_to - and self.date_from >= self.date_to - ): - raise ValidationError( - "Дата начала должна быть раньше даты окончания" - ) + if self.date_from and self.date_to and self.date_from >= self.date_to: + raise ValidationError("Дата начала должна быть раньше даты окончания") class EmployeeQuerySet(models.QuerySet): @@ -66,9 +60,7 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)): def create_user(self, email, password=None, **extra_fields): send_activation_email = extra_fields.pop("send_activation_email", True) if not email: - raise ValueError( - "Пользователь должен иметь email для подтверждения" - ) + raise ValueError("Пользователь должен иметь email для подтверждения") email = self.normalize_email(email).lower() phone_number = extra_fields.pop("phone_number", None) @@ -107,11 +99,7 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)): ) if "phone" in msg or "phone_number" in msg: raise ValidationError( - { - "phone_number": ( - "Пользователь с таким номером уже существует" - ) - } + {"phone_number": ("Пользователь с таким номером уже существует")} ) raise @@ -131,13 +119,9 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)): extra_fields.setdefault("email_verified", True) if extra_fields.get("is_staff") is not True: - raise ValueError( - "Суперпользователь должен иметь is_staff=True" - ) + raise ValueError("Суперпользователь должен иметь is_staff=True") if extra_fields.get("is_superuser") is not True: - raise ValueError( - "Суперпользователь должен иметь is_superuser=True" - ) + raise ValueError("Суперпользователь должен иметь is_superuser=True") # для суперпользователя не отправляем код, просто создаём user = self.model( @@ -147,9 +131,7 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)): **extra_fields, ) if not user.phone_number: - raise ValueError( - "Для суперпользователя также укажите phone_number" - ) + raise ValueError("Для суперпользователя также укажите phone_number") user.set_password(password) user.email_activation_code = None user.email_activation_sent_at = None @@ -190,8 +172,7 @@ class Position(models.Model): blank=True, default="", help_text=( - "Напр.: CN=POS_engineer,OU=Position,OU=company," - "DC=robotail,DC=local" + "Напр.: CN=POS_engineer,OU=Position,OU=company,DC=robotail,DC=local" ), ) history = HistoricalRecords() @@ -223,12 +204,8 @@ class Employee(AbstractUser): gender = models.PositiveSmallIntegerField( "Пол", choices=GENDER_CHOICES, default=0, blank=True ) - avatar = models.ImageField( - "Фото", upload_to="users/avatars", blank=True, null=True - ) - phone_number = PhoneNumberField( - "Номер телефона", max_length=100, unique=True - ) + avatar = models.ImageField("Фото", upload_to="users/avatars", blank=True, null=True) + phone_number = PhoneNumberField("Номер телефона", max_length=100, unique=True) patronymic = models.CharField("Отчество", max_length=100, blank=True) birth_date = models.DateField("Дата рождения", blank=True, null=True) position = models.ForeignKey( @@ -259,9 +236,7 @@ class Employee(AbstractUser): ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - skills = models.ManyToManyField( - "Skill", related_name="employees", blank=True - ) + skills = models.ManyToManyField("Skill", related_name="employees", blank=True) is_active = models.BooleanField(default=False) is_hidden_in_ui = models.BooleanField( "Скрыт в интерфейсе", @@ -275,9 +250,7 @@ class Employee(AbstractUser): max_length=6, blank=True, null=True ) # Для будущей SMS-активации в bots (fallback к email) email_verified = models.BooleanField("Email подтверждён", default=False) - email_activation_code = models.CharField( - max_length=6, blank=True, null=True - ) + email_activation_code = models.CharField(max_length=6, blank=True, null=True) email_activation_sent_at = models.DateTimeField( "Код подтверждения email отправлен", blank=True, @@ -341,9 +314,7 @@ class Employee(AbstractUser): employee=self, is_active=True ).values_list("department_id", flat=True) # Плюс отделы, где сотрудник начальник - head_ids = Department.objects.filter(head=self).values_list( - "id", flat=True - ) + head_ids = Department.objects.filter(head=self).values_list("id", flat=True) return Department.objects.filter( models.Q(id__in=department_ids) | models.Q(id__in=head_ids) ).distinct() @@ -391,9 +362,7 @@ class EmployeeAction(models.Model): employee = models.ForeignKey( Employee, related_name="actions", on_delete=models.CASCADE ) - action = models.CharField( - "Кадровое событие", max_length=50, choices=ACTION_CHOICES - ) + action = models.CharField("Кадровое событие", max_length=50, choices=ACTION_CHOICES) source_request = models.ForeignKey( "requests_app.Request", related_name="employee_actions", @@ -425,10 +394,7 @@ class EmployeeAction(models.Model): ] def __str__(self): - return ( - f"{self.employee} — {self.get_action_display()} — " - f"{self.date:%d.%m.%Y}" - ) + return f"{self.employee} — {self.get_action_display()} — {self.date:%d.%m.%Y}" class Department(models.Model): @@ -452,10 +418,7 @@ class Department(models.Model): max_length=512, blank=True, default="", - help_text=( - "Напр.: CN=DEP_it,OU=Department,OU=company," - "DC=robotail,DC=local" - ), + help_text=("Напр.: CN=DEP_it,OU=Department,OU=company,DC=robotail,DC=local"), ) class Meta: @@ -503,9 +466,7 @@ class Department(models.Model): if not link.date_from: updates["date_from"] = timezone.now().date() if updates: - EmployeeDepartment.objects.filter(pk=link.pk).update( - **updates - ) + EmployeeDepartment.objects.filter(pk=link.pk).update(**updates) except IntegrityError: # На случай гонки EmployeeDepartment.objects.filter( @@ -556,18 +517,10 @@ class Department(models.Model): Все активные сотрудники отдела (через EmployeeDepartment), включая руководителя, если он существует и активен. """ - qs = self.employeedepartment.filter(is_active=True).select_related( - "employee" - ) - employees = [ - ed.employee for ed in qs if ed.employee.is_actually_active - ] + qs = self.employeedepartment.filter(is_active=True).select_related("employee") + employees = [ed.employee for ed in qs if ed.employee.is_actually_active] - if ( - self.head - and self.head.is_actually_active - and self.head not in employees - ): + if self.head and self.head.is_actually_active and self.head not in employees: employees.append(self.head) return employees @@ -582,9 +535,7 @@ class Department(models.Model): is_active=True, date_from__gte=month_ago ).select_related("employee") - employees = [ - ed.employee for ed in qs if ed.employee.is_actually_active - ] + employees = [ed.employee for ed in qs if ed.employee.is_actually_active] # Добавляем руководителя, если он недавно назначен и активен if ( @@ -600,9 +551,7 @@ class Department(models.Model): class DepartmentPermission(models.Model): - code = models.CharField( - max_length=64, choices=DeptPerm.CHOICES, unique=True - ) + code = models.CharField(max_length=64, choices=DeptPerm.CHOICES, unique=True) name = models.CharField(max_length=150) def __str__(self): @@ -627,10 +576,7 @@ class DepartmentRole(models.Model): max_length=512, blank=True, default="", - help_text=( - "Напр.: CN=ROLE_it__oncall,OU=Role,OU=company," - "DC=robotail,DC=local" - ), + help_text=("Напр.: CN=ROLE_it__oncall,OU=Role,OU=company,DC=robotail,DC=local"), ) class Meta: @@ -746,7 +692,44 @@ class RoleAssignment(models.Model): class Skill(models.Model): + class ProgressionType(models.TextChoices): + STANDARD = "standard", "Обычный опыт" + ATTENDANCE_PUNCTUALITY = ( + "attendance_punctuality", + "Посещаемость: пунктуальность", + ) + ATTENDANCE_SELF_ORGANIZATION = ( + "attendance_self_organization", + "Посещаемость: самоорганизация", + ) + ATTENDANCE_RELIABILITY = ( + "attendance_reliability", + "Посещаемость: надёжность", + ) + PERSONNEL_RESILIENCE = ( + "personnel_resilience", + "Кадровые события: стойкость", + ) + TASK_EXECUTION = ( + "task_execution", + "Задачи: исполнительность", + ) + name = models.CharField(max_length=100, unique=True) + is_secret = models.BooleanField("Секретный навык", default=False) + system_code = models.CharField( + "Системный код", + max_length=64, + unique=True, + null=True, + blank=True, + ) + progression_type = models.CharField( + "Механика прогресса", + max_length=48, + choices=ProgressionType.choices, + default=ProgressionType.STANDARD, + ) class Meta: verbose_name = "Навык" @@ -756,6 +739,142 @@ class Skill(models.Model): return self.name +class EmployeeSkillProgress(models.Model): + """Прогресс конкретного сотрудника по навыку. + + Связь ``Employee.skills`` остаётся обратно совместимой, а эта модель + хранит игровое состояние и может быть восстановлена по журналу опыта. + """ + + employee = models.ForeignKey( + Employee, + on_delete=models.CASCADE, + related_name="skill_progresses", + verbose_name="Сотрудник", + ) + skill = models.ForeignKey( + Skill, + on_delete=models.CASCADE, + related_name="employee_progresses", + verbose_name="Навык", + ) + experience = models.PositiveIntegerField("Опыт", default=0) + special_rank = models.CharField( + "Ранг системного навыка", + max_length=16, + choices=( + ("none", "Без ранга"), + ("bronze", "Бронза"), + ("silver", "Серебро"), + ("gold", "Золото"), + ("ruby", "Рубин"), + ("diamond", "Бриллиант"), + ), + default="none", + ) + progress_data = models.JSONField( + "Данные системного прогресса", + default=dict, + blank=True, + ) + is_active = models.BooleanField("Показывается в профиле", default=True) + created_by = models.ForeignKey( + Employee, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_skill_progresses", + verbose_name="Добавил навык", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Прогресс навыка сотрудника" + verbose_name_plural = "Прогресс навыков сотрудников" + constraints = [ + models.UniqueConstraint( + fields=["employee", "skill"], + name="uniq_employee_skill_progress", + ), + ] + indexes = [ + models.Index(fields=["skill", "is_active", "experience"]), + models.Index(fields=["employee", "is_active"]), + ] + + def __str__(self): + return f"{self.employee} — {self.skill}: {self.experience} XP" + + +class SkillExperienceEvent(models.Model): + class Source(models.TextChoices): + ENDORSEMENT = "endorsement", "Подтверждение сотрудником" + TASK = "task", "Завершённая задача" + TRAINING = "training", "Обучение" + CERTIFICATION = "certification", "Аттестация" + MANUAL = "manual", "Ручное начисление" + + progress = models.ForeignKey( + EmployeeSkillProgress, + on_delete=models.CASCADE, + related_name="experience_events", + verbose_name="Прогресс навыка", + ) + points = models.PositiveIntegerField("Баллы опыта") + source_type = models.CharField( + "Источник", + max_length=24, + choices=Source.choices, + ) + source_id = models.CharField("ID источника", max_length=100, blank=True) + actor = models.ForeignKey( + Employee, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="skill_experience_events_given", + verbose_name="Автор начисления", + ) + reason = models.TextField("Причина", blank=True) + metadata = models.JSONField("Дополнительные данные", default=dict, blank=True) + idempotency_key = models.CharField( + "Ключ идемпотентности", + max_length=180, + blank=True, + default="", + ) + revoked_at = models.DateTimeField("Отозвано", null=True, blank=True) + revoked_by = models.ForeignKey( + Employee, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="skill_experience_events_revoked", + verbose_name="Кто отозвал", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Начисление опыта навыка" + verbose_name_plural = "Начисления опыта навыков" + ordering = ["-created_at", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["idempotency_key"], + condition=~models.Q(idempotency_key=""), + name="uniq_skill_xp_idempotency_key", + ), + ] + indexes = [ + models.Index(fields=["progress", "source_type", "revoked_at"]), + models.Index(fields=["actor", "source_type", "created_at"]), + ] + + def __str__(self): + return f"{self.progress}: +{self.points} ({self.get_source_type_display()})" + + # --- LDAP sync state ------------------------------------------------- @@ -788,9 +907,7 @@ class LdapSyncState(models.Model): object_pk = models.CharField(max_length=64, db_index=True) ldap_dn = models.TextField(blank=True, default="") - ldap_guid = models.CharField( - max_length=64, null=True, blank=True, db_index=True - ) + ldap_guid = models.CharField(max_length=64, null=True, blank=True, db_index=True) last_ldap_modify_ts = models.DateTimeField(null=True, blank=True) last_django_modify_ts = models.DateTimeField(null=True, blank=True) @@ -830,9 +947,7 @@ class LdapSyncState(models.Model): ValueError: Если sync_dir не из допустимого набора. """ if sync_dir and sync_dir not in ("ldap", "django", "auto", "manual"): - raise ValueError( - "sync_dir должен быть 'ldap'|'django'|'auto'|'manual'" - ) + raise ValueError("sync_dir должен быть 'ldap'|'django'|'auto'|'manual'") if ldap_dn is not None: self.ldap_dn = ldap_dn if ldap_guid is not None: @@ -877,10 +992,7 @@ class LdapSyncQueue(models.Model): operation = models.CharField( max_length=64, db_index=True, - help_text=( - "Имя операции: employee_save, department_delete, " - "group_m2m и т.д." - ), + help_text=("Имя операции: employee_save, department_delete, group_m2m и т.д."), ) # Модель и объект @@ -923,10 +1035,7 @@ class LdapSyncQueue(models.Model): ] def __str__(self): - return ( - f"[{self.status}] {self.operation} " - f"{self.model_name}:{self.object_pk}" - ) + return f"[{self.status}] {self.operation} {self.model_name}:{self.object_pk}" def schedule_retry(self, error: str): """Планирует следующую попытку с экспоненциальным backoff.""" diff --git a/backend/employees/services/secret_skills.py b/backend/employees/services/secret_skills.py new file mode 100644 index 00000000..afd6504a --- /dev/null +++ b/backend/employees/services/secret_skills.py @@ -0,0 +1,1086 @@ +"""Автоматический прогресс секретных навыков по системным данным.""" + +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.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": 60}, + "silver": {"best_resilience_streak": 120}, + "gold": {"best_resilience_streak": 240}, + "ruby": {"best_resilience_streak": 480}, + "diamond": {"best_resilience_streak": 800}, +} + +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: "Больничные за 120 дней", + 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=3, + 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 calculate_personnel_secret_metrics( # noqa: C901 + employee: Employee, + actions: Iterable[EmployeeAction], + *, + reference_date: 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 = { + definition.system_code: _initial_secret_state(definition, rolling=True) + for definition in PERSONNEL_SECRET_SKILL_DEFINITIONS + } + employment_period = _employment_period(employee, sorted_actions, reference_date) + if employment_period is None: + 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, + } + + start_date, end_date = employment_period + 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 + 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: + for definition in PERSONNEL_SECRET_SKILL_DEFINITIONS: + rolling_dates = skill_states[definition.system_code]["_rolling_dates"] + _prune_rolling_dates(rolling_dates, current_date, 120) + skill_states[definition.system_code]["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 + _register_rolling_violation( + skill_states[resilience_definition.system_code], + resilience_definition, + current_metrics(), + violation_date=current_date, + window_days=120, + ) + + 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: + resilience_streak += 1 + best_resilience_streak = max(best_resilience_streak, resilience_streak) + _advance_rolling_recovery( + skill_states[resilience_definition.system_code], + resilience_definition, + current_metrics(), + record_date=current_date, + ) + else: + _refresh_secret_state( + skill_states[resilience_definition.system_code], + resilience_definition, + current_metrics(), + ) + current_date += timedelta(days=1) + + for definition in PERSONNEL_SECRET_SKILL_DEFINITIONS: + state = skill_states[definition.system_code] + rolling_dates = state.pop("_rolling_dates") + _prune_rolling_dates(rolling_dates, end_date, 120) + state["anti_streak"] = len(rolling_dates) + state["recent_violation_dates"] = [ + violation_date.isoformat() for violation_date in rolling_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(employee_id: int) -> list[str]: # noqa: C901 + """Пересчитать и при необходимости открыть системные навыки сотрудника.""" + + from attendance.models import AttendanceRecord + + employee = Employee.objects.select_for_update().filter(pk=employee_id).first() + if employee is None: + return [] + + records = list( + AttendanceRecord.objects.filter(employee_id=employee_id).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) + 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) diff --git a/backend/employees/services/skill_progress.py b/backend/employees/services/skill_progress.py new file mode 100644 index 00000000..91198266 --- /dev/null +++ b/backend/employees/services/skill_progress.py @@ -0,0 +1,357 @@ +"""Ранги, редкость и журнал опыта навыков сотрудников.""" + +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 diff --git a/backend/employees/services/task_execution_skill.py b/backend/employees/services/task_execution_skill.py new file mode 100644 index 00000000..c74af01e --- /dev/null +++ b/backend/employees/services/task_execution_skill.py @@ -0,0 +1,402 @@ +"""System-owned progress for the secret skill ``Исполнительность``.""" + +from __future__ import annotations + +from datetime import date, timedelta + +from django.db import transaction +from django.db.models import Exists, F, OuterRef +from django.utils import timezone + +from employees.models import Employee, EmployeeSkillProgress, Skill +from employees.services.secret_skills import ( + RANK_CODES, + RANK_INDEX, + TASK_SECRET_SKILL_DEFINITIONS, +) +from tasks.models import Task, TaskExecutionCycle + +PROTECTION_DAYS = 7 +OVERDUE_GRACE_BUSINESS_DAYS = 3 +VIOLATION_WINDOW_DAYS = 60 + + +def _local_date(value) -> date: + if timezone.is_aware(value): + return timezone.localtime(value).date() + return value.date() + + +def _add_business_days(value: date, amount: int) -> date: + current = value + added = 0 + while added < amount: + current += timedelta(days=1) + if current.weekday() < 5: + added += 1 + return current + + +def _cycle_key(task_id: int, completed_at) -> str: + return f"task:{task_id}:completed:{completed_at.isoformat()}" + + +@transaction.atomic +def capture_task_execution_cycle(task_id: int) -> TaskExecutionCycle | None: + """Create the immutable completion snapshot if the task is currently done.""" + + task = ( + Task.objects.select_related("assignee") + .filter(pk=task_id, completed_at__isnull=False) + .first() + ) + if task is None: + return None + + completed_at = task.completed_at + protection_until = completed_at + timedelta(days=PROTECTION_DAYS) + is_self_assigned = bool(task.assignee_id and task.assignee_id == task.created_by_id) + exclusion_reason = "" + if task.assignee_id is None: + exclusion_reason = "missing_assignee" + elif task.due_date is None: + exclusion_reason = "missing_due_date" + elif is_self_assigned: + exclusion_reason = "self_assigned" + elif not task.assignee.is_active: + exclusion_reason = "inactive_assignee" + + if exclusion_reason: + status = TaskExecutionCycle.Status.EXCLUDED + elif _local_date(completed_at) > task.due_date: + status = TaskExecutionCycle.Status.LATE + elif protection_until <= timezone.now(): + status = TaskExecutionCycle.Status.CONFIRMED + else: + status = TaskExecutionCycle.Status.PENDING + + cycle, _ = TaskExecutionCycle.objects.get_or_create( + cycle_key=_cycle_key(task.id, completed_at), + defaults={ + "task": task, + "task_id_snapshot": task.id, + "employee_id": task.assignee_id, + "task_title": task.title, + "due_date_snapshot": task.due_date, + "completed_at_snapshot": completed_at, + "protection_until": protection_until, + "status": status, + "is_self_assigned": is_self_assigned, + "exclusion_reason": exclusion_reason, + }, + ) + if cycle.employee_id: + recalculate_task_execution_skill(cycle.employee_id) + return cycle + + +@transaction.atomic +def reopen_task_execution_cycle( + task_id: int, + completed_at, +) -> TaskExecutionCycle | None: + """Turn the matching positive/pending completion into one violation.""" + + if completed_at is None: + return None + cycle = ( + TaskExecutionCycle.objects.select_for_update() + .filter(cycle_key=_cycle_key(task_id, completed_at)) + .first() + ) + if cycle is None or cycle.status == TaskExecutionCycle.Status.EXCLUDED: + return cycle + cycle.status = TaskExecutionCycle.Status.REOPENED + cycle.reopened_at = timezone.now() + cycle.save(update_fields=["status", "reopened_at", "updated_at"]) + if cycle.employee_id: + recalculate_task_execution_skill(cycle.employee_id) + return cycle + + +def _open_overdue_events(employee_id: int, today: date): + tasks = ( + Task.objects.filter( + assignee_id=employee_id, + completed_at__isnull=True, + due_date__isnull=False, + ) + .exclude(created_by_id=F("assignee_id")) + .prefetch_related("execution_cycles") + ) + events = [] + for task in tasks: + violation_date = _add_business_days( + task.due_date, + OVERDUE_GRACE_BUSINESS_DAYS, + ) + if violation_date > today: + continue + latest_cycle = max( + task.execution_cycles.all(), + key=lambda item: (item.completed_at_snapshot, item.id), + default=None, + ) + if ( + latest_cycle + and latest_cycle.due_date_snapshot == task.due_date + and latest_cycle.status + in { + TaskExecutionCycle.Status.LATE, + TaskExecutionCycle.Status.REOPENED, + } + ): + continue + events.append((violation_date, "overdue", task.id)) + return events + + +def calculate_task_execution_metrics(employee_id: int, *, today: date | None = None): + today = today or timezone.localdate() + cycles = list( + TaskExecutionCycle.objects.filter(employee_id=employee_id) + .exclude( + status__in=[ + TaskExecutionCycle.Status.PENDING, + TaskExecutionCycle.Status.EXCLUDED, + ] + ) + .order_by("completed_at_snapshot", "id") + ) + events = [] + late_tasks = 0 + reopened_tasks = 0 + for cycle in cycles: + if cycle.status == TaskExecutionCycle.Status.CONFIRMED: + events.append( + (_local_date(cycle.completed_at_snapshot), "confirmed", cycle.id) + ) + elif cycle.status == TaskExecutionCycle.Status.LATE: + late_tasks += 1 + events.append((_local_date(cycle.completed_at_snapshot), "late", cycle.id)) + elif cycle.status == TaskExecutionCycle.Status.REOPENED: + reopened_tasks += 1 + violation_at = cycle.reopened_at or cycle.completed_at_snapshot + events.append((_local_date(violation_at), "reopened", cycle.id)) + + overdue_events = _open_overdue_events(employee_id, today) + events.extend(overdue_events) + events.sort(key=lambda item: (item[0], str(item[2]))) + + confirmed_tasks = 0 + current_on_time_streak = 0 + best_on_time_streak = 0 + for _, event_type, _ in events: + if event_type == "confirmed": + confirmed_tasks += 1 + current_on_time_streak += 1 + best_on_time_streak = max( + best_on_time_streak, + current_on_time_streak, + ) + else: + current_on_time_streak = 0 + + attempts = len(events) + on_time_percent = round(confirmed_tasks / attempts * 100) if attempts else 0 + violation_cutoff = today - timedelta(days=VIOLATION_WINDOW_DAYS) + violations_last_60 = sum( + event_type != "confirmed" and event_date >= violation_cutoff + for event_date, event_type, _ in events + ) + base_penalty_levels = violations_last_60 // 3 + recovered_levels = current_on_time_streak // 10 + penalty_levels = max(base_penalty_levels - recovered_levels, 0) + + definition = TASK_SECRET_SKILL_DEFINITIONS[0] + rank_metrics = { + "confirmed_tasks": confirmed_tasks, + "on_time_percent": on_time_percent, + "best_on_time_streak": best_on_time_streak, + } + earned_rank = "none" + for rank_code in RANK_CODES[1:]: + requirements = definition.requirements[rank_code] + if all(rank_metrics[key] >= target for key, target in requirements.items()): + earned_rank = rank_code + else: + break + effective_index = max(RANK_INDEX[earned_rank] - penalty_levels, 0) + effective_rank = RANK_CODES[effective_index] + + return { + **rank_metrics, + "attempted_tasks": attempts, + "current_on_time_streak": current_on_time_streak, + "late_tasks": late_tasks, + "reopened_tasks": reopened_tasks, + "open_overdue_tasks": len(overdue_events), + "pending_tasks": TaskExecutionCycle.objects.filter( + employee_id=employee_id, + status=TaskExecutionCycle.Status.PENDING, + ).count(), + "violations_last_60": violations_last_60, + "anti_streak": violations_last_60 % 3, + "anti_streak_limit": 3, + "recovery_streak": current_on_time_streak % 10 if penalty_levels else 0, + "recovery_streak_target": 10, + "penalty_levels": penalty_levels, + "earned_rank": earned_rank, + "effective_rank": effective_rank, + "last_evaluated_date": today.isoformat(), + } + + +@transaction.atomic +def recalculate_task_execution_skill(employee_id: int) -> list[str]: + employee = Employee.objects.select_for_update().filter(pk=employee_id).first() + if employee is None: + return [] + skill = Skill.objects.filter( + system_code="task_execution", + is_secret=True, + ).first() + if skill is None: + return [] + + metrics = calculate_task_execution_metrics(employee_id) + calculated_rank = metrics["effective_rank"] + progress = EmployeeSkillProgress.objects.filter( + employee=employee, + skill=skill, + ).first() + previous_rank = progress.special_rank if progress else "none" + previous_active = progress.is_active if progress else False + if progress is None and calculated_rank == "none": + return [] + + progress, _ = EmployeeSkillProgress.objects.get_or_create( + employee=employee, + skill=skill, + defaults={"is_active": calculated_rank != "none"}, + ) + now = timezone.now() + rank_history = dict(progress.progress_data.get("rank_achieved_at") or {}) + rank_losses = list(progress.progress_data.get("rank_losses") or []) + if RANK_INDEX[calculated_rank] > RANK_INDEX[previous_rank]: + rank_history[calculated_rank] = now.isoformat() + elif 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 = metrics["confirmed_tasks"] + progress.is_active = calculated_rank != "none" + progress.created_by = None + progress.progress_data = { + **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", + ] + ) + if calculated_rank == "none": + employee.skills.remove(skill) + else: + employee.skills.add(skill) + + changed = calculated_rank != previous_rank or progress.is_active != previous_active + return [skill.name] if changed else [] + + +def refresh_task_execution_skills( # noqa: C901 + employee_id: int | None = None, +) -> dict[str, int]: + """Reconcile missed transitions, mature pending cycles and recalculate users.""" + + now = timezone.now() + existing_cycle = TaskExecutionCycle.objects.filter( + task_id=OuterRef("pk"), + completed_at_snapshot=OuterRef("completed_at"), + ) + completed_tasks = Task.objects.filter(completed_at__isnull=False) + if employee_id is not None: + completed_tasks = completed_tasks.filter(assignee_id=employee_id) + missing_task_ids = list( + completed_tasks.annotate(has_cycle=Exists(existing_cycle)) + .filter(has_cycle=False) + .values_list("id", flat=True) + ) + for task_id in missing_task_ids: + capture_task_execution_cycle(task_id) + + changed_cycles = 0 + employee_ids: set[int] = set() + cycles = TaskExecutionCycle.objects.filter( + status__in=[ + TaskExecutionCycle.Status.PENDING, + TaskExecutionCycle.Status.CONFIRMED, + ] + ).select_related("task") + if employee_id is not None: + cycles = cycles.filter(employee_id=employee_id) + for cycle in cycles: + task = cycle.task + if task is None: + if cycle.status == TaskExecutionCycle.Status.PENDING: + cycle.status = TaskExecutionCycle.Status.EXCLUDED + cycle.exclusion_reason = "task_deleted_during_protection" + else: + continue + elif task.completed_at != cycle.completed_at_snapshot: + cycle.status = TaskExecutionCycle.Status.REOPENED + cycle.reopened_at = now + elif ( + cycle.status == TaskExecutionCycle.Status.PENDING + and cycle.protection_until <= now + ): + cycle.status = TaskExecutionCycle.Status.CONFIRMED + else: + continue + cycle.save( + update_fields=[ + "status", + "exclusion_reason", + "reopened_at", + "updated_at", + ] + ) + changed_cycles += 1 + if cycle.employee_id: + employee_ids.add(cycle.employee_id) + + cycle_employees = TaskExecutionCycle.objects.exclude(employee_id=None) + open_tasks = Task.objects.filter( + completed_at__isnull=True, + assignee__isnull=False, + due_date__isnull=False, + ) + if employee_id is not None: + cycle_employees = cycle_employees.filter(employee_id=employee_id) + open_tasks = open_tasks.filter(assignee_id=employee_id) + employee_ids.update(cycle_employees.values_list("employee_id", flat=True)) + employee_ids.update(open_tasks.values_list("assignee_id", flat=True)) + rank_changes = 0 + for employee_id in employee_ids: + rank_changes += len(recalculate_task_execution_skill(employee_id)) + return { + "captured_cycles": len(missing_task_ids), + "changed_cycles": changed_cycles, + "processed_employees": len(employee_ids), + "rank_changes": rank_changes, + } diff --git a/backend/employees/signals/__init__.py b/backend/employees/signals/__init__.py index 0ede9700..c8ec8e80 100644 --- a/backend/employees/signals/__init__.py +++ b/backend/employees/signals/__init__.py @@ -13,7 +13,7 @@ from importlib import import_module from django.conf import settings -from . import common, birthday +from . import birthday, common, secret_skills, skills from .common import ( IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING, @@ -30,6 +30,8 @@ if getattr(settings, "LDAP_ENABLED", False): __all__ = [ "common", "birthday", + "skills", + "secret_skills", "IMMEDIATE_ACTION_MAPPING", "SCHEDULED_ACTION_MAPPING", "_apply_action_effects", @@ -39,6 +41,8 @@ else: __all__ = [ "common", "birthday", + "skills", + "secret_skills", "IMMEDIATE_ACTION_MAPPING", "SCHEDULED_ACTION_MAPPING", "_apply_action_effects", diff --git a/backend/employees/signals/secret_skills.py b/backend/employees/signals/secret_skills.py new file mode 100644 index 00000000..50b41be5 --- /dev/null +++ b/backend/employees/signals/secret_skills.py @@ -0,0 +1,62 @@ +"""Пересчёт системных навыков после изменения кадровых событий.""" + +import logging + +from django.db import transaction +from django.db.models.signals import post_delete, post_save, pre_save +from django.dispatch import receiver + +from employees.models import EmployeeAction + +logger = logging.getLogger(__name__) + + +def _schedule_recalculation(employee_ids) -> None: + normalized_ids = {employee_id for employee_id in employee_ids if employee_id} + if not normalized_ids: + return + + def recalculate(): + from employees.services.secret_skills import ( + recalculate_attendance_secret_skills, + ) + + for employee_id in normalized_ids: + try: + recalculate_attendance_secret_skills(employee_id) + except Exception: + logger.exception( + "Secret skill recalculation after personnel action failed", + extra={ + "event": "employee.secret_skills.recalculation_failed", + "employee_id": employee_id, + }, + ) + + transaction.on_commit(recalculate) + + +@receiver(pre_save, sender=EmployeeAction) +def remember_previous_action_employee(sender, instance, **kwargs): + if not instance.pk: + return + instance._secret_skills_previous_employee_id = ( + sender.objects.filter(pk=instance.pk) + .values_list("employee_id", flat=True) + .first() + ) + + +@receiver(post_save, sender=EmployeeAction) +def recalculate_secret_skills_after_action_save(sender, instance, **kwargs): + _schedule_recalculation( + { + instance.employee_id, + getattr(instance, "_secret_skills_previous_employee_id", None), + } + ) + + +@receiver(post_delete, sender=EmployeeAction) +def recalculate_secret_skills_after_action_delete(sender, instance, **kwargs): + _schedule_recalculation({instance.employee_id}) diff --git a/backend/employees/signals/skills.py b/backend/employees/signals/skills.py new file mode 100644 index 00000000..fde32f1c --- /dev/null +++ b/backend/employees/signals/skills.py @@ -0,0 +1,27 @@ +"""Синхронизация обычной M2M-связи навыков с игровым прогрессом.""" + +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from employees.models import Employee, EmployeeSkillProgress + + +@receiver(m2m_changed, sender=Employee.skills.through) +def sync_employee_skill_progress(sender, instance, action, pk_set, **kwargs): + if action == "post_add": + for skill_id in pk_set or (): + progress, _ = EmployeeSkillProgress.objects.get_or_create( + employee=instance, + skill_id=skill_id, + defaults={"is_active": True}, + ) + if not progress.is_active: + progress.is_active = True + progress.save(update_fields=["is_active", "updated_at"]) + elif action == "post_remove": + EmployeeSkillProgress.objects.filter( + employee=instance, + skill_id__in=pk_set or (), + ).update(is_active=False) + elif action == "pre_clear": + EmployeeSkillProgress.objects.filter(employee=instance).update(is_active=False) diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 84c161f2..30035a0b 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -827,6 +827,10 @@ CELERY_BEAT_SCHEDULE = { "task": "tasks.dispatch_scheduled_automations", "schedule": 60.0, }, + "tasks-refresh-execution-skills": { + "task": "tasks.refresh_task_execution_skills", + "schedule": crontab(hour=0, minute=20), + }, "process-ldap-sync-queue": { "task": "employees.tasks.process_ldap_queue", "schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 3c340d9f..48db471c 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -15,6 +15,7 @@ from .models import ( TaskColumnRow, TaskCover, TaskExternalLink, + TaskExecutionCycle, TaskLabel, TaskLinkedObject, TaskUserSettings, @@ -223,6 +224,27 @@ class TaskActivityAdmin(admin.ModelAdmin): search_fields = ["task__title", "actor__email", "object_id"] +@admin.register(TaskExecutionCycle) +class TaskExecutionCycleAdmin(admin.ModelAdmin): + list_display = [ + "task_title", + "employee", + "due_date_snapshot", + "completed_at_snapshot", + "status", + "protection_until", + ] + list_filter = ["status", "is_self_assigned", "completed_at_snapshot"] + search_fields = ["task_title", "employee__email", "cycle_key"] + readonly_fields = [ + "task_id_snapshot", + "cycle_key", + "completed_at_snapshot", + "created_at", + "updated_at", + ] + + @admin.register(TaskUserSettings) class TaskUserSettingsAdmin(admin.ModelAdmin): list_display = ["user", "default_board", "default_group", "updated_at"] diff --git a/backend/tasks/migrations/0040_task_skills.py b/backend/tasks/migrations/0040_task_skills.py new file mode 100644 index 00000000..f16ec974 --- /dev/null +++ b/backend/tasks/migrations/0040_task_skills.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2026-08-25 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0057_employeeskillprogress_skillexperienceevent_and_more"), + ("tasks", "0039_taskboardgroupmembership_rank_key"), + ] + + operations = [ + migrations.AddField( + model_name="task", + name="skills", + field=models.ManyToManyField( + blank=True, + related_name="tasks", + to="employees.skill", + verbose_name="Применяемые навыки", + ), + ), + ] diff --git a/backend/tasks/migrations/0041_taskexecutioncycle.py b/backend/tasks/migrations/0041_taskexecutioncycle.py new file mode 100644 index 00000000..f4111bd4 --- /dev/null +++ b/backend/tasks/migrations/0041_taskexecutioncycle.py @@ -0,0 +1,139 @@ +# Generated by Django 5.2.4 on 2026-08-26 07:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0040_task_skills"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TaskExecutionCycle", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "task_id_snapshot", + models.PositiveBigIntegerField(verbose_name="ID задачи"), + ), + ( + "cycle_key", + models.CharField( + max_length=180, + unique=True, + verbose_name="Ключ цикла", + ), + ), + ( + "task_title", + models.CharField(max_length=255, verbose_name="Название задачи"), + ), + ( + "due_date_snapshot", + models.DateField( + null=True, verbose_name="Срок на момент завершения" + ), + ), + ( + "completed_at_snapshot", + models.DateTimeField(verbose_name="Время завершения"), + ), + ( + "protection_until", + models.DateTimeField(verbose_name="Подтвердить после"), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Ожидает подтверждения"), + ("confirmed", "Подтверждено"), + ("late", "Завершено после срока"), + ("reopened", "Возвращено в работу"), + ("excluded", "Не участвует в расчёте"), + ], + default="pending", + max_length=16, + verbose_name="Состояние", + ), + ), + ( + "is_self_assigned", + models.BooleanField(default=False, verbose_name="Самоназначение"), + ), + ( + "exclusion_reason", + models.CharField( + blank=True, + max_length=80, + verbose_name="Причина исключения", + ), + ), + ( + "reopened_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Возвращено в работу", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "employee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="task_execution_cycles", + to=settings.AUTH_USER_MODEL, + verbose_name="Исполнитель на момент завершения", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="execution_cycles", + to="tasks.task", + verbose_name="Задача", + ), + ), + ], + options={ + "verbose_name": "Цикл исполнения задачи", + "verbose_name_plural": "Циклы исполнения задач", + "ordering": ["completed_at_snapshot", "id"], + "indexes": [ + models.Index( + fields=["employee", "status", "completed_at_snapshot"], + name="tasks_taske_employe_e1d1f9_idx", + ), + models.Index( + fields=["task", "status"], + name="tasks_taske_task_id_d09903_idx", + ), + ], + }, + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 0a3642a3..6beb6a3e 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -407,6 +407,12 @@ class Task(models.Model): related_name="tasks", verbose_name="Метки", ) + skills = models.ManyToManyField( + "employees.Skill", + blank=True, + related_name="tasks", + verbose_name="Применяемые навыки", + ) priority = models.CharField( "Приоритет", max_length=20, @@ -443,6 +449,69 @@ class Task(models.Model): super().save(*args, **kwargs) +class TaskExecutionCycle(models.Model): + """Immutable role/deadline snapshot for one task completion cycle. + + The task may be reopened, reassigned or deleted later. Secret skill + progress must still belong to the employee who was the assignee when the + task entered the final column, so the mutable task itself is not a safe + source of historical truth. + """ + + class Status(models.TextChoices): + PENDING = "pending", "Ожидает подтверждения" + CONFIRMED = "confirmed", "Подтверждено" + LATE = "late", "Завершено после срока" + REOPENED = "reopened", "Возвращено в работу" + EXCLUDED = "excluded", "Не участвует в расчёте" + + task = models.ForeignKey( + Task, + on_delete=models.SET_NULL, + related_name="execution_cycles", + null=True, + blank=True, + verbose_name="Задача", + ) + task_id_snapshot = models.PositiveBigIntegerField("ID задачи") + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="task_execution_cycles", + null=True, + blank=True, + verbose_name="Исполнитель на момент завершения", + ) + cycle_key = models.CharField("Ключ цикла", max_length=180, unique=True) + task_title = models.CharField("Название задачи", max_length=255) + due_date_snapshot = models.DateField("Срок на момент завершения", null=True) + completed_at_snapshot = models.DateTimeField("Время завершения") + protection_until = models.DateTimeField("Подтвердить после") + status = models.CharField( + "Состояние", + max_length=16, + choices=Status.choices, + default=Status.PENDING, + ) + is_self_assigned = models.BooleanField("Самоназначение", default=False) + exclusion_reason = models.CharField("Причина исключения", max_length=80, blank=True) + reopened_at = models.DateTimeField("Возвращено в работу", null=True, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Цикл исполнения задачи" + verbose_name_plural = "Циклы исполнения задач" + ordering = ["completed_at_snapshot", "id"] + indexes = [ + models.Index(fields=["employee", "status", "completed_at_snapshot"]), + models.Index(fields=["task", "status"]), + ] + + def __str__(self): + return f"{self.task_title}: {self.get_status_display()}" + + class TaskAutomation(models.Model): name = models.CharField("Название", max_length=160) description = models.TextField("Описание", blank=True) diff --git a/backend/tasks/queries.py b/backend/tasks/queries.py index 1a55f481..f7697be4 100644 --- a/backend/tasks/queries.py +++ b/backend/tasks/queries.py @@ -5,6 +5,7 @@ from django.db.models import Count, IntegerField, OuterRef, Prefetch, Q, Subquer from django.db.models.functions import Coalesce from communications.models import Message +from employees.services.skill_progress import skill_queryset_with_rarity from tasks.access import task_board_access_q from tasks.models import ( Task, @@ -48,6 +49,7 @@ def task_api_queryset(*, user, params): ) .prefetch_related( "labels", + Prefetch("skills", queryset=skill_queryset_with_rarity()), "participants", "checklist_items", Prefetch( diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index 18d20366..72340485 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -74,6 +74,59 @@ def ensure_task_core_participants(sender, instance, **kwargs): instance.participants.add(*participant_ids) +@receiver(pre_save, sender=Task) +def remember_task_completion_state(sender, instance, **kwargs): + if not instance.pk: + instance._skill_was_completed = False + instance._skill_previous_completed_at = None + return + previous_completed_at = ( + sender.objects.filter(pk=instance.pk) + .values_list( + "completed_at", + flat=True, + ) + .first() + ) + instance._skill_previous_completed_at = previous_completed_at + instance._skill_was_completed = previous_completed_at is not None + + +@receiver(post_save, sender=Task) +def sync_task_skill_experience(sender, instance, **kwargs): + was_completed = bool(getattr(instance, "_skill_was_completed", False)) + is_completed = instance.completed_at is not None + if was_completed == is_completed: + return + + task_id = instance.id + actor = getattr(instance, "_skill_completion_actor", None) + previous_completed_at = getattr( + instance, + "_skill_previous_completed_at", + None, + ) + + def sync_after_commit(): + from employees.services.skill_progress import ( + award_task_skill_experience, + revoke_task_skill_experience, + ) + from employees.services.task_execution_skill import ( + capture_task_execution_cycle, + reopen_task_execution_cycle, + ) + + if is_completed: + award_task_skill_experience(task_id) + capture_task_execution_cycle(task_id) + else: + revoke_task_skill_experience(task_id, actor=actor) + reopen_task_execution_cycle(task_id, previous_completed_at) + + transaction.on_commit(sync_after_commit) + + @receiver(post_delete, sender=TaskAttachment) def delete_task_attachment_file(sender, instance, **kwargs): _delete_file_after_commit(instance.file) diff --git a/backend/tasks/tasks.py b/backend/tasks/tasks.py index 2b79136f..4645cbe1 100644 --- a/backend/tasks/tasks.py +++ b/backend/tasks/tasks.py @@ -20,3 +20,12 @@ def process_task_automation_event_task(activity_id): @shared_task(name="tasks.dispatch_scheduled_automations") def dispatch_scheduled_automations_task(): return dispatch_scheduled_automations() + + +@shared_task(name="tasks.refresh_task_execution_skills") +def refresh_task_execution_skills_task(): + from employees.services.task_execution_skill import ( + refresh_task_execution_skills, + ) + + return refresh_task_execution_skills() diff --git a/backend/tests/api/v1/employees/test_skills.py b/backend/tests/api/v1/employees/test_skills.py index ad8e9d5b..ead084d7 100644 --- a/backend/tests/api/v1/employees/test_skills.py +++ b/backend/tests/api/v1/employees/test_skills.py @@ -1,13 +1,29 @@ +from datetime import timedelta + import pytest from django.contrib.auth.models import Permission from django.urls import reverse -from employees.models import Employee, Skill -from rest_framework import status +from employees.models import ( + Employee, + EmployeeSkillProgress, + Skill, + SkillExperienceEvent, +) +from employees.services.skill_progress import ( + award_task_skill_experience, + confirmed_holder_count, + rank_payload, + rarity_payload, + revoke_task_skill_experience, + skill_queryset_with_rarity, +) +from tasks.models import Task, TaskBoard, TaskColumn from tests.conftest import _unique_email, _unique_phone from tests.test_config import DEFAULT_PASSWORD # --- helpers --- + def _mk_user(staff=False, superuser=False) -> Employee: u = Employee.objects.create_user( email=_unique_email(), @@ -82,6 +98,334 @@ def test_create_allowed_for_regular_user(api_client): assert Skill.objects.filter(name="Go").exists() +@pytest.mark.django_db +def test_secret_skills_are_hidden_and_cannot_be_added_or_endorsed(api_client): + actor = _mk_user() + target = _mk_user() + secret_skill = Skill.objects.get(system_code="attendance_punctuality") + api_client.force_authenticate(user=actor) + + list_response = api_client.get(reverse("api:v1:skills-list")) + assert list_response.status_code == 200 + assert secret_skill.id not in {item["id"] for item in list_response.data} + + add_response = api_client.post( + reverse("api:v1:employees-add-skill", args=[target.id]), + {"skill_id": secret_skill.id}, + format="json", + ) + assert add_response.status_code == 400 + + target.skills.add(secret_skill) + progress = EmployeeSkillProgress.objects.get( + employee=target, + skill=secret_skill, + ) + progress.special_rank = "bronze" + progress.progress_data = { + "current_streak": 20, + "best_streak": 20, + } + progress.save( + update_fields=["special_rank", "progress_data", "updated_at"], + ) + + progress_response = api_client.get( + reverse( + "api:v1:employees-skill-progress", + args=[target.id, secret_skill.id], + ) + ) + assert progress_response.status_code == 200 + assert progress_response.data["rank"] == "bronze" + assert progress_response.data["rarity"] == "secret" + assert progress_response.data["can_endorse"] is False + assert progress_response.data["anti_streak"] == 0 + assert progress_response.data["anti_streak_limit"] == 3 + assert progress_response.data["anti_streak_remaining"] == 3 + assert progress_response.data["recovery_streak_target"] == 20 + assert progress_response.data["next_rank_requirements"] == [ + { + "key": "best_streak", + "label": "Лучшая серия без опозданий", + "current": 20, + "target": 45, + "complete": False, + } + ] + + endorse_response = api_client.post( + reverse( + "api:v1:employees-endorse-skill", + args=[target.id, secret_skill.id], + ), + {"reason": "Подтверждение не должно приниматься"}, + format="json", + ) + assert endorse_response.status_code == 400 + + +@pytest.mark.django_db +def test_skill_rank_and_rarity_boundaries(): + assert rank_payload(0)["rank"] == "none" + assert rank_payload(99)["rank"] == "none" + assert rank_payload(100)["rank"] == "bronze" + assert rank_payload(300)["rank"] == "silver" + assert rank_payload(800)["rank"] == "gold" + assert rank_payload(1800)["rank"] == "ruby" + assert rank_payload(4000)["rank"] == "diamond" + assert rarity_payload(1)["rarity"] == "legendary" + assert rarity_payload(3)["rarity"] == "epic" + assert rarity_payload(9)["rarity"] == "rare" + assert rarity_payload(24)["rarity"] == "uncommon" + assert rarity_payload(25)["rarity"] == "common" + + +@pytest.mark.django_db +def test_skill_rarity_counts_holders_from_fifty_experience(): + employee = _mk_user() + skill = Skill.objects.create(name="Redis") + employee.skills.add(skill) + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + + progress.experience = 49 + progress.save(update_fields=["experience", "updated_at"]) + assert confirmed_holder_count(skill) == 0 + + progress.experience = 50 + progress.save(update_fields=["experience", "updated_at"]) + assert confirmed_holder_count(skill) == 1 + annotated_skill = skill_queryset_with_rarity( + Skill.objects.filter(pk=skill.pk) + ).get() + assert annotated_skill.confirmed_holders == 1 + + +@pytest.mark.django_db +def test_adding_skill_creates_active_progress(api_client): + actor = _mk_user() + target = _mk_user() + skill = Skill.objects.create(name="TypeScript") + api_client.force_authenticate(user=actor) + + response = api_client.post( + reverse("api:v1:employees-add-skill", args=[target.id]), + {"skill_id": skill.id}, + format="json", + ) + + assert response.status_code == 200 + progress = EmployeeSkillProgress.objects.get(employee=target, skill=skill) + assert progress.is_active is True + assert progress.created_by == actor + payload = next(row for row in response.data["skills"] if row["id"] == skill.id) + assert payload["rank"] == "none" + assert payload["experience"] == 0 + + +@pytest.mark.django_db +def test_endorsement_adds_weighted_experience_and_has_cooldown(api_client): + actor = _mk_user() + target = _mk_user() + skill = Skill.objects.create(name="Python") + target.skills.add(skill) + api_client.force_authenticate(user=actor) + url = reverse( + "api:v1:employees-endorse-skill", + kwargs={"pk": target.id, "skill_id": skill.id}, + ) + + response = api_client.post( + url, + {"reason": "Применил навык при разработке внутреннего сервиса"}, + format="json", + ) + + assert response.status_code == 201 + assert response.data["points"] == 5 + assert response.data["experience"] == 5 + progress = EmployeeSkillProgress.objects.get(employee=target, skill=skill) + assert progress.experience == 5 + assert ( + SkillExperienceEvent.objects.filter( + progress=progress, + actor=actor, + source_type=SkillExperienceEvent.Source.ENDORSEMENT, + ).count() + == 1 + ) + + repeated = api_client.post( + url, + {"reason": "Повторное подтверждение того же навыка"}, + format="json", + ) + assert repeated.status_code == 409 + + +@pytest.mark.django_db +def test_employee_cannot_endorse_own_skill(api_client): + employee = _mk_user() + skill = Skill.objects.create(name="SQL") + employee.skills.add(skill) + api_client.force_authenticate(user=employee) + url = reverse( + "api:v1:employees-endorse-skill", + kwargs={"pk": employee.id, "skill_id": skill.id}, + ) + response = api_client.post( + url, + {"reason": "Самостоятельно подтвердил применение навыка"}, + format="json", + ) + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_task_skill_experience_is_idempotent_and_revocable(): + employee = _mk_user() + skill = Skill.objects.create(name="Django") + employee.skills.add(skill) + board = TaskBoard.objects.create(name="Проект", created_by=employee) + done = TaskColumn.objects.create( + board=board, + name="Готово", + position=1000, + is_done=True, + ) + task = Task.objects.create( + board=board, + column=done, + title="Сервис отчётов", + created_by=employee, + assignee=employee, + ) + task.skills.add(skill) + + assert award_task_skill_experience(task.id) == 1 + assert award_task_skill_experience(task.id) == 0 + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.experience == 30 + event = progress.experience_events.get() + assert event.metadata["reward_role"] == "assignee" + assert event.metadata["points"] == 30 + + assert revoke_task_skill_experience(task.id, actor=employee) == 1 + progress.refresh_from_db() + assert progress.experience == 0 + + +@pytest.mark.django_db +def test_task_skill_experience_snapshots_roles_for_each_completion(): + creator = _mk_user() + assignee = _mk_user() + participant = _mk_user() + skill = Skill.objects.create(name="PostgreSQL") + creator.skills.add(skill) + assignee.skills.add(skill) + participant.skills.add(skill) + board = TaskBoard.objects.create(name="Роли", created_by=creator) + done = TaskColumn.objects.create( + board=board, + name="Готово", + position=1000, + is_done=True, + ) + task = Task.objects.create( + board=board, + column=done, + title="Смена исполнителя", + created_by=creator, + assignee=assignee, + ) + task.participants.add(participant) + task.skills.add(skill) + + assert award_task_skill_experience(task.id) == 2 + assert ( + EmployeeSkillProgress.objects.get( + employee=assignee, + skill=skill, + ).experience + == 30 + ) + assert ( + EmployeeSkillProgress.objects.get( + employee=participant, + skill=skill, + ).experience + == 15 + ) + creator_progress = EmployeeSkillProgress.objects.get( + employee=creator, + skill=skill, + ) + assert creator_progress.experience == 0 + assert not creator_progress.experience_events.filter( + source_type=SkillExperienceEvent.Source.TASK, + ).exists() + + task.assignee = participant + task.save(update_fields=["assignee", "updated_at"]) + assert ( + SkillExperienceEvent.objects.filter( + source_type=SkillExperienceEvent.Source.TASK, + source_id=str(task.id), + revoked_at__isnull=True, + ).count() + == 2 + ) + assert ( + EmployeeSkillProgress.objects.get( + employee=assignee, + skill=skill, + ).experience + == 30 + ) + + assert revoke_task_skill_experience(task.id, actor=creator) == 2 + first_completed_at = task.completed_at + Task.objects.filter(id=task.id).update( + completed_at=first_completed_at + timedelta(minutes=1), + ) + task.refresh_from_db() + + assert award_task_skill_experience(task.id) == 2 + assignee_progress = EmployeeSkillProgress.objects.get( + employee=assignee, + skill=skill, + ) + participant_progress = EmployeeSkillProgress.objects.get( + employee=participant, + skill=skill, + ) + assert assignee_progress.experience == 15 + assert participant_progress.experience == 30 + + task_events = SkillExperienceEvent.objects.filter( + source_type=SkillExperienceEvent.Source.TASK, + source_id=str(task.id), + ) + assert task_events.count() == 4 + assert task_events.filter(revoked_at__isnull=False).count() == 2 + assert ( + task_events.filter( + revoked_at__isnull=True, + metadata__reward_role="assignee", + points=30, + ).count() + == 1 + ) + assert ( + task_events.filter( + revoked_at__isnull=True, + metadata__reward_role="participant", + points=15, + ).count() + == 1 + ) + + @pytest.mark.django_db def test_update_delete_permissions(api_client): user = _mk_user() diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 815964ab..f1973ac8 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -19,7 +19,7 @@ from rest_framework.test import APIClient from attendance.models import AttendanceAnalysisRun, AttendanceRecord from communications.models import Chat, ChatMembership, Message from documents.models import Document -from employees.models import Department, EmployeeDepartment, RoleAssignment +from employees.models import Department, EmployeeDepartment, RoleAssignment, Skill from feed.models import Post from guests.models import Guest, GuestVisit from procurement.constants import ProcurementStatus, UrgencyLevel @@ -1975,6 +1975,45 @@ def test_create_task_and_move_to_done_column(api_client, user): assert task.completed_at is not None +def test_create_and_update_task_skills(api_client, user): + api_client.force_authenticate(user=user) + board_response = api_client.get(reverse("api:v1:tasks:task-board-default")) + board_id = board_response.data["id"] + column_id = board_response.data["columns"][0]["id"] + python = Skill.objects.create(name="Python") + analytics = Skill.objects.create(name="Аналитика") + + create_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board_id, + "column": column_id, + "title": "Задача с навыком", + "skill_ids": [python.id], + }, + format="json", + ) + + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.data["id"] + assert [skill["id"] for skill in create_response.data["skills"]] == [python.id] + assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [ + python.id + ] + + update_response = api_client.patch( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task_id}), + {"skill_ids": [analytics.id]}, + format="json", + ) + + assert update_response.status_code == status.HTTP_200_OK + assert [skill["id"] for skill in update_response.data["skills"]] == [analytics.id] + assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [ + analytics.id + ] + + def test_task_participants_can_change_assignee_in_same_update(api_client, user): previous_assignee = make_user( "task-previous-assignee@example.com", diff --git a/backend/tests/employees/test_recalculate_secret_skills_command.py b/backend/tests/employees/test_recalculate_secret_skills_command.py new file mode 100644 index 00000000..869135eb --- /dev/null +++ b/backend/tests/employees/test_recalculate_secret_skills_command.py @@ -0,0 +1,64 @@ +from datetime import timedelta +from io import StringIO + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from employees.models import EmployeeSkillProgress, Skill +from tasks.models import TaskExecutionCycle + +pytestmark = pytest.mark.django_db + + +def _confirmed_cycles(employee, amount=10): + for index in range(amount): + completed_at = timezone.now() - timedelta(days=20 - index) + TaskExecutionCycle.objects.create( + task_id_snapshot=20000 + index, + employee=employee, + cycle_key=f"command-test:{employee.id}:{index}", + task_title=f"Командная задача {index}", + due_date_snapshot=timezone.localdate(completed_at), + completed_at_snapshot=completed_at, + protection_until=completed_at + timedelta(days=7), + status=TaskExecutionCycle.Status.CONFIRMED, + ) + + +def test_dry_run_reports_skill_and_rolls_back(user_factory): + employee = user_factory(first_name="Гарик", last_name="Мусик") + _confirmed_cycles(employee) + skill = Skill.objects.get(system_code="task_execution") + output = StringIO() + + call_command( + "recalculate_secret_skills", + employee_id=employee.id, + dry_run=True, + stdout=output, + ) + + result = output.getvalue() + assert "СУХОЙ ПРОГОН" in result + assert "Исполнительность: получат 1" in result + assert f"#{employee.id} Мусик Гарик" in result + assert "без ранга → бронза" in result + assert not EmployeeSkillProgress.objects.filter( + employee=employee, + skill=skill, + ).exists() + assert not employee.skills.filter(pk=skill.pk).exists() + + +def test_regular_run_persists_the_same_calculation(user_factory): + employee = user_factory() + _confirmed_cycles(employee) + skill = Skill.objects.get(system_code="task_execution") + + call_command("recalculate_secret_skills", employee_id=employee.id) + + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.is_active is True + assert employee.skills.filter(pk=skill.pk).exists() diff --git a/backend/tests/employees/test_secret_skills.py b/backend/tests/employees/test_secret_skills.py new file mode 100644 index 00000000..fe773228 --- /dev/null +++ b/backend/tests/employees/test_secret_skills.py @@ -0,0 +1,423 @@ +from datetime import date, datetime, time, timedelta +from unittest.mock import patch + +import pytest +from django.utils import timezone + +from attendance.models import AttendanceAnalysisRun, AttendanceRecord +from employees.constants import ( + ACTION_DISMISSED, + ACTION_HIRED, + ACTION_ON_DAY_OFF, + ACTION_ON_SICK_LEAVE, + ACTION_REMOTE, +) +from employees.models import EmployeeAction, EmployeeSkillProgress, Skill +from employees.services.secret_skills import recalculate_attendance_secret_skills + +pytestmark = pytest.mark.django_db + + +def _run(employee, start: date, end: date): + return AttendanceAnalysisRun.objects.create( + employee=employee, + period_start=start, + period_end=end, + status=AttendanceAnalysisRun.STATUS_SUCCESS, + ) + + +def _record(run, employee, record_date, **overrides): + values = { + "arrival_time": "08:00", + "departure_time": "17:00", + "work_hours": 9, + "expected_hours": 9, + "is_workday": True, + "effective_is_workday": True, + "personnel_status": "normal", + } + values.update(overrides) + return AttendanceRecord.objects.create( + analysis_run=run, + employee=employee, + date=record_date, + **values, + ) + + +def _action(employee, action, action_date, **overrides): + values = { + "date": timezone.make_aware(datetime.combine(action_date, time.min)), + } + values.update(overrides) + return EmployeeAction.objects.create( + employee=employee, + action=action, + **values, + ) + + +def test_punctuality_uses_best_streak_and_pauses_unknown_days(user_factory): + employee = user_factory() + start = date(2026, 1, 1) + run = _run(employee, start, start + timedelta(days=21)) + + for offset in range(10): + _record(run, employee, start + timedelta(days=offset)) + _record( + run, + employee, + start + timedelta(days=10), + arrival_time=None, + departure_time=None, + work_hours=0, + technical_issues=["Нет данных контроллера"], + ) + _record( + run, + employee, + start + timedelta(days=11), + arrival_time=None, + departure_time=None, + work_hours=0, + personnel_status=ACTION_REMOTE, + ) + for offset in range(12, 22): + _record(run, employee, start + timedelta(days=offset)) + + assert recalculate_attendance_secret_skills(employee.id) == ["Пунктуальность"] + skill = Skill.objects.get(system_code="attendance_punctuality") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.progress_data["best_streak"] == 20 + assert progress.progress_data["current_streak"] == 20 + assert employee.skills.filter(pk=skill.pk).exists() + + late_record = _record( + run, + employee, + start + timedelta(days=22), + is_late=True, + late_minutes=15, + ) + assert recalculate_attendance_secret_skills(employee.id) == [] + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.progress_data["current_streak"] == 0 + assert progress.progress_data["anti_streak"] == 1 + + late_record.delete() + assert recalculate_attendance_secret_skills(employee.id) == [] + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.progress_data["anti_streak"] == 0 + + +def test_self_organization_requires_every_bronze_condition( + user_factory, +): + employee = user_factory() + start = date(2024, 1, 1) + run = _run(employee, start, start + timedelta(days=64)) + + for offset in range(60): + overtime = offset < 5 + _record( + run, + employee, + start + timedelta(days=offset), + is_overtime=overtime, + overtime_hours=1.5 if overtime else None, + ) + off_schedule_records = [] + for offset in range(60, 65): + off_schedule_records.append( + _record( + run, + employee, + start + timedelta(days=offset), + is_workday=False, + effective_is_workday=False, + is_overtime=True, + overtime_hours=2, + ) + ) + + changes = recalculate_attendance_secret_skills(employee.id) + assert "Самоорганизация" in changes + skill = Skill.objects.get(system_code="attendance_self_organization") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.progress_data["clean_days"] == 60 + assert progress.progress_data["best_clean_streak"] == 60 + assert progress.progress_data["off_schedule_days"] == 5 + assert progress.progress_data["overtime_days"] == 10 + + off_schedule_records[0].is_overtime = False + off_schedule_records[0].overtime_hours = None + off_schedule_records[0].save(update_fields=["is_overtime", "overtime_hours"]) + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.progress_data["off_schedule_days"] == 4 + assert progress.special_rank == "none" + assert progress.is_active is False + assert not employee.skills.filter(pk=skill.pk).exists() + + +def test_punctuality_anti_streak_demotes_revokes_and_recovers(user_factory): + employee = user_factory() + start = date(2025, 1, 1) + run = _run(employee, start, start + timedelta(days=90)) + + for offset in range(45): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="attendance_punctuality") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "silver" + + for offset in range(45, 48): + _record( + run, + employee, + start + timedelta(days=offset), + is_late=True, + late_minutes=10, + ) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.progress_data["penalty_levels"] == 1 + assert progress.progress_data["anti_streak"] == 0 + + for offset in range(48, 51): + _record( + run, + employee, + start + timedelta(days=offset), + is_late=True, + late_minutes=10, + ) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "none" + assert progress.is_active is False + assert progress.progress_data["penalty_levels"] == 2 + assert not employee.skills.filter(pk=skill.pk).exists() + + for offset in range(51, 71): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.is_active is True + assert progress.progress_data["penalty_levels"] == 1 + assert employee.skills.filter(pk=skill.pk).exists() + + for offset in range(71, 91): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "silver" + assert progress.progress_data["penalty_levels"] == 0 + + +def test_self_organization_anti_streak_revokes_and_recovers(user_factory): + employee = user_factory() + start = date(2024, 1, 1) + run = _run(employee, start, start + timedelta(days=99)) + + for offset in range(60): + _record( + run, + employee, + start + timedelta(days=offset), + is_overtime=offset < 5, + overtime_hours=1.5 if offset < 5 else None, + ) + for offset in range(60, 65): + _record( + run, + employee, + start + timedelta(days=offset), + is_workday=False, + effective_is_workday=False, + is_overtime=True, + overtime_hours=2, + ) + + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="attendance_self_organization") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + + for offset in range(65, 70): + _record( + run, + employee, + start + timedelta(days=offset), + is_late=True, + late_minutes=10, + ) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "none" + assert progress.is_active is False + assert progress.progress_data["penalty_levels"] == 1 + + for offset in range(70, 100): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.is_active is True + assert progress.progress_data["penalty_levels"] == 0 + + +def test_reliability_uses_only_personnel_events_and_recovers_rank(user_factory): + employee = user_factory() + start = date(2025, 1, 1) + _action(employee, ACTION_HIRED, start) + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=59), + ): + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="attendance_reliability") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.progress_data["worked_days"] == 60 + + for offset in (60, 65, 70): + _action( + employee, + ACTION_ON_DAY_OFF, + start + timedelta(days=offset), + date_to=start + timedelta(days=offset), + ) + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=70), + ): + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "none" + assert progress.is_active is False + assert progress.progress_data["penalty_levels"] == 1 + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=130), + ): + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.is_active is True + assert progress.progress_data["worked_days"] == 128 + assert progress.progress_data["leave_or_day_off_episodes"] == 3 + assert progress.progress_data["penalty_levels"] == 0 + + +def test_remote_reactivates_employment_without_resetting_active_period(user_factory): + employee = user_factory() + start = date(2025, 1, 1) + _action(employee, ACTION_HIRED, start) + _action(employee, ACTION_REMOTE, start + timedelta(days=30)) + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=59), + ): + recalculate_attendance_secret_skills(employee.id) + + skill = Skill.objects.get(system_code="attendance_reliability") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.progress_data["worked_days"] == 60 + + _action(employee, ACTION_DISMISSED, start + timedelta(days=60)) + _action(employee, ACTION_REMOTE, start + timedelta(days=70)) + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=129), + ): + recalculate_attendance_secret_skills(employee.id) + + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.progress_data["worked_days"] == 60 + assert ( + progress.progress_data["last_evaluated_date"] + == (start + timedelta(days=129)).isoformat() + ) + + +def test_resilience_counts_sick_episodes_and_recovers_rank(user_factory): + employee = user_factory() + start = date(2024, 1, 1) + _action(employee, ACTION_HIRED, start) + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=59), + ): + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="personnel_resilience") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.progress_data["best_resilience_streak"] == 60 + + _action( + employee, + ACTION_ON_SICK_LEAVE, + start + timedelta(days=60), + date_to=start + timedelta(days=79), + ) + _action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=90)) + _action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=120)) + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=120), + ): + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "none" + assert progress.is_active is False + assert progress.progress_data["sick_leave_episodes"] == 3 + assert progress.progress_data["penalty_levels"] == 1 + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=210), + ): + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.is_active is True + assert progress.progress_data["current_resilience_streak"] == 90 + assert progress.progress_data["penalty_levels"] == 0 + + +def test_personnel_action_schedules_secret_skill_recalculation( + user_factory, + django_capture_on_commit_callbacks, +): + employee = user_factory() + with patch( + "employees.services.secret_skills.recalculate_attendance_secret_skills" + ) as recalculate: + with django_capture_on_commit_callbacks(execute=True): + _action(employee, ACTION_ON_SICK_LEAVE, date(2026, 8, 1)) + + recalculate.assert_called_once_with(employee.id) diff --git a/backend/tests/tasks/__init__.py b/backend/tests/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/tasks/test_task_execution_skill.py b/backend/tests/tasks/test_task_execution_skill.py new file mode 100644 index 00000000..f57295c4 --- /dev/null +++ b/backend/tests/tasks/test_task_execution_skill.py @@ -0,0 +1,176 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from employees.models import EmployeeSkillProgress, Skill +from employees.services.task_execution_skill import ( + capture_task_execution_cycle, + recalculate_task_execution_skill, + refresh_task_execution_skills, + reopen_task_execution_cycle, +) +from tasks.models import Task, TaskBoard, TaskColumn, TaskExecutionCycle + +pytestmark = pytest.mark.django_db + + +def _board_columns(creator): + board = TaskBoard.objects.create(name="Исполнительность", created_by=creator) + source = TaskColumn.objects.create(board=board, name="В работе", position=1000) + done = TaskColumn.objects.create( + board=board, + name="Готово", + position=2000, + is_done=True, + ) + return board, source, done + + +def _cycle(employee, index, status, *, days_ago): + completed_at = timezone.now() - timedelta(days=days_ago, minutes=index) + return TaskExecutionCycle.objects.create( + task_id_snapshot=10000 + index, + employee=employee, + cycle_key=f"test-execution:{employee.id}:{index}:{status}", + task_title=f"Задача {index}", + due_date_snapshot=timezone.localdate(completed_at), + completed_at_snapshot=completed_at, + protection_until=completed_at + timedelta(days=7), + status=status, + reopened_at=( + completed_at if status == TaskExecutionCycle.Status.REOPENED else None + ), + ) + + +def test_completion_snapshot_keeps_original_assignee(user_factory): + creator = user_factory() + assignee = user_factory() + replacement = user_factory() + board, source, done = _board_columns(creator) + task = Task.objects.create( + board=board, + column=done, + title="Закрытая задача", + created_by=creator, + assignee=assignee, + due_date=timezone.localdate() + timedelta(days=1), + ) + + cycle = capture_task_execution_cycle(task.id) + assert cycle.status == TaskExecutionCycle.Status.PENDING + assert cycle.employee_id == assignee.id + + task.assignee = replacement + task.save(update_fields=["assignee", "updated_at"]) + cycle.refresh_from_db() + assert cycle.employee_id == assignee.id + + previous_completed_at = task.completed_at + task.column = source + task.save(update_fields=["column", "completed_at", "updated_at"]) + reopen_task_execution_cycle(task.id, previous_completed_at) + cycle.refresh_from_db() + assert cycle.status == TaskExecutionCycle.Status.REOPENED + assert cycle.employee_id == assignee.id + + +def test_self_assigned_and_unscheduled_tasks_are_excluded(user_factory): + employee = user_factory() + board, _, done = _board_columns(employee) + self_assigned = Task.objects.create( + board=board, + column=done, + title="Сам себе исполнитель", + created_by=employee, + assignee=employee, + due_date=timezone.localdate(), + ) + no_due_date = Task.objects.create( + board=board, + column=done, + title="Без срока", + created_by=user_factory(), + assignee=employee, + ) + + self_cycle = capture_task_execution_cycle(self_assigned.id) + no_due_cycle = capture_task_execution_cycle(no_due_date.id) + + assert self_cycle.status == TaskExecutionCycle.Status.EXCLUDED + assert self_cycle.exclusion_reason == "self_assigned" + assert no_due_cycle.status == TaskExecutionCycle.Status.EXCLUDED + assert no_due_cycle.exclusion_reason == "missing_due_date" + + +def test_ten_confirmed_tasks_unlock_bronze(user_factory): + employee = user_factory() + for index in range(10): + _cycle( + employee, + index, + TaskExecutionCycle.Status.CONFIRMED, + days_ago=20 - index, + ) + + assert recalculate_task_execution_skill(employee.id) == ["Исполнительность"] + skill = Skill.objects.get(system_code="task_execution") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + assert progress.progress_data["confirmed_tasks"] == 10 + assert progress.progress_data["on_time_percent"] == 100 + assert progress.progress_data["best_on_time_streak"] == 10 + assert employee.skills.filter(pk=skill.pk).exists() + + +def test_three_recent_violations_demote_silver_to_bronze(user_factory): + employee = user_factory() + for index in range(30): + _cycle( + employee, + index, + TaskExecutionCycle.Status.CONFIRMED, + days_ago=50 - index, + ) + recalculate_task_execution_skill(employee.id) + skill = Skill.objects.get(system_code="task_execution") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "silver" + + for index in range(30, 33): + _cycle( + employee, + index, + TaskExecutionCycle.Status.REOPENED, + days_ago=3 - (index - 30), + ) + recalculate_task_execution_skill(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.progress_data["violations_last_60"] == 3 + assert progress.progress_data["penalty_levels"] == 1 + + +def test_refresh_captures_old_completion_and_confirms_after_protection(user_factory): + creator = user_factory() + assignee = user_factory() + board, _, done = _board_columns(creator) + task = Task.objects.create( + board=board, + column=done, + title="Старая завершённая задача", + created_by=creator, + assignee=assignee, + due_date=timezone.localdate() - timedelta(days=8), + ) + old_completion = timezone.now() - timedelta(days=8) + TaskExecutionCycle.objects.filter(task=task).delete() + Task.objects.filter(pk=task.pk).update(completed_at=old_completion) + + result = refresh_task_execution_skills(employee_id=assignee.id) + + cycle = TaskExecutionCycle.objects.get(task=task) + assert result["captured_cycles"] == 1 + assert cycle.completed_at_snapshot == old_completion + assert cycle.status == TaskExecutionCycle.Status.CONFIRMED diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 0b94f09d..e18a5a47 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -161,6 +161,7 @@ export default function ProfilePage() {
void handleAddSkill()} diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 1ca14b50..20651e57 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -57,6 +57,7 @@ import { RefreshCw, Rows3, Search, + Sparkles, ShoppingCart, Tag, Trash2, @@ -108,6 +109,7 @@ import type { Department, Document, Message, + Skill, TaskActivity, TaskAttachment, TaskAutomation, @@ -152,6 +154,7 @@ type TaskFormState = { priority: TaskPriority; due_date: string; label_ids: number[]; + skill_ids: number[]; }; type BoardFormState = { @@ -190,7 +193,7 @@ type ColumnRowFormState = { type LinkedItemMode = "document" | "calendar_event" | "external_link"; type LinkedItemTarget = "view" | "form"; -type TaskAddPanel = "due_date" | "labels" | "priority" | null; +type TaskAddPanel = "due_date" | "labels" | "priority" | "skills" | null; type TaskCoverLinkedOption = { id: number; @@ -281,6 +284,7 @@ const emptyForm: TaskFormState = { priority: "medium", due_date: "", label_ids: [], + skill_ids: [], }; const emptyBoardForm: BoardFormState = { @@ -3762,6 +3766,7 @@ function TasksPageContent() { const [taskParticipantCandidates, setTaskParticipantCandidates] = useState([]); const [departments, setDepartments] = useState([]); const [taskLabels, setTaskLabels] = useState([]); + const [availableSkills, setAvailableSkills] = useState([]); const [loading, setLoading] = useState(true); const [boardLoading, setBoardLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -3796,6 +3801,7 @@ function TasksPageContent() { const [taskAddMenuPlacement, setTaskAddMenuPlacement] = useState<"top" | "bottom">("top"); const [taskAddPanel, setTaskAddPanel] = useState(null); const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState(""); + const [viewTaskSkillSearch, setViewTaskSkillSearch] = useState(""); const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false); const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); @@ -4457,6 +4463,7 @@ function TasksPageContent() { employeesData, departmentsData, labelsData, + skillsData, ] = await Promise.all([ requestedBoardId > 0 ? apiClient.getTaskBoard(requestedBoardId) @@ -4466,6 +4473,7 @@ function TasksPageContent() { apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }), apiClient.getDepartments({ limit: 200 }), apiClient.getTaskLabels(), + apiClient.getSkills(), ]); if (!mounted) return; const boardList = Array.isArray(boardsData) ? boardsData : boardsData.results || []; @@ -4490,6 +4498,7 @@ function TasksPageContent() { setEmployees(employeesData.results || employeesData || []); setDepartments(departmentsData.results || departmentsData || []); setTaskLabels(Array.isArray(labelsData) ? labelsData : labelsData.results || []); + setAvailableSkills(Array.isArray(skillsData) ? skillsData : skillsData.results || []); if ( boardData && requestedTaskId > 0 && @@ -4742,6 +4751,16 @@ function TasksPageContent() { () => (board?.tasks || []).find((task) => task.id === viewTaskId) || null, [board?.tasks, viewTaskId], ); + const filteredViewTaskSkills = useMemo(() => { + const query = viewTaskSkillSearch.trim().toLowerCase(); + const selectedIds = new Set((viewTask?.skills || []).map((skill) => skill.id)); + return availableSkills + .filter((skill) => !query || skill.name.toLowerCase().includes(query)) + .sort((left, right) => { + const selectedDifference = Number(selectedIds.has(right.id)) - Number(selectedIds.has(left.id)); + return selectedDifference || left.name.localeCompare(right.name, "ru"); + }); + }, [availableSkills, viewTask?.skills, viewTaskSkillSearch]); const viewTaskColumn = useMemo( () => activeColumns.find((column) => column.id === viewTask?.column) || null, @@ -5110,6 +5129,7 @@ function TasksPageContent() { setTaskAddMenuOpen(false); setTaskAddPanel(null); setTaskLabelSearch(""); + setViewTaskSkillSearch(""); setViewTaskDueDateDraft(task.due_date || ""); setViewColumnMenuOpen(false); setLinkedObjectsOpen(false); @@ -5172,6 +5192,7 @@ function TasksPageContent() { setTaskAddMenuOpen(false); setTaskAddPanel(null); setTaskLabelSearch(""); + setViewTaskSkillSearch(""); setViewTaskDueDateDraft(""); setViewColumnMenuOpen(false); setLinkedMessages([]); @@ -5308,6 +5329,7 @@ function TasksPageContent() { priority: task.priority, due_date: task.due_date || "", label_ids: (task.labels || []).map((label) => label.id), + skill_ids: (task.skills || []).map((skill) => skill.id), }); setLabelName(""); setTaskLabelSearch(""); @@ -6192,6 +6214,7 @@ function TasksPageContent() { priority: form.priority, due_date: form.due_date || null, label_ids: form.label_ids, + skill_ids: form.skill_ids, }; let savedTaskId = form.id || null; let primaryDataSaved = false; @@ -6328,6 +6351,27 @@ function TasksPageContent() { } }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + const toggleViewedTaskSkill = useCallback(async (skillId: number) => { + if (!board || !viewTask || saving) return; + + const currentSkillIds = viewTask.skills?.map((skill) => skill.id) || []; + const nextSkillIds = currentSkillIds.includes(skillId) + ? currentSkillIds.filter((id) => id !== skillId) + : [...currentSkillIds, skillId]; + + setSaving(true); + setError(null); + try { + await apiClient.updateTask(viewTask.id, { skill_ids: nextSkillIds }); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (skillError) { + setError(getTaskError(skillError, "Не удалось изменить навыки задачи")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + const createViewedTaskLabel = useCallback(async () => { if (!board || !viewTask || !labelName.trim() || saving) return; @@ -6374,6 +6418,15 @@ function TasksPageContent() { })); }, []); + const toggleTaskSkill = useCallback((skillId: number) => { + setForm((current) => ({ + ...current, + skill_ids: current.skill_ids.includes(skillId) + ? current.skill_ids.filter((id) => id !== skillId) + : [...current.skill_ids, skillId], + })); + }, []); + const toggleTaskParticipant = useCallback((employeeId: number) => { setForm((current) => { const requiredIds = new Set(); @@ -7615,6 +7668,18 @@ function TasksPageContent() { Метки +
) : null} + {taskAddPanel === "skills" ? ( +
+
+ + + Навыки задачи + {(viewTask.skills?.length || 0) > 0 ? ( + + {viewTask.skills?.length} + + ) : null} + + +
+ + {filteredViewTaskSkills.length ? ( +
+ {filteredViewTaskSkills.map((skill) => { + const selected = Boolean(viewTask.skills?.some((item) => item.id === skill.id)); + return ( + + ); + })} +
+ ) : ( +
+

+ {availableSkills.length ? "Навыки не найдены" : "Навыки пока не созданы"} +

+
+ )} +

+ После завершения задачи исполнители и участники с выбранными навыками получат опыт. +

+
+ ) : null} + {viewTask.description ? (

@@ -9682,6 +9813,26 @@ function TasksPageContent() {

) : null} + {viewTask.skills && viewTask.skills.length > 0 ? ( +
+
+ + Навыки задачи +
+
+ {viewTask.skills.map((skill) => ( + + {skill.name} + + ))} +
+
+ ) : null} + {attachmentsBadgeCount > 0 || attachmentsOpen ? (
+
+
+
+ + Навыки задачи +
+ {form.skill_ids.length ? ( + + {form.skill_ids.length} + + ) : null} +
+ + {availableSkills.length ? availableSkills.map((skill) => { + const selected = form.skill_ids.includes(skill.id); + return ( + + ); + }) : ( + Навыки пока не созданы + )} + +

+ После завершения задачи участники с этими навыками получат опыт. +

+
+
diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index 38ca22aa..887fa7d4 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -38,6 +38,7 @@ import { import { useUser } from "@/contexts/UserContext"; import { useUserDetailPage } from "@/hooks/useUserDetailPage"; import { apiClient } from "@/lib/api"; +import type { Skill } from "@/types/api"; import { formatBirthdayWithYear, formatProfileDate, @@ -122,12 +123,8 @@ export default function UserDetailPage() { || currentUser.auth?.is_superuser ), ); - const [availableSkills, setAvailableSkills] = useState< - Array<{ id: number; name: string; description?: string }> - >([]); - const [personSkills, setPersonSkills] = useState< - Array<{ id: number; name: string; description?: string }> - >([]); + const [availableSkills, setAvailableSkills] = useState([]); + const [personSkills, setPersonSkills] = useState([]); const [skillName, setSkillName] = useState(""); const [skillsLoading, setSkillsLoading] = useState(false); const [skillsSaving, setSkillsSaving] = useState(false); @@ -303,7 +300,7 @@ export default function UserDetailPage() { const response = (await apiClient.addEmployeeSkill( person.id, existingSkill ? { skill_id: existingSkill.id } : { name: trimmedName }, - )) as { skills?: Array<{ id: number; name: string; description?: string }> }; + )) as { skills?: Skill[] }; setPersonSkills(response.skills || []); @@ -517,6 +514,7 @@ export default function UserDetailPage() { emptyText="Контакты не указаны" /> void handleAddSkill()} @@ -527,6 +525,7 @@ export default function UserDetailPage() { error={skillsError} loading={skillsLoading} emptyText="Навыки не указаны" + onSkillUpdated={() => refreshPerson()} />
} diff --git a/frontend/src/components/users/ProfileSections.tsx b/frontend/src/components/users/ProfileSections.tsx index 44101b48..61663b15 100644 --- a/frontend/src/components/users/ProfileSections.tsx +++ b/frontend/src/components/users/ProfileSections.tsx @@ -10,17 +10,22 @@ import { type ReactNode, } from "react"; import { + Award, CalendarClock, Check, ChevronDown, CircleHelp, Clock3, Crown, + Gem, Info, Link2, Loader2, Pencil, Plus, + Shield, + Sparkles, + Trophy, X, } from "lucide-react"; import { apiClient } from "@/lib/api"; @@ -31,7 +36,10 @@ import type { EmployeeAttendanceStatus, EmployeeDepartment, EmployeePersonnelState, + EmployeeSkillProgress, Skill, + SkillRank, + SkillRarity, } from "@/types/api"; import { getEmployeeActionTone } from "@/lib/users/userDetailUtils"; @@ -50,6 +58,77 @@ export type ProfileInfoItem = { value: string; }; +const skillRarityStyles: Record = { + unrated: "border-[var(--border-subtle)]", + common: "border-slate-500/60", + uncommon: "border-emerald-500/60", + rare: "border-sky-500/70", + epic: "border-violet-500/70", + legendary: "border-amber-400/80", + secret: "border-fuchsia-400/80 bg-fuchsia-500/5", +}; + +const skillRarityBadgeStyles: Record = { + unrated: "bg-slate-500/10 text-slate-400", + common: "bg-slate-500/15 text-slate-300", + uncommon: "bg-emerald-500/15 text-emerald-300", + rare: "bg-sky-500/15 text-sky-300", + epic: "bg-violet-500/15 text-violet-300", + legendary: "bg-amber-400/15 text-amber-300", + secret: "bg-fuchsia-400/15 text-fuchsia-200", +}; + +const skillRankOrder: Record = { + none: 0, + bronze: 1, + silver: 2, + gold: 3, + ruby: 4, + diamond: 5, +}; + +const skillRarityOrder: Record = { + unrated: 0, + common: 1, + uncommon: 2, + rare: 3, + epic: 4, + legendary: 5, + secret: 6, +}; + +function sortProfileSkills(skills: Skill[]) { + return [...skills].sort((left, right) => { + const rankDifference = skillRankOrder[right.rank || "none"] - skillRankOrder[left.rank || "none"]; + if (rankDifference) return rankDifference; + const experienceDifference = (right.experience || 0) - (left.experience || 0); + if (experienceDifference) return experienceDifference; + const rarityDifference = skillRarityOrder[right.rarity || "unrated"] - skillRarityOrder[left.rarity || "unrated"]; + if (rarityDifference) return rarityDifference; + return left.name.localeCompare(right.name, "ru"); + }); +} + +function SkillRankMark({ rank = "none", size = 14 }: { rank?: SkillRank; size?: number }) { + const props = { size, strokeWidth: 2.2, "aria-hidden": true }; + if (rank === "bronze") return ; + if (rank === "silver") return ; + if (rank === "gold") return ; + if (rank === "ruby") return ; + if (rank === "diamond") return ; + return ; +} + +function formatSkillEventDate(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(date); +} + const workScheduleDays = [ { value: "Monday", label: "Пн" }, { value: "Tuesday", label: "Вт" }, @@ -708,6 +787,7 @@ export function ProfileWorkScheduleCard({ } export function ProfileSkillsInline({ + employeeId, title = "Навыки", inputValue, onInputChange, @@ -721,7 +801,9 @@ export function ProfileSkillsInline({ onRemoveSkill, removeDisabled, emptyText = "Навыки не указаны", + onSkillUpdated, }: { + employeeId: number; title?: string; inputValue: string; onInputChange: (value: string) => void; @@ -735,29 +817,112 @@ export function ProfileSkillsInline({ onRemoveSkill?: (skillId: number) => void; removeDisabled?: boolean; emptyText?: string; + onSkillUpdated?: () => unknown | Promise; }) { const [modalOpen, setModalOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); + const [progressOpen, setProgressOpen] = useState(false); + const [progressLoading, setProgressLoading] = useState(false); + const [progress, setProgress] = useState(null); + const [progressError, setProgressError] = useState(null); + const [endorsementReason, setEndorsementReason] = useState(""); + const [endorsementSaving, setEndorsementSaving] = useState(false); const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`; - const helpId = `${listId}-help`; + const sortedSkills = sortProfileSkills(skills); + const editableSkills = skills.filter((skill) => !skill.is_secret); + + const openSkillProgress = async (skill: Skill) => { + setProgressOpen(true); + setProgressLoading(true); + setProgressError(null); + setProgress(null); + setEndorsementReason(""); + try { + const response = await apiClient.getEmployeeSkillProgress(employeeId, skill.id); + setProgress(response as EmployeeSkillProgress); + } catch (requestError) { + setProgressError( + requestError instanceof Error ? requestError.message : "Не удалось загрузить прогресс навыка", + ); + } finally { + setProgressLoading(false); + } + }; + + const endorseSkill = async () => { + if (!progress || endorsementReason.trim().length < 8 || endorsementSaving) return; + setEndorsementSaving(true); + setProgressError(null); + try { + await apiClient.endorseEmployeeSkill(employeeId, progress.skill_id, { + reason: endorsementReason.trim(), + }); + const refreshed = await apiClient.getEmployeeSkillProgress(employeeId, progress.skill_id); + setProgress(refreshed as EmployeeSkillProgress); + setEndorsementReason(""); + await onSkillUpdated?.(); + } catch (requestError) { + setProgressError( + requestError instanceof Error ? requestError.message : "Не удалось подтвердить навык", + ); + } finally { + setEndorsementSaving(false); + } + }; return ( <>
+ `${skill.id}:${skill.rank}:${skill.experience}:${skill.rarity}:${skill.anti_streak}:${skill.penalty_levels}`, + ) + .join("|")} className="min-w-0 flex-1" ariaLabel="Навыки сотрудника" fadeColor="var(--surface-primary)" > - {skills.length ? ( - skills.map((skill) => ( - ( + )) ) : ( @@ -788,12 +953,12 @@ export function ProfileSkillsInline({ {title} @@ -803,23 +968,6 @@ export function ProfileSkillsInline({ closeOnClickOutside >
- {helpOpen ? ( -
-

Как заполнять навыки

-
    -
  • • Один навык — одна конкретная компетенция или технология.
  • -
  • • Выбирайте существующее название из списка, если оно уже есть.
  • -
  • • Не объединяйте несколько навыков и не указывайте должность или уровень владения.
  • -
-

- Например: «Python», «1С», «Электромонтаж». -

-
- ) : null} -

Добавленные навыки

- {skills.length ? ( + {editableSkills.length ? (
- {skills.map((skill) => + {editableSkills.map((skill) => onRemoveSkill ? (