diff --git a/backend/employees/management/commands/recalculate_secret_skills.py b/backend/employees/management/commands/recalculate_secret_skills.py index 49d4f3be..ab44c272 100644 --- a/backend/employees/management/commands/recalculate_secret_skills.py +++ b/backend/employees/management/commands/recalculate_secret_skills.py @@ -1,7 +1,11 @@ from collections import Counter, defaultdict +from datetime import date from django.core.management.base import BaseCommand +from django.core.management.base import CommandError from django.db import transaction +from django.utils import timezone +from django.utils.dateparse import parse_date from employees.models import Employee, EmployeeSkillProgress from employees.services.secret_skills import recalculate_attendance_secret_skills @@ -80,14 +84,30 @@ class Command(BaseCommand): action="store_true", help="Показать изменения без записи в базу", ) + parser.add_argument( + "--since", + type=str, + help="Учитывать события начиная с даты в формате YYYY-MM-DD", + ) - def _recalculate(self, employee_ids, employee_id=None): + def _recalculate( + self, + employee_ids, + employee_id=None, + since: date | None = None, + ): processed = 0 rank_changes = 0 - task_result = refresh_task_execution_skills(employee_id=employee_id) + task_result = refresh_task_execution_skills( + employee_id=employee_id, + since=since, + ) rank_changes += task_result["rank_changes"] for current_employee_id in employee_ids: - changes = recalculate_attendance_secret_skills(current_employee_id) + changes = recalculate_attendance_secret_skills( + current_employee_id, + since=since, + ) processed += 1 rank_changes += len(changes) return processed, rank_changes @@ -143,6 +163,12 @@ class Command(BaseCommand): def handle(self, *args, **options): employee_id = options.get("employee_id") dry_run = options.get("dry_run", False) + since_text = options.get("since") + since = parse_date(since_text) if since_text else None + if since_text and since is None: + raise CommandError("Параметр --since должен быть датой в формате YYYY-MM-DD.") + if since is not None and since > timezone.localdate(): + raise CommandError("Параметр --since не может быть датой из будущего.") if employee_id: employee_ids = [employee_id] else: @@ -153,13 +179,13 @@ class Command(BaseCommand): if dry_run: with transaction.atomic(): before = _progress_snapshot(employee_ids) - processed, _ = self._recalculate(employee_ids, employee_id) + processed, _ = self._recalculate(employee_ids, employee_id, since) after = _progress_snapshot(employee_ids) transaction.set_rollback(True) self._print_dry_run(employee_ids, before, after, processed) return - processed, rank_changes = self._recalculate(employee_ids, employee_id) + processed, rank_changes = self._recalculate(employee_ids, employee_id, since) self.stdout.write( self.style.SUCCESS( diff --git a/backend/employees/services/secret_skills.py b/backend/employees/services/secret_skills.py index afd6504a..e494646e 100644 --- a/backend/employees/services/secret_skills.py +++ b/backend/employees/services/secret_skills.py @@ -567,6 +567,7 @@ def calculate_personnel_secret_metrics( # noqa: C901 actions: Iterable[EmployeeAction], *, reference_date: date | None = None, + since: date | None = None, ) -> dict[str, object]: """Рассчитать «Надёжность» и «Стойкость» только по кадровым событиям.""" @@ -604,6 +605,20 @@ def calculate_personnel_secret_metrics( # noqa: C901 } start_date, end_date = employment_period + if since is not None: + start_date = max(start_date, since) + if start_date > end_date: + return { + "worked_days": 0, + "current_reliability_streak": 0, + "best_reliability_streak": 0, + "current_resilience_streak": 0, + "best_resilience_streak": 0, + "leave_or_day_off_episodes": 0, + "sick_leave_episodes": 0, + "personnel_last_evaluated_date": "", + "skill_states": skill_states, + } reliability_blocked_dates: set[date] = set() reliability_paused_dates: set[date] = set() resilience_blocked_dates: set[date] = set() @@ -804,7 +819,11 @@ def _metrics_for_definition( @transaction.atomic -def recalculate_attendance_secret_skills(employee_id: int) -> list[str]: # noqa: C901 +def recalculate_attendance_secret_skills( # noqa: C901 + employee_id: int, + *, + since: date | None = None, +) -> list[str]: """Пересчитать и при необходимости открыть системные навыки сотрудника.""" from attendance.models import AttendanceRecord @@ -813,14 +832,19 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]: # noqa if employee is None: return [] - records = list( - AttendanceRecord.objects.filter(employee_id=employee_id).order_by("date", "id") - ) + records_queryset = AttendanceRecord.objects.filter(employee_id=employee_id) + if since is not None: + records_queryset = records_queryset.filter(date__gte=since) + records = list(records_queryset.order_by("date", "id")) actions = list( EmployeeAction.objects.filter(employee_id=employee_id).order_by("date", "id") ) attendance_metrics = calculate_attendance_secret_metrics(records) - personnel_metrics = calculate_personnel_secret_metrics(employee, actions) + personnel_metrics = calculate_personnel_secret_metrics( + employee, + actions, + since=since, + ) metrics = { **attendance_metrics, **personnel_metrics, diff --git a/backend/employees/services/task_execution_skill.py b/backend/employees/services/task_execution_skill.py index c74af01e..83b886f1 100644 --- a/backend/employees/services/task_execution_skill.py +++ b/backend/employees/services/task_execution_skill.py @@ -42,7 +42,11 @@ def _cycle_key(task_id: int, completed_at) -> str: @transaction.atomic -def capture_task_execution_cycle(task_id: int) -> TaskExecutionCycle | None: +def capture_task_execution_cycle( + task_id: int, + *, + since: date | None = None, +) -> TaskExecutionCycle | None: """Create the immutable completion snapshot if the task is currently done.""" task = ( @@ -91,7 +95,7 @@ def capture_task_execution_cycle(task_id: int) -> TaskExecutionCycle | None: }, ) if cycle.employee_id: - recalculate_task_execution_skill(cycle.employee_id) + recalculate_task_execution_skill(cycle.employee_id, since=since) return cycle @@ -119,7 +123,12 @@ def reopen_task_execution_cycle( return cycle -def _open_overdue_events(employee_id: int, today: date): +def _open_overdue_events( + employee_id: int, + today: date, + *, + since: date | None = None, +): tasks = ( Task.objects.filter( assignee_id=employee_id, @@ -137,6 +146,8 @@ def _open_overdue_events(employee_id: int, today: date): ) if violation_date > today: continue + if since is not None and violation_date < since: + continue latest_cycle = max( task.execution_cycles.all(), key=lambda item: (item.completed_at_snapshot, item.id), @@ -156,7 +167,23 @@ def _open_overdue_events(employee_id: int, today: date): return events -def calculate_task_execution_metrics(employee_id: int, *, today: date | None = None): +def _cycle_event(cycle: TaskExecutionCycle) -> tuple[date, str, int] | None: + if cycle.status == TaskExecutionCycle.Status.CONFIRMED: + return _local_date(cycle.completed_at_snapshot), "confirmed", cycle.id + if cycle.status == TaskExecutionCycle.Status.LATE: + return _local_date(cycle.completed_at_snapshot), "late", cycle.id + if cycle.status == TaskExecutionCycle.Status.REOPENED: + violation_at = cycle.reopened_at or cycle.completed_at_snapshot + return _local_date(violation_at), "reopened", cycle.id + return None + + +def calculate_task_execution_metrics( + employee_id: int, + *, + today: date | None = None, + since: date | None = None, +): today = today or timezone.localdate() cycles = list( TaskExecutionCycle.objects.filter(employee_id=employee_id) @@ -172,19 +199,14 @@ def calculate_task_execution_metrics(employee_id: int, *, today: date | None = N late_tasks = 0 reopened_tasks = 0 for cycle in cycles: - if cycle.status == TaskExecutionCycle.Status.CONFIRMED: - events.append( - (_local_date(cycle.completed_at_snapshot), "confirmed", cycle.id) - ) - elif cycle.status == TaskExecutionCycle.Status.LATE: - late_tasks += 1 - events.append((_local_date(cycle.completed_at_snapshot), "late", cycle.id)) - elif cycle.status == TaskExecutionCycle.Status.REOPENED: - reopened_tasks += 1 - violation_at = cycle.reopened_at or cycle.completed_at_snapshot - events.append((_local_date(violation_at), "reopened", cycle.id)) + event = _cycle_event(cycle) + if event is None or (since is not None and event[0] < since): + continue + events.append(event) + late_tasks += event[1] == "late" + reopened_tasks += event[1] == "reopened" - overdue_events = _open_overdue_events(employee_id, today) + overdue_events = _open_overdue_events(employee_id, today, since=since) events.extend(overdue_events) events.sort(key=lambda item: (item[0], str(item[2]))) @@ -205,6 +227,8 @@ def calculate_task_execution_metrics(employee_id: int, *, today: date | None = N attempts = len(events) on_time_percent = round(confirmed_tasks / attempts * 100) if attempts else 0 violation_cutoff = today - timedelta(days=VIOLATION_WINDOW_DAYS) + if since is not None: + violation_cutoff = max(violation_cutoff, since) violations_last_60 = sum( event_type != "confirmed" and event_date >= violation_cutoff for event_date, event_type, _ in events @@ -239,6 +263,11 @@ def calculate_task_execution_metrics(employee_id: int, *, today: date | None = N "pending_tasks": TaskExecutionCycle.objects.filter( employee_id=employee_id, status=TaskExecutionCycle.Status.PENDING, + **( + {"completed_at_snapshot__date__gte": since} + if since is not None + else {} + ), ).count(), "violations_last_60": violations_last_60, "anti_streak": violations_last_60 % 3, @@ -253,7 +282,11 @@ def calculate_task_execution_metrics(employee_id: int, *, today: date | None = N @transaction.atomic -def recalculate_task_execution_skill(employee_id: int) -> list[str]: +def recalculate_task_execution_skill( + employee_id: int, + *, + since: date | None = None, +) -> list[str]: employee = Employee.objects.select_for_update().filter(pk=employee_id).first() if employee is None: return [] @@ -264,7 +297,7 @@ def recalculate_task_execution_skill(employee_id: int) -> list[str]: if skill is None: return [] - metrics = calculate_task_execution_metrics(employee_id) + metrics = calculate_task_execution_metrics(employee_id, since=since) calculated_rank = metrics["effective_rank"] progress = EmployeeSkillProgress.objects.filter( employee=employee, @@ -321,6 +354,8 @@ def recalculate_task_execution_skill(employee_id: int) -> list[str]: def refresh_task_execution_skills( # noqa: C901 employee_id: int | None = None, + *, + since: date | None = None, ) -> dict[str, int]: """Reconcile missed transitions, mature pending cycles and recalculate users.""" @@ -330,6 +365,8 @@ def refresh_task_execution_skills( # noqa: C901 completed_at_snapshot=OuterRef("completed_at"), ) completed_tasks = Task.objects.filter(completed_at__isnull=False) + if since is not None: + completed_tasks = completed_tasks.filter(completed_at__date__gte=since) if employee_id is not None: completed_tasks = completed_tasks.filter(assignee_id=employee_id) missing_task_ids = list( @@ -338,7 +375,7 @@ def refresh_task_execution_skills( # noqa: C901 .values_list("id", flat=True) ) for task_id in missing_task_ids: - capture_task_execution_cycle(task_id) + capture_task_execution_cycle(task_id, since=since) changed_cycles = 0 employee_ids: set[int] = set() @@ -348,6 +385,8 @@ def refresh_task_execution_skills( # noqa: C901 TaskExecutionCycle.Status.CONFIRMED, ] ).select_related("task") + if since is not None: + cycles = cycles.filter(completed_at_snapshot__date__gte=since) if employee_id is not None: cycles = cycles.filter(employee_id=employee_id) for cycle in cycles: @@ -381,6 +420,10 @@ def refresh_task_execution_skills( # noqa: C901 employee_ids.add(cycle.employee_id) cycle_employees = TaskExecutionCycle.objects.exclude(employee_id=None) + if since is not None: + cycle_employees = cycle_employees.filter( + completed_at_snapshot__date__gte=since + ) open_tasks = Task.objects.filter( completed_at__isnull=True, assignee__isnull=False, @@ -391,9 +434,19 @@ def refresh_task_execution_skills( # noqa: C901 open_tasks = open_tasks.filter(assignee_id=employee_id) employee_ids.update(cycle_employees.values_list("employee_id", flat=True)) employee_ids.update(open_tasks.values_list("assignee_id", flat=True)) + if employee_id is not None: + employee_ids.add(employee_id) + elif since is not None: + employee_ids.update( + EmployeeSkillProgress.objects.filter( + skill__system_code="task_execution", + ).values_list("employee_id", flat=True) + ) rank_changes = 0 for employee_id in employee_ids: - rank_changes += len(recalculate_task_execution_skill(employee_id)) + rank_changes += len( + recalculate_task_execution_skill(employee_id, since=since) + ) return { "captured_cycles": len(missing_task_ids), "changed_cycles": changed_cycles, diff --git a/backend/tests/employees/test_recalculate_secret_skills_command.py b/backend/tests/employees/test_recalculate_secret_skills_command.py index 869135eb..253e034a 100644 --- a/backend/tests/employees/test_recalculate_secret_skills_command.py +++ b/backend/tests/employees/test_recalculate_secret_skills_command.py @@ -11,9 +11,9 @@ from tasks.models import TaskExecutionCycle pytestmark = pytest.mark.django_db -def _confirmed_cycles(employee, amount=10): +def _confirmed_cycles(employee, amount=10, *, start_days_ago=20): for index in range(amount): - completed_at = timezone.now() - timedelta(days=20 - index) + completed_at = timezone.now() - timedelta(days=start_days_ago - index) TaskExecutionCycle.objects.create( task_id_snapshot=20000 + index, employee=employee, @@ -62,3 +62,44 @@ def test_regular_run_persists_the_same_calculation(user_factory): assert progress.special_rank == "bronze" assert progress.is_active is True assert employee.skills.filter(pk=skill.pk).exists() + + +def test_since_excludes_older_task_cycles_from_dry_run(user_factory): + employee = user_factory(first_name="Старые", last_name="Задачи") + _confirmed_cycles(employee, start_days_ago=140) + call_command("recalculate_secret_skills", employee_id=employee.id) + progress = EmployeeSkillProgress.objects.get( + employee=employee, + skill__system_code="task_execution", + ) + assert progress.is_active is True + output = StringIO() + + call_command( + "recalculate_secret_skills", + employee_id=employee.id, + since=(timezone.localdate() - timedelta(days=90)).isoformat(), + dry_run=True, + stdout=output, + ) + + assert "потеряют 1" in output.getvalue() + progress.refresh_from_db() + assert progress.is_active is True + + +def test_since_includes_task_cycles_on_or_after_boundary(user_factory): + employee = user_factory(first_name="Новые", last_name="Задачи") + _confirmed_cycles(employee, start_days_ago=20) + output = StringIO() + + call_command( + "recalculate_secret_skills", + employee_id=employee.id, + since=(timezone.localdate() - timedelta(days=90)).isoformat(), + dry_run=True, + stdout=output, + ) + + assert "Исполнительность: получат 1" in output.getvalue() + assert not EmployeeSkillProgress.objects.filter(employee=employee).exists() diff --git a/backend/tests/employees/test_secret_skills.py b/backend/tests/employees/test_secret_skills.py index fe773228..7da92691 100644 --- a/backend/tests/employees/test_secret_skills.py +++ b/backend/tests/employees/test_secret_skills.py @@ -114,6 +114,29 @@ def test_punctuality_uses_best_streak_and_pauses_unknown_days(user_factory): assert progress.progress_data["anti_streak"] == 0 +def test_attendance_recalculation_respects_since_boundary(user_factory): + employee = user_factory() + start = date(2026, 1, 1) + run = _run(employee, start, start + timedelta(days=24)) + for offset in range(25): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="attendance_punctuality") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + + recalculate_attendance_secret_skills( + employee.id, + since=start + timedelta(days=10), + ) + + progress.refresh_from_db() + assert progress.progress_data["best_streak"] == 15 + assert progress.special_rank == "none" + assert progress.is_active is False + + def test_self_organization_requires_every_bronze_condition( user_factory, ): @@ -328,6 +351,31 @@ def test_reliability_uses_only_personnel_events_and_recovers_rank(user_factory): assert progress.progress_data["penalty_levels"] == 0 +def test_personnel_recalculation_counts_days_only_since_boundary(user_factory): + employee = user_factory() + start = date(2025, 1, 1) + _action(employee, ACTION_HIRED, start) + + with patch( + "employees.services.secret_skills.timezone.localdate", + return_value=start + timedelta(days=89), + ): + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="attendance_reliability") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + + recalculate_attendance_secret_skills( + employee.id, + since=start + timedelta(days=40), + ) + + progress.refresh_from_db() + assert progress.progress_data["worked_days"] == 50 + assert progress.special_rank == "none" + assert progress.is_active is False + + def test_remote_reactivates_employment_without_resetting_active_period(user_factory): employee = user_factory() start = date(2025, 1, 1)