Add attendance auto sync settings

This commit is contained in:
lizerk 2026-04-22 18:26:43 +03:00
parent 8e11b7fbba
commit da218db805
13 changed files with 1031 additions and 4 deletions

View File

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

View File

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

View File

@ -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/<int:record_id>/",
AttendanceRecordDetailAPIView.as_view(),

View File

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

View File

@ -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": "Автообновление посещаемости",
},
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
},
}
# Опции по умолчанию для всех задач

View File

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

View File

@ -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<string | null>(null);
const [scheduleFeedback, setScheduleFeedback] = useState<string | null>(null);
const [autoSyncSettings, setAutoSyncSettings] =
useState<AttendanceAutoSyncSettings | null>(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<string | null>(null);
const [matrices, setMatrices] = useState<MonthlyAttendanceMatrix[]>([]);
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<Pick<
AttendanceAutoSyncSettings,
"enabled" | "frequency_minutes" | "lookback_days"
>>,
) {
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() {
</div>
</div>
<div className="mt-4 app-surface-muted rounded-2xl p-3 sm:p-4">
<button
type="button"
onClick={() => setAutoSyncOpen((current) => !current)}
className="flex w-full items-start justify-between gap-3 text-left"
>
<span>
<span className="text-sm font-semibold text-[var(--foreground)]">
Автообновление
</span>
<span className="app-text-muted mt-1 block text-xs">
{loadingAutoSync
? "Загрузка настроек"
: autoSyncSettings?.enabled
? `Включено · ${autoSyncStatusText(autoSyncSettings)}`
: "Выключено"}
</span>
</span>
<ChevronDown
size={16}
className={`mt-1 shrink-0 text-[var(--muted-foreground)] transition ${
autoSyncOpen ? "rotate-180" : ""
}`}
/>
</button>
{autoSyncOpen ? (
<div className="mt-4 grid gap-3">
{loadingAutoSync ? (
<p className="app-text-muted flex items-center gap-2 text-xs">
<Loader2 size={14} className="animate-spin" />
Загрузка автообновления
</p>
) : autoSyncSettings ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-sm text-[var(--foreground)]">
<input
type="checkbox"
checked={autoSyncSettings.enabled}
onChange={(event) => {
setAutoSyncSettings({
...autoSyncSettings,
enabled: event.target.checked,
});
void saveAutoSyncSettings({
enabled: event.target.checked,
});
}}
disabled={savingAutoSync || runningAutoSync}
className="h-4 w-4 accent-[var(--accent-primary)]"
/>
Включить автообновление
</label>
<button
type="button"
onClick={() => void runAutoSyncNow()}
disabled={savingAutoSync || runningAutoSync}
className="app-action-secondary inline-flex min-h-9 items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{runningAutoSync ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Запустить сейчас
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="app-card-caption mb-2 block">Частота</span>
<select
value={autoSyncSettings.frequency_minutes}
onChange={(event) => {
const value = Number(event.target.value);
setAutoSyncSettings({
...autoSyncSettings,
frequency_minutes: value,
});
void saveAutoSyncSettings({
frequency_minutes: value,
});
}}
disabled={savingAutoSync || runningAutoSync}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
>
{autoSyncFrequencyOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Период</span>
<select
value={autoSyncSettings.lookback_days}
onChange={(event) => {
const value = Number(event.target.value);
setAutoSyncSettings({
...autoSyncSettings,
lookback_days: value,
});
void saveAutoSyncSettings({
lookback_days: value,
});
}}
disabled={savingAutoSync || runningAutoSync}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
>
{autoSyncLookbackOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="grid gap-2 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
<p>Статус: {autoSyncStatusText(autoSyncSettings)}</p>
<p>Следующий запуск: {formatOptionalDateTime(autoSyncSettings.next_run_at)}</p>
<p>Последний старт: {formatOptionalDateTime(autoSyncSettings.last_started_at)}</p>
<p>Завершено: {formatOptionalDateTime(autoSyncSettings.last_finished_at)}</p>
<p>Успешно: {autoSyncSettings.last_success_count}</p>
<p>Ошибок: {autoSyncSettings.last_error_count}</p>
</div>
{autoSyncSettings.last_error ? (
<p className="rounded-lg border border-amber-400/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{autoSyncSettings.last_error}
</p>
) : null}
{autoSyncFeedback ? (
<p className="text-xs font-medium text-emerald-300">
{autoSyncFeedback}
</p>
) : null}
</>
) : (
<p className="app-text-muted text-xs">
Настройки автообновления недоступны
</p>
)}
</div>
) : null}
</div>
{error ? (
<div className="mt-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />

View File

@ -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<StandardWorkSchedule>,
getAttendanceAutoSyncSettings: () =>
request('/api/v1/attendance/auto-sync-settings/') as Promise<AttendanceAutoSyncSettings>,
updateAttendanceAutoSyncSettings: (
data: Partial<Pick<
AttendanceAutoSyncSettings,
'enabled' | 'frequency_minutes' | 'lookback_days'
>>,
) =>
request('/api/v1/attendance/auto-sync-settings/', {
method: 'PATCH',
body: JSON.stringify(data),
}) as Promise<AttendanceAutoSyncSettings>,
runAttendanceAutoSyncNow: () =>
request('/api/v1/attendance/auto-sync/run-now/', {
method: 'POST',
}) as Promise<AttendanceAutoSyncSettings>,
getEmployeeWorkSchedule: (employeeId: number | string) =>
request(`/api/v1/attendance/work-schedules/${employeeId}/`) as Promise<EmployeeWorkSchedule>,
updateEmployeeWorkSchedule: (