feat: add secret skill anti-streaks
This commit is contained in:
parent
2e807d83dd
commit
9d16efadec
@ -1,7 +1,9 @@
|
||||
"""Общие (shared) сериализаторы: Skill, Position, EmployeeAction."""
|
||||
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from django.contrib.auth.models import Group
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from employees.constants import ACTION_CHOICES, TEMPORARY_START_ACTIONS
|
||||
from employees.models import (
|
||||
EmployeeAction,
|
||||
@ -10,15 +12,14 @@ from employees.models import (
|
||||
Skill,
|
||||
SkillExperienceEvent,
|
||||
)
|
||||
from employees.services.skill_progress import (
|
||||
confirmed_holder_count,
|
||||
rank_payload,
|
||||
)
|
||||
from employees.services.secret_skills import (
|
||||
secret_skill_progress_payload,
|
||||
skill_rarity_payload,
|
||||
)
|
||||
from rest_framework import serializers
|
||||
from employees.services.skill_progress import (
|
||||
confirmed_holder_count,
|
||||
rank_payload,
|
||||
)
|
||||
|
||||
|
||||
class GroupVerboseSerializer(serializers.Serializer):
|
||||
@ -54,6 +55,12 @@ class SkillSerializer(serializers.ModelSerializer):
|
||||
progress_label = serializers.SerializerMethodField()
|
||||
progress_data = serializers.SerializerMethodField()
|
||||
next_rank_requirements = serializers.SerializerMethodField()
|
||||
anti_streak = serializers.SerializerMethodField()
|
||||
anti_streak_limit = serializers.SerializerMethodField()
|
||||
anti_streak_remaining = serializers.SerializerMethodField()
|
||||
recovery_streak = serializers.SerializerMethodField()
|
||||
recovery_streak_target = serializers.SerializerMethodField()
|
||||
penalty_levels = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Skill
|
||||
@ -77,6 +84,12 @@ class SkillSerializer(serializers.ModelSerializer):
|
||||
"progress_label",
|
||||
"progress_data",
|
||||
"next_rank_requirements",
|
||||
"anti_streak",
|
||||
"anti_streak_limit",
|
||||
"anti_streak_remaining",
|
||||
"recovery_streak",
|
||||
"recovery_streak_target",
|
||||
"penalty_levels",
|
||||
]
|
||||
read_only_fields = ["is_secret", "progression_type"]
|
||||
|
||||
@ -141,6 +154,24 @@ class SkillSerializer(serializers.ModelSerializer):
|
||||
def get_next_rank_requirements(self, obj):
|
||||
return self._rank(obj).get("next_rank_requirements", [])
|
||||
|
||||
def get_anti_streak(self, obj):
|
||||
return self._rank(obj).get("anti_streak", 0)
|
||||
|
||||
def get_anti_streak_limit(self, obj):
|
||||
return self._rank(obj).get("anti_streak_limit", 0)
|
||||
|
||||
def get_anti_streak_remaining(self, obj):
|
||||
return self._rank(obj).get("anti_streak_remaining", 0)
|
||||
|
||||
def get_recovery_streak(self, obj):
|
||||
return self._rank(obj).get("recovery_streak", 0)
|
||||
|
||||
def get_recovery_streak_target(self, obj):
|
||||
return self._rank(obj).get("recovery_streak_target", 0)
|
||||
|
||||
def get_penalty_levels(self, obj):
|
||||
return self._rank(obj).get("penalty_levels", 0)
|
||||
|
||||
|
||||
class SkillExperienceEventSerializer(serializers.ModelSerializer):
|
||||
source_display = serializers.CharField(
|
||||
|
||||
@ -24,15 +24,15 @@ class Command(BaseCommand):
|
||||
employee_ids = employee_ids.filter(employee_id=employee_id)
|
||||
|
||||
processed = 0
|
||||
unlocked_or_promoted = 0
|
||||
rank_changes = 0
|
||||
for current_employee_id in employee_ids.iterator():
|
||||
changes = recalculate_attendance_secret_skills(current_employee_id)
|
||||
processed += 1
|
||||
unlocked_or_promoted += len(changes)
|
||||
rank_changes += len(changes)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Пересчитано сотрудников: "
|
||||
f"{processed}; открыто или повышено навыков: {unlocked_or_promoted}"
|
||||
f"{processed}; изменений рангов и навыков: {rank_changes}"
|
||||
)
|
||||
)
|
||||
|
||||
@ -12,7 +12,6 @@ from employees.constants import ACTION_REMOTE
|
||||
from employees.models import Employee, EmployeeSkillProgress, Skill
|
||||
from employees.services.skill_progress import SKILL_RANKS, rarity_payload
|
||||
|
||||
|
||||
RANK_CODES = tuple(rank["code"] for rank in SKILL_RANKS)
|
||||
RANK_LABELS = {rank["code"]: rank["label"] for rank in SKILL_RANKS}
|
||||
RANK_INDEX = {code: index for index, code in enumerate(RANK_CODES)}
|
||||
@ -73,6 +72,8 @@ class SecretSkillDefinition:
|
||||
name: str
|
||||
progression_type: str
|
||||
requirements: dict[str, dict[str, int]]
|
||||
anti_streak_limit: int
|
||||
recovery_streak_target: int
|
||||
|
||||
|
||||
SECRET_SKILL_DEFINITIONS = (
|
||||
@ -81,12 +82,16 @@ SECRET_SKILL_DEFINITIONS = (
|
||||
name="Пунктуальность",
|
||||
progression_type=Skill.ProgressionType.ATTENDANCE_PUNCTUALITY,
|
||||
requirements=PUNCTUALITY_REQUIREMENTS,
|
||||
anti_streak_limit=3,
|
||||
recovery_streak_target=20,
|
||||
),
|
||||
SecretSkillDefinition(
|
||||
system_code="attendance_self_organization",
|
||||
name="Самоорганизация",
|
||||
progression_type=Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION,
|
||||
requirements=SELF_ORGANIZATION_REQUIREMENTS,
|
||||
anti_streak_limit=5,
|
||||
recovery_streak_target=30,
|
||||
),
|
||||
)
|
||||
|
||||
@ -99,7 +104,78 @@ def _has_work(record) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | str]:
|
||||
def _rank_metrics(metrics: dict[str, object], definition: SecretSkillDefinition):
|
||||
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
|
||||
return {"best_streak": metrics["best_streak"]}
|
||||
return {
|
||||
"clean_days": metrics["clean_days"],
|
||||
"best_clean_streak": metrics["best_clean_streak"],
|
||||
"off_schedule_days": metrics["off_schedule_days"],
|
||||
"overtime_days": metrics["overtime_days"],
|
||||
}
|
||||
|
||||
|
||||
def _refresh_secret_state(
|
||||
state: dict[str, int | str],
|
||||
definition: SecretSkillDefinition,
|
||||
metrics: dict[str, object],
|
||||
) -> None:
|
||||
earned_rank = _candidate_rank(
|
||||
_rank_metrics(metrics, definition),
|
||||
definition.requirements,
|
||||
)
|
||||
earned_rank_index = RANK_INDEX[earned_rank]
|
||||
penalty_levels = min(int(state["penalty_levels"]), earned_rank_index)
|
||||
state["penalty_levels"] = penalty_levels
|
||||
state["earned_rank"] = earned_rank
|
||||
state["effective_rank"] = RANK_CODES[max(earned_rank_index - penalty_levels, 0)]
|
||||
|
||||
|
||||
def _advance_secret_state(
|
||||
state: dict[str, int | str],
|
||||
definition: SecretSkillDefinition,
|
||||
metrics: dict[str, object],
|
||||
*,
|
||||
is_bad: bool,
|
||||
record_date,
|
||||
) -> None:
|
||||
_refresh_secret_state(state, definition, metrics)
|
||||
earned_rank_index = RANK_INDEX[str(state["earned_rank"])]
|
||||
if earned_rank_index == 0:
|
||||
state.update(
|
||||
anti_streak=0,
|
||||
recovery_streak=0,
|
||||
penalty_levels=0,
|
||||
effective_rank="none",
|
||||
)
|
||||
return
|
||||
|
||||
penalty_levels = int(state["penalty_levels"])
|
||||
if is_bad:
|
||||
state["recovery_streak"] = 0
|
||||
if penalty_levels < earned_rank_index:
|
||||
state["anti_streak"] = int(state["anti_streak"]) + 1
|
||||
if int(state["anti_streak"]) >= definition.anti_streak_limit:
|
||||
state["penalty_levels"] = penalty_levels + 1
|
||||
state["anti_streak"] = 0
|
||||
state["last_demotion_date"] = record_date.isoformat()
|
||||
else:
|
||||
state["anti_streak"] = 0
|
||||
else:
|
||||
state["anti_streak"] = 0
|
||||
if penalty_levels > 0:
|
||||
state["recovery_streak"] = int(state["recovery_streak"]) + 1
|
||||
if int(state["recovery_streak"]) >= definition.recovery_streak_target:
|
||||
state["penalty_levels"] = penalty_levels - 1
|
||||
state["recovery_streak"] = 0
|
||||
state["last_recovery_date"] = record_date.isoformat()
|
||||
else:
|
||||
state["recovery_streak"] = 0
|
||||
|
||||
_refresh_secret_state(state, definition, metrics)
|
||||
|
||||
|
||||
def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, object]:
|
||||
"""Рассчитать фактические показатели по нормализованным записям."""
|
||||
|
||||
punctuality_streak = 0
|
||||
@ -110,6 +186,41 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st
|
||||
off_schedule_days = 0
|
||||
overtime_days = 0
|
||||
last_evaluated_date = ""
|
||||
skill_states: dict[str, dict[str, int | str]] = {
|
||||
definition.system_code: {
|
||||
"earned_rank": "none",
|
||||
"effective_rank": "none",
|
||||
"anti_streak": 0,
|
||||
"anti_streak_limit": definition.anti_streak_limit,
|
||||
"recovery_streak": 0,
|
||||
"recovery_streak_target": definition.recovery_streak_target,
|
||||
"penalty_levels": 0,
|
||||
"last_demotion_date": "",
|
||||
"last_recovery_date": "",
|
||||
}
|
||||
for definition in SECRET_SKILL_DEFINITIONS
|
||||
}
|
||||
|
||||
def current_metrics():
|
||||
return {
|
||||
"current_streak": punctuality_streak,
|
||||
"best_streak": best_punctuality_streak,
|
||||
"current_clean_streak": clean_streak,
|
||||
"best_clean_streak": best_clean_streak,
|
||||
"clean_days": clean_days,
|
||||
"off_schedule_days": off_schedule_days,
|
||||
"overtime_days": overtime_days,
|
||||
"last_evaluated_date": last_evaluated_date,
|
||||
}
|
||||
|
||||
def refresh_paused_states():
|
||||
metrics = current_metrics()
|
||||
for definition in SECRET_SKILL_DEFINITIONS:
|
||||
_refresh_secret_state(
|
||||
skill_states[definition.system_code],
|
||||
definition,
|
||||
metrics,
|
||||
)
|
||||
|
||||
for record in sorted(records, key=lambda item: (item.date, item.id or 0)):
|
||||
if record.technical_issues:
|
||||
@ -127,15 +238,26 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st
|
||||
and has_work
|
||||
):
|
||||
off_schedule_days += 1
|
||||
refresh_paused_states()
|
||||
continue
|
||||
|
||||
if record.personnel_status == ACTION_REMOTE:
|
||||
refresh_paused_states()
|
||||
continue
|
||||
|
||||
last_evaluated_date = record.date.isoformat()
|
||||
if record.is_absent or not has_work:
|
||||
punctuality_streak = 0
|
||||
clean_streak = 0
|
||||
metrics = current_metrics()
|
||||
for definition in SECRET_SKILL_DEFINITIONS:
|
||||
_advance_secret_state(
|
||||
skill_states[definition.system_code],
|
||||
definition,
|
||||
metrics,
|
||||
is_bad=True,
|
||||
record_date=record.date,
|
||||
)
|
||||
continue
|
||||
|
||||
if record.is_late:
|
||||
@ -162,6 +284,22 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st
|
||||
else:
|
||||
clean_streak = 0
|
||||
|
||||
metrics = current_metrics()
|
||||
for definition in SECRET_SKILL_DEFINITIONS:
|
||||
is_bad = (
|
||||
bool(record.is_late)
|
||||
if definition.progression_type
|
||||
== Skill.ProgressionType.ATTENDANCE_PUNCTUALITY
|
||||
else not is_clean
|
||||
)
|
||||
_advance_secret_state(
|
||||
skill_states[definition.system_code],
|
||||
definition,
|
||||
metrics,
|
||||
is_bad=is_bad,
|
||||
record_date=record.date,
|
||||
)
|
||||
|
||||
return {
|
||||
"current_streak": punctuality_streak,
|
||||
"best_streak": best_punctuality_streak,
|
||||
@ -171,6 +309,7 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st
|
||||
"off_schedule_days": off_schedule_days,
|
||||
"overtime_days": overtime_days,
|
||||
"last_evaluated_date": last_evaluated_date,
|
||||
"skill_states": skill_states,
|
||||
}
|
||||
|
||||
|
||||
@ -204,13 +343,15 @@ def _definition_for_skill(skill: Skill) -> SecretSkillDefinition | None:
|
||||
|
||||
def _metrics_for_definition(
|
||||
definition: SecretSkillDefinition,
|
||||
metrics: dict[str, int | str],
|
||||
) -> dict[str, int | str]:
|
||||
metrics: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
skill_state = dict(metrics["skill_states"][definition.system_code])
|
||||
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
|
||||
return {
|
||||
"current_streak": metrics["current_streak"],
|
||||
"best_streak": metrics["best_streak"],
|
||||
"last_evaluated_date": metrics["last_evaluated_date"],
|
||||
**skill_state,
|
||||
}
|
||||
return {
|
||||
"current_clean_streak": metrics["current_clean_streak"],
|
||||
@ -219,6 +360,7 @@ def _metrics_for_definition(
|
||||
"off_schedule_days": metrics["off_schedule_days"],
|
||||
"overtime_days": metrics["overtime_days"],
|
||||
"last_evaluated_date": metrics["last_evaluated_date"],
|
||||
**skill_state,
|
||||
}
|
||||
|
||||
|
||||
@ -254,35 +396,50 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]:
|
||||
continue
|
||||
|
||||
definition_metrics = _metrics_for_definition(definition, metrics)
|
||||
candidate_rank = _candidate_rank(
|
||||
definition_metrics,
|
||||
definition.requirements,
|
||||
)
|
||||
calculated_rank = str(definition_metrics["effective_rank"])
|
||||
progress = EmployeeSkillProgress.objects.filter(
|
||||
employee=employee,
|
||||
skill=skill,
|
||||
).first()
|
||||
previous_rank = progress.special_rank if progress else "none"
|
||||
achieved_rank = (
|
||||
candidate_rank
|
||||
if RANK_INDEX[candidate_rank] > RANK_INDEX[previous_rank]
|
||||
else previous_rank
|
||||
)
|
||||
|
||||
if achieved_rank == "none":
|
||||
if progress and progress.is_active:
|
||||
if calculated_rank == "none":
|
||||
if progress:
|
||||
was_active = progress.is_active
|
||||
rank_history = dict(
|
||||
progress.progress_data.get("rank_achieved_at") or {}
|
||||
)
|
||||
rank_losses = list(progress.progress_data.get("rank_losses") or [])
|
||||
if previous_rank != "none":
|
||||
rank_losses.append(
|
||||
{
|
||||
"from": previous_rank,
|
||||
"to": "none",
|
||||
"at": now.isoformat(),
|
||||
}
|
||||
)
|
||||
progress.special_rank = "none"
|
||||
progress.is_active = False
|
||||
progress.experience = 0
|
||||
progress.progress_data = definition_metrics
|
||||
progress.progress_data = {
|
||||
**definition_metrics,
|
||||
"rank_achieved_at": rank_history,
|
||||
"rank_losses": rank_losses[-20:],
|
||||
"recalculated_at": now.isoformat(),
|
||||
}
|
||||
progress.save(
|
||||
update_fields=[
|
||||
"special_rank",
|
||||
"is_active",
|
||||
"experience",
|
||||
"progress_data",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
employee.skills.remove(skill)
|
||||
if was_active:
|
||||
employee.skills.remove(skill)
|
||||
if previous_rank != "none" or was_active:
|
||||
changed_skills.append(skill.name)
|
||||
continue
|
||||
|
||||
employee.skills.add(skill)
|
||||
@ -291,16 +448,26 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]:
|
||||
skill=skill,
|
||||
)
|
||||
rank_history = dict(progress.progress_data.get("rank_achieved_at") or {})
|
||||
if RANK_INDEX[achieved_rank] > RANK_INDEX[previous_rank]:
|
||||
rank_history[achieved_rank] = now.isoformat()
|
||||
if RANK_INDEX[calculated_rank] > RANK_INDEX[previous_rank]:
|
||||
rank_history[calculated_rank] = now.isoformat()
|
||||
rank_losses = list(progress.progress_data.get("rank_losses") or [])
|
||||
if RANK_INDEX[calculated_rank] < RANK_INDEX[previous_rank]:
|
||||
rank_losses.append(
|
||||
{
|
||||
"from": previous_rank,
|
||||
"to": calculated_rank,
|
||||
"at": now.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
progress.special_rank = achieved_rank
|
||||
progress.special_rank = calculated_rank
|
||||
progress.experience = 0
|
||||
progress.is_active = True
|
||||
progress.created_by = None
|
||||
progress.progress_data = {
|
||||
**definition_metrics,
|
||||
"rank_achieved_at": rank_history,
|
||||
"rank_losses": rank_losses[-20:],
|
||||
"recalculated_at": now.isoformat(),
|
||||
}
|
||||
progress.save(
|
||||
@ -316,7 +483,7 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]:
|
||||
progress.experience_events.filter(revoked_at__isnull=True).update(
|
||||
revoked_at=now,
|
||||
)
|
||||
if achieved_rank != previous_rank:
|
||||
if calculated_rank != previous_rank:
|
||||
changed_skills.append(skill.name)
|
||||
|
||||
return changed_skills
|
||||
@ -334,22 +501,38 @@ def secret_skill_progress_payload(
|
||||
metrics = dict(progress.progress_data or {}) if progress else {}
|
||||
rank_index = RANK_INDEX.get(rank_code, 0)
|
||||
next_rank = RANK_CODES[rank_index + 1] if rank_index + 1 < len(RANK_CODES) else None
|
||||
next_requirements = definition.requirements.get(next_rank or "", {})
|
||||
requirement_payload = [
|
||||
{
|
||||
"key": metric,
|
||||
"label": REQUIREMENT_LABELS[metric],
|
||||
"current": int(metrics.get(metric, 0) or 0),
|
||||
"target": target,
|
||||
"complete": int(metrics.get(metric, 0) or 0) >= target,
|
||||
penalty_levels = int(metrics.get("penalty_levels", 0) or 0)
|
||||
if penalty_levels > 0 and next_rank:
|
||||
next_requirements = {
|
||||
"recovery_streak": definition.recovery_streak_target,
|
||||
}
|
||||
for metric, target in next_requirements.items()
|
||||
]
|
||||
if next_requirements:
|
||||
requirement_payload = [
|
||||
{
|
||||
"key": "recovery_streak",
|
||||
"label": "Новая положительная серия",
|
||||
"current": int(metrics.get("recovery_streak", 0) or 0),
|
||||
"target": definition.recovery_streak_target,
|
||||
"complete": int(metrics.get("recovery_streak", 0) or 0)
|
||||
>= definition.recovery_streak_target,
|
||||
}
|
||||
]
|
||||
else:
|
||||
next_requirements = definition.requirements.get(next_rank or "", {})
|
||||
requirement_payload = [
|
||||
{
|
||||
"key": metric,
|
||||
"label": REQUIREMENT_LABELS[metric],
|
||||
"current": int(metrics.get(metric, 0) or 0),
|
||||
"target": target,
|
||||
"complete": int(metrics.get(metric, 0) or 0) >= target,
|
||||
}
|
||||
for metric, target in next_requirements.items()
|
||||
]
|
||||
if requirement_payload:
|
||||
progress_percent = round(
|
||||
min(
|
||||
min(int(metrics.get(metric, 0) or 0) / max(target, 1), 1)
|
||||
for metric, target in next_requirements.items()
|
||||
min(item["current"] / max(item["target"], 1), 1)
|
||||
for item in requirement_payload
|
||||
)
|
||||
* 100
|
||||
)
|
||||
@ -358,12 +541,20 @@ def secret_skill_progress_payload(
|
||||
|
||||
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
|
||||
progress_value = int(metrics.get("best_streak", 0) or 0)
|
||||
progress_label = f"Лучшая серия: {progress_value} дн."
|
||||
progress_label = (
|
||||
f"Восстановление ранга: {metrics.get('recovery_streak', 0)}/"
|
||||
f"{definition.recovery_streak_target} дн."
|
||||
if penalty_levels
|
||||
else f"Лучшая серия: {progress_value} дн."
|
||||
)
|
||||
else:
|
||||
progress_value = int(metrics.get("clean_days", 0) or 0)
|
||||
completed = sum(item["complete"] for item in requirement_payload)
|
||||
progress_label = (
|
||||
f"До следующего ранга: {completed}/{len(requirement_payload)} условий"
|
||||
f"Восстановление ранга: {metrics.get('recovery_streak', 0)}/"
|
||||
f"{definition.recovery_streak_target} дн."
|
||||
if penalty_levels
|
||||
else f"До следующего ранга: {completed}/{len(requirement_payload)} условий"
|
||||
if requirement_payload
|
||||
else "Все условия выполнены"
|
||||
)
|
||||
@ -391,6 +582,15 @@ def secret_skill_progress_payload(
|
||||
"progress_label": progress_label,
|
||||
"progress_data": metrics,
|
||||
"next_rank_requirements": requirement_payload,
|
||||
"anti_streak": int(metrics.get("anti_streak", 0) or 0),
|
||||
"anti_streak_limit": definition.anti_streak_limit,
|
||||
"anti_streak_remaining": max(
|
||||
definition.anti_streak_limit - int(metrics.get("anti_streak", 0) or 0),
|
||||
0,
|
||||
),
|
||||
"recovery_streak": int(metrics.get("recovery_streak", 0) or 0),
|
||||
"recovery_streak_target": definition.recovery_streak_target,
|
||||
"penalty_levels": penalty_levels,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -140,6 +140,10 @@ def test_secret_skills_are_hidden_and_cannot_be_added_or_endorsed(api_client):
|
||||
assert progress_response.data["rank"] == "bronze"
|
||||
assert progress_response.data["rarity"] == "secret"
|
||||
assert progress_response.data["can_endorse"] is False
|
||||
assert progress_response.data["anti_streak"] == 0
|
||||
assert progress_response.data["anti_streak_limit"] == 3
|
||||
assert progress_response.data["anti_streak_remaining"] == 3
|
||||
assert progress_response.data["recovery_streak_target"] == 20
|
||||
assert progress_response.data["next_rank_requirements"] == [
|
||||
{
|
||||
"key": "best_streak",
|
||||
|
||||
@ -7,7 +7,6 @@ from employees.constants import ACTION_REMOTE
|
||||
from employees.models import EmployeeSkillProgress, Skill
|
||||
from employees.services.secret_skills import recalculate_attendance_secret_skills
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@ -86,14 +85,16 @@ def test_punctuality_uses_best_streak_and_pauses_unknown_days(user_factory):
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "bronze"
|
||||
assert progress.progress_data["current_streak"] == 0
|
||||
assert progress.progress_data["anti_streak"] == 1
|
||||
|
||||
late_record.delete()
|
||||
assert recalculate_attendance_secret_skills(employee.id) == []
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "bronze"
|
||||
assert progress.progress_data["anti_streak"] == 0
|
||||
|
||||
|
||||
def test_self_organization_requires_every_bronze_condition_and_never_demotes(
|
||||
def test_self_organization_requires_every_bronze_condition(
|
||||
user_factory,
|
||||
):
|
||||
employee = user_factory()
|
||||
@ -139,6 +140,123 @@ def test_self_organization_requires_every_bronze_condition_and_never_demotes(
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
assert progress.progress_data["off_schedule_days"] == 4
|
||||
assert progress.special_rank == "none"
|
||||
assert progress.is_active is False
|
||||
assert not employee.skills.filter(pk=skill.pk).exists()
|
||||
|
||||
|
||||
def test_punctuality_anti_streak_demotes_revokes_and_recovers(user_factory):
|
||||
employee = user_factory()
|
||||
start = date(2025, 1, 1)
|
||||
run = _run(employee, start, start + timedelta(days=90))
|
||||
|
||||
for offset in range(45):
|
||||
_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 == "silver"
|
||||
|
||||
for offset in range(45, 48):
|
||||
_record(
|
||||
run,
|
||||
employee,
|
||||
start + timedelta(days=offset),
|
||||
is_late=True,
|
||||
late_minutes=10,
|
||||
)
|
||||
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "bronze"
|
||||
assert progress.progress_data["penalty_levels"] == 1
|
||||
assert progress.progress_data["anti_streak"] == 0
|
||||
|
||||
for offset in range(48, 51):
|
||||
_record(
|
||||
run,
|
||||
employee,
|
||||
start + timedelta(days=offset),
|
||||
is_late=True,
|
||||
late_minutes=10,
|
||||
)
|
||||
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "none"
|
||||
assert progress.is_active is False
|
||||
assert progress.progress_data["penalty_levels"] == 2
|
||||
assert not employee.skills.filter(pk=skill.pk).exists()
|
||||
|
||||
for offset in range(51, 71):
|
||||
_record(run, employee, start + timedelta(days=offset))
|
||||
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "bronze"
|
||||
assert progress.is_active is True
|
||||
assert progress.progress_data["penalty_levels"] == 1
|
||||
assert employee.skills.filter(pk=skill.pk).exists()
|
||||
|
||||
for offset in range(71, 91):
|
||||
_record(run, employee, start + timedelta(days=offset))
|
||||
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "silver"
|
||||
assert progress.progress_data["penalty_levels"] == 0
|
||||
|
||||
|
||||
def test_self_organization_anti_streak_revokes_and_recovers(user_factory):
|
||||
employee = user_factory()
|
||||
start = date(2024, 1, 1)
|
||||
run = _run(employee, start, start + timedelta(days=99))
|
||||
|
||||
for offset in range(60):
|
||||
_record(
|
||||
run,
|
||||
employee,
|
||||
start + timedelta(days=offset),
|
||||
is_overtime=offset < 5,
|
||||
overtime_hours=1.5 if offset < 5 else None,
|
||||
)
|
||||
for offset in range(60, 65):
|
||||
_record(
|
||||
run,
|
||||
employee,
|
||||
start + timedelta(days=offset),
|
||||
is_workday=False,
|
||||
effective_is_workday=False,
|
||||
is_overtime=True,
|
||||
overtime_hours=2,
|
||||
)
|
||||
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
skill = Skill.objects.get(system_code="attendance_self_organization")
|
||||
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
|
||||
assert progress.special_rank == "bronze"
|
||||
|
||||
for offset in range(65, 70):
|
||||
_record(
|
||||
run,
|
||||
employee,
|
||||
start + timedelta(days=offset),
|
||||
is_late=True,
|
||||
late_minutes=10,
|
||||
)
|
||||
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "none"
|
||||
assert progress.is_active is False
|
||||
assert progress.progress_data["penalty_levels"] == 1
|
||||
|
||||
for offset in range(70, 100):
|
||||
_record(run, employee, start + timedelta(days=offset))
|
||||
|
||||
recalculate_attendance_secret_skills(employee.id)
|
||||
progress.refresh_from_db()
|
||||
assert progress.special_rank == "bronze"
|
||||
assert progress.is_active is True
|
||||
assert progress.progress_data["penalty_levels"] == 0
|
||||
|
||||
@ -876,7 +876,10 @@ export function ProfileSkillsInline({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HorizontalChipScroller
|
||||
key={sortedSkills
|
||||
.map((skill) => `${skill.id}:${skill.rank}:${skill.experience}:${skill.rarity}`)
|
||||
.map(
|
||||
(skill) =>
|
||||
`${skill.id}:${skill.rank}:${skill.experience}:${skill.rarity}:${skill.anti_streak}:${skill.penalty_levels}`,
|
||||
)
|
||||
.join("|")}
|
||||
className="min-w-0 flex-1"
|
||||
ariaLabel="Навыки сотрудника"
|
||||
@ -903,6 +906,22 @@ export function ProfileSkillsInline({
|
||||
>
|
||||
{skill.rarity_display || "Не определена"}
|
||||
</span>
|
||||
{(skill.anti_streak || 0) > 0 ? (
|
||||
<span
|
||||
className="rounded-full bg-red-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-red-200"
|
||||
title={`Антисерия ${skill.anti_streak}/${skill.anti_streak_limit}`}
|
||||
>
|
||||
! {skill.anti_streak}/{skill.anti_streak_limit}
|
||||
</span>
|
||||
) : null}
|
||||
{(skill.penalty_levels || 0) > 0 ? (
|
||||
<span
|
||||
className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-amber-200"
|
||||
title={`Ранг снижен на ${skill.penalty_levels} уровней`}
|
||||
>
|
||||
−{skill.penalty_levels}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
@ -1146,6 +1165,29 @@ export function ProfileSkillsInline({
|
||||
{progress.is_secret && progress.progress_label ? (
|
||||
<p className="app-text-muted mt-3 text-xs">{progress.progress_label}</p>
|
||||
) : null}
|
||||
{progress.is_secret && (progress.anti_streak || 0) > 0 ? (
|
||||
<div className="mt-3 rounded-xl border border-red-400/40 bg-red-500/10 px-3 py-2.5 text-xs text-red-200">
|
||||
<p className="font-semibold">
|
||||
Антисерия: {progress.anti_streak}/{progress.anti_streak_limit}
|
||||
</p>
|
||||
<p className="mt-1 text-red-200/80">
|
||||
{progress.anti_streak_remaining === 1
|
||||
? "Следующее нарушение понизит ранг."
|
||||
: `До понижения ранга: ${progress.anti_streak_remaining} наруш.`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{progress.is_secret && (progress.penalty_levels || 0) > 0 ? (
|
||||
<div className="mt-3 rounded-xl border border-amber-400/40 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-200">
|
||||
<p className="font-semibold">
|
||||
Ранг снижен на {progress.penalty_levels} ур.
|
||||
</p>
|
||||
<p className="mt-1 text-amber-200/80">
|
||||
Восстановление: {progress.recovery_streak || 0}/
|
||||
{progress.recovery_streak_target} положительных дней.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-[var(--surface-primary)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent-primary)] transition-[width]"
|
||||
|
||||
@ -20,6 +20,12 @@ export interface Skill {
|
||||
progress_label?: string;
|
||||
progress_data?: Record<string, unknown>;
|
||||
next_rank_requirements?: SkillRankRequirement[];
|
||||
anti_streak?: number;
|
||||
anti_streak_limit?: number;
|
||||
anti_streak_remaining?: number;
|
||||
recovery_streak?: number;
|
||||
recovery_streak_target?: number;
|
||||
penalty_levels?: number;
|
||||
}
|
||||
|
||||
export type SkillRank = "none" | "bronze" | "silver" | "gold" | "ruby" | "diamond";
|
||||
@ -67,6 +73,12 @@ export interface EmployeeSkillProgress {
|
||||
progress_label?: string;
|
||||
progress_data?: Record<string, unknown>;
|
||||
next_rank_requirements?: SkillRankRequirement[];
|
||||
anti_streak?: number;
|
||||
anti_streak_limit?: number;
|
||||
anti_streak_remaining?: number;
|
||||
recovery_streak?: number;
|
||||
recovery_streak_target?: number;
|
||||
penalty_levels?: number;
|
||||
can_endorse: boolean;
|
||||
endorsement_points?: number;
|
||||
endorsement_role?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user