From 53871a8f37ca435dd6a6a20ca0c2771728f7c408 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 25 Aug 2026 14:57:15 +0300 Subject: [PATCH] feat: weight task skill rewards by role --- backend/employees/services/skill_progress.py | 47 ++++++-- backend/tests/api/v1/employees/test_skills.py | 100 +++++++++++++++++- .../src/components/users/ProfileSections.tsx | 2 +- 3 files changed, 136 insertions(+), 13 deletions(-) diff --git a/backend/employees/services/skill_progress.py b/backend/employees/services/skill_progress.py index 8d7bca9d..556c2aa0 100644 --- a/backend/employees/services/skill_progress.py +++ b/backend/employees/services/skill_progress.py @@ -29,7 +29,8 @@ SKILL_RANKS = ( ) ENDORSEMENT_COOLDOWN_DAYS = 90 -TASK_SKILL_EXPERIENCE = 25 +TASK_ASSIGNEE_SKILL_EXPERIENCE = 30 +TASK_PARTICIPANT_SKILL_EXPERIENCE = 15 def skill_queryset_with_rarity(queryset=None): @@ -265,10 +266,13 @@ def endorsement_available_at( def award_task_skill_experience(task_id: int) -> int: - """Начисляет опыт исполнителю и участникам завершённой задачи. + """Начисляет опыт по снимку ролей на момент завершения задачи. Баллы получают только сотрудники, у которых навык уже добавлен в профиль. - Идемпотентный ключ исключает повторное начисление после повторного save(). + Исполнитель получает больше участника. Создатель задачи, автоматически + добавленный в participants, не получает опыт, если не является исполнителем. + Время завершения в ключе сохраняет отдельную историю каждого повторного + завершения, а повторный вызов для одного снимка остаётся идемпотентным. """ from tasks.models import Task @@ -282,32 +286,53 @@ def award_task_skill_experience(task_id: int) -> int: if task is None: return 0 - employee_ids = set(task.participants.values_list("id", flat=True)) + reward_roles = { + employee_id: "participant" + for employee_id in task.participants.values_list("id", flat=True) + if employee_id not in {task.created_by_id, task.assignee_id} + } if task.assignee_id: - employee_ids.add(task.assignee_id) - if not employee_ids: + reward_roles[task.assignee_id] = "assignee" + if not reward_roles: return 0 + completion_key = task.completed_at.isoformat() awarded = 0 for skill in task.skills.all(): recipients = Employee.objects.filter( - id__in=employee_ids, + id__in=reward_roles, is_active=True, skills=skill, ).distinct() for employee in recipients: + reward_role = reward_roles[employee.id] + is_assignee = reward_role == "assignee" + points = ( + TASK_ASSIGNEE_SKILL_EXPERIENCE + if is_assignee + else TASK_PARTICIPANT_SKILL_EXPERIENCE + ) + role_display = "Исполнитель" if is_assignee else "Участник" _, _, created = record_skill_experience( employee=employee, skill=skill, - points=TASK_SKILL_EXPERIENCE, + points=points, source_type=SkillExperienceEvent.Source.TASK, actor=task.created_by, source_id=str(task.id), - reason=f"Завершена задача «{task.title}»", + reason=f"Завершена задача «{task.title}» — {role_display.lower()}", idempotency_key=( - f"task:{task.id}:employee:{employee.id}:skill:{skill.id}" + f"task:{task.id}:completed:{completion_key}:" + f"employee:{employee.id}:skill:{skill.id}" ), - metadata={"task_id": task.id, "task_title": task.title}, + metadata={ + "task_id": task.id, + "task_title": task.title, + "completed_at": completion_key, + "reward_role": reward_role, + "reward_role_display": role_display, + "points": points, + }, ) awarded += int(created) return awarded diff --git a/backend/tests/api/v1/employees/test_skills.py b/backend/tests/api/v1/employees/test_skills.py index 02e71651..c0c2492b 100644 --- a/backend/tests/api/v1/employees/test_skills.py +++ b/backend/tests/api/v1/employees/test_skills.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import pytest from django.contrib.auth.models import Permission from django.urls import reverse @@ -214,13 +216,109 @@ def test_task_skill_experience_is_idempotent_and_revocable(): assert award_task_skill_experience(task.id) == 1 assert award_task_skill_experience(task.id) == 0 progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) - assert progress.experience == 25 + assert progress.experience == 30 + event = progress.experience_events.get() + assert event.metadata["reward_role"] == "assignee" + assert event.metadata["points"] == 30 assert revoke_task_skill_experience(task.id, actor=employee) == 1 progress.refresh_from_db() assert progress.experience == 0 +@pytest.mark.django_db +def test_task_skill_experience_snapshots_roles_for_each_completion(): + creator = _mk_user() + assignee = _mk_user() + participant = _mk_user() + skill = Skill.objects.create(name="PostgreSQL") + creator.skills.add(skill) + assignee.skills.add(skill) + participant.skills.add(skill) + board = TaskBoard.objects.create(name="Роли", created_by=creator) + done = TaskColumn.objects.create( + board=board, + name="Готово", + position=1000, + is_done=True, + ) + task = Task.objects.create( + board=board, + column=done, + title="Смена исполнителя", + created_by=creator, + assignee=assignee, + ) + task.participants.add(participant) + task.skills.add(skill) + + assert award_task_skill_experience(task.id) == 2 + assert EmployeeSkillProgress.objects.get( + employee=assignee, + skill=skill, + ).experience == 30 + assert EmployeeSkillProgress.objects.get( + employee=participant, + skill=skill, + ).experience == 15 + creator_progress = EmployeeSkillProgress.objects.get( + employee=creator, + skill=skill, + ) + assert creator_progress.experience == 0 + assert not creator_progress.experience_events.filter( + source_type=SkillExperienceEvent.Source.TASK, + ).exists() + + task.assignee = participant + task.save(update_fields=["assignee", "updated_at"]) + assert SkillExperienceEvent.objects.filter( + source_type=SkillExperienceEvent.Source.TASK, + source_id=str(task.id), + revoked_at__isnull=True, + ).count() == 2 + assert EmployeeSkillProgress.objects.get( + employee=assignee, + skill=skill, + ).experience == 30 + + assert revoke_task_skill_experience(task.id, actor=creator) == 2 + first_completed_at = task.completed_at + Task.objects.filter(id=task.id).update( + completed_at=first_completed_at + timedelta(minutes=1), + ) + task.refresh_from_db() + + assert award_task_skill_experience(task.id) == 2 + assignee_progress = EmployeeSkillProgress.objects.get( + employee=assignee, + skill=skill, + ) + participant_progress = EmployeeSkillProgress.objects.get( + employee=participant, + skill=skill, + ) + assert assignee_progress.experience == 15 + assert participant_progress.experience == 30 + + task_events = SkillExperienceEvent.objects.filter( + source_type=SkillExperienceEvent.Source.TASK, + source_id=str(task.id), + ) + assert task_events.count() == 4 + assert task_events.filter(revoked_at__isnull=False).count() == 2 + assert task_events.filter( + revoked_at__isnull=True, + metadata__reward_role="assignee", + points=30, + ).count() == 1 + assert task_events.filter( + revoked_at__isnull=True, + metadata__reward_role="participant", + points=15, + ).count() == 1 + + @pytest.mark.django_db def test_update_delete_permissions(api_client): user = _mk_user() diff --git a/frontend/src/components/users/ProfileSections.tsx b/frontend/src/components/users/ProfileSections.tsx index 9e7d859a..f80dd9a3 100644 --- a/frontend/src/components/users/ProfileSections.tsx +++ b/frontend/src/components/users/ProfileSections.tsx @@ -1065,7 +1065,7 @@ export function ProfileSkillsInline({ ))}

- Завершённая задача даёт 25 XP. Подтверждение коллеги — 5–15 XP в зависимости от его роли. + Завершённая задача даёт исполнителю 30 XP, участнику — 15 XP. Подтверждение коллеги — 5–15 XP в зависимости от его роли.