Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-26 16:53:15 +03:00
commit 7ab7a3cfc8
12 changed files with 312 additions and 91 deletions

View File

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

View File

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

View File

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

View 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,
),
]

View File

@ -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", "Обычный опыт"

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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