feat: recalculate secret skills from date

This commit is contained in:
lizerk 2026-08-26 15:23:48 +03:00
parent 4283dc64d4
commit fede6882a2
5 changed files with 224 additions and 32 deletions

View File

@ -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(

View File

@ -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,

View File

@ -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,

View File

@ -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()

View File

@ -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)