Merge branch 'develop'
This commit is contained in:
commit
7ab7a3cfc8
@ -2,12 +2,10 @@ 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_skill_settings import get_secret_skills_start_date
|
||||
from employees.services.secret_skills import recalculate_attendance_secret_skills
|
||||
from employees.services.task_execution_skill import refresh_task_execution_skills
|
||||
|
||||
@ -84,11 +82,6 @@ class Command(BaseCommand):
|
||||
action="store_true",
|
||||
help="Показать изменения без записи в базу",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--since",
|
||||
type=str,
|
||||
help="Учитывать события начиная с даты в формате YYYY-MM-DD",
|
||||
)
|
||||
|
||||
def _recalculate(
|
||||
self,
|
||||
@ -163,12 +156,7 @@ 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 не может быть датой из будущего.")
|
||||
since = get_secret_skills_start_date()
|
||||
if employee_id:
|
||||
employee_ids = [employee_id]
|
||||
else:
|
||||
@ -183,6 +171,7 @@ class Command(BaseCommand):
|
||||
after = _progress_snapshot(employee_ids)
|
||||
transaction.set_rollback(True)
|
||||
self._print_dry_run(employee_ids, before, after, processed)
|
||||
self.stdout.write(f"Дата начала расчёта: {since.isoformat()}")
|
||||
return
|
||||
|
||||
processed, rank_changes = self._recalculate(employee_ids, employee_id, since)
|
||||
@ -190,6 +179,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Пересчитано сотрудников: "
|
||||
f"{processed}; изменений рангов и навыков: {rank_changes}"
|
||||
f"{processed}; изменений рангов и навыков: {rank_changes}; "
|
||||
f"начало расчёта: {since.isoformat()}"
|
||||
)
|
||||
)
|
||||
|
||||
@ -4,11 +4,10 @@ from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
from attendance.models import AttendanceRecord
|
||||
from employees.models import Employee, EmployeeAction
|
||||
from employees.services.secret_skill_settings import get_secret_skills_start_date
|
||||
from employees.services.secret_skills import (
|
||||
ATTENDANCE_AND_PERSONNEL_SECRET_SKILL_DEFINITIONS,
|
||||
RANK_INDEX,
|
||||
@ -125,24 +124,10 @@ def _bronze_gap_summary(row: SkillReportRow) -> str:
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _parse_since(value: str | None) -> date | None:
|
||||
since = parse_date(value) if value else None
|
||||
if value and since is None:
|
||||
raise CommandError("Параметр --since должен быть датой в формате YYYY-MM-DD.")
|
||||
if since is not None and since > timezone.localdate():
|
||||
raise CommandError("Параметр --since не может быть датой из будущего.")
|
||||
return since
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Показать лидеров и ближайших кандидатов по секретным навыкам"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--since",
|
||||
type=str,
|
||||
help="Учитывать события начиная с даты в формате YYYY-MM-DD",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
@ -151,7 +136,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
since = _parse_since(options.get("since"))
|
||||
since = get_secret_skills_start_date()
|
||||
top = options.get("top", 3)
|
||||
if top < 1:
|
||||
raise CommandError("Параметр --top должен быть больше нуля.")
|
||||
@ -173,8 +158,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
|
||||
period_label = since.isoformat() if since else "за всё время"
|
||||
self.stdout.write(f"Секретные навыки — расчёт {period_label}")
|
||||
self.stdout.write(f"Секретные навыки — расчёт с {since.isoformat()}")
|
||||
self.stdout.write(f"Проверено сотрудников: {employees.count()}")
|
||||
|
||||
for definition in definitions:
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
from employees.models import SecretSkillSettings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Показать или изменить общую дату начала расчёта секретных навыков"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--set",
|
||||
dest="start_date",
|
||||
metavar="YYYY-MM-DD",
|
||||
help="Сохранить новую дату начала расчёта",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
value = options.get("start_date")
|
||||
if value is None:
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
self.stdout.write(settings.calculation_start_date.isoformat())
|
||||
return
|
||||
|
||||
start_date = parse_date(value)
|
||||
if start_date is None:
|
||||
raise CommandError("Дата должна быть указана в формате YYYY-MM-DD.")
|
||||
if start_date > timezone.localdate():
|
||||
raise CommandError("Дата начала расчёта не может быть из будущего.")
|
||||
|
||||
with transaction.atomic():
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
previous_date = settings.calculation_start_date
|
||||
if previous_date != start_date:
|
||||
settings.calculation_start_date = start_date
|
||||
settings.save(update_fields=["calculation_start_date", "updated_at"])
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Дата начала расчёта: {previous_date.isoformat()} → "
|
||||
f"{start_date.isoformat()}"
|
||||
)
|
||||
)
|
||||
self.stdout.write(
|
||||
"Для применения выполните: "
|
||||
"python manage.py recalculate_secret_skills --dry-run"
|
||||
)
|
||||
54
backend/employees/migrations/0061_secretskillsettings.py
Normal file
54
backend/employees/migrations/0061_secretskillsettings.py
Normal file
@ -0,0 +1,54 @@
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def create_secret_skill_settings(apps, schema_editor):
|
||||
SecretSkillSettings = apps.get_model("employees", "SecretSkillSettings")
|
||||
SecretSkillSettings.objects.get_or_create(
|
||||
singleton=True,
|
||||
defaults={"calculation_start_date": datetime.date(2026, 4, 1)},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("employees", "0060_task_execution_skill"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SecretSkillSettings",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"singleton",
|
||||
models.BooleanField(default=True, editable=False, unique=True),
|
||||
),
|
||||
(
|
||||
"calculation_start_date",
|
||||
models.DateField(
|
||||
default=datetime.date(2026, 4, 1),
|
||||
verbose_name="Начало расчёта секретных навыков",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Настройки секретных навыков",
|
||||
"verbose_name_plural": "Настройки секретных навыков",
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
create_secret_skill_settings,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,6 @@
|
||||
# backend\employees\models.py
|
||||
from datetime import date
|
||||
|
||||
from common.emails import send_templated_mail
|
||||
from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -691,6 +693,29 @@ class RoleAssignment(models.Model):
|
||||
return f"{self.employee} → {self.role} [{status}]"
|
||||
|
||||
|
||||
class SecretSkillSettings(models.Model):
|
||||
"""Постоянная общая граница истории для системных секретных навыков."""
|
||||
|
||||
singleton = models.BooleanField(default=True, unique=True, editable=False)
|
||||
calculation_start_date = models.DateField(
|
||||
"Начало расчёта секретных навыков",
|
||||
default=date(2026, 4, 1),
|
||||
)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Настройки секретных навыков"
|
||||
verbose_name_plural = "Настройки секретных навыков"
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
instance, _ = cls.objects.get_or_create(singleton=True)
|
||||
return instance
|
||||
|
||||
def __str__(self):
|
||||
return f"Секретные навыки с {self.calculation_start_date:%d.%m.%Y}"
|
||||
|
||||
|
||||
class Skill(models.Model):
|
||||
class ProgressionType(models.TextChoices):
|
||||
STANDARD = "standard", "Обычный опыт"
|
||||
|
||||
9
backend/employees/services/secret_skill_settings.py
Normal file
9
backend/employees/services/secret_skill_settings.py
Normal file
@ -0,0 +1,9 @@
|
||||
from datetime import date
|
||||
|
||||
from employees.models import SecretSkillSettings
|
||||
|
||||
|
||||
def get_secret_skills_start_date() -> date:
|
||||
"""Вернуть единую сохранённую границу истории секретных навыков."""
|
||||
|
||||
return SecretSkillSettings.get_solo().calculation_start_date
|
||||
@ -22,6 +22,7 @@ from employees.constants import (
|
||||
ACTION_WORKING,
|
||||
)
|
||||
from employees.models import Employee, EmployeeAction, EmployeeSkillProgress, Skill
|
||||
from employees.services.secret_skill_settings import get_secret_skills_start_date
|
||||
from employees.services.skill_progress import SKILL_RANKS, rarity_payload
|
||||
|
||||
RANK_CODES = tuple(rank["code"] for rank in SKILL_RANKS)
|
||||
@ -78,11 +79,11 @@ RELIABILITY_REQUIREMENTS = {
|
||||
}
|
||||
|
||||
RESILIENCE_REQUIREMENTS = {
|
||||
"bronze": {"best_resilience_streak": 60},
|
||||
"silver": {"best_resilience_streak": 120},
|
||||
"gold": {"best_resilience_streak": 240},
|
||||
"ruby": {"best_resilience_streak": 480},
|
||||
"diamond": {"best_resilience_streak": 800},
|
||||
"bronze": {"best_resilience_streak": 180},
|
||||
"silver": {"best_resilience_streak": 365},
|
||||
"gold": {"best_resilience_streak": 730},
|
||||
"ruby": {"best_resilience_streak": 1095},
|
||||
"diamond": {"best_resilience_streak": 1825},
|
||||
}
|
||||
|
||||
TASK_EXECUTION_REQUIREMENTS = {
|
||||
@ -130,7 +131,7 @@ ANTI_STREAK_LABELS = {
|
||||
Skill.ProgressionType.ATTENDANCE_PUNCTUALITY: "Опоздания подряд",
|
||||
Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION: "Проблемные дни подряд",
|
||||
Skill.ProgressionType.ATTENDANCE_RELIABILITY: "Отпуска и отгулы за 120 дней",
|
||||
Skill.ProgressionType.PERSONNEL_RESILIENCE: "Больничные за 120 дней",
|
||||
Skill.ProgressionType.PERSONNEL_RESILIENCE: "Больничные за 180 дней",
|
||||
Skill.ProgressionType.TASK_EXECUTION: "Нарушения за 60 дней",
|
||||
}
|
||||
|
||||
@ -175,7 +176,7 @@ SECRET_SKILL_DEFINITIONS = (
|
||||
name="Стойкость",
|
||||
progression_type=Skill.ProgressionType.PERSONNEL_RESILIENCE,
|
||||
requirements=RESILIENCE_REQUIREMENTS,
|
||||
anti_streak_limit=3,
|
||||
anti_streak_limit=2,
|
||||
recovery_streak_target=90,
|
||||
),
|
||||
SecretSkillDefinition(
|
||||
@ -586,7 +587,7 @@ def calculate_personnel_secret_metrics( # noqa: C901
|
||||
reference_date: date | None = None,
|
||||
since: date | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Рассчитать «Надёжность» и «Стойкость» только по кадровым событиям."""
|
||||
"""Рассчитать кадровые навыки в пределах достоверного периода."""
|
||||
|
||||
reference_date = reference_date or timezone.localdate()
|
||||
sorted_actions = sorted(
|
||||
@ -604,8 +605,13 @@ def calculate_personnel_secret_metrics( # noqa: C901
|
||||
Skill.ProgressionType.PERSONNEL_RESILIENCE
|
||||
]
|
||||
skill_states = {
|
||||
definition.system_code: _initial_secret_state(definition, rolling=True)
|
||||
for definition in PERSONNEL_SECRET_SKILL_DEFINITIONS
|
||||
reliability_definition.system_code: _initial_secret_state(
|
||||
reliability_definition,
|
||||
rolling=True,
|
||||
),
|
||||
resilience_definition.system_code: _initial_secret_state(
|
||||
resilience_definition,
|
||||
),
|
||||
}
|
||||
employment_period = _employment_period(employee, sorted_actions, reference_date)
|
||||
if employment_period is None:
|
||||
@ -654,6 +660,7 @@ def calculate_personnel_secret_metrics( # noqa: C901
|
||||
worked_days = 0
|
||||
reliability_streak = 0
|
||||
best_reliability_streak = 0
|
||||
recovery_days_since_last_sick = 0
|
||||
resilience_streak = 0
|
||||
best_resilience_streak = 0
|
||||
|
||||
@ -671,10 +678,10 @@ def calculate_personnel_secret_metrics( # noqa: C901
|
||||
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
for definition in PERSONNEL_SECRET_SKILL_DEFINITIONS:
|
||||
rolling_dates = skill_states[definition.system_code]["_rolling_dates"]
|
||||
_prune_rolling_dates(rolling_dates, current_date, 120)
|
||||
skill_states[definition.system_code]["anti_streak"] = len(rolling_dates)
|
||||
reliability_state = skill_states[reliability_definition.system_code]
|
||||
rolling_dates = reliability_state["_rolling_dates"]
|
||||
_prune_rolling_dates(rolling_dates, current_date, 120)
|
||||
reliability_state["anti_streak"] = len(rolling_dates)
|
||||
|
||||
if current_date in reliability_episode_dates:
|
||||
reliability_streak = 0
|
||||
@ -687,13 +694,7 @@ def calculate_personnel_secret_metrics( # noqa: C901
|
||||
)
|
||||
if current_date in sick_episode_dates:
|
||||
resilience_streak = 0
|
||||
_register_rolling_violation(
|
||||
skill_states[resilience_definition.system_code],
|
||||
resilience_definition,
|
||||
current_metrics(),
|
||||
violation_date=current_date,
|
||||
window_days=120,
|
||||
)
|
||||
recovery_days_since_last_sick = 0
|
||||
|
||||
if current_date not in reliability_blocked_dates | reliability_paused_dates:
|
||||
worked_days += 1
|
||||
@ -716,30 +717,60 @@ def calculate_personnel_secret_metrics( # noqa: C901
|
||||
)
|
||||
|
||||
if current_date not in resilience_blocked_dates | resilience_paused_dates:
|
||||
recovery_days_since_last_sick += 1
|
||||
resilience_streak += 1
|
||||
best_resilience_streak = max(best_resilience_streak, resilience_streak)
|
||||
_advance_rolling_recovery(
|
||||
skill_states[resilience_definition.system_code],
|
||||
resilience_definition,
|
||||
current_metrics(),
|
||||
record_date=current_date,
|
||||
)
|
||||
else:
|
||||
_refresh_secret_state(
|
||||
skill_states[resilience_definition.system_code],
|
||||
resilience_definition,
|
||||
current_metrics(),
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
for definition in PERSONNEL_SECRET_SKILL_DEFINITIONS:
|
||||
state = skill_states[definition.system_code]
|
||||
rolling_dates = state.pop("_rolling_dates")
|
||||
_prune_rolling_dates(rolling_dates, end_date, 120)
|
||||
state["anti_streak"] = len(rolling_dates)
|
||||
state["recent_violation_dates"] = [
|
||||
violation_date.isoformat() for violation_date in rolling_dates
|
||||
]
|
||||
reliability_state = skill_states[reliability_definition.system_code]
|
||||
rolling_dates = reliability_state.pop("_rolling_dates")
|
||||
_prune_rolling_dates(rolling_dates, end_date, 120)
|
||||
reliability_state["anti_streak"] = len(rolling_dates)
|
||||
reliability_state["recent_violation_dates"] = [
|
||||
violation_date.isoformat() for violation_date in rolling_dates
|
||||
]
|
||||
|
||||
resilience_state = skill_states[resilience_definition.system_code]
|
||||
recent_sick_dates = sorted(
|
||||
sick_date
|
||||
for sick_date in sick_episode_dates
|
||||
if sick_date >= end_date - timedelta(days=179)
|
||||
)
|
||||
_refresh_secret_state(
|
||||
resilience_state,
|
||||
resilience_definition,
|
||||
current_metrics(),
|
||||
)
|
||||
earned_rank_index = RANK_INDEX[str(resilience_state["earned_rank"])]
|
||||
sick_count = len(recent_sick_dates)
|
||||
if sick_count < 2:
|
||||
base_penalty_levels = 0
|
||||
elif sick_count == 2:
|
||||
base_penalty_levels = 1
|
||||
elif sick_count == 3:
|
||||
base_penalty_levels = 2
|
||||
else:
|
||||
base_penalty_levels = earned_rank_index
|
||||
recovered_levels = recovery_days_since_last_sick // 90
|
||||
penalty_levels = max(
|
||||
min(base_penalty_levels, earned_rank_index) - recovered_levels,
|
||||
0,
|
||||
)
|
||||
resilience_state.update(
|
||||
anti_streak=sick_count,
|
||||
penalty_levels=penalty_levels,
|
||||
effective_rank=RANK_CODES[
|
||||
max(earned_rank_index - penalty_levels, 0)
|
||||
],
|
||||
recovery_streak=(
|
||||
recovery_days_since_last_sick % 90 if penalty_levels else 0
|
||||
),
|
||||
last_demotion_date=(
|
||||
recent_sick_dates[-1].isoformat() if penalty_levels else ""
|
||||
),
|
||||
last_recovery_date=(end_date.isoformat() if recovered_levels else ""),
|
||||
recent_violation_dates=[item.isoformat() for item in recent_sick_dates],
|
||||
)
|
||||
|
||||
return {**current_metrics(), "skill_states": skill_states}
|
||||
|
||||
@ -828,6 +859,7 @@ def recalculate_attendance_secret_skills( # noqa: C901
|
||||
employee = Employee.objects.select_for_update().filter(pk=employee_id).first()
|
||||
if employee is None:
|
||||
return []
|
||||
since = since or get_secret_skills_start_date()
|
||||
|
||||
records_queryset = AttendanceRecord.objects.filter(employee_id=employee_id)
|
||||
if since is not None:
|
||||
|
||||
@ -9,6 +9,7 @@ from django.db.models import Exists, F, OuterRef
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.models import Employee, EmployeeSkillProgress, Skill
|
||||
from employees.services.secret_skill_settings import get_secret_skills_start_date
|
||||
from employees.services.secret_skills import (
|
||||
RANK_CODES,
|
||||
RANK_INDEX,
|
||||
@ -287,6 +288,7 @@ def recalculate_task_execution_skill(
|
||||
*,
|
||||
since: date | None = None,
|
||||
) -> list[str]:
|
||||
since = since or get_secret_skills_start_date()
|
||||
employee = Employee.objects.select_for_update().filter(pk=employee_id).first()
|
||||
if employee is None:
|
||||
return []
|
||||
@ -359,6 +361,7 @@ def refresh_task_execution_skills( # noqa: C901
|
||||
) -> dict[str, int]:
|
||||
"""Reconcile missed transitions, mature pending cycles and recalculate users."""
|
||||
|
||||
since = since or get_secret_skills_start_date()
|
||||
now = timezone.now()
|
||||
existing_cycle = TaskExecutionCycle.objects.filter(
|
||||
task_id=OuterRef("pk"),
|
||||
|
||||
@ -5,7 +5,7 @@ import pytest
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.models import EmployeeSkillProgress, Skill
|
||||
from employees.models import EmployeeSkillProgress, SecretSkillSettings, Skill
|
||||
from tasks.models import TaskExecutionCycle
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@ -64,7 +64,7 @@ def test_regular_run_persists_the_same_calculation(user_factory):
|
||||
assert employee.skills.filter(pk=skill.pk).exists()
|
||||
|
||||
|
||||
def test_since_excludes_older_task_cycles_from_dry_run(user_factory):
|
||||
def test_saved_start_date_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)
|
||||
@ -74,11 +74,13 @@ def test_since_excludes_older_task_cycles_from_dry_run(user_factory):
|
||||
)
|
||||
assert progress.is_active is True
|
||||
output = StringIO()
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
settings.calculation_start_date = timezone.localdate() - timedelta(days=90)
|
||||
settings.save(update_fields=["calculation_start_date", "updated_at"])
|
||||
|
||||
call_command(
|
||||
"recalculate_secret_skills",
|
||||
employee_id=employee.id,
|
||||
since=(timezone.localdate() - timedelta(days=90)).isoformat(),
|
||||
dry_run=True,
|
||||
stdout=output,
|
||||
)
|
||||
@ -88,15 +90,17 @@ def test_since_excludes_older_task_cycles_from_dry_run(user_factory):
|
||||
assert progress.is_active is True
|
||||
|
||||
|
||||
def test_since_includes_task_cycles_on_or_after_boundary(user_factory):
|
||||
def test_saved_start_date_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()
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
settings.calculation_start_date = timezone.localdate() - timedelta(days=90)
|
||||
settings.save(update_fields=["calculation_start_date", "updated_at"])
|
||||
|
||||
call_command(
|
||||
"recalculate_secret_skills",
|
||||
employee_id=employee.id,
|
||||
since=(timezone.localdate() - timedelta(days=90)).isoformat(),
|
||||
dry_run=True,
|
||||
stdout=output,
|
||||
)
|
||||
|
||||
@ -12,12 +12,24 @@ from employees.constants import (
|
||||
ACTION_ON_SICK_LEAVE,
|
||||
ACTION_REMOTE,
|
||||
)
|
||||
from employees.models import EmployeeAction, EmployeeSkillProgress, Skill
|
||||
from employees.models import (
|
||||
EmployeeAction,
|
||||
EmployeeSkillProgress,
|
||||
SecretSkillSettings,
|
||||
Skill,
|
||||
)
|
||||
from employees.services.secret_skills import recalculate_attendance_secret_skills
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _use_full_test_history():
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
settings.calculation_start_date = date(2000, 1, 1)
|
||||
settings.save(update_fields=["calculation_start_date", "updated_at"])
|
||||
|
||||
|
||||
def _run(employee, start: date, end: date):
|
||||
return AttendanceAnalysisRun.objects.create(
|
||||
employee=employee,
|
||||
@ -444,29 +456,28 @@ def test_resilience_counts_sick_episodes_and_recovers_rank(user_factory):
|
||||
employee = user_factory()
|
||||
start = date(2024, 1, 1)
|
||||
_action(employee, ACTION_HIRED, start)
|
||||
|
||||
with patch(
|
||||
"employees.services.secret_skills.timezone.localdate",
|
||||
return_value=start + timedelta(days=59),
|
||||
return_value=start + timedelta(days=179),
|
||||
):
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
skill = Skill.objects.get(system_code="personnel_resilience")
|
||||
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
|
||||
assert progress.special_rank == "bronze"
|
||||
assert progress.progress_data["best_resilience_streak"] == 60
|
||||
assert progress.progress_data["best_resilience_streak"] == 180
|
||||
|
||||
_action(
|
||||
employee,
|
||||
ACTION_ON_SICK_LEAVE,
|
||||
start + timedelta(days=60),
|
||||
date_to=start + timedelta(days=79),
|
||||
start + timedelta(days=180),
|
||||
date_to=start + timedelta(days=189),
|
||||
)
|
||||
_action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=90))
|
||||
_action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=120))
|
||||
_action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=195))
|
||||
_action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=200))
|
||||
|
||||
with patch(
|
||||
"employees.services.secret_skills.timezone.localdate",
|
||||
return_value=start + timedelta(days=120),
|
||||
return_value=start + timedelta(days=200),
|
||||
):
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
@ -477,7 +488,7 @@ def test_resilience_counts_sick_episodes_and_recovers_rank(user_factory):
|
||||
|
||||
with patch(
|
||||
"employees.services.secret_skills.timezone.localdate",
|
||||
return_value=start + timedelta(days=210),
|
||||
return_value=start + timedelta(days=290),
|
||||
):
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
@ -487,6 +498,26 @@ def test_resilience_counts_sick_episodes_and_recovers_rank(user_factory):
|
||||
assert progress.progress_data["penalty_levels"] == 0
|
||||
|
||||
|
||||
def test_resilience_counts_calendar_days_without_attendance(user_factory):
|
||||
employee = user_factory()
|
||||
start = date(2024, 1, 1)
|
||||
_action(employee, ACTION_HIRED, start)
|
||||
|
||||
with patch(
|
||||
"employees.services.secret_skills.timezone.localdate",
|
||||
return_value=start + timedelta(days=179),
|
||||
):
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
|
||||
skill = Skill.objects.get(system_code="personnel_resilience")
|
||||
progress = EmployeeSkillProgress.objects.get(
|
||||
employee=employee,
|
||||
skill=skill,
|
||||
)
|
||||
assert progress.special_rank == "bronze"
|
||||
assert progress.progress_data["best_resilience_streak"] == 180
|
||||
|
||||
|
||||
def test_personnel_action_schedules_secret_skill_recalculation(
|
||||
user_factory,
|
||||
django_capture_on_commit_callbacks,
|
||||
|
||||
@ -5,6 +5,7 @@ import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from employees.models import SecretSkillSettings
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -52,10 +53,12 @@ def test_report_lists_holders_and_nearest_candidates(user_factory):
|
||||
personnel_status="normal",
|
||||
)
|
||||
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
settings.calculation_start_date = start
|
||||
settings.save(update_fields=["calculation_start_date", "updated_at"])
|
||||
output = StringIO()
|
||||
call_command(
|
||||
"secret_skills_report",
|
||||
since=start.isoformat(),
|
||||
top=3,
|
||||
stdout=output,
|
||||
)
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.models import SecretSkillSettings
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_command_shows_and_persists_start_date():
|
||||
target = timezone.localdate() - timedelta(days=120)
|
||||
output = StringIO()
|
||||
|
||||
call_command(
|
||||
"secret_skills_start_date",
|
||||
start_date=target.isoformat(),
|
||||
stdout=output,
|
||||
)
|
||||
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
assert settings.calculation_start_date == target
|
||||
assert target.isoformat() in output.getvalue()
|
||||
|
||||
shown = StringIO()
|
||||
call_command("secret_skills_start_date", stdout=shown)
|
||||
assert shown.getvalue().strip() == target.isoformat()
|
||||
|
||||
|
||||
def test_command_rejects_future_date():
|
||||
future = timezone.localdate() + timedelta(days=1)
|
||||
|
||||
with pytest.raises(CommandError, match="не может быть из будущего"):
|
||||
call_command("secret_skills_start_date", start_date=future.isoformat())
|
||||
Loading…
x
Reference in New Issue
Block a user