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/employee_serializers/detail.py b/backend/api/v1/employees/serializers/employee_serializers/detail.py index 01a858ce..315e33db 100644 --- a/backend/api/v1/employees/serializers/employee_serializers/detail.py +++ b/backend/api/v1/employees/serializers/employee_serializers/detail.py @@ -42,7 +42,10 @@ class EmployeeSerializer(serializers.ModelSerializer): 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) @@ -142,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/shared.py b/backend/api/v1/employees/serializers/shared.py index b7609325..a821380b 100644 --- a/backend/api/v1/employees/serializers/shared.py +++ b/backend/api/v1/employees/serializers/shared.py @@ -13,7 +13,10 @@ from employees.models import ( from employees.services.skill_progress import ( confirmed_holder_count, rank_payload, - rarity_payload, +) +from employees.services.secret_skills import ( + secret_skill_progress_payload, + skill_rarity_payload, ) from rest_framework import serializers @@ -47,12 +50,18 @@ class SkillSerializer(serializers.ModelSerializer): 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() class Meta: model = Skill fields = [ "id", "name", + "is_secret", + "progression_type", "experience", "rank", "rank_display", @@ -64,20 +73,28 @@ class SkillSerializer(serializers.ModelSerializer): "confirmed_holders", "rarity", "rarity_display", + "progress_value", + "progress_label", + "progress_data", + "next_rank_requirements", ] + 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): - return rank_payload(getattr(self._progress(obj), "experience", 0)) + 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 rarity_payload(count) + return skill_rarity_payload(obj, count) def get_experience(self, obj): return getattr(self._progress(obj), "experience", 0) @@ -112,6 +129,18 @@ class SkillSerializer(serializers.ModelSerializer): 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", []) + class SkillExperienceEventSerializer(serializers.ModelSerializer): source_display = serializers.CharField( 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 686bd697..9fc13754 100644 --- a/backend/api/v1/employees/views/employee_mixins/profile.py +++ b/backend/api/v1/employees/views/employee_mixins/profile.py @@ -16,12 +16,15 @@ from employees.services.skill_progress import ( endorsement_available_at, endorsement_weight, ensure_skill_progress, - rarity_payload, 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 @@ -102,6 +105,12 @@ 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, @@ -135,13 +144,17 @@ class EmployeeProfileMixin: "-created_at", "-id", )[:50] - rank = rank_payload(progress.experience) + 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 = rarity_payload(holder_count) + rarity = skill_rarity_payload(skill, holder_count) available_at = None - if request.user.id != employee.id: + if not skill.is_secret and request.user.id != employee.id: available_at = endorsement_available_at( progress=progress, actor=request.user, @@ -151,11 +164,15 @@ class EmployeeProfileMixin: "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": ( - request.user.id != employee.id and available_at is None + 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, @@ -183,6 +200,11 @@ class EmployeeProfileMixin: {"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: diff --git a/backend/api/v1/employees/views/skills.py b/backend/api/v1/employees/views/skills.py index f24d078a..9aa792ea 100644 --- a/backend/api/v1/employees/views/skills.py +++ b/backend/api/v1/employees/views/skills.py @@ -38,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()] @@ -116,6 +122,11 @@ 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: diff --git a/backend/api/v1/tasks/serializers/tasks.py b/backend/api/v1/tasks/serializers/tasks.py index b6ef92df..74c591f4 100644 --- a/backend/api/v1/tasks/serializers/tasks.py +++ b/backend/api/v1/tasks/serializers/tasks.py @@ -56,7 +56,7 @@ class TaskSerializer(serializers.ModelSerializer): skills = SkillSerializer(many=True, read_only=True) skill_ids = serializers.PrimaryKeyRelatedField( source="skills", - queryset=Skill.objects.all(), + queryset=Skill.objects.filter(is_secret=False), many=True, required=False, write_only=True, 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 0aa2b645..b0bd6a17 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -1905,14 +1905,22 @@ 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", "is_active", "updated_at") - list_filter = ("is_active", "skill") + 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", 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..c9cfb6a7 --- /dev/null +++ b/backend/employees/management/commands/recalculate_secret_skills.py @@ -0,0 +1,38 @@ +from django.core.management.base import BaseCommand + +from attendance.models import AttendanceRecord +from employees.services.secret_skills import recalculate_attendance_secret_skills + + +class Command(BaseCommand): + help = "Пересчитать секретные навыки по сохранённой посещаемости" + + def add_arguments(self, parser): + parser.add_argument("--employee-id", type=int) + + def handle(self, *args, **options): + employee_id = options.get("employee_id") + employee_ids = ( + AttendanceRecord.objects.order_by() + .values_list( + "employee_id", + flat=True, + ) + .distinct() + ) + if employee_id: + employee_ids = employee_ids.filter(employee_id=employee_id) + + processed = 0 + unlocked_or_promoted = 0 + for current_employee_id in employee_ids.iterator(): + changes = recalculate_attendance_secret_skills(current_employee_id) + processed += 1 + unlocked_or_promoted += len(changes) + + self.stdout.write( + self.style.SUCCESS( + "Пересчитано сотрудников: " + f"{processed}; открыто или повышено навыков: {unlocked_or_promoted}" + ) + ) 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/models.py b/backend/employees/models.py index fcfd62a9..a77fdc3b 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -692,7 +692,32 @@ class RoleAssignment(models.Model): class Skill(models.Model): + class ProgressionType(models.TextChoices): + STANDARD = "standard", "Обычный опыт" + ATTENDANCE_PUNCTUALITY = ( + "attendance_punctuality", + "Посещаемость: пунктуальность", + ) + ATTENDANCE_SELF_ORGANIZATION = ( + "attendance_self_organization", + "Посещаемость: самоорганизация", + ) + 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 = "Навык" @@ -722,6 +747,24 @@ class EmployeeSkillProgress(models.Model): 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, diff --git a/backend/employees/services/secret_skills.py b/backend/employees/services/secret_skills.py new file mode 100644 index 00000000..5430ee94 --- /dev/null +++ b/backend/employees/services/secret_skills.py @@ -0,0 +1,404 @@ +"""Автоматический прогресс секретных навыков по данным посещаемости.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from django.db import transaction +from django.utils import timezone + +from employees.constants import ACTION_REMOTE +from employees.models import Employee, 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, + }, +} + +REQUIREMENT_LABELS = { + "best_streak": "Лучшая серия без опозданий", + "clean_days": "Чистые рабочие дни", + "best_clean_streak": "Лучшая чистая серия", + "off_schedule_days": "Выходы в нерабочий день", + "overtime_days": "Дни с переработкой от 1 часа", +} + + +@dataclass(frozen=True) +class SecretSkillDefinition: + system_code: str + name: str + progression_type: str + requirements: dict[str, dict[str, int]] + + +SECRET_SKILL_DEFINITIONS = ( + SecretSkillDefinition( + system_code="attendance_punctuality", + name="Пунктуальность", + progression_type=Skill.ProgressionType.ATTENDANCE_PUNCTUALITY, + requirements=PUNCTUALITY_REQUIREMENTS, + ), + SecretSkillDefinition( + system_code="attendance_self_organization", + name="Самоорганизация", + progression_type=Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION, + requirements=SELF_ORGANIZATION_REQUIREMENTS, + ), +) + + +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 calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | str]: + """Рассчитать фактические показатели по нормализованным записям.""" + + 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 = "" + + 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 + continue + + if record.personnel_status == ACTION_REMOTE: + continue + + last_evaluated_date = record.date.isoformat() + if record.is_absent or not has_work: + punctuality_streak = 0 + clean_streak = 0 + 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 + + 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 _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, int | str], +) -> dict[str, int | str]: + 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"], + } + 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"], + } + + +@transaction.atomic +def recalculate_attendance_secret_skills(employee_id: int) -> list[str]: + """Пересчитать и при необходимости открыть системные навыки сотрудника.""" + + 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") + ) + metrics = calculate_attendance_secret_metrics(records) + 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 SECRET_SKILL_DEFINITIONS + ], + is_secret=True, + ) + } + for definition in SECRET_SKILL_DEFINITIONS: + skill = skills.get(definition.system_code) + if skill is None: + continue + + definition_metrics = _metrics_for_definition(definition, metrics) + candidate_rank = _candidate_rank( + definition_metrics, + definition.requirements, + ) + progress = EmployeeSkillProgress.objects.filter( + employee=employee, + skill=skill, + ).first() + previous_rank = progress.special_rank if progress else "none" + achieved_rank = ( + candidate_rank + if RANK_INDEX[candidate_rank] > RANK_INDEX[previous_rank] + else previous_rank + ) + + if achieved_rank == "none": + if progress and progress.is_active: + progress.is_active = False + progress.experience = 0 + progress.progress_data = definition_metrics + progress.save( + update_fields=[ + "is_active", + "experience", + "progress_data", + "updated_at", + ] + ) + employee.skills.remove(skill) + 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[achieved_rank] > RANK_INDEX[previous_rank]: + rank_history[achieved_rank] = now.isoformat() + + progress.special_rank = achieved_rank + progress.experience = 0 + progress.is_active = True + progress.created_by = None + progress.progress_data = { + **definition_metrics, + "rank_achieved_at": rank_history, + "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 achieved_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 + 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 next_requirements: + progress_percent = round( + min( + min(int(metrics.get(metric, 0) or 0) / max(target, 1), 1) + for metric, target in next_requirements.items() + ) + * 100 + ) + else: + progress_percent = 100 + + if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY: + progress_value = int(metrics.get("best_streak", 0) or 0) + progress_label = f"Лучшая серия: {progress_value} дн." + else: + progress_value = int(metrics.get("clean_days", 0) or 0) + completed = sum(item["complete"] for item in requirement_payload) + progress_label = ( + f"До следующего ранга: {completed}/{len(requirement_payload)} условий" + if requirement_payload + else "Все условия выполнены" + ) + + 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, + } + + +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 index df690dbd..91198266 100644 --- a/backend/employees/services/skill_progress.py +++ b/backend/employees/services/skill_progress.py @@ -39,9 +39,12 @@ def skill_queryset_with_rarity(queryset=None): return queryset.annotate( confirmed_holders=Count( "employee_progresses", - filter=Q( + filter=( + Q(is_secret=True) + | Q(employee_progresses__experience__gte=RARITY_CONFIRMATION_EXPERIENCE) + ) + & Q( employee_progresses__is_active=True, - employee_progresses__experience__gte=RARITY_CONFIRMATION_EXPERIENCE, employee_progresses__employee__is_active=True, employee_progresses__employee__is_hidden_in_ui=False, ), @@ -105,18 +108,16 @@ def rarity_payload(confirmed_holders: int) -> dict: def confirmed_holder_count(skill: Skill) -> int: - return ( - EmployeeSkillProgress.objects.filter( - skill=skill, - is_active=True, - experience__gte=RARITY_CONFIRMATION_EXPERIENCE, - employee__is_active=True, - employee__is_hidden_in_ui=False, - employee__skills=skill, - ) - .distinct() - .count() - ) + 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( @@ -158,6 +159,8 @@ def record_skill_experience( 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("Баллы опыта должны быть положительным числом") @@ -300,6 +303,8 @@ def award_task_skill_experience(task_id: int) -> int: 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, diff --git a/backend/tests/api/v1/employees/test_skills.py b/backend/tests/api/v1/employees/test_skills.py index 8c485516..c570fb9e 100644 --- a/backend/tests/api/v1/employees/test_skills.py +++ b/backend/tests/api/v1/employees/test_skills.py @@ -98,6 +98,69 @@ 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["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" @@ -128,7 +191,9 @@ def test_skill_rarity_counts_holders_from_fifty_experience(): 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() + annotated_skill = skill_queryset_with_rarity( + Skill.objects.filter(pk=skill.pk) + ).get() assert annotated_skill.confirmed_holders == 1 @@ -273,14 +338,20 @@ def test_task_skill_experience_snapshots_roles_for_each_completion(): 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 + 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, @@ -292,15 +363,21 @@ def test_task_skill_experience_snapshots_roles_for_each_completion(): 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 ( + 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 @@ -327,16 +404,22 @@ def test_task_skill_experience_snapshots_roles_for_each_completion(): ) 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 + 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 diff --git a/backend/tests/employees/test_secret_skills.py b/backend/tests/employees/test_secret_skills.py new file mode 100644 index 00000000..b705ea3d --- /dev/null +++ b/backend/tests/employees/test_secret_skills.py @@ -0,0 +1,144 @@ +from datetime import date, timedelta + +import pytest + +from attendance.models import AttendanceAnalysisRun, AttendanceRecord +from employees.constants import ACTION_REMOTE +from employees.models import 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 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 + + late_record.delete() + assert recalculate_attendance_secret_skills(employee.id) == [] + progress.refresh_from_db() + assert progress.special_rank == "bronze" + + +def test_self_organization_requires_every_bronze_condition_and_never_demotes( + 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 == "bronze" + assert progress.is_active is True + assert employee.skills.filter(pk=skill.pk).exists() diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index ff85b0f1..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 || []); diff --git a/frontend/src/components/users/ProfileSections.tsx b/frontend/src/components/users/ProfileSections.tsx index 20f83c23..5299b28b 100644 --- a/frontend/src/components/users/ProfileSections.tsx +++ b/frontend/src/components/users/ProfileSections.tsx @@ -65,6 +65,7 @@ const skillRarityStyles: Record = { 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 = { @@ -74,6 +75,7 @@ const skillRarityBadgeStyles: Record = { 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 = { @@ -92,6 +94,7 @@ const skillRarityOrder: Record = { rare: 3, epic: 4, legendary: 5, + secret: 6, }; function sortProfileSkills(skills: Skill[]) { @@ -826,6 +829,7 @@ export function ProfileSkillsInline({ const [endorsementSaving, setEndorsementSaving] = useState(false); const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`; const sortedSkills = sortProfileSkills(skills); + const editableSkills = skills.filter((skill) => !skill.is_secret); const openSkillProgress = async (skill: Skill) => { setProgressOpen(true); @@ -981,9 +985,9 @@ export function ProfileSkillsInline({

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

- {skills.length ? ( + {editableSkills.length ? (
- {skills.map((skill) => + {editableSkills.map((skill) => onRemoveSkill ? (
- {progress.experience} XP + {progress.is_secret ? "Автоматически" : `${progress.experience} XP`}
+ {progress.is_secret && progress.progress_label ? ( +

{progress.progress_label}

+ ) : null}
- {progress.rank_progress_percent}% текущего ранга + + {progress.rank_progress_percent}% {progress.is_secret ? "до следующего ранга" : "текущего ранга"} + {progress.next_rank_display - ? `До «${progress.next_rank_display}»: ${progress.experience_to_next_rank} XP` + ? progress.is_secret + ? `Следующий: ${progress.next_rank_display}` + : `До «${progress.next_rank_display}»: ${progress.experience_to_next_rank} XP` : "Высший ранг"}
+ {progress.is_secret && progress.next_rank_requirements?.length ? ( +
+ {progress.next_rank_requirements.map((requirement) => ( +
+ + {requirement.label} + + + {requirement.current}/{requirement.target} + +
+ ))} +
+ ) : null}
{progress.can_endorse ? ( @@ -1183,7 +1217,9 @@ export function ProfileSkillsInline({ ) : null}
-

История опыта

+

+ {progress.is_secret ? "Системный прогресс" : "История опыта"} +

{progress.events.length ? (
{progress.events.map((event) => ( @@ -1203,7 +1239,9 @@ export function ProfileSkillsInline({
) : (

- Начислений пока нет + {progress.is_secret + ? "Навык обновляется автоматически по подтверждённым данным посещаемости." + : "Начислений пока нет"}

)}
diff --git a/frontend/src/hooks/useProfilePage.ts b/frontend/src/hooks/useProfilePage.ts index 7cd67bc9..f35816b8 100644 --- a/frontend/src/hooks/useProfilePage.ts +++ b/frontend/src/hooks/useProfilePage.ts @@ -22,6 +22,7 @@ type SkillOption = { id: number; name: string; description?: string; + is_secret?: boolean; }; export type ProfileContactKind = @@ -224,6 +225,7 @@ export function useProfilePage(): ProfilePageController { async (skillId: number) => { if (!user) return; const nextIds = profileSkills + .filter((skill) => !skill.is_secret) .map((skill) => skill.id) .filter((id) => id !== skillId); await saveSkills(nextIds); @@ -265,7 +267,10 @@ export function useProfilePage(): ProfilePageController { } const nextIds = Array.from( - new Set([...profileSkills.map((skill) => skill.id), skillId]), + new Set([ + ...profileSkills.filter((skill) => !skill.is_secret).map((skill) => skill.id), + skillId, + ]), ); const response = (await apiClient.updateCurrentUserProfile({ skills_ids: nextIds, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8fd6528b..133a0f89 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -2,6 +2,8 @@ export interface Skill { id: number; name: string; + is_secret?: boolean; + progression_type?: "standard" | "attendance_punctuality" | "attendance_self_organization"; description?: string; experience?: number; rank?: SkillRank; @@ -14,10 +16,22 @@ export interface Skill { confirmed_holders?: number; rarity?: SkillRarity; rarity_display?: string; + progress_value?: number | null; + progress_label?: string; + progress_data?: Record; + next_rank_requirements?: SkillRankRequirement[]; } export type SkillRank = "none" | "bronze" | "silver" | "gold" | "ruby" | "diamond"; -export type SkillRarity = "unrated" | "common" | "uncommon" | "rare" | "epic" | "legendary"; +export type SkillRarity = "unrated" | "common" | "uncommon" | "rare" | "epic" | "legendary" | "secret"; + +export interface SkillRankRequirement { + key: string; + label: string; + current: number; + target: number; + complete: boolean; +} export interface SkillExperienceEvent { id: number; @@ -36,6 +50,8 @@ export interface EmployeeSkillProgress { employee_id: number; skill_id: number; skill_name: string; + is_secret: boolean; + progression_type: "standard" | "attendance_punctuality" | "attendance_self_organization"; experience: number; rank: SkillRank; rank_display: string; @@ -47,6 +63,10 @@ export interface EmployeeSkillProgress { confirmed_holders: number; rarity: SkillRarity; rarity_display: string; + progress_value?: number | null; + progress_label?: string; + progress_data?: Record; + next_rank_requirements?: SkillRankRequirement[]; can_endorse: boolean; endorsement_points?: number; endorsement_role?: string;