Add attendance auto sync settings
This commit is contained in:
parent
8e11b7fbba
commit
da218db805
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
125
backend/attendance/migrations/0006_attendanceautosyncsettings.py
Normal file
125
backend/attendance/migrations/0006_attendanceautosyncsettings.py
Normal 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": "Автообновление посещаемости",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
33
backend/attendance/tasks.py
Normal file
33
backend/attendance/tasks.py
Normal 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,
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
# Опции по умолчанию для всех задач
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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: (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user