diff --git a/backend/employees/management/commands/recalculate_secret_skills.py b/backend/employees/management/commands/recalculate_secret_skills.py index ab44c272..16d097d5 100644 --- a/backend/employees/management/commands/recalculate_secret_skills.py +++ b/backend/employees/management/commands/recalculate_secret_skills.py @@ -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()}" ) ) diff --git a/backend/employees/management/commands/secret_skills_report.py b/backend/employees/management/commands/secret_skills_report.py index ca00ef06..1b1d9352 100644 --- a/backend/employees/management/commands/secret_skills_report.py +++ b/backend/employees/management/commands/secret_skills_report.py @@ -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: diff --git a/backend/employees/management/commands/secret_skills_start_date.py b/backend/employees/management/commands/secret_skills_start_date.py new file mode 100644 index 00000000..48f775a9 --- /dev/null +++ b/backend/employees/management/commands/secret_skills_start_date.py @@ -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" + ) diff --git a/backend/employees/migrations/0061_secretskillsettings.py b/backend/employees/migrations/0061_secretskillsettings.py new file mode 100644 index 00000000..f9a44dc4 --- /dev/null +++ b/backend/employees/migrations/0061_secretskillsettings.py @@ -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, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index 861c6d62..f648fb97 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -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", "Обычный опыт" diff --git a/backend/employees/services/secret_skill_settings.py b/backend/employees/services/secret_skill_settings.py new file mode 100644 index 00000000..292a0889 --- /dev/null +++ b/backend/employees/services/secret_skill_settings.py @@ -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 diff --git a/backend/employees/services/secret_skills.py b/backend/employees/services/secret_skills.py index 03c4bcd7..552e1050 100644 --- a/backend/employees/services/secret_skills.py +++ b/backend/employees/services/secret_skills.py @@ -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: diff --git a/backend/employees/services/task_execution_skill.py b/backend/employees/services/task_execution_skill.py index 83b886f1..be2ad74e 100644 --- a/backend/employees/services/task_execution_skill.py +++ b/backend/employees/services/task_execution_skill.py @@ -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"), diff --git a/backend/tests/employees/test_recalculate_secret_skills_command.py b/backend/tests/employees/test_recalculate_secret_skills_command.py index 253e034a..d6d4275a 100644 --- a/backend/tests/employees/test_recalculate_secret_skills_command.py +++ b/backend/tests/employees/test_recalculate_secret_skills_command.py @@ -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, ) diff --git a/backend/tests/employees/test_secret_skills.py b/backend/tests/employees/test_secret_skills.py index b9ab35f7..dfec3758 100644 --- a/backend/tests/employees/test_secret_skills.py +++ b/backend/tests/employees/test_secret_skills.py @@ -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, diff --git a/backend/tests/employees/test_secret_skills_report_command.py b/backend/tests/employees/test_secret_skills_report_command.py index a80c0f27..6b0a878d 100644 --- a/backend/tests/employees/test_secret_skills_report_command.py +++ b/backend/tests/employees/test_secret_skills_report_command.py @@ -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, ) diff --git a/backend/tests/employees/test_secret_skills_start_date_command.py b/backend/tests/employees/test_secret_skills_start_date_command.py new file mode 100644 index 00000000..81f27f08 --- /dev/null +++ b/backend/tests/employees/test_secret_skills_start_date_command.py @@ -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())