feat: add attendance secret skills
This commit is contained in:
parent
dec526898e
commit
2e807d83dd
@ -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")
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}"
|
||||
)
|
||||
)
|
||||
92
backend/employees/migrations/0058_secret_skill_progress.py
Normal file
92
backend/employees/migrations/0058_secret_skill_progress.py
Normal file
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
404
backend/employees/services/secret_skills.py
Normal file
404
backend/employees/services/secret_skills.py
Normal file
@ -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)
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
144
backend/tests/employees/test_secret_skills.py
Normal file
144
backend/tests/employees/test_secret_skills.py
Normal file
@ -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()
|
||||
@ -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<Skill[]>([]);
|
||||
const [personSkills, setPersonSkills] = useState<Skill[]>([]);
|
||||
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 || []);
|
||||
|
||||
|
||||
@ -65,6 +65,7 @@ const skillRarityStyles: Record<SkillRarity, string> = {
|
||||
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<SkillRarity, string> = {
|
||||
@ -74,6 +75,7 @@ const skillRarityBadgeStyles: Record<SkillRarity, string> = {
|
||||
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<SkillRank, number> = {
|
||||
@ -92,6 +94,7 @@ const skillRarityOrder: Record<SkillRarity, number> = {
|
||||
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({
|
||||
|
||||
<div>
|
||||
<p className="app-card-caption mb-2">Добавленные навыки</p>
|
||||
{skills.length ? (
|
||||
{editableSkills.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((skill) =>
|
||||
{editableSkills.map((skill) =>
|
||||
onRemoveSkill ? (
|
||||
<button
|
||||
key={skill.id}
|
||||
@ -1077,6 +1081,9 @@ export function ProfileSkillsInline({
|
||||
<p className="app-text-muted mt-1 text-[11px] leading-relaxed">
|
||||
Сотрудник считается подтверждённым обладателем навыка после достижения 50 XP — ранг «Бронза» для этого не требуется.
|
||||
</p>
|
||||
<p className="app-text-muted mt-1 text-[11px] leading-relaxed">
|
||||
Секретные навыки открываются системой по рабочим показателям: их нельзя добавить, удалить или подтвердить вручную, а их редкость не зависит от числа обладателей.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{[
|
||||
{ rarity: "legendary" as const, label: "Легендарный · 1" },
|
||||
@ -1084,6 +1091,7 @@ export function ProfileSkillsInline({
|
||||
{ rarity: "rare" as const, label: "Редкий · 4–9" },
|
||||
{ rarity: "uncommon" as const, label: "Необычный · 10–24" },
|
||||
{ rarity: "common" as const, label: "Обычный · 25+" },
|
||||
{ rarity: "secret" as const, label: "Секретный · системное условие" },
|
||||
].map((item) => (
|
||||
<span
|
||||
key={item.rarity}
|
||||
@ -1125,14 +1133,19 @@ export function ProfileSkillsInline({
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-[var(--foreground)]">{progress.rank_display}</p>
|
||||
<p className="app-text-muted text-xs">
|
||||
{progress.rarity_display} · у {progress.confirmed_holders} сотрудников
|
||||
{progress.is_secret
|
||||
? `${progress.rarity_display} системный навык`
|
||||
: `${progress.rarity_display} · у ${progress.confirmed_holders} сотрудников`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="app-badge shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold">
|
||||
{progress.experience} XP
|
||||
{progress.is_secret ? "Автоматически" : `${progress.experience} XP`}
|
||||
</span>
|
||||
</div>
|
||||
{progress.is_secret && progress.progress_label ? (
|
||||
<p className="app-text-muted mt-3 text-xs">{progress.progress_label}</p>
|
||||
) : null}
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-[var(--surface-primary)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent-primary)] transition-[width]"
|
||||
@ -1140,13 +1153,34 @@ export function ProfileSkillsInline({
|
||||
/>
|
||||
</div>
|
||||
<div className="app-text-muted mt-1.5 flex justify-between gap-3 text-[11px]">
|
||||
<span>{progress.rank_progress_percent}% текущего ранга</span>
|
||||
<span>
|
||||
{progress.rank_progress_percent}% {progress.is_secret ? "до следующего ранга" : "текущего ранга"}
|
||||
</span>
|
||||
<span>
|
||||
{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`
|
||||
: "Высший ранг"}
|
||||
</span>
|
||||
</div>
|
||||
{progress.is_secret && progress.next_rank_requirements?.length ? (
|
||||
<div className="mt-3 grid gap-1.5">
|
||||
{progress.next_rank_requirements.map((requirement) => (
|
||||
<div
|
||||
key={requirement.key}
|
||||
className="app-surface flex items-center justify-between gap-3 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 truncate text-[var(--foreground)]">
|
||||
{requirement.label}
|
||||
</span>
|
||||
<span className={requirement.complete ? "text-emerald-300" : "app-text-muted"}>
|
||||
{requirement.current}/{requirement.target}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{progress.can_endorse ? (
|
||||
@ -1183,7 +1217,9 @@ export function ProfileSkillsInline({
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="app-card-caption mb-2">История опыта</p>
|
||||
<p className="app-card-caption mb-2">
|
||||
{progress.is_secret ? "Системный прогресс" : "История опыта"}
|
||||
</p>
|
||||
{progress.events.length ? (
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto pr-1">
|
||||
{progress.events.map((event) => (
|
||||
@ -1203,7 +1239,9 @@ export function ProfileSkillsInline({
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center text-xs text-[var(--muted-foreground)]">
|
||||
Начислений пока нет
|
||||
{progress.is_secret
|
||||
? "Навык обновляется автоматически по подтверждённым данным посещаемости."
|
||||
: "Начислений пока нет"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
next_rank_requirements?: SkillRankRequirement[];
|
||||
can_endorse: boolean;
|
||||
endorsement_points?: number;
|
||||
endorsement_role?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user