From da218db80537a5502d9c640ebb95a470a99b504d Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 22 Apr 2026 18:26:43 +0300 Subject: [PATCH] Add attendance auto sync settings --- backend/api/v1/attendance/serializers.py | 52 ++++ backend/api/v1/attendance/views.py | 55 +++- backend/api/v1/urls.py | 12 + backend/attendance/admin.py | 25 ++ .../0006_attendanceautosyncsettings.py | 125 +++++++++ backend/attendance/models.py | 84 ++++++ backend/attendance/services.py | 134 +++++++++- backend/attendance/tasks.py | 33 +++ backend/eusrr_backend/celery.py | 4 + backend/eusrr_backend/settings.py | 4 + .../v1/attendance/test_attendance_storage.py | 230 ++++++++++++++++- frontend/src/app/attendance/page.tsx | 244 +++++++++++++++++- frontend/src/lib/api/attendance.ts | 33 +++ 13 files changed, 1031 insertions(+), 4 deletions(-) create mode 100644 backend/attendance/migrations/0006_attendanceautosyncsettings.py create mode 100644 backend/attendance/tasks.py diff --git a/backend/api/v1/attendance/serializers.py b/backend/api/v1/attendance/serializers.py index 0b0f0112..fdb9e6e6 100644 --- a/backend/api/v1/attendance/serializers.py +++ b/backend/api/v1/attendance/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from attendance.models import ( AttendanceAnalysisRun, + AttendanceAutoSyncSettings, AttendanceRecord, EmployeeWorkSchedule, StandardWorkSchedule, @@ -134,6 +135,57 @@ class StandardWorkScheduleSerializer(serializers.ModelSerializer): return value +class AttendanceAutoSyncSettingsSerializer(serializers.ModelSerializer): + last_status_label = serializers.CharField( + source="get_last_status_display", + read_only=True, + ) + + class Meta: + model = AttendanceAutoSyncSettings + fields = ( + "id", + "enabled", + "frequency_minutes", + "lookback_days", + "next_run_at", + "last_started_at", + "last_finished_at", + "last_status", + "last_status_label", + "last_error", + "last_success_count", + "last_error_count", + "updated_by", + "updated_at", + ) + read_only_fields = ( + "id", + "next_run_at", + "last_started_at", + "last_finished_at", + "last_status", + "last_status_label", + "last_error", + "last_success_count", + "last_error_count", + "updated_by", + "updated_at", + ) + + def validate_frequency_minutes(self, value): + allowed = {item[0] for item in AttendanceAutoSyncSettings.FREQUENCY_CHOICES} + if value not in allowed: + raise serializers.ValidationError("Unsupported frequency") + return value + + def validate_lookback_days(self, value): + allowed = {item[0] for item in AttendanceAutoSyncSettings.LOOKBACK_CHOICES} + if value not in allowed: + raise serializers.ValidationError("Unsupported lookback") + return value + + def default_work_schedule_response(employee_id: int) -> dict: payload = get_standard_work_schedule_payload() return { diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py index a8a8e8a7..e0254e7a 100644 --- a/backend/api/v1/attendance/views.py +++ b/backend/api/v1/attendance/views.py @@ -3,13 +3,19 @@ from calendar import monthrange from datetime import date from urllib.parse import quote -from attendance.models import AttendanceRecord, EmployeeWorkSchedule, StandardWorkSchedule +from attendance.models import ( + AttendanceRecord, + EmployeeWorkSchedule, + StandardWorkSchedule, +) from attendance.services import ( build_attendance_matrix_export_workbook, build_monthly_attendance_matrix, + get_attendance_auto_sync_settings, get_employee_work_schedule_payload, get_standard_work_schedule_payload, normalize_attendance_record_manual_issues, + run_attendance_auto_sync, save_logstorm_attendance_result, ) from communications import comments_helpers @@ -34,6 +40,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from .serializers import ( + AttendanceAutoSyncSettingsSerializer, AttendanceMonthlyMatrixExportQuerySerializer, AttendanceMonthlyMatrixQuerySerializer, AttendanceRecordCommentSerializer, @@ -260,6 +267,52 @@ class StandardWorkScheduleAPIView(APIView): return Response(StandardWorkScheduleSerializer(instance).data) +class AttendanceAutoSyncSettingsAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + _check_staff_access(request.user) + return Response( + AttendanceAutoSyncSettingsSerializer( + get_attendance_auto_sync_settings() + ).data + ) + + def patch(self, request): + _check_staff_access(request.user) + settings = get_attendance_auto_sync_settings() + was_enabled = settings.enabled + old_frequency = settings.frequency_minutes + serializer = AttendanceAutoSyncSettingsSerializer( + settings, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + instance = serializer.save(updated_by=request.user) + + update_fields = ["updated_by", "updated_at"] + if not instance.enabled: + instance.next_run_at = None + update_fields.append("next_run_at") + elif not was_enabled or old_frequency != instance.frequency_minutes: + instance.next_run_at = timezone.now() + update_fields.append("next_run_at") + if update_fields != ["updated_by", "updated_at"]: + instance.save(update_fields=update_fields) + + return Response(AttendanceAutoSyncSettingsSerializer(instance).data) + + +class AttendanceAutoSyncRunNowAPIView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + _check_staff_access(request.user) + settings = run_attendance_auto_sync(force=True) + return Response(AttendanceAutoSyncSettingsSerializer(settings).data) + + class AttendanceRecordListAPIView(ListAPIView): permission_classes = [IsAuthenticated] serializer_class = AttendanceRecordSerializer diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 4b00c7f0..3dba45d5 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -30,6 +30,8 @@ from .feed.views import PostViewSet from .requests_app.views import RequestViewSet from .search.views import search_api_view from .attendance.views import ( + AttendanceAutoSyncRunNowAPIView, + AttendanceAutoSyncSettingsAPIView, AttendanceMonthlyMatrixExportAPIView, AttendanceMonthlyMatrixAPIView, AttendanceRecordDayEventPhotoAPIView, @@ -131,6 +133,16 @@ urlpatterns = [ StandardWorkScheduleAPIView.as_view(), name="attendance-standard-work-schedule", ), + path( + "attendance/auto-sync-settings/", + AttendanceAutoSyncSettingsAPIView.as_view(), + name="attendance-auto-sync-settings", + ), + path( + "attendance/auto-sync/run-now/", + AttendanceAutoSyncRunNowAPIView.as_view(), + name="attendance-auto-sync-run-now", + ), path( "attendance/records//", AttendanceRecordDetailAPIView.as_view(), diff --git a/backend/attendance/admin.py b/backend/attendance/admin.py index f55949e6..b1a2799a 100644 --- a/backend/attendance/admin.py +++ b/backend/attendance/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from attendance.models import ( AttendanceAnalysisRun, + AttendanceAutoSyncSettings, AttendanceRecord, EmployeeWorkSchedule, StandardWorkSchedule, @@ -78,3 +79,27 @@ class StandardWorkScheduleAdmin(admin.ModelAdmin): "updated_at", ) readonly_fields = ("created_at", "updated_at") + + +@admin.register(AttendanceAutoSyncSettings) +class AttendanceAutoSyncSettingsAdmin(admin.ModelAdmin): + list_display = ( + "enabled", + "frequency_minutes", + "lookback_days", + "next_run_at", + "last_status", + "last_success_count", + "last_error_count", + "updated_at", + ) + readonly_fields = ( + "next_run_at", + "last_started_at", + "last_finished_at", + "last_status", + "last_error", + "last_success_count", + "last_error_count", + "updated_at", + ) diff --git a/backend/attendance/migrations/0006_attendanceautosyncsettings.py b/backend/attendance/migrations/0006_attendanceautosyncsettings.py new file mode 100644 index 00000000..972785e4 --- /dev/null +++ b/backend/attendance/migrations/0006_attendanceautosyncsettings.py @@ -0,0 +1,125 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("attendance", "0005_standardworkschedule"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AttendanceAutoSyncSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "singleton", + models.BooleanField(default=True, editable=False, unique=True), + ), + ("enabled", models.BooleanField(default=False, verbose_name="Включено")), + ( + "frequency_minutes", + models.PositiveIntegerField( + choices=[ + (5, "5 минут"), + (15, "15 минут"), + (30, "30 минут"), + (60, "1 час"), + (1440, "1 сутки"), + ], + default=1440, + verbose_name="Периодичность", + ), + ), + ( + "lookback_days", + models.PositiveIntegerField( + choices=[(1, "1 день"), (3, "3 дня"), (7, "7 дней")], + default=3, + verbose_name="Глубина обновления", + ), + ), + ( + "next_run_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Следующий запуск", + ), + ), + ( + "last_started_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Последний старт", + ), + ), + ( + "last_finished_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Последнее завершение", + ), + ), + ( + "last_status", + models.CharField( + choices=[ + ("idle", "Ожидание"), + ("running", "Выполняется"), + ("success", "Успешно"), + ("partial", "Частично"), + ("failed", "Ошибка"), + ], + default="idle", + max_length=16, + verbose_name="Последний статус", + ), + ), + ( + "last_error", + models.TextField(blank=True, verbose_name="Последняя ошибка"), + ), + ( + "last_success_count", + models.PositiveIntegerField(default=0, verbose_name="Успешно"), + ), + ( + "last_error_count", + models.PositiveIntegerField(default=0, verbose_name="Ошибок"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="updated_attendance_auto_sync_settings", + to=settings.AUTH_USER_MODEL, + verbose_name="Изменил", + ), + ), + ], + options={ + "verbose_name": "Автообновление посещаемости", + "verbose_name_plural": "Автообновление посещаемости", + }, + ), + ] diff --git a/backend/attendance/models.py b/backend/attendance/models.py index a1e5f754..343614a4 100644 --- a/backend/attendance/models.py +++ b/backend/attendance/models.py @@ -157,6 +157,90 @@ class StandardWorkSchedule(models.Model): } +class AttendanceAutoSyncSettings(models.Model): + STATUS_IDLE = "idle" + STATUS_RUNNING = "running" + STATUS_SUCCESS = "success" + STATUS_PARTIAL = "partial" + STATUS_FAILED = "failed" + STATUS_CHOICES = ( + (STATUS_IDLE, "Ожидание"), + (STATUS_RUNNING, "Выполняется"), + (STATUS_SUCCESS, "Успешно"), + (STATUS_PARTIAL, "Частично"), + (STATUS_FAILED, "Ошибка"), + ) + FREQUENCY_CHOICES = ( + (5, "5 минут"), + (15, "15 минут"), + (30, "30 минут"), + (60, "1 час"), + (1440, "1 сутки"), + ) + LOOKBACK_CHOICES = ( + (1, "1 день"), + (3, "3 дня"), + (7, "7 дней"), + ) + + singleton = models.BooleanField(default=True, unique=True, editable=False) + enabled = models.BooleanField("Включено", default=False) + frequency_minutes = models.PositiveIntegerField( + "Периодичность", + choices=FREQUENCY_CHOICES, + default=1440, + ) + lookback_days = models.PositiveIntegerField( + "Глубина обновления", + choices=LOOKBACK_CHOICES, + default=3, + ) + next_run_at = models.DateTimeField( + "Следующий запуск", + null=True, + blank=True, + ) + last_started_at = models.DateTimeField( + "Последний старт", + null=True, + blank=True, + ) + last_finished_at = models.DateTimeField( + "Последнее завершение", + null=True, + blank=True, + ) + last_status = models.CharField( + "Последний статус", + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_IDLE, + ) + last_error = models.TextField("Последняя ошибка", blank=True) + last_success_count = models.PositiveIntegerField("Успешно", default=0) + last_error_count = models.PositiveIntegerField("Ошибок", default=0) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="updated_attendance_auto_sync_settings", + verbose_name="Изменил", + ) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Автообновление посещаемости" + verbose_name_plural = "Автообновление посещаемости" + + def save(self, *args, **kwargs): + self.singleton = True + super().save(*args, **kwargs) + + def __str__(self) -> str: + return "Автообновление посещаемости" + + class AttendanceRecord(models.Model): analysis_run = models.ForeignKey( AttendanceAnalysisRun, diff --git a/backend/attendance/services.py b/backend/attendance/services.py index 3a285a0f..472e6f2f 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -2,12 +2,13 @@ from __future__ import annotations from io import BytesIO from dataclasses import dataclass -from datetime import date +from datetime import date, timedelta from calendar import monthrange from typing import Any from django.conf import settings from django.db import transaction +from django.utils import timezone from django.utils.dateparse import parse_date from openpyxl import Workbook from openpyxl.comments import Comment @@ -15,6 +16,7 @@ from openpyxl.styles import Alignment, Font, PatternFill, Side, Border from openpyxl.utils import get_column_letter from attendance.models import ( + AttendanceAutoSyncSettings, AttendanceAnalysisRun, AttendanceRecord, EmployeeWorkSchedule, @@ -251,6 +253,136 @@ def get_standard_work_schedule_payload() -> dict[str, Any]: return schedule.to_logstorm_payload() +def get_attendance_auto_sync_settings() -> AttendanceAutoSyncSettings: + settings, _ = AttendanceAutoSyncSettings.objects.get_or_create( + singleton=True, + ) + return settings + + +def run_attendance_auto_sync(*, force: bool = False) -> AttendanceAutoSyncSettings: + from common.logstorm_attendance import ( + analyze_employee_attendance, + build_logstorm_attendance_payload, + ) + now = timezone.now() + with transaction.atomic(): + settings = ( + AttendanceAutoSyncSettings.objects.select_for_update() + .filter(singleton=True) + .first() + ) + if settings is None: + settings = AttendanceAutoSyncSettings.objects.create(singleton=True) + + if settings.last_status == AttendanceAutoSyncSettings.STATUS_RUNNING: + return settings + if not force: + if not settings.enabled: + return settings + if settings.next_run_at and settings.next_run_at > now: + return settings + + settings.last_status = AttendanceAutoSyncSettings.STATUS_RUNNING + settings.last_started_at = now + settings.last_finished_at = None + settings.last_error = "" + settings.last_success_count = 0 + settings.last_error_count = 0 + if settings.enabled: + settings.next_run_at = now + timedelta( + minutes=settings.frequency_minutes + ) + settings.save( + update_fields=[ + "last_status", + "last_started_at", + "last_finished_at", + "last_error", + "last_success_count", + "last_error_count", + "next_run_at", + "updated_at", + ] + ) + settings_id = settings.id + lookback_days = settings.lookback_days + + period_end = timezone.localdate() + period_start = period_end - timedelta(days=lookback_days - 1) + success_count = 0 + error_count = 0 + errors: list[str] = [] + + for employee in Employee.objects.get_active(): + schedule_payload = ( + get_employee_work_schedule_payload(employee) + or get_standard_work_schedule_payload() + ) + try: + result = analyze_employee_attendance( + employee=employee, + period_start=period_start, + period_end=period_end, + schedule=schedule_payload, + client=None, + ) + request_payload = build_logstorm_attendance_payload( + employee=employee, + period_start=period_start, + period_end=period_end, + schedule=schedule_payload, + ) + save_logstorm_attendance_result( + employee=employee, + period_start=period_start, + period_end=period_end, + schedule_payload=request_payload.get("schedule"), + request_payload=request_payload, + response_payload=result, + triggered_by=None, + ) + success_count += 1 + except Exception as exc: + error_count += 1 + errors.append(f"{_employee_display_name(employee)}: {exc}") + + finished_at = timezone.now() + if error_count and success_count: + status = AttendanceAutoSyncSettings.STATUS_PARTIAL + elif error_count: + status = AttendanceAutoSyncSettings.STATUS_FAILED + else: + status = AttendanceAutoSyncSettings.STATUS_SUCCESS + + with transaction.atomic(): + settings = AttendanceAutoSyncSettings.objects.select_for_update().get( + pk=settings_id, + ) + settings.last_status = status + settings.last_finished_at = finished_at + settings.last_success_count = success_count + settings.last_error_count = error_count + settings.last_error = "\n".join(errors)[:4000] + settings.next_run_at = ( + finished_at + timedelta(minutes=settings.frequency_minutes) + if settings.enabled + else None + ) + settings.save( + update_fields=[ + "last_status", + "last_finished_at", + "last_success_count", + "last_error_count", + "last_error", + "next_run_at", + "updated_at", + ] + ) + return settings + + def build_monthly_attendance_matrix( *, employees, diff --git a/backend/attendance/tasks.py b/backend/attendance/tasks.py new file mode 100644 index 00000000..5a091e12 --- /dev/null +++ b/backend/attendance/tasks.py @@ -0,0 +1,33 @@ +import logging + +from celery import shared_task + +from attendance.services import run_attendance_auto_sync + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True) +def dispatch_attendance_auto_sync(self): + """Periodic dispatcher for attendance auto-sync settings.""" + try: + settings = run_attendance_auto_sync(force=False) + return { + "status": settings.last_status, + "success": settings.last_success_count, + "errors": settings.last_error_count, + } + except Exception as exc: + logger.exception("Attendance auto-sync dispatcher failed: %s", exc) + raise + + +@shared_task(bind=True) +def run_attendance_auto_sync_now(self): + """Run attendance auto-sync immediately using the saved settings.""" + settings = run_attendance_auto_sync(force=True) + return { + "status": settings.last_status, + "success": settings.last_success_count, + "errors": settings.last_error_count, + } diff --git a/backend/eusrr_backend/celery.py b/backend/eusrr_backend/celery.py index 36fc04d3..d70d5902 100644 --- a/backend/eusrr_backend/celery.py +++ b/backend/eusrr_backend/celery.py @@ -24,6 +24,10 @@ app.conf.beat_schedule = { 'task': 'requests_app.tasks.cleanup_missed_returns', 'schedule': crontab(hour=0, minute=5), # Каждый день в 00:05 }, + 'attendance-auto-sync-dispatcher': { + 'task': 'attendance.tasks.dispatch_attendance_auto_sync', + 'schedule': 60.0, + }, } diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 613736e4..fe0dce8b 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -682,6 +682,10 @@ CELERY_BEAT_SCHEDULE = { "task": "employees.tasks.process_ldap_queue", "schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry }, + "attendance-auto-sync-dispatcher": { + "task": "attendance.tasks.dispatch_attendance_auto_sync", + "schedule": 60.0, + }, } # Опции по умолчанию для всех задач diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index d3776771..9f02b926 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -1,6 +1,6 @@ from unittest.mock import patch from io import BytesIO -from datetime import datetime +from datetime import datetime, timedelta import pytest from django.urls import reverse @@ -9,6 +9,7 @@ from openpyxl import load_workbook from attendance.models import ( AttendanceAnalysisRun, + AttendanceAutoSyncSettings, AttendanceRecord, EmployeeWorkSchedule, StandardWorkSchedule, @@ -44,6 +45,14 @@ def _standard_work_schedule_url(): return reverse("api:v1:attendance-standard-work-schedule") +def _auto_sync_settings_url(): + return reverse("api:v1:attendance-auto-sync-settings") + + +def _auto_sync_run_now_url(): + return reverse("api:v1:attendance-auto-sync-run-now") + + def _record_detail_url(record_id): return reverse("api:v1:attendance-record-detail", args=[record_id]) @@ -289,6 +298,69 @@ def test_non_staff_cannot_manage_standard_work_schedule( assert not StandardWorkSchedule.objects.exists() +def test_staff_can_read_default_auto_sync_settings( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.get(_auto_sync_settings_url()) + + assert response.status_code == 200 + assert response.data["enabled"] is False + assert response.data["frequency_minutes"] == 1440 + assert response.data["lookback_days"] == 3 + assert response.data["last_status"] == "idle" + assert AttendanceAutoSyncSettings.objects.count() == 1 + + +def test_staff_can_update_auto_sync_settings( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.patch( + _auto_sync_settings_url(), + { + "enabled": True, + "frequency_minutes": 15, + "lookback_days": 7, + }, + format="json", + ) + + assert response.status_code == 200 + assert response.data["enabled"] is True + assert response.data["frequency_minutes"] == 15 + assert response.data["lookback_days"] == 7 + assert response.data["next_run_at"] is not None + settings = AttendanceAutoSyncSettings.objects.get() + assert settings.updated_by == staff + + +def test_non_staff_cannot_manage_auto_sync_settings( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + client = auth_client_factory(user) + + get_response = client.get(_auto_sync_settings_url()) + patch_response = client.patch( + _auto_sync_settings_url(), + {"enabled": True}, + format="json", + ) + run_response = client.post(_auto_sync_run_now_url()) + + assert get_response.status_code == 403 + assert patch_response.status_code == 403 + assert run_response.status_code == 403 + + def test_non_staff_cannot_save_other_employee_work_schedule( auth_client_factory, user_factory, @@ -376,6 +448,162 @@ def test_analyze_uses_standard_work_schedule_when_payload_omits_schedule( assert run.schedule_payload == schedule +def _auto_sync_result(employee, target_date): + return { + "records": [ + { + "date": target_date.isoformat(), + "employee_id": str(employee.id), + "display_name": str(employee), + "arrival_time": "08:00", + "departure_time": "17:00", + "work_hours": 9, + "expected_hours": 9, + "is_workday": True, + "is_absent": False, + "statuses": [], + "employee_issues": [], + "technical_issues": [], + } + ] + } + + +def test_auto_sync_disabled_settings_do_not_run_analysis(user_factory): + from attendance.services import run_attendance_auto_sync + + user_factory() + AttendanceAutoSyncSettings.objects.create(enabled=False) + + with patch("common.logstorm_attendance.analyze_employee_attendance") as analyze: + settings = run_attendance_auto_sync(force=False) + + analyze.assert_not_called() + assert settings.last_status == "idle" + + +def test_auto_sync_updates_due_active_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) + EmployeeWorkSchedule.objects.create( + employee=employee, + start_time="10:00", + end_time="19:00", + expected_hours=8, + workdays=["Monday"], + ) + StandardWorkSchedule.objects.create( + start_time="07:30", + end_time="16:30", + expected_hours=8, + workdays=["Tuesday"], + ) + now = timezone.now() + AttendanceAutoSyncSettings.objects.create( + enabled=True, + frequency_minutes=15, + lookback_days=3, + next_run_at=now - timedelta(minutes=1), + ) + today = timezone.localdate() + + def analyze_result(*, employee, **kwargs): + return _auto_sync_result(employee, today) + + with patch( + "common.logstorm_attendance.analyze_employee_attendance", + side_effect=analyze_result, + ) as analyze: + settings = run_attendance_auto_sync(force=False) + + assert analyze.call_count == 2 + 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] + 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 settings.last_status == "success" + assert settings.last_success_count == 2 + 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 + + +def test_auto_sync_continues_after_employee_error(user_factory): + from attendance.services import run_attendance_auto_sync + + first = user_factory(first_name="Broken", last_name="User") + second = user_factory(first_name="Good", last_name="User") + AttendanceAutoSyncSettings.objects.create( + enabled=True, + frequency_minutes=60, + lookback_days=1, + next_run_at=timezone.now() - timedelta(minutes=1), + ) + today = timezone.localdate() + + def analyze_result(*, employee, **kwargs): + if employee == first: + raise LogStormClientError("logstorm down") + return _auto_sync_result(employee, today) + + with patch( + "common.logstorm_attendance.analyze_employee_attendance", + side_effect=analyze_result, + ): + settings = run_attendance_auto_sync(force=False) + + assert settings.last_status == "partial" + assert settings.last_success_count == 1 + assert settings.last_error_count == 1 + assert "logstorm down" in settings.last_error + assert AttendanceRecord.objects.filter(employee=second, date=today).exists() + + +def test_auto_sync_skips_parallel_running_state(user_factory): + from attendance.services import run_attendance_auto_sync + + user_factory() + AttendanceAutoSyncSettings.objects.create( + enabled=True, + last_status=AttendanceAutoSyncSettings.STATUS_RUNNING, + next_run_at=timezone.now() - timedelta(minutes=1), + ) + + with patch("common.logstorm_attendance.analyze_employee_attendance") as analyze: + settings = run_attendance_auto_sync(force=False) + + analyze.assert_not_called() + assert settings.last_status == "running" + + +def test_staff_can_run_auto_sync_now(auth_client_factory, user_factory): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + settings = AttendanceAutoSyncSettings.objects.create( + enabled=False, + last_status=AttendanceAutoSyncSettings.STATUS_SUCCESS, + ) + + with patch( + "api.v1.attendance.views.run_attendance_auto_sync", + return_value=settings, + ) as run_sync: + response = client.post(_auto_sync_run_now_url()) + + assert response.status_code == 200 + run_sync.assert_called_once_with(force=True) + assert response.data["last_status"] == "success" + + def test_analyze_updates_existing_daily_record(auth_client_factory, user_factory): staff = user_factory(staff=True) employee = user_factory() diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index ce7b6d55..faa020c4 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, CalendarCheck, Check, + ChevronDown, ChevronLeft, ChevronRight, Download, @@ -27,7 +28,7 @@ import { import { Modal } from "@/components/ui"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; -import { loadAllPages, displayUserName } from "@/lib/shared"; +import { loadAllPages, displayUserName, formatDateTime } from "@/lib/shared"; import { resolveMediaUrl } from "@/lib/url"; import type { MonthlyAttendanceMatrix, @@ -36,6 +37,7 @@ import type { MonthlyAttendanceMatrixRow, AttendanceSchedulePayload, AttendanceRecord, + AttendanceAutoSyncSettings, } from "@/lib/api/attendance"; import type { User } from "@/types/api"; @@ -63,6 +65,20 @@ const periodPresets: Array<{ value: AttendancePeriodPreset; label: string }> = [ { value: "custom", label: "Свой период" }, ]; +const autoSyncFrequencyOptions = [ + { value: 5, label: "5 минут" }, + { value: 15, label: "15 минут" }, + { value: 30, label: "30 минут" }, + { value: 60, label: "1 час" }, + { value: 1440, label: "1 сутки" }, +]; + +const autoSyncLookbackOptions = [ + { value: 1, label: "1 день" }, + { value: 3, label: "3 дня" }, + { value: 7, label: "7 дней" }, +]; + function toDateInputValue(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -107,6 +123,15 @@ function getErrorMessage(error: unknown, fallback: string) { return String((error as Error)?.message || fallback); } +function formatOptionalDateTime(value?: string | null) { + return value ? formatDateTime(value) : "-"; +} + +function autoSyncStatusText(settings: AttendanceAutoSyncSettings | null) { + if (!settings) return "Не загружено"; + return settings.last_status_label || settings.last_status || "Ожидание"; +} + function monthKeyFromDate(value: string) { return value.slice(0, 7); } @@ -304,6 +329,13 @@ export default function AttendancePage() { const [downloadingReport, setDownloadingReport] = useState(false); const [error, setError] = useState(null); const [scheduleFeedback, setScheduleFeedback] = useState(null); + const [autoSyncSettings, setAutoSyncSettings] = + useState(null); + const [autoSyncOpen, setAutoSyncOpen] = useState(false); + const [loadingAutoSync, setLoadingAutoSync] = useState(true); + const [savingAutoSync, setSavingAutoSync] = useState(false); + const [runningAutoSync, setRunningAutoSync] = useState(false); + const [autoSyncFeedback, setAutoSyncFeedback] = useState(null); const [matrices, setMatrices] = useState([]); const [activeMonthIndex, setActiveMonthIndex] = useState(0); const [visibleEmployeePage, setVisibleEmployeePage] = useState(0); @@ -394,6 +426,35 @@ export default function AttendancePage() { }; }, [canManageAttendance]); + useEffect(() => { + let cancelled = false; + + async function loadAutoSyncSettings() { + if (!canManageAttendance) { + setLoadingAutoSync(false); + return; + } + + try { + setLoadingAutoSync(true); + const settings = await apiClient.getAttendanceAutoSyncSettings(); + if (!cancelled) setAutoSyncSettings(settings); + } catch (autoSyncError) { + if (!cancelled) { + setError(getErrorMessage(autoSyncError, "Не удалось загрузить автообновление")); + } + } finally { + if (!cancelled) setLoadingAutoSync(false); + } + } + + void loadAutoSyncSettings(); + + return () => { + cancelled = true; + }; + }, [canManageAttendance]); + useEffect(() => { function updateMatrixEmployeesPerPage() { setMatrixEmployeesPerPage( @@ -583,6 +644,41 @@ export default function AttendancePage() { } } + async function saveAutoSyncSettings( + patch: Partial>, + ) { + try { + setSavingAutoSync(true); + setError(null); + setAutoSyncFeedback(null); + const settings = await apiClient.updateAttendanceAutoSyncSettings(patch); + setAutoSyncSettings(settings); + setAutoSyncFeedback("Автообновление сохранено"); + } catch (saveError) { + setError(getErrorMessage(saveError, "Не удалось сохранить автообновление")); + } finally { + setSavingAutoSync(false); + } + } + + async function runAutoSyncNow() { + try { + setRunningAutoSync(true); + setError(null); + setAutoSyncFeedback(null); + const settings = await apiClient.runAttendanceAutoSyncNow(); + setAutoSyncSettings(settings); + setAutoSyncFeedback("Автообновление выполнено"); + } catch (runError) { + setError(getErrorMessage(runError, "Не удалось запустить автообновление")); + } finally { + setRunningAutoSync(false); + } + } + function validateSelectionAndPeriod() { if (selectedEmployees.length === 0) { setError("Выберите хотя бы одного сотрудника"); @@ -1092,6 +1188,152 @@ export default function AttendancePage() { +
+ + + {autoSyncOpen ? ( +
+ {loadingAutoSync ? ( +

+ + Загрузка автообновления +

+ ) : autoSyncSettings ? ( + <> +
+ + +
+ +
+ + +
+ +
+

Статус: {autoSyncStatusText(autoSyncSettings)}

+

Следующий запуск: {formatOptionalDateTime(autoSyncSettings.next_run_at)}

+

Последний старт: {formatOptionalDateTime(autoSyncSettings.last_started_at)}

+

Завершено: {formatOptionalDateTime(autoSyncSettings.last_finished_at)}

+

Успешно: {autoSyncSettings.last_success_count}

+

Ошибок: {autoSyncSettings.last_error_count}

+
+ + {autoSyncSettings.last_error ? ( +

+ {autoSyncSettings.last_error} +

+ ) : null} + + {autoSyncFeedback ? ( +

+ {autoSyncFeedback} +

+ ) : null} + + ) : ( +

+ Настройки автообновления недоступны +

+ )} +
+ ) : null} +
+ {error ? (
diff --git a/frontend/src/lib/api/attendance.ts b/frontend/src/lib/api/attendance.ts index a30edcce..9548617a 100644 --- a/frontend/src/lib/api/attendance.ts +++ b/frontend/src/lib/api/attendance.ts @@ -36,6 +36,23 @@ export type StandardWorkSchedule = AttendanceSchedulePayload & { updated_at?: string | null; }; +export type AttendanceAutoSyncSettings = { + id: number | null; + enabled: boolean; + frequency_minutes: number; + lookback_days: number; + next_run_at?: string | null; + last_started_at?: string | null; + last_finished_at?: string | null; + last_status: 'idle' | 'running' | 'success' | 'partial' | 'failed' | string; + last_status_label?: string; + last_error?: string; + last_success_count: number; + last_error_count: number; + updated_by?: number | null; + updated_at?: string | null; +}; + export type AttendanceAnalyzePayload = { employee_id: number; period_start: string; @@ -263,6 +280,22 @@ export function createAttendanceApi(request: RequestFn, getToken: GetTokenFn) { method: 'PATCH', body: JSON.stringify(data), }) as Promise, + getAttendanceAutoSyncSettings: () => + request('/api/v1/attendance/auto-sync-settings/') as Promise, + updateAttendanceAutoSyncSettings: ( + data: Partial>, + ) => + request('/api/v1/attendance/auto-sync-settings/', { + method: 'PATCH', + body: JSON.stringify(data), + }) as Promise, + runAttendanceAutoSyncNow: () => + request('/api/v1/attendance/auto-sync/run-now/', { + method: 'POST', + }) as Promise, getEmployeeWorkSchedule: (employeeId: number | string) => request(`/api/v1/attendance/work-schedules/${employeeId}/`) as Promise, updateEmployeeWorkSchedule: (