From 3632eee85bd4f30df21276f1adcd939e86c2ba2b Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 27 Apr 2026 11:29:07 +0300 Subject: [PATCH] Update attendance auto sync coverage --- backend/attendance/services.py | 12 +++- .../v1/attendance/test_attendance_storage.py | 72 +++++++++++++++++-- frontend/src/app/attendance/page.tsx | 8 ++- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/backend/attendance/services.py b/backend/attendance/services.py index eaceabaf..0a7aaaf5 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -61,6 +61,7 @@ DAYS_RU = { MATRIX_STATUS_LABELS = { "empty": "Нет записи", "technical": "Техсбой", + "personnel_issue": "Кадровое событие", "underwork": "Недоработка", "late": "Опоздание", "overtime": "Переработка", @@ -72,6 +73,7 @@ MATRIX_STATUS_LABELS = { MATRIX_STATUS_SHORT_LABELS = { "empty": "", "technical": "Тех", + "personnel_issue": "Кадр", "underwork": "НД", "late": "ОП", "overtime": "ПР", @@ -99,6 +101,7 @@ PERSONNEL_STATUS_LEGEND = { MATRIX_STATUS_FILLS = { "empty": "F1F5F9", "technical": "FEE2E2", + "personnel_issue": "FEF3C7", "underwork": "FEF3C7", "late": "FEF3C7", "absent": "FEF3C7", @@ -262,6 +265,10 @@ def get_attendance_auto_sync_settings() -> AttendanceAutoSyncSettings: return settings +def get_attendance_auto_sync_employees(): + return Employee.objects.prefetch_related("actions").order_by("-id") + + def run_attendance_auto_sync(*, force: bool = False) -> AttendanceAutoSyncSettings: from common.logstorm_attendance import ( analyze_employee_attendance, @@ -316,7 +323,7 @@ def run_attendance_auto_sync(*, force: bool = False) -> AttendanceAutoSyncSettin error_count = 0 errors: list[str] = [] - for employee in Employee.objects.get_active(): + for employee in get_attendance_auto_sync_employees(): schedule_payload = ( get_employee_work_schedule_payload(employee) or get_standard_work_schedule_payload() @@ -614,6 +621,7 @@ def _append_attendance_matrix_legend_sheet(workbook: Workbook) -> None: ("ОТГ", "Отгул"), ("ДЕКР", "Декрет"), ("Вне штата", "Уволен или еще не принят в выбранный день"), + ("Кадровое событие", "Проход при кадровом статусе вне штата"), ("+ Nч", "Зафиксирована работа в нерабочий день"), ("К: N", "Количество комментариев EUSRR к записи"), ("Ссылка", "Клик по ячейке записи открывает модал событий LogStorm в EUSRR"), @@ -1172,6 +1180,8 @@ def _matrix_detail_lines( def _monthly_matrix_status(record: AttendanceRecord) -> str: if not record.effective_is_workday: + if record.personnel_status == ACTION_DISMISSED and record.is_overtime: + return "personnel_issue" if record.is_overtime: return "overtime" return "non_working" diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 8b210908..f61367d5 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -15,7 +15,11 @@ from attendance.models import ( StandardWorkSchedule, ) from common.logstorm_client import LogStormClientError -from employees.constants import ACTION_ON_LEAVE, ACTION_RETURNED_FROM_LEAVE +from employees.constants import ( + ACTION_DISMISSED, + ACTION_ON_LEAVE, + ACTION_RETURNED_FROM_LEAVE, +) from employees.models import EmployeeAction pytestmark = pytest.mark.django_db @@ -482,12 +486,18 @@ def test_auto_sync_disabled_settings_do_not_run_analysis(user_factory): assert settings.last_status == "idle" -def test_auto_sync_updates_due_active_employees_and_uses_schedules(user_factory): +def test_auto_sync_updates_due_employees_and_uses_schedules(user_factory): from attendance.services import run_attendance_auto_sync employee = user_factory(first_name="Ivan", last_name="Petrov") other = user_factory(first_name="Anna", last_name="Sidorova") - user_factory(active=False) + inactive = user_factory(first_name="Inactive", last_name="User", active=False) + dismissed = user_factory(first_name="Dismissed", last_name="User", active=False) + EmployeeAction.objects.create( + employee=dismissed, + action=ACTION_DISMISSED, + date=timezone.now() - timedelta(days=10), + ) EmployeeWorkSchedule.objects.create( employee=employee, start_time="10:00", @@ -519,22 +529,28 @@ def test_auto_sync_updates_due_active_employees_and_uses_schedules(user_factory) ) as analyze: settings = run_attendance_auto_sync(force=False) - assert analyze.call_count == 2 + assert analyze.call_count == 4 calls_by_employee_id = { call.kwargs["employee"].id: call.kwargs for call in analyze.call_args_list } employee_call = calls_by_employee_id[employee.id] other_call = calls_by_employee_id[other.id] + inactive_call = calls_by_employee_id[inactive.id] + dismissed_call = calls_by_employee_id[dismissed.id] assert employee_call["period_start"] == today - timedelta(days=2) assert employee_call["period_end"] == today assert employee_call["schedule"]["start_time"] == "10:00" assert other_call["schedule"]["start_time"] == "07:30" + assert inactive_call["schedule"]["start_time"] == "07:30" + assert dismissed_call["schedule"]["start_time"] == "07:30" assert settings.last_status == "success" - assert settings.last_success_count == 2 + assert settings.last_success_count == 4 assert settings.last_error_count == 0 assert settings.next_run_at > settings.last_finished_at - assert AttendanceRecord.objects.filter(employee__in=[employee, other]).count() == 2 + assert AttendanceRecord.objects.filter( + employee__in=[employee, other, inactive, dismissed], + ).count() == 4 def test_auto_sync_continues_after_employee_error(user_factory): @@ -1481,6 +1497,50 @@ def test_personnel_non_working_state_counts_work_as_overtime( assert worked_record.statuses == ["work_outside_personnel_schedule"] +def test_dismissed_employee_work_is_highlighted_as_personnel_issue( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory(active=False) + _move_auto_hired_before_test_period(employee) + client = auth_client_factory(staff) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=_aware_datetime(2026, 4, 20), + ) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + worked_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + assert worked_record.personnel_status == ACTION_DISMISSED + assert worked_record.effective_is_workday is False + assert worked_record.is_overtime is True + assert worked_record.statuses == ["work_outside_personnel_schedule"] + + matrix_response = client.get( + _monthly_matrix_url(), + { + "employee_ids": str(employee.id), + "month": "2026-04", + }, + ) + matrix_row = next( + row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-20" + ) + matrix_cell = matrix_row["cells"][str(employee.id)] + assert matrix_cell["status"] == "personnel_issue" + assert matrix_cell["short_label"] == "Вне штата" + assert matrix_cell["display_text"] == "Вне штата + 8.75ч" + assert matrix_cell["primary_label"] == "Уволен / вне штата" + + def test_personnel_state_is_recalculated_when_actions_change( auth_client_factory, user_factory, diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index de6fffab..17952c56 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -183,7 +183,12 @@ function getMonthKeysBetween(start: string, end: string) { function matrixCellTone(status: string) { if (status === "technical") return "bg-red-500/15 text-red-200 hover:bg-red-500/20"; - if (status === "underwork" || status === "late" || status === "absent") { + if ( + status === "personnel_issue" + || status === "underwork" + || status === "late" + || status === "absent" + ) { return "bg-amber-500/15 text-amber-100 hover:bg-amber-500/20"; } if (status === "overtime") return "bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/20"; @@ -201,6 +206,7 @@ function matrixStatusLabel(status: string) { const labels: Record = { empty: "Нет записи", technical: "Техсбой", + personnel_issue: "Кадровое событие", underwork: "Недоработка", late: "Опоздание", overtime: "Переработка",