Merge branch 'develop'
This commit is contained in:
commit
8211e7bb89
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user