Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-22 18:30:38 +03:00
commit 925d4b9fed
25 changed files with 4048 additions and 242 deletions

View File

@ -2,10 +2,12 @@ from rest_framework import serializers
from attendance.models import (
AttendanceAnalysisRun,
AttendanceAutoSyncSettings,
AttendanceRecord,
EmployeeWorkSchedule,
StandardWorkSchedule,
)
from attendance.services import get_default_work_schedule_payload
from attendance.services import get_standard_work_schedule_payload
from api.v1.employees.serializers import EmployeeBriefSerializer
@ -90,8 +92,102 @@ class EmployeeWorkScheduleSerializer(serializers.ModelSerializer):
return value
class StandardWorkScheduleSerializer(serializers.ModelSerializer):
is_default = serializers.BooleanField(read_only=True, default=False)
class Meta:
model = StandardWorkSchedule
fields = (
"id",
"start_time",
"end_time",
"expected_hours",
"workdays",
"date_overrides",
"is_default",
"updated_by",
"created_at",
"updated_at",
)
read_only_fields = (
"id",
"is_default",
"updated_by",
"created_at",
"updated_at",
)
def validate_workdays(self, value):
allowed = set(StandardWorkSchedule.DEFAULT_WORKDAYS) | {"Saturday", "Sunday"}
if not isinstance(value, list):
raise serializers.ValidationError("workdays must be a list")
normalized = []
for item in value:
if item not in allowed:
raise serializers.ValidationError(f"Unknown weekday: {item}")
if item not in normalized:
normalized.append(item)
return normalized
def validate_expected_hours(self, value):
if value <= 0 or value > 24:
raise serializers.ValidationError("expected_hours must be between 0 and 24")
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_default_work_schedule_payload()
payload = get_standard_work_schedule_payload()
return {
"id": None,
"employee_id": employee_id,
@ -108,6 +204,22 @@ def default_work_schedule_response(employee_id: int) -> dict:
}
def default_standard_work_schedule_response() -> dict:
payload = get_standard_work_schedule_payload()
return {
"id": None,
"start_time": payload["start_time"],
"end_time": payload["end_time"],
"expected_hours": payload["expected_hours"],
"workdays": payload["workdays"],
"date_overrides": payload["date_overrides"],
"is_default": True,
"updated_by": None,
"created_at": None,
"updated_at": None,
}
class AttendanceMonthlyMatrixQuerySerializer(serializers.Serializer):
employee_ids = serializers.CharField()
month = serializers.RegexField(r"^\d{4}-\d{2}$")
@ -138,6 +250,38 @@ class AttendanceMonthlyMatrixQuerySerializer(serializers.Serializer):
return value
class AttendanceMonthlyMatrixExportQuerySerializer(serializers.Serializer):
employee_ids = serializers.CharField()
period_start = serializers.DateField()
period_end = serializers.DateField()
def validate_employee_ids(self, value):
employee_ids = []
for raw_item in value.split(","):
item = raw_item.strip()
if not item:
continue
try:
employee_id = int(item)
except ValueError as exc:
raise serializers.ValidationError(
"employee_ids must be a comma-separated list of integers"
) from exc
if employee_id not in employee_ids:
employee_ids.append(employee_id)
if not employee_ids:
raise serializers.ValidationError("employee_ids is required")
return employee_ids
def validate(self, attrs):
if attrs["period_start"] > attrs["period_end"]:
raise serializers.ValidationError(
"period_start must be less than or equal to period_end"
)
return attrs
class AttendanceAnalysisRunSerializer(serializers.ModelSerializer):
class Meta:
model = AttendanceAnalysisRun

View File

@ -1,12 +1,21 @@
import logging
from calendar import monthrange
from datetime import date
from urllib.parse import quote
from attendance.models import AttendanceRecord, EmployeeWorkSchedule
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
@ -17,6 +26,7 @@ from common.logstorm_attendance import (
)
from common.logstorm_client import LogStormClient, LogStormClientError
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
@ -30,13 +40,17 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import (
AttendanceAutoSyncSettingsSerializer,
AttendanceMonthlyMatrixExportQuerySerializer,
AttendanceMonthlyMatrixQuerySerializer,
AttendanceRecordCommentSerializer,
AttendanceRecordSerializer,
AttendanceRecordUpdateSerializer,
EmployeeWorkScheduleSerializer,
LogStormAttendanceAnalyzeSerializer,
StandardWorkScheduleSerializer,
default_work_schedule_response,
default_standard_work_schedule_response,
)
logger = logging.getLogger(__name__)
@ -51,7 +65,11 @@ def _can_access_employee(user, employee_id) -> bool:
return bool(
user
and user.is_authenticated
and (user.is_staff or normalized_employee_id == int(user.id))
and (
user.is_staff
or user.is_superuser
or normalized_employee_id == int(user.id)
)
)
@ -64,11 +82,16 @@ def _check_employee_access(user, employee_id) -> None:
def _records_visible_to(user):
queryset = AttendanceRecord.objects.all()
if user.is_staff:
if user.is_staff or user.is_superuser:
return queryset
return queryset.filter(employee_id=user.id)
def _check_staff_access(user) -> None:
if not (user and user.is_authenticated and (user.is_staff or user.is_superuser)):
raise PermissionDenied("You don't have permission to manage attendance")
def _comments_count_annotation():
record_ct = ContentType.objects.get_for_model(AttendanceRecord)
comments_subquery = (
@ -90,6 +113,44 @@ def _comments_count_annotation():
}
def _comment_author_name(author) -> str:
if not author:
return "Сотрудник"
first_name = getattr(author, "first_name", "") or ""
last_name = getattr(author, "last_name", "") or ""
full_name = f"{last_name} {first_name}".strip()
return full_name or getattr(author, "email", "") or "Сотрудник"
def _attendance_record_comments_map(record_ids: list[int]) -> dict[int, list[dict[str, str]]]:
if not record_ids:
return {}
record_ct = ContentType.objects.get_for_model(AttendanceRecord)
messages = (
Message.objects.filter(
chat__type="comments",
chat__context_content_type=record_ct,
chat__context_object_id__in=record_ids,
is_deleted=False,
)
.select_related("author", "chat")
.order_by("created_at", "id")
)
result: dict[int, list[dict[str, str]]] = {}
for message in messages:
record_id = int(message.chat.context_object_id)
result.setdefault(record_id, []).append(
{
"author": _comment_author_name(message.author),
"text": message.content,
"created_at": timezone.localtime(message.created_at).strftime(
"%d.%m.%Y %H:%M"
),
}
)
return result
class LogStormAttendanceAnalyzeAPIView(APIView):
permission_classes = [IsAuthenticated]
@ -109,7 +170,10 @@ class LogStormAttendanceAnalyzeAPIView(APIView):
schedule_payload = data.get("schedule")
if schedule_payload is None:
schedule_payload = get_employee_work_schedule_payload(employee)
schedule_payload = (
get_employee_work_schedule_payload(employee)
or get_standard_work_schedule_payload()
)
try:
result = analyze_employee_attendance(
@ -163,7 +227,7 @@ class EmployeeWorkScheduleAPIView(APIView):
return Response(EmployeeWorkScheduleSerializer(schedule).data)
def patch(self, request, employee_id):
if not request.user.is_staff:
if not (request.user.is_staff or request.user.is_superuser):
raise PermissionDenied("You don't have permission to edit schedules")
employee = get_object_or_404(Employee, pk=employee_id)
try:
@ -180,6 +244,75 @@ class EmployeeWorkScheduleAPIView(APIView):
return Response(EmployeeWorkScheduleSerializer(instance).data)
class StandardWorkScheduleAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
_check_staff_access(request.user)
schedule = StandardWorkSchedule.objects.order_by("id").first()
if schedule is None:
return Response(default_standard_work_schedule_response())
return Response(StandardWorkScheduleSerializer(schedule).data)
def patch(self, request):
_check_staff_access(request.user)
schedule = StandardWorkSchedule.objects.order_by("id").first()
serializer = StandardWorkScheduleSerializer(
schedule,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(updated_by=request.user)
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
@ -257,9 +390,72 @@ class AttendanceMonthlyMatrixAPIView(APIView):
)
class AttendanceMonthlyMatrixExportAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
_check_staff_access(request.user)
serializer = AttendanceMonthlyMatrixExportQuerySerializer(
data=request.query_params,
)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
employee_ids = data["employee_ids"]
period_start = data["period_start"]
period_end = data["period_end"]
employees = list(Employee.objects.filter(id__in=employee_ids))
employees_by_id = {employee.id: employee for employee in employees}
employees = [
employees_by_id[employee_id]
for employee_id in employee_ids
if employee_id in employees_by_id
]
records = list(
_records_visible_to(request.user)
.filter(
employee_id__in=[employee.id for employee in employees],
date__gte=period_start,
date__lte=period_end,
)
.select_related("employee")
.annotate(**_comments_count_annotation())
.order_by("employee_id", "date", "id")
)
content = build_attendance_matrix_export_workbook(
employees=employees,
records=records,
period_start=period_start,
period_end=period_end,
record_comments=_attendance_record_comments_map(
[record.id for record in records]
),
)
filename = f"attendance-{period_start.isoformat()}_{period_end.isoformat()}.xlsx"
response = HttpResponse(
content,
content_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
)
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
class AttendanceRecordDetailAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, record_id):
record = get_object_or_404(
_records_visible_to(request.user)
.select_related("employee", "analysis_run")
.annotate(**_comments_count_annotation()),
pk=record_id,
)
return Response(AttendanceRecordSerializer(record).data)
def patch(self, request, record_id):
if not request.user.is_staff:
raise PermissionDenied(
@ -324,6 +520,101 @@ class AttendanceRecordDetailAPIView(APIView):
return Response(AttendanceRecordSerializer(record).data)
class AttendanceRecordDayEventsAPIView(APIView):
permission_classes = [IsAuthenticated]
def get_record(self, request, record_id):
return get_object_or_404(
_records_visible_to(request.user).select_related("employee"),
pk=record_id,
)
def get(self, request, record_id):
record = self.get_record(request, record_id)
try:
events = LogStormClient().get_attendance_day_events(
employee_id=str(record.employee_id),
record_date=record.date,
)
except LogStormClientError as exc:
logger.warning("LogStorm attendance day events failed: %s", exc)
return Response(
{"detail": str(exc), "error": "logstorm_unavailable"},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(
[
self._with_proxy_photo_url(request, record, event)
for event in events
if isinstance(event, dict)
]
)
@staticmethod
def _with_proxy_photo_url(request, record, event):
result = dict(event)
event_key = str(result.get("event_key") or "")
if result.get("has_photo") and event_key:
result["photo_url"] = (
f"/api/v1/attendance/records/{record.id}/day-events/"
f"{quote(event_key)}/photo/"
)
else:
result["photo_url"] = None
return result
class AttendanceRecordDayEventPhotoAPIView(APIView):
permission_classes = [IsAuthenticated]
def get_record(self, request, record_id):
return get_object_or_404(
_records_visible_to(request.user).select_related("employee"),
pk=record_id,
)
def get(self, request, record_id, event_key):
record = self.get_record(request, record_id)
client = LogStormClient()
try:
events = client.get_attendance_day_events(
employee_id=str(record.employee_id),
record_date=record.date,
)
event = next(
(
item
for item in events
if isinstance(item, dict)
and str(item.get("event_key")) == str(event_key)
),
None,
)
if not event or not event.get("has_photo"):
return Response(
{"detail": "Event photo not found"},
status=status.HTTP_404_NOT_FOUND,
)
photo_response = client.get_attendance_event_photo(event_key)
except LogStormClientError as exc:
if exc.status_code == status.HTTP_404_NOT_FOUND:
return Response(
{"detail": "Event photo not found"},
status=status.HTTP_404_NOT_FOUND,
)
logger.warning("LogStorm attendance event photo failed: %s", exc)
return Response(
{"detail": str(exc), "error": "logstorm_unavailable"},
status=status.HTTP_502_BAD_GATEWAY,
)
return HttpResponse(
photo_response.content,
content_type=photo_response.headers.get("Content-Type", "image/jpeg"),
)
class AttendanceRecordCommentsAPIView(APIView):
permission_classes = [IsAuthenticated]

View File

@ -30,13 +30,19 @@ 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,
AttendanceRecordDayEventsAPIView,
AttendanceRecordCommentDetailAPIView,
AttendanceRecordCommentsAPIView,
AttendanceRecordDetailAPIView,
AttendanceRecordListAPIView,
EmployeeWorkScheduleAPIView,
LogStormAttendanceAnalyzeAPIView,
StandardWorkScheduleAPIView,
)
app_name = "v1"
@ -112,16 +118,46 @@ urlpatterns = [
AttendanceMonthlyMatrixAPIView.as_view(),
name="attendance-monthly-matrix",
),
path(
"attendance/monthly-matrix/export/",
AttendanceMonthlyMatrixExportAPIView.as_view(),
name="attendance-monthly-matrix-export",
),
path(
"attendance/work-schedules/<int:employee_id>/",
EmployeeWorkScheduleAPIView.as_view(),
name="attendance-work-schedule",
),
path(
"attendance/standard-work-schedule/",
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(),
name="attendance-record-detail",
),
path(
"attendance/records/<int:record_id>/day-events/",
AttendanceRecordDayEventsAPIView.as_view(),
name="attendance-record-day-events",
),
path(
"attendance/records/<int:record_id>/day-events/<str:event_key>/photo/",
AttendanceRecordDayEventPhotoAPIView.as_view(),
name="attendance-record-day-event-photo",
),
path(
"attendance/records/<int:record_id>/comments/",
AttendanceRecordCommentsAPIView.as_view(),

View File

@ -2,8 +2,10 @@ from django.contrib import admin
from attendance.models import (
AttendanceAnalysisRun,
AttendanceAutoSyncSettings,
AttendanceRecord,
EmployeeWorkSchedule,
StandardWorkSchedule,
)
@ -65,3 +67,39 @@ class EmployeeWorkScheduleAdmin(admin.ModelAdmin):
"employee__email",
)
readonly_fields = ("created_at", "updated_at")
@admin.register(StandardWorkSchedule)
class StandardWorkScheduleAdmin(admin.ModelAdmin):
list_display = (
"start_time",
"end_time",
"expected_hours",
"updated_by",
"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,92 @@
# Generated by Codex on 2026-04-22
import datetime
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("attendance", "0004_employeeworkschedule"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="StandardWorkSchedule",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"singleton",
models.BooleanField(default=True, editable=False, unique=True),
),
(
"start_time",
models.TimeField(
default=datetime.time(8, 0),
verbose_name="Начало рабочего дня",
),
),
(
"end_time",
models.TimeField(
default=datetime.time(17, 0),
verbose_name="Конец рабочего дня",
),
),
(
"expected_hours",
models.FloatField(default=9, verbose_name="Норма часов"),
),
(
"workdays",
models.JSONField(
blank=True,
default=list,
verbose_name="Рабочие дни",
),
),
(
"date_overrides",
models.JSONField(
blank=True,
default=list,
verbose_name="Календарные исключения",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, 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_standard_work_schedules",
to=settings.AUTH_USER_MODEL,
verbose_name="Изменил",
),
),
],
options={
"verbose_name": "Стандартный график работы",
"verbose_name_plural": "Стандартный график работы",
},
),
]

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

@ -110,6 +110,137 @@ class EmployeeWorkSchedule(models.Model):
}
class StandardWorkSchedule(models.Model):
DEFAULT_WORKDAYS = EmployeeWorkSchedule.DEFAULT_WORKDAYS
singleton = models.BooleanField(default=True, unique=True, editable=False)
start_time = models.TimeField("Начало рабочего дня", default=time(8, 0))
end_time = models.TimeField("Конец рабочего дня", default=time(17, 0))
expected_hours = models.FloatField("Норма часов", default=9)
workdays = models.JSONField("Рабочие дни", default=list, blank=True)
date_overrides = models.JSONField(
"Календарные исключения",
default=list,
blank=True,
)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="updated_standard_work_schedules",
verbose_name="Изменил",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Стандартный график работы"
verbose_name_plural = "Стандартный график работы"
def save(self, *args, **kwargs):
self.singleton = True
if not self.workdays:
self.workdays = list(self.DEFAULT_WORKDAYS)
super().save(*args, **kwargs)
def __str__(self) -> str:
return f"{self.start_time} - {self.end_time}"
def to_logstorm_payload(self) -> dict:
return {
"start_time": self.start_time.strftime("%H:%M"),
"end_time": self.end_time.strftime("%H:%M"),
"expected_hours": self.expected_hours,
"workdays": list(self.workdays or self.DEFAULT_WORKDAYS),
"date_overrides": list(self.date_overrides or []),
}
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

@ -1,17 +1,26 @@
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
from openpyxl.styles import Alignment, Font, PatternFill, Side, Border
from openpyxl.utils import get_column_letter
from attendance.models import (
AttendanceAutoSyncSettings,
AttendanceAnalysisRun,
AttendanceRecord,
EmployeeWorkSchedule,
StandardWorkSchedule,
)
from employees.constants import (
ACTION_DISMISSED,
@ -49,6 +58,55 @@ DAYS_RU = {
6: "Вс",
}
MATRIX_STATUS_LABELS = {
"empty": "Нет записи",
"technical": "Техсбой",
"underwork": "Недоработка",
"late": "Опоздание",
"overtime": "Переработка",
"absent": "Отсутствие",
"non_working": "Нерабочий",
"normal": "Норма",
}
MATRIX_STATUS_SHORT_LABELS = {
"empty": "",
"technical": "Тех",
"underwork": "НД",
"late": "ОП",
"overtime": "ПР",
"absent": "Н",
"non_working": "Нер",
"normal": "OK",
}
PERSONNEL_STATUS_SHORT_LABELS = {
ACTION_ON_LEAVE: "ОТП",
ACTION_ON_SICK_LEAVE: "БЛ",
ACTION_ON_DAY_OFF: "ОТГ",
ACTION_ON_MATERNITY: "ДЕКР",
ACTION_DISMISSED: "Вне штата",
}
PERSONNEL_STATUS_LEGEND = {
ACTION_ON_LEAVE: "Отпуск",
ACTION_ON_SICK_LEAVE: "Больничный",
ACTION_ON_DAY_OFF: "Отгул",
ACTION_ON_MATERNITY: "Декрет",
ACTION_DISMISSED: "Уволен / вне штата",
}
MATRIX_STATUS_FILLS = {
"empty": "F1F5F9",
"technical": "FEE2E2",
"underwork": "FEF3C7",
"late": "FEF3C7",
"absent": "FEF3C7",
"overtime": "DCFCE7",
"non_working": "E2E8F0",
"normal": "E0F2FE",
}
NON_WORKING_PERSONNEL_ACTIONS = {
ACTION_ON_LEAVE,
ACTION_ON_SICK_LEAVE,
@ -184,6 +242,147 @@ def get_default_work_schedule_payload() -> dict[str, Any]:
}
def get_standard_work_schedule() -> StandardWorkSchedule | None:
return StandardWorkSchedule.objects.order_by("id").first()
def get_standard_work_schedule_payload() -> dict[str, Any]:
schedule = get_standard_work_schedule()
if schedule is None:
return get_default_work_schedule_payload()
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,
@ -258,6 +457,235 @@ def build_monthly_attendance_matrix(
}
def build_attendance_matrix_export_workbook(
*,
employees,
records,
period_start: date,
period_end: date,
record_comments: dict[int, list[dict[str, str]]] | None = None,
) -> bytes:
employees = list(employees)
records_by_month: dict[tuple[int, int], list[AttendanceRecord]] = {}
for record in records:
records_by_month.setdefault((record.date.year, record.date.month), []).append(
record
)
workbook = Workbook()
workbook.remove(workbook.active)
for year, month in _month_pairs_between(period_start, period_end):
matrix = build_monthly_attendance_matrix(
employees=employees,
records=records_by_month.get((year, month), []),
year=year,
month=month,
)
_append_attendance_matrix_sheet(
workbook,
matrix,
record_comments=record_comments or {},
)
_append_attendance_matrix_legend_sheet(workbook)
if not workbook.worksheets:
workbook.create_sheet("Посещаемость")
stream = BytesIO()
workbook.save(stream)
return stream.getvalue()
def _month_pairs_between(
period_start: date,
period_end: date,
) -> list[tuple[int, int]]:
result = []
year = period_start.year
month = period_start.month
while year < period_end.year or (year == period_end.year and month <= period_end.month):
result.append((year, month))
month += 1
if month > 12:
month = 1
year += 1
return result
def _append_attendance_matrix_sheet(
workbook: Workbook,
matrix: dict[str, Any],
*,
record_comments: dict[int, list[dict[str, str]]],
) -> None:
sheet = workbook.create_sheet(_safe_sheet_title(matrix["month_label"]))
header_fill = PatternFill("solid", fgColor="E2E8F0")
summary_fill = PatternFill("solid", fgColor="DBEAFE")
thin = Side(style="thin", color="CBD5E1")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
sheet.append(["Дата", "День", *[employee["name"] for employee in matrix["employees"]]])
for cell in sheet[1]:
cell.font = Font(bold=True, color="0F172A")
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = border
for row in matrix["rows"]:
sheet.append(
[
row["date"],
row["weekday"],
*[
_format_matrix_export_cell(row["cells"][str(employee["id"])])
for employee in matrix["employees"]
],
]
)
excel_row = sheet.max_row
for index, excel_cell in enumerate(sheet[excel_row], start=1):
excel_cell.border = border
excel_cell.alignment = Alignment(vertical="top", wrap_text=True)
if index > 2:
matrix_cell = row["cells"][str(matrix["employees"][index - 3]["id"])]
status = matrix_cell["status"]
excel_cell.fill = PatternFill(
"solid",
fgColor=MATRIX_STATUS_FILLS.get(status, MATRIX_STATUS_FILLS["empty"]),
)
note = _format_matrix_export_note(
row=row,
cell=matrix_cell,
comments=record_comments.get(matrix_cell.get("record_id") or 0, []),
)
if note:
excel_cell.comment = Comment(note, "EUSRR")
record_id = matrix_cell.get("record_id")
if record_id:
excel_cell.hyperlink = _attendance_record_day_events_url(record_id)
excel_cell.font = Font(color="0563C1", underline="single")
if matrix["summary"]:
sheet.append([])
for summary_row in matrix["summary"]:
sheet.append(
[
summary_row["label"],
"",
*[
summary_row["values"].get(str(employee["id"]), 0)
for employee in matrix["employees"]
],
]
)
excel_row = sheet.max_row
for cell in sheet[excel_row]:
cell.font = Font(bold=True, color="0F172A")
cell.fill = summary_fill
cell.border = border
sheet.freeze_panes = "C2"
sheet.column_dimensions["A"].width = 14
sheet.column_dimensions["B"].width = 10
for column_index in range(3, len(matrix["employees"]) + 3):
sheet.column_dimensions[get_column_letter(column_index)].width = 18
def _append_attendance_matrix_legend_sheet(workbook: Workbook) -> None:
sheet = workbook.create_sheet("Легенда")
header_fill = PatternFill("solid", fgColor="E2E8F0")
thin = Side(style="thin", color="CBD5E1")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
rows = [
("Код", "Расшифровка"),
("OK", "Норма"),
("ОП", "Опоздание"),
("НД", "Недоработка"),
("ПР", "Переработка"),
("Н", "Отсутствие"),
("Вых", "Выходной по графику/календарю"),
("Тех", "Технический сбой"),
("ОТП", "Отпуск"),
("БЛ", "Больничный"),
("ОТГ", "Отгул"),
("ДЕКР", "Декрет"),
("Вне штата", "Уволен или еще не принят в выбранный день"),
("+ Nч", "Зафиксирована работа в нерабочий день"),
("К: N", "Количество комментариев EUSRR к записи"),
("Ссылка", "Клик по ячейке записи открывает модал событий LogStorm в EUSRR"),
]
for row in rows:
sheet.append(row)
for row_index, row in enumerate(sheet.iter_rows(), start=1):
for cell in row:
cell.border = border
cell.alignment = Alignment(vertical="top", wrap_text=True)
if row_index == 1:
cell.font = Font(bold=True, color="0F172A")
cell.fill = header_fill
sheet.column_dimensions["A"].width = 16
sheet.column_dimensions["B"].width = 56
def _attendance_record_day_events_url(record_id: int) -> str:
site_url = getattr(settings, "SITE_URL", "https://corp.robotail.pro").rstrip("/")
return f"{site_url}/attendance?record={record_id}&events=1"
def _safe_sheet_title(value: str) -> str:
forbidden = str.maketrans({char: " " for char in "[]:*?/\\\""})
return value.translate(forbidden)[:31] or "Посещаемость"
def _format_matrix_export_cell(cell: dict[str, Any]) -> str:
if cell["status"] == "empty":
return ""
return str(cell.get("display_text") or cell.get("short_label") or "")
def _format_matrix_export_note(
*,
row: dict[str, Any],
cell: dict[str, Any],
comments: list[dict[str, str]],
) -> str:
if cell["status"] == "empty" and not comments:
return ""
lines = [
f"Дата: {row['date']} ({row['weekday']})",
f"Статус: {cell.get('primary_label') or MATRIX_STATUS_LABELS.get(cell['status'], cell['status'])}",
]
details = [str(item) for item in cell.get("detail_lines", []) if item]
if details:
lines.extend(details)
if comments:
lines.append("")
lines.append("Комментарии EUSRR:")
for comment in comments:
author = comment.get("author") or "Сотрудник"
created_at = comment.get("created_at") or ""
text = comment.get("text") or ""
prefix = f"- {author}"
if created_at:
prefix = f"{prefix}, {created_at}"
lines.append(f"{prefix}: {text}")
return "\n".join(lines)
def _format_export_number(value: Any) -> str:
if value is None or value == "":
return "0"
numeric = float(value)
if numeric.is_integer():
return str(int(numeric))
return f"{numeric:.2f}".rstrip("0").rstrip(".")
def resolve_employee_day_state(
personnel_actions,
record_date: date,
@ -506,10 +934,16 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]:
"work_hours": None,
"expected_hours": None,
"status": "empty",
"short_label": "",
"display_text": "",
"primary_label": MATRIX_STATUS_LABELS["empty"],
"detail_lines": [],
"issues": [],
"is_workday": None,
"effective_is_workday": None,
"non_working_reason": "",
"is_manually_edited": False,
"manual_edited_at": None,
"comments_count": 0,
}
@ -519,13 +953,21 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]:
*record.technical_issues,
]
comments_count = getattr(record, "comments_count", 0) or 0
status = _monthly_matrix_status(record)
presentation = _monthly_matrix_cell_presentation(
record=record,
status=status,
issues=issues,
comments_count=comments_count,
)
return {
"record_id": record.id,
"arrival_time": record.arrival_time,
"departure_time": record.departure_time,
"work_hours": record.work_hours,
"expected_hours": record.expected_hours,
"status": _monthly_matrix_status(record),
"status": status,
**presentation,
"issues": issues,
"is_workday": record.is_workday,
"effective_is_workday": record.effective_is_workday,
@ -538,10 +980,161 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]:
"is_absent": record.is_absent,
"personnel_status": record.personnel_status,
"personnel_status_label": record.personnel_status_label,
"is_manually_edited": record.is_manually_edited,
"manual_edited_at": record.manual_edited_at,
"comments_count": comments_count,
}
def _monthly_matrix_cell_presentation(
*,
record: AttendanceRecord,
status: str,
issues: list[Any],
comments_count: int,
) -> dict[str, Any]:
primary_label = _matrix_primary_label(record, status)
short_label = _matrix_short_label(record, status)
display_text = _matrix_display_text(record, status, short_label)
detail_lines = _matrix_detail_lines(
record=record,
status=status,
issues=issues,
comments_count=comments_count,
)
return {
"short_label": short_label,
"display_text": display_text,
"primary_label": primary_label,
"detail_lines": detail_lines,
}
def _matrix_primary_label(record: AttendanceRecord, status: str) -> str:
if record.personnel_status != PERSONNEL_STATUS_NORMAL:
return (
PERSONNEL_STATUS_LEGEND.get(record.personnel_status)
or record.personnel_status_label
or "Кадровое событие"
)
if not record.effective_is_workday:
return _record_non_working_reason(record) or MATRIX_STATUS_LABELS["non_working"]
return MATRIX_STATUS_LABELS.get(status, status)
def _matrix_short_label(record: AttendanceRecord, status: str) -> str:
if record.personnel_status != PERSONNEL_STATUS_NORMAL:
return (
PERSONNEL_STATUS_SHORT_LABELS.get(record.personnel_status)
or record.personnel_status_label
or MATRIX_STATUS_SHORT_LABELS.get(status, status)
)
if not record.effective_is_workday:
if not record.is_workday:
return "Вых"
return MATRIX_STATUS_SHORT_LABELS["non_working"]
return MATRIX_STATUS_SHORT_LABELS.get(status, status)
def _matrix_display_text(
record: AttendanceRecord,
status: str,
short_label: str,
) -> str:
if not record.effective_is_workday:
if record.work_hours:
return f"{short_label} + {_format_export_number(record.work_hours)}ч"
return short_label
if status == "normal":
time_part = f"{record.arrival_time or '-'}/{record.departure_time or '-'}"
return f"{time_part} · {_format_export_number(record.work_hours)}ч"
time_part = f"{record.arrival_time or '-'}/{record.departure_time or '-'}"
if status == "late":
suffix = f" {record.late_minutes}м" if record.late_minutes else ""
return f"{short_label}{suffix} · {time_part}"
if status == "underwork":
suffix = (
f" {_format_export_number(record.underwork_hours)}ч"
if record.underwork_hours
else ""
)
return f"{short_label}{suffix} · {time_part}"
if status == "overtime":
suffix = (
f" +{_format_export_number(record.overtime_hours)}ч"
if record.overtime_hours
else ""
)
return f"{short_label}{suffix} · {time_part}"
if status == "technical":
issue_count = len(record.technical_issues)
label = f"{short_label} ({issue_count})" if issue_count else short_label
return f"{label} · {time_part}" if record.arrival_time or record.departure_time else label
return short_label
def _matrix_detail_lines(
*,
record: AttendanceRecord,
status: str,
issues: list[Any],
comments_count: int,
) -> list[str]:
lines = [
f"Приход: {record.arrival_time or '-'}",
f"Уход: {record.departure_time or '-'}",
(
"Часы: "
f"{_format_export_number(record.work_hours)} / "
f"{_format_export_number(record.expected_hours)}"
),
f"Рабочий день: {'да' if record.effective_is_workday else 'нет'}",
]
if record.personnel_status != PERSONNEL_STATUS_NORMAL:
lines.append(f"Кадровое событие: {_matrix_primary_label(record, status)}")
elif not record.effective_is_workday:
reason = _record_non_working_reason(record)
if reason:
lines.append(f"Причина нерабочего дня: {reason}")
if record.is_manually_edited:
lines.append("Источник: ручная корректировка EUSRR")
if record.is_late and record.late_minutes:
lines.append(f"Опоздание: {record.late_minutes} мин.")
if record.is_early_leave and record.early_leave_minutes:
lines.append(f"Ранний уход: {record.early_leave_minutes} мин.")
if status != "absent" and record.is_underwork and record.underwork_hours:
lines.append(f"Недоработка: {_format_export_number(record.underwork_hours)} ч.")
if record.is_overtime and record.overtime_hours:
lines.append(f"Переработка: {_format_export_number(record.overtime_hours)} ч.")
if record.is_absent:
lines.append("Отсутствие: да")
if record.technical_issues:
lines.append(
"Технические проблемы: "
+ "; ".join(str(issue) for issue in record.technical_issues)
)
employee_issues = [
str(issue)
for issue in record.employee_issues
if not (status == "absent" and "underwork" in str(issue).lower())
]
if employee_issues:
lines.append("Проблемы сотрудника: " + "; ".join(employee_issues))
extra_issues = [
str(issue)
for issue in issues
if issue not in record.technical_issues and issue not in record.employee_issues
and not (status == "absent" and "underwork" in str(issue).lower())
]
if extra_issues:
lines.append("Статусы анализа: " + "; ".join(extra_issues))
if comments_count:
lines.append(f"Комментарии: {comments_count}")
return lines
def _monthly_matrix_status(record: AttendanceRecord) -> str:
if not record.effective_is_workday:
if record.is_overtime:
@ -549,6 +1142,8 @@ def _monthly_matrix_status(record: AttendanceRecord) -> str:
return "non_working"
if record.technical_issues:
return "technical"
if record.is_absent and not record.arrival_time and not record.departure_time:
return "absent"
if record.is_underwork:
return "underwork"
if record.is_late:

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

@ -11,6 +11,10 @@ from django.conf import settings
class LogStormClientError(RuntimeError):
"""Raised when LogStorm cannot process an attendance request."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class LogStormConfig:
@ -50,7 +54,8 @@ class LogStormClient:
if not response.ok:
raise LogStormClientError(
f"LogStorm API returned HTTP {response.status_code}: "
f"{response.text}"
f"{response.text}",
status_code=response.status_code,
)
try:
return response.json()
@ -73,13 +78,59 @@ class LogStormClient:
if not response.ok:
raise LogStormClientError(
f"LogStorm API returned HTTP {response.status_code}: "
f"{response.text}"
f"{response.text}",
status_code=response.status_code,
)
try:
return response.json()
except ValueError as exc:
raise LogStormClientError("LogStorm API returned invalid JSON") from exc
def get_attendance_day_events(
self,
*,
employee_id: str,
record_date: date | str,
) -> list[dict[str, Any]]:
response = self.session.get(
self._url("/attendance/events/day/"),
params={
"employee_id": str(employee_id),
"date": str(record_date),
},
headers=self._headers(),
timeout=self.config.timeout,
)
if not response.ok:
raise LogStormClientError(
f"LogStorm API returned HTTP {response.status_code}: "
f"{response.text}",
status_code=response.status_code,
)
try:
payload = response.json()
except ValueError as exc:
raise LogStormClientError("LogStorm API returned invalid JSON") from exc
if not isinstance(payload, list):
raise LogStormClientError("LogStorm API returned invalid events payload")
return payload
def get_attendance_event_photo(self, event_key: str) -> requests.Response:
headers = self._headers()
headers["Accept"] = "image/*"
response = self.session.get(
self._url(f"/attendance/events/photos/{event_key}/"),
headers=headers,
timeout=self.config.timeout,
)
if not response.ok:
raise LogStormClientError(
f"LogStorm API returned HTTP {response.status_code}: "
f"{response.text}",
status_code=response.status_code,
)
return response
def health(self) -> bool:
try:
response = self.session.get(

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,15 +1,20 @@
from unittest.mock import patch
from datetime import datetime
from io import BytesIO
from datetime import datetime, timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from openpyxl import load_workbook
from attendance.models import (
AttendanceAnalysisRun,
AttendanceAutoSyncSettings,
AttendanceRecord,
EmployeeWorkSchedule,
StandardWorkSchedule,
)
from common.logstorm_client import LogStormClientError
from employees.constants import ACTION_ON_LEAVE, ACTION_RETURNED_FROM_LEAVE
from employees.models import EmployeeAction
@ -28,14 +33,41 @@ def _monthly_matrix_url():
return reverse("api:v1:attendance-monthly-matrix")
def _monthly_matrix_export_url():
return reverse("api:v1:attendance-monthly-matrix-export")
def _work_schedule_url(employee_id):
return reverse("api:v1:attendance-work-schedule", args=[employee_id])
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])
def _record_day_events_url(record_id):
return reverse("api:v1:attendance-record-day-events", args=[record_id])
def _record_day_event_photo_url(record_id, event_key):
return reverse(
"api:v1:attendance-record-day-event-photo",
args=[record_id, event_key],
)
def _record_comments_url(record_id):
return reverse("api:v1:attendance-record-comments", args=[record_id])
@ -107,6 +139,11 @@ def _move_auto_hired_before_test_period(employee):
employee.actions.filter(action="hired").update(date=_aware_datetime(2026, 1, 1))
class _PhotoResponse:
content = b"fake-image"
headers = {"Content-Type": "image/jpeg"}
def test_analyze_saves_run_and_records(auth_client_factory, user_factory):
staff = user_factory(staff=True)
employee = user_factory(first_name="Ivan", last_name="Petrov")
@ -185,6 +222,145 @@ def test_staff_can_save_employee_work_schedule(
assert schedule.expected_hours == 8
def test_staff_can_read_default_standard_work_schedule(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
client = auth_client_factory(staff)
response = client.get(_standard_work_schedule_url())
assert response.status_code == 200
assert response.data["id"] is None
assert response.data["is_default"] is True
assert response.data["start_time"] == "08:00"
assert response.data["end_time"] == "17:00"
assert response.data["expected_hours"] == 9
assert response.data["workdays"] == EmployeeWorkSchedule.DEFAULT_WORKDAYS
def test_staff_can_save_standard_work_schedule(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
client = auth_client_factory(staff)
response = client.patch(
_standard_work_schedule_url(),
{
"start_time": "07:30",
"end_time": "16:30",
"expected_hours": 8,
"workdays": ["Monday", "Tuesday", "Wednesday", "Thursday"],
},
format="json",
)
assert response.status_code == 200
assert response.data["is_default"] is False
assert response.data["start_time"] == "07:30:00"
assert response.data["end_time"] == "16:30:00"
assert response.data["workdays"] == [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
]
schedule = StandardWorkSchedule.objects.get()
assert schedule.updated_by == staff
assert schedule.expected_hours == 8
get_response = client.get(_standard_work_schedule_url())
assert get_response.status_code == 200
assert get_response.data["id"] == schedule.id
assert get_response.data["start_time"] == "07:30:00"
def test_non_staff_cannot_manage_standard_work_schedule(
auth_client_factory,
user_factory,
):
user = user_factory()
client = auth_client_factory(user)
get_response = client.get(_standard_work_schedule_url())
patch_response = client.patch(
_standard_work_schedule_url(),
{"start_time": "09:00"},
format="json",
)
assert get_response.status_code == 403
assert patch_response.status_code == 403
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,
@ -238,6 +414,196 @@ def test_analyze_uses_saved_work_schedule_when_payload_omits_schedule(
assert run.schedule_payload == schedule
def test_analyze_uses_standard_work_schedule_when_payload_omits_schedule(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
StandardWorkSchedule.objects.create(
start_time="07:00",
end_time="16:00",
expected_hours=8,
workdays=["Monday", "Tuesday", "Wednesday", "Thursday"],
)
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
) as analyze:
response = client.post(_analyze_url(), _payload(employee.id), format="json")
assert response.status_code == 200
schedule = analyze.call_args.kwargs["schedule"]
assert schedule == {
"start_time": "07:00",
"end_time": "16:00",
"expected_hours": 8,
"workdays": ["Monday", "Tuesday", "Wednesday", "Thursday"],
"date_overrides": [],
}
run = AttendanceAnalysisRun.objects.get()
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()
@ -312,6 +678,198 @@ def test_records_endpoint_returns_saved_records(auth_client_factory, user_factor
assert {item["date"] for item in results} == {"2026-04-20", "2026-04-21"}
def test_user_can_read_own_attendance_record_detail(
auth_client_factory,
user_factory,
):
user = user_factory(staff=False)
staff = user_factory(staff=True)
staff_client = auth_client_factory(staff)
client = auth_client_factory(user)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
staff_client.post(_analyze_url(), _payload(user.id), format="json")
record = AttendanceRecord.objects.get(employee=user, date="2026-04-20")
response = client.get(_record_detail_url(record.id))
assert response.status_code == 200
assert response.json()["id"] == record.id
assert response.json()["employee"] == user.id
def test_staff_can_load_attendance_day_events(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
client.post(_analyze_url(), _payload(employee.id), format="json")
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
with patch(
"api.v1.attendance.views.LogStormClient.get_attendance_day_events",
return_value=[
{
"event_key": "abc",
"time": "2026-04-20T09:15:00+03:00",
"time_label": "09:15:00",
"caption": "Успешный вход",
"device": "door-1",
"device_name": "Вход",
"serial_no": 10,
"has_photo": True,
"photo_url": "/attendance/events/photos/abc/",
},
{
"event_key": "def",
"time": "2026-04-20T18:00:00+03:00",
"time_label": "18:00:00",
"caption": "Успешный выход",
"device": "door-2",
"device_name": "Выход",
"serial_no": 11,
"has_photo": False,
"photo_url": None,
},
],
) as logstorm_events:
response = client.get(_record_day_events_url(record.id))
assert response.status_code == 200
logstorm_events.assert_called_once_with(
employee_id=str(employee.id),
record_date=record.date,
)
events = response.json()
assert [event["caption"] for event in events] == ["Успешный вход", "Успешный выход"]
assert events[0]["photo_url"].endswith(
f"/api/v1/attendance/records/{record.id}/day-events/abc/photo/"
)
assert events[1]["photo_url"] is None
def test_user_cannot_load_other_employee_attendance_day_events(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
user = user_factory(staff=False)
employee = user_factory()
staff_client = auth_client_factory(staff)
client = auth_client_factory(user)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
staff_client.post(_analyze_url(), _payload(employee.id), format="json")
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
response = client.get(_record_day_events_url(record.id))
assert response.status_code == 404
def test_attendance_day_events_logstorm_error_returns_502(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
client.post(_analyze_url(), _payload(employee.id), format="json")
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
with patch(
"api.v1.attendance.views.LogStormClient.get_attendance_day_events",
side_effect=LogStormClientError("boom"),
):
response = client.get(_record_day_events_url(record.id))
assert response.status_code == 502
assert response.json()["error"] == "logstorm_unavailable"
def test_staff_can_proxy_attendance_day_event_photo(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
client.post(_analyze_url(), _payload(employee.id), format="json")
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
with (
patch(
"api.v1.attendance.views.LogStormClient.get_attendance_day_events",
return_value=[{"event_key": "abc", "has_photo": True}],
),
patch(
"api.v1.attendance.views.LogStormClient.get_attendance_event_photo",
return_value=_PhotoResponse(),
) as logstorm_photo,
):
response = client.get(_record_day_event_photo_url(record.id, "abc"))
assert response.status_code == 200
assert response.content == b"fake-image"
assert response["Content-Type"] == "image/jpeg"
logstorm_photo.assert_called_once_with("abc")
def test_attendance_day_event_photo_rejects_event_outside_record_day(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
client.post(_analyze_url(), _payload(employee.id), format="json")
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
with (
patch(
"api.v1.attendance.views.LogStormClient.get_attendance_day_events",
return_value=[{"event_key": "other", "has_photo": True}],
),
patch(
"api.v1.attendance.views.LogStormClient.get_attendance_event_photo",
) as logstorm_photo,
):
response = client.get(_record_day_event_photo_url(record.id, "abc"))
assert response.status_code == 404
logstorm_photo.assert_not_called()
def test_monthly_matrix_endpoint_returns_excel_like_month(
auth_client_factory,
user_factory,
@ -348,6 +906,9 @@ def test_monthly_matrix_endpoint_returns_excel_like_month(
assert employee_cell["arrival_time"] == "09:15"
assert employee_cell["departure_time"] == "18:00"
assert employee_cell["status"] == "underwork"
assert employee_cell["short_label"] == "НД"
assert employee_cell["display_text"] == "НД 0.25ч · 09:15/18:00"
assert "Недоработка: 0.25 ч." in employee_cell["detail_lines"]
summary_by_key = {item["key"]: item for item in payload["summary"]}
assert summary_by_key["late_days"]["values"][str(employee.id)] == 1
@ -355,6 +916,222 @@ def test_monthly_matrix_endpoint_returns_excel_like_month(
assert summary_by_key["worked_hours"]["values"][str(employee.id)] == 8.75
def test_staff_can_export_monthly_matrix_xlsx_from_saved_records(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory(first_name="Ivan", last_name="Petrov")
other = user_factory(first_name="Anna", last_name="Sidorova")
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
client.post(_analyze_url(), _payload(employee.id), format="json")
client.post(_analyze_url(), _payload(other.id), format="json")
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
client.post(
_record_comments_url(record.id),
{"text": "Проверить причину опоздания"},
format="json",
)
with patch("api.v1.attendance.views.analyze_employee_attendance") as analyze:
response = client.get(
_monthly_matrix_export_url(),
{
"employee_ids": f"{employee.id},{other.id}",
"period_start": "2026-04-20",
"period_end": "2026-05-05",
},
)
assert response.status_code == 200
assert response["Content-Type"] == (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
assert response["Content-Disposition"] == (
'attachment; filename="attendance-2026-04-20_2026-05-05.xlsx"'
)
analyze.assert_not_called()
workbook = load_workbook(BytesIO(response.content))
assert workbook.sheetnames == ["Апрель 2026", "Май 2026", "Легенда"]
april = workbook["Апрель 2026"]
assert april["A1"].value == "Дата"
assert april["B1"].value == "День"
assert april["C1"].value == "Petrov Ivan"
assert april["D1"].value == "Sidorova Anna"
assert april["A21"].value == "2026-04-20"
assert april["C21"].value == "НД 0.25ч · 09:15/18:00"
assert april["C21"].hyperlink.target.endswith(
f"/attendance?record={record.id}&events=1"
)
assert "Недоработка: 0.25 ч." in april["C21"].comment.text
assert "Проверить причину опоздания" in april["C21"].comment.text
assert april["C33"].value == 2
assert april["C35"].value == 8.75
assert workbook["Легенда"]["A2"].value == "OK"
def test_monthly_matrix_prioritizes_absence_when_no_times_and_no_personnel_reason(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
result = {
"records": [
{
"date": "2026-04-20",
"employee_id": str(employee.id),
"display_name": "Absent User",
"arrival_time": None,
"departure_time": None,
"work_hours": 0,
"expected_hours": 9,
"is_workday": True,
"is_absent": True,
"is_underwork": True,
"underwork_hours": 9,
"statuses": ["absence", "underwork"],
"employee_issues": ["absence", "underwork"],
"technical_issues": [],
}
]
}
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=result,
):
response = client.post(
_analyze_url(),
_payload(employee.id, period_start="2026-04-20", period_end="2026-04-20"),
format="json",
)
assert response.status_code == 200
matrix_response = client.get(
_monthly_matrix_url(),
{
"employee_ids": str(employee.id),
"month": "2026-04",
},
)
matrix_row = next(
row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-20"
)
matrix_cell = matrix_row["cells"][str(employee.id)]
assert matrix_cell["status"] == "absent"
assert matrix_cell["display_text"] == "Н"
assert matrix_cell["primary_label"] == "Отсутствие"
def test_non_staff_cannot_export_monthly_matrix_xlsx(
auth_client_factory,
user_factory,
):
user = user_factory(staff=False)
client = auth_client_factory(user)
response = client.get(
_monthly_matrix_export_url(),
{
"employee_ids": str(user.id),
"period_start": "2026-04-20",
"period_end": "2026-04-21",
},
)
assert response.status_code == 403
def test_monthly_matrix_xlsx_export_rejects_invalid_period(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
response = client.get(
_monthly_matrix_export_url(),
{
"employee_ids": str(employee.id),
"period_start": "2026-04-21",
"period_end": "2026-04-20",
},
)
assert response.status_code == 400
def test_monthly_matrix_xlsx_export_includes_empty_month_without_records(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory(first_name="Empty", last_name="Month")
client = auth_client_factory(staff)
response = client.get(
_monthly_matrix_export_url(),
{
"employee_ids": str(employee.id),
"period_start": "2026-04-20",
"period_end": "2026-04-21",
},
)
assert response.status_code == 200
workbook = load_workbook(BytesIO(response.content))
april = workbook["Апрель 2026"]
assert april["C1"].value == "Month Empty"
assert april["C21"].value is None
def test_monthly_matrix_xlsx_export_keeps_full_month_but_limits_records_to_period(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory(first_name="Ivan", last_name="Petrov")
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
client.post(_analyze_url(), _payload(employee.id), format="json")
response = client.get(
_monthly_matrix_export_url(),
{
"employee_ids": str(employee.id),
"period_start": "2026-04-21",
"period_end": "2026-04-21",
},
)
assert response.status_code == 200
workbook = load_workbook(BytesIO(response.content))
april = workbook["Апрель 2026"]
assert april.max_row == 39
assert april["A20"].value == "2026-04-19"
assert april["A21"].value == "2026-04-20"
assert april["A22"].value == "2026-04-21"
assert april["C21"].value is None
assert april["C22"].value == "Н"
assert april["C33"].value == 1
assert april["C35"].value == 0
def test_user_can_read_own_attendance_records(auth_client_factory, user_factory):
user = user_factory(staff=False)
other = user_factory()
@ -521,6 +1298,21 @@ def test_personnel_non_working_state_is_saved_and_suppresses_absence(
assert absent_record.employee_issues == []
assert absent_record.statuses == []
matrix_response = client.get(
_monthly_matrix_url(),
{
"employee_ids": str(employee.id),
"month": "2026-04",
},
)
matrix_row = next(
row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-21"
)
matrix_cell = matrix_row["cells"][str(employee.id)]
assert matrix_cell["short_label"] == "ОТП"
assert matrix_cell["display_text"] == "ОТП"
assert matrix_cell["primary_label"] == "Отпуск"
def test_calendar_non_working_day_suppresses_absence_without_personnel_state(
auth_client_factory,
@ -594,6 +1386,8 @@ def test_calendar_non_working_day_suppresses_absence_without_personnel_state(
)
matrix_cell = matrix_row["cells"][str(employee.id)]
assert matrix_cell["status"] == "non_working"
assert matrix_cell["short_label"] == "Вых"
assert matrix_cell["primary_label"] == "Выходной по графику/календарю"
assert matrix_cell["issues"] == []

View File

@ -51,7 +51,7 @@ def test_logstorm_attendance_api_rejects_other_employee_for_user(
assert response.status_code == 403
def test_logstorm_attendance_api_calls_builder_without_schedule(
def test_logstorm_attendance_api_uses_default_schedule_when_schedule_omitted(
auth_client_factory,
user_factory,
):
@ -69,7 +69,13 @@ def test_logstorm_attendance_api_calls_builder_without_schedule(
assert response.json() == {"records": []}
kwargs = analyze.call_args.kwargs
assert kwargs["employee"] == employee
assert kwargs["schedule"] is None
assert kwargs["schedule"] == {
"start_time": "08:00",
"end_time": "17:00",
"expected_hours": 9,
"workdays": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"date_overrides": [],
}
def test_logstorm_attendance_api_passes_schedule(

View File

@ -9,10 +9,12 @@ from common.logstorm_client import (
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
def __init__(self, status_code=200, payload=None, text="", content=b""):
self.status_code = status_code
self._payload = payload
self.text = text
self.content = content
self.headers = {"Content-Type": "image/jpeg"}
self.ok = 200 <= status_code < 400
def json(self):
@ -116,3 +118,53 @@ def test_health_returns_boolean():
assert ok_client.health() is True
assert bad_client.health() is False
def test_get_attendance_day_events_requests_employee_date():
session = FakeSession(FakeResponse(payload=[]))
client = LogStormClient(
LogStormConfig(base_url="http://logstorm:8000", token="secret"),
session=session,
)
result = client.get_attendance_day_events(
employee_id="100",
record_date="2026-04-20",
)
assert result == []
method, url, kwargs = session.requests[0]
assert method == "GET"
assert url == "http://logstorm:8000/attendance/events/day/"
assert kwargs["params"] == {"employee_id": "100", "date": "2026-04-20"}
assert kwargs["headers"]["Authorization"] == "Bearer secret"
def test_get_attendance_day_events_rejects_invalid_payload():
client = LogStormClient(
LogStormConfig(base_url="http://logstorm:8000"),
session=FakeSession(FakeResponse(payload={"events": []})),
)
with pytest.raises(LogStormClientError, match="invalid events payload"):
client.get_attendance_day_events(
employee_id="100",
record_date="2026-04-20",
)
def test_get_attendance_event_photo_returns_response():
session = FakeSession(FakeResponse(content=b"image"))
client = LogStormClient(
LogStormConfig(base_url="http://logstorm:8000", token="secret"),
session=session,
)
response = client.get_attendance_event_photo("abc")
assert response.content == b"image"
method, url, kwargs = session.requests[0]
assert method == "GET"
assert url == "http://logstorm:8000/attendance/events/photos/abc/"
assert kwargs["headers"]["Accept"] == "image/*"
assert kwargs["headers"]["Authorization"] == "Bearer secret"

View File

@ -85,25 +85,25 @@ async function proxyRequest(request: NextRequest, method: string) {
});
}
// response.text() уже декодирует gzip/br/deflate.
// Удаляем encoding-заголовки, иначе браузер попытается
// распаковать повторно → ERR_CONTENT_DECODING_FAILED.
// arrayBuffer сохраняет бинарные ответы (.xlsx, изображения) и работает
// для JSON/текста без потери заголовков.
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
responseHeaders.delete('transfer-encoding');
// Возвращаем ответ от backend
const responseBody = await response.text();
const responseBody = await response.arrayBuffer();
return new NextResponse(responseBody, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error: any) {
console.error('[Proxy Error]', error.message);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error('[Proxy Error]', message);
return NextResponse.json(
{ error: 'Ошибка при обращении к backend', message: error.message },
{ error: 'Ошибка при обращении к backend', message },
{ status: 502 }
);
}

File diff suppressed because it is too large Load Diff

View File

@ -173,12 +173,6 @@ export default function ProfilePage() {
<ProfileInfoCard items={infoItems} />
<ProfileWorkScheduleCard
canEdit={Boolean(user.auth?.is_staff || user.auth?.is_superuser)}
currentAction={currentAction}
employeeId={user.id}
/>
{user.departments?.length ? (
<ProfileDepartmentsCard departments={user.departments} />
) : null}
@ -195,6 +189,12 @@ export default function ProfilePage() {
expandedCommentLength={240}
/>
<ProfileWorkScheduleCard
canEdit={Boolean(user.auth?.is_staff || user.auth?.is_superuser)}
currentAction={currentAction}
employeeId={user.id}
/>
<EmployeeAttendanceCard employeeActions={user.actions} employeeId={user.id} />
</div>
</AppShell>

View File

@ -509,12 +509,6 @@ export default function UserDetailPage() {
<ProfileInfoCard items={infoItems} />
<ProfileWorkScheduleCard
canEdit={Boolean(currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser)}
currentAction={latestAction}
employeeId={person.id}
/>
{person.departments?.length ? (
<ProfileDepartmentsCard departments={person.departments} />
) : null}
@ -532,6 +526,12 @@ export default function UserDetailPage() {
expandedCommentLength={240}
/>
<ProfileWorkScheduleCard
canEdit={Boolean(currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser)}
currentAction={latestAction}
employeeId={person.id}
/>
<EmployeeAttendanceCard employeeActions={person.actions} employeeId={person.id} />
</>
) : null}

View File

@ -317,8 +317,14 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const { user } = useUser();
const { notifications: notificationsData, markCategoryAsRead } = useNotifications();
const notifications = useMemo(() => Array.isArray(notificationsData) ? notificationsData : [], [notificationsData]);
const canManageAttendance = Boolean(user?.auth?.is_staff || user?.auth?.is_superuser);
const visibleNavItems = useMemo(
() => navItems.filter((item) => item.href !== "/attendance" || canManageAttendance),
[canManageAttendance],
);
// Подсчет уведомлений по категориям
const categoryCounts = useMemo(() => {
@ -355,7 +361,7 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
return (
<div className="app-surface rounded-2xl p-5">
<div className="space-y-2 text-sm">
{navItems.map(({ href, label, icon: Icon, category }) => {
{visibleNavItems.map(({ href, label, icon: Icon, category }) => {
const count = category ? categoryCounts[category] || 0 : 0;
return (

View File

@ -0,0 +1,244 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Camera, Clock3, ImageOff, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { apiClient } from "@/lib/api";
import type { AttendanceDayEvent } from "@/lib/api/attendance";
export type AttendanceDayEventsPreview = {
recordId: number;
employeeName: string;
date: string;
statusLabel: string;
displayText: string;
detailLines?: string[];
issues?: string[];
isManuallyEdited?: boolean;
};
type AttendanceDayEventsModalProps = {
isOpen: boolean;
onClose: () => void;
record: AttendanceDayEventsPreview | null;
};
function getErrorMessage(error: unknown, fallback: string) {
return String((error as Error)?.message || fallback);
}
function revokePhotoUrls(urls: Record<string, string>) {
Object.values(urls).forEach((url) => URL.revokeObjectURL(url));
}
export function AttendanceDayEventsModal({
isOpen,
onClose,
record,
}: AttendanceDayEventsModalProps) {
const [events, setEvents] = useState<AttendanceDayEvent[]>([]);
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
const [loadingEvents, setLoadingEvents] = useState(false);
const [loadingPhotos, setLoadingPhotos] = useState(false);
const [error, setError] = useState<string | null>(null);
const photoUrlsRef = useRef<Record<string, string>>({});
useEffect(() => {
photoUrlsRef.current = photoUrls;
}, [photoUrls]);
useEffect(() => {
if (!isOpen || !record?.recordId) return;
let cancelled = false;
const recordId = record.recordId;
async function loadEvents() {
revokePhotoUrls(photoUrlsRef.current);
setEvents([]);
setPhotoUrls({});
setError(null);
setLoadingEvents(true);
setLoadingPhotos(false);
try {
const nextEvents = await apiClient.getAttendanceRecordDayEvents(recordId);
if (cancelled) return;
setEvents(nextEvents);
const eventsWithPhotos = nextEvents.filter(
(event) => event.has_photo && event.photo_url,
);
if (eventsWithPhotos.length === 0) return;
setLoadingPhotos(true);
const loadedEntries = await Promise.all(
eventsWithPhotos.map(async (event) => {
const blob = await apiClient.getAttendanceDayEventPhoto(event.photo_url as string);
return [event.event_key, URL.createObjectURL(blob)] as const;
}),
);
if (cancelled) {
loadedEntries.forEach(([, url]) => URL.revokeObjectURL(url));
return;
}
setPhotoUrls(Object.fromEntries(loadedEntries));
} catch (loadError) {
if (!cancelled) {
setError(getErrorMessage(loadError, "Не удалось загрузить события дня"));
}
} finally {
if (!cancelled) {
setLoadingEvents(false);
setLoadingPhotos(false);
}
}
}
void loadEvents();
return () => {
cancelled = true;
};
}, [isOpen, record?.recordId]);
useEffect(() => (
() => {
revokePhotoUrls(photoUrlsRef.current);
}
), []);
const close = () => {
revokePhotoUrls(photoUrlsRef.current);
setPhotoUrls({});
onClose();
};
return (
<Modal
isOpen={isOpen && !!record}
onClose={close}
title="События посещения"
size="xl"
>
{record ? (
<div className="space-y-4">
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Запись посещения</p>
<div className="mt-2 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{record.employeeName}
</h3>
<p className="app-text-muted mt-1 text-sm">
{record.date} · {record.displayText || "Нет записи"}
</p>
</div>
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--foreground)]">
{record.statusLabel}
</span>
</div>
{record.detailLines?.length ? (
<div className="mt-3 grid gap-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{record.detailLines.slice(0, 8).map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
{record.issues?.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{record.issues.map((issue) => (
<span key={issue} className="app-status-pill bg-amber-500/15 text-amber-300">
{issue}
</span>
))}
</div>
) : null}
{record.isManuallyEdited ? (
<div className="mt-3 inline-flex rounded-lg border border-violet-400/30 bg-violet-500/10 px-2.5 py-1 text-xs font-medium text-violet-200">
Ручная корректировка EUSRR
</div>
) : null}
</section>
<section className="app-surface rounded-xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Camera size={16} className="app-text-muted" />
<p className="app-card-caption">События LogStorm за день</p>
</div>
{loadingPhotos ? (
<span className="app-text-muted inline-flex items-center gap-2 text-xs">
<Loader2 size={14} className="animate-spin" />
Загрузка фото
</span>
) : null}
</div>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{error}
</div>
) : loadingEvents ? (
<div className="py-10 text-center">
<Loader2 className="mx-auto animate-spin text-[var(--muted-foreground)]" size={22} />
<p className="app-text-muted mt-3 text-sm">Загрузка событий...</p>
</div>
) : events.length === 0 ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Событий за день нет
</p>
<p className="app-text-muted mt-2 text-sm">
LogStorm не вернул проходы для этой даты.
</p>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{events.map((event) => {
const photoUrl = photoUrls[event.event_key];
return (
<article
key={event.event_key}
className="app-surface-muted overflow-hidden rounded-xl"
>
<div className="aspect-video bg-[var(--surface-primary)]">
{photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl}
alt={`${event.caption}, ${event.time_label}`}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-[var(--muted-foreground)]">
<ImageOff size={24} />
<span className="text-xs">Фото не сохранено</span>
</div>
)}
</div>
<div className="space-y-2 p-3">
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground)]">
{event.caption}
</p>
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--foreground)]">
<Clock3 size={13} />
{event.time_label}
</span>
</div>
<p className="app-text-muted text-xs">
{event.device_name || event.device || "Устройство"} · #{event.serial_no}
</p>
</div>
</article>
);
})}
</div>
)}
</section>
</div>
) : null}
</Modal>
);
}

View File

@ -0,0 +1,334 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Loader2, MessageSquare, Send } from "lucide-react";
import { CommentDeleteButton } from "@/components/shared/CommentControls";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { Modal } from "@/components/ui/Modal";
import { apiClient } from "@/lib/api";
import { displayUserName, formatDateTime } from "@/lib/shared";
import type { AttendanceRecordComment } from "@/lib/api/attendance";
export type AttendanceRecordCommentsPreview = {
recordId: number;
employeeName: string;
date: string;
statusLabel: string;
displayText: string;
detailLines?: string[];
commentsCount?: number;
};
type AttendanceRecordCommentsModalProps = {
currentUserId?: number | null;
isOpen: boolean;
onClose: () => void;
onCommentCountChange?: (recordId: number, count: number) => void;
record: AttendanceRecordCommentsPreview | null;
};
function getErrorMessage(error: unknown, fallback: string) {
return String((error as Error)?.message || fallback);
}
function commentAuthorName(comment: AttendanceRecordComment) {
return displayUserName(comment.author) || comment.author?.email || "Сотрудник";
}
function commentFallback(comment: AttendanceRecordComment) {
return (
comment.author?.last_name?.[0] ||
comment.author?.first_name?.[0] ||
comment.author?.email?.[0] ||
"К"
).toUpperCase();
}
export function AttendanceRecordCommentsModal({
currentUserId,
isOpen,
onClose,
onCommentCountChange,
record,
}: AttendanceRecordCommentsModalProps) {
const [comments, setComments] = useState<AttendanceRecordComment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [actionId, setActionId] = useState<number | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const commentsBottomRef = useRef<HTMLDivElement | null>(null);
const onCommentCountChangeRef = useRef(onCommentCountChange);
const activeRecordId = record?.recordId;
useEffect(() => {
onCommentCountChangeRef.current = onCommentCountChange;
}, [onCommentCountChange]);
const reset = useCallback(() => {
setDraft("");
setError(null);
setActionId(null);
}, []);
const scrollToBottom = useCallback((smooth = true) => {
requestAnimationFrame(() => {
commentsBottomRef.current?.scrollIntoView({
behavior: smooth ? "smooth" : "auto",
block: "end",
});
});
}, []);
const loadComments = useCallback(async (recordId: number) => {
try {
setLoading(true);
setError(null);
const nextComments = await apiClient.getAttendanceRecordComments(recordId);
setComments(nextComments);
onCommentCountChangeRef.current?.(recordId, nextComments.length);
} catch (loadError) {
setComments([]);
setError(getErrorMessage(loadError, "Не удалось загрузить комментарии"));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!isOpen || !activeRecordId) return;
reset();
void loadComments(activeRecordId);
}, [activeRecordId, isOpen, loadComments, reset]);
useEffect(() => {
if (!isOpen || loading) return;
scrollToBottom(false);
}, [comments.length, isOpen, loading, scrollToBottom]);
useLayoutEffect(() => {
const input = inputRef.current;
if (!input) return;
const minHeight = 38;
const maxHeight = 128;
input.style.height = "0px";
const nextHeight = Math.min(input.scrollHeight, maxHeight);
const resolvedHeight = Math.max(nextHeight, minHeight);
input.style.height = `${resolvedHeight}px`;
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
setComposerExpanded(resolvedHeight > minHeight + 2);
}, [draft]);
const submitComment = useCallback(async () => {
if (!record) return;
const text = draft.trim();
if (!text) {
setError("Введите текст комментария");
return;
}
try {
setSending(true);
setError(null);
const saved = await apiClient.addAttendanceRecordComment(record.recordId, text);
const nextComments = [...comments, saved];
setComments(nextComments);
setDraft("");
onCommentCountChange?.(record.recordId, nextComments.length);
scrollToBottom();
} catch (submitError) {
setError(getErrorMessage(submitError, "Не удалось отправить комментарий"));
} finally {
setSending(false);
}
}, [comments, draft, onCommentCountChange, record, scrollToBottom]);
const deleteComment = useCallback(
async (comment: AttendanceRecordComment) => {
if (!record) return;
try {
setActionId(comment.id);
await apiClient.deleteAttendanceRecordComment(record.recordId, comment.id);
const nextComments = comments.filter((item) => item.id !== comment.id);
setComments(nextComments);
onCommentCountChange?.(record.recordId, nextComments.length);
} catch (deleteError) {
setError(getErrorMessage(deleteError, "Не удалось удалить комментарий"));
} finally {
setActionId(null);
}
},
[comments, onCommentCountChange, record],
);
return (
<Modal
isOpen={isOpen && !!record}
onClose={onClose}
title="Комментарии к посещению"
size="lg"
noPadding
>
{record ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 space-y-4 px-4 pb-4 sm:px-6">
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Запись посещения</p>
<div className="mt-2 space-y-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{record.employeeName}
</h3>
<p className="app-text-muted mt-1 text-sm">
{record.date} · {record.displayText || "Нет записи"}
</p>
</div>
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--foreground)]">
{record.statusLabel}
</span>
</div>
{record.detailLines?.length ? (
<div className="grid gap-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{record.detailLines.slice(0, 6).map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>Комментариев: {Math.max(comments.length, record.commentsCount || 0)}</span>
</div>
</div>
</section>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{error}
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 sm:px-6">
<section className="app-surface rounded-xl p-4">
<div className="mb-3 flex items-center gap-2">
<MessageSquare size={16} className="app-text-muted" />
<p className="app-card-caption">Обсуждение</p>
</div>
{loading ? (
<div className="py-8 text-center">
<Loader2 className="mx-auto animate-spin text-[var(--muted-foreground)]" size={22} />
<p className="app-text-muted mt-3 text-sm">
Загрузка комментариев...
</p>
</div>
) : comments.length === 0 ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Комментариев пока нет
</p>
<p className="app-text-muted mt-2 text-sm">
Добавьте пояснение к записи посещения.
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => {
const authorName = commentAuthorName(comment);
const isOwnComment = Boolean(
comment.author?.id &&
currentUserId &&
comment.author.id === currentUserId,
);
return (
<article
key={comment.id}
className="app-surface-muted rounded-xl p-3"
>
<div className="flex items-start gap-3">
<RequestAvatar
alt={authorName}
fallback={commentFallback(comment)}
size="lg"
src={comment.author?.avatar}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{authorName}
</p>
<p className="app-text-muted mt-0.5 text-xs">
{formatDateTime(comment.created_at)}
</p>
</div>
{isOwnComment ? (
<CommentDeleteButton
disabled={actionId === comment.id}
onClick={() => void deleteComment(comment)}
/>
) : null}
</div>
<p className="app-text-wrap mt-2 text-sm leading-6 text-[var(--foreground)]">
{comment.text}
</p>
</div>
</div>
</article>
);
})}
<div ref={commentsBottomRef} />
</div>
)}
</section>
</div>
<div className="app-surface-elevated shrink-0 border-t border-[var(--border-primary)] px-4 py-4 sm:px-6">
<div className="flex items-end gap-2">
<div
className={`app-input min-w-0 flex-1 overflow-hidden shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] ${
composerExpanded ? "rounded-[1.5rem]" : "h-10 rounded-full"
}`}
>
<textarea
ref={inputRef}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submitComment();
}
}}
rows={1}
placeholder="Введите комментарий..."
aria-label="Поле ввода комментария"
className={`w-full resize-none bg-transparent px-4 py-2.5 text-sm leading-5 text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)] ${
composerExpanded ? "rounded-[1.5rem]" : "rounded-full"
}`}
/>
</div>
<button
type="button"
onClick={() => void submitComment()}
disabled={sending || !draft.trim()}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:border disabled:border-[var(--border-subtle)] disabled:bg-[var(--surface-secondary)] disabled:text-[var(--muted-foreground)] disabled:shadow-none"
title="Отправить комментарий"
aria-label="Отправить комментарий"
>
{sending ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
</button>
</div>
</div>
</div>
) : null}
</Modal>
);
}

View File

@ -3,7 +3,6 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
AlertTriangle,
CalendarCheck,
ChevronDown,
Clock,
Edit3,
@ -21,9 +20,9 @@ import { apiClient } from "@/lib/api";
import { displayUserName, formatDate } from "@/lib/shared";
import type {
AttendanceRecordComment,
LogStormAttendanceRecord,
LogStormAttendanceRecordUpdatePayload,
LogStormAttendanceResponse,
AttendanceAnalysisResponse,
AttendanceRecord,
AttendanceRecordUpdatePayload,
} from "@/lib/api/attendance";
import type { EmployeeAction } from "@/types/api";
@ -160,7 +159,7 @@ function formatAttendanceIssueLabel(label: string) {
return attendanceIssueLabelMap[normalized] || label;
}
function hasWorked(record: LogStormAttendanceRecord) {
function hasWorked(record: AttendanceRecord) {
const hours = Number(record.work_hours || 0);
return Boolean(record.arrival_time || record.departure_time || hours > 0);
}
@ -176,7 +175,7 @@ function getDateEnd(value: unknown) {
function getPersonnelDayMeta(
actions: EmployeeAction[] | null | undefined,
dateValue: unknown,
record?: LogStormAttendanceRecord,
record?: AttendanceRecord,
): PersonnelDayMeta | null {
if (record?.personnel_status && record.personnel_status !== "normal") {
return {
@ -255,7 +254,7 @@ function getPersonnelDayMeta(
return null;
}
function issueLabels(record: LogStormAttendanceRecord, personnelMeta?: PersonnelDayMeta | null) {
function issueLabels(record: AttendanceRecord, personnelMeta?: PersonnelDayMeta | null) {
const labels = [
...(record.statuses || []),
...(record.employee_issues || []),
@ -281,7 +280,7 @@ function issueLabels(record: LogStormAttendanceRecord, personnelMeta?: Personnel
return Array.from(new Set(result));
}
function getRecordTone(record: LogStormAttendanceRecord, personnelMeta?: PersonnelDayMeta | null) {
function getRecordTone(record: AttendanceRecord, personnelMeta?: PersonnelDayMeta | null) {
const nonWorking = personnelMeta?.nonWorking || record.effective_is_workday === false || record.is_workday === false;
if (nonWorking && hasWorked(record)) {
@ -317,7 +316,7 @@ function getRecordTone(record: LogStormAttendanceRecord, personnelMeta?: Personn
};
}
function getWorkdayMeta(record: LogStormAttendanceRecord, personnelMeta?: PersonnelDayMeta | null) {
function getWorkdayMeta(record: AttendanceRecord, personnelMeta?: PersonnelDayMeta | null) {
if (personnelMeta?.nonWorking) {
return {
className: personnelMeta.className,
@ -357,7 +356,7 @@ function getErrorMessage(error: unknown, fallback: string) {
return String((error as Error)?.message || fallback);
}
function getRecordId(record: LogStormAttendanceRecord) {
function getRecordId(record: AttendanceRecord) {
const value = Number(record.id);
return Number.isFinite(value) && value > 0 ? value : null;
}
@ -367,7 +366,7 @@ function valueToEditString(value: unknown) {
return String(value);
}
function recordToEditForm(record: LogStormAttendanceRecord): AttendanceRecordEditForm | null {
function recordToEditForm(record: AttendanceRecord): AttendanceRecordEditForm | null {
const recordId = getRecordId(record);
if (!recordId) return null;
@ -415,7 +414,7 @@ export default function EmployeeAttendanceCard({
const [loadingMode, setLoadingMode] = useState<"saved" | "analyze" | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<LogStormAttendanceResponse | null>(null);
const [result, setResult] = useState<AttendanceAnalysisResponse | null>(null);
const [expandedRecordKey, setExpandedRecordKey] = useState<string | null>(null);
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentsMap, setCommentsMap] = useState<Record<number, AttendanceRecordComment[]>>({});
@ -465,12 +464,12 @@ export default function EmployeeAttendanceCard({
try {
setLoading(true);
setLoadingMode("analyze");
await apiClient.analyzeLogStormAttendance({
await apiClient.analyzeAttendance({
employee_id: employeeId,
period_start: periodStart,
period_end: periodEnd,
});
const savedRecords = await apiClient.getLogStormAttendanceRecords({
const savedRecords = await apiClient.getAttendanceRecords({
employee_id: employeeId,
date_from: periodStart,
date_to: periodEnd,
@ -514,7 +513,7 @@ export default function EmployeeAttendanceCard({
setLoading(true);
setLoadingMode("saved");
setError(null);
const response = await apiClient.getLogStormAttendanceRecords({
const response = await apiClient.getAttendanceRecords({
employee_id: employeeId,
date_from: periodStart,
date_to: periodEnd,
@ -658,7 +657,7 @@ export default function EmployeeAttendanceCard({
async function saveEditedRecord() {
if (!editForm) return;
const payload: LogStormAttendanceRecordUpdatePayload = {
const payload: AttendanceRecordUpdatePayload = {
arrival_time: nullableTime(editForm.arrival_time),
departure_time: nullableTime(editForm.departure_time),
work_hours: nullableNumber(editForm.work_hours),
@ -704,11 +703,8 @@ export default function EmployeeAttendanceCard({
<form onSubmit={handleSubmit} className="mb-4 space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
<CalendarCheck size={18} />
Посещаемость
</div>
<p className="app-text-muted mt-1 text-sm">Анализ проходов LogStorm по сотруднику</p>
<h2 className="app-card-caption">Посещаемость</h2>
<p className="app-text-muted mt-2 text-sm">Анализ проходов по сотруднику</p>
</div>
<div className="flex items-center gap-2 sm:shrink-0">

View File

@ -1,7 +1,7 @@
import { buildQuery, type RequestFn } from './utils';
import { buildQuery, type GetTokenFn, type RequestFn } from './utils';
import type { User } from '@/types/api';
export type LogStormDateOverridePayload = {
export type AttendanceDateOverridePayload = {
date: string;
is_workday: boolean;
reason?: string;
@ -10,15 +10,15 @@ export type LogStormDateOverridePayload = {
expected_hours?: number;
};
export type LogStormSchedulePayload = {
export type AttendanceSchedulePayload = {
start_time: string;
end_time: string;
expected_hours: number;
workdays: string[];
date_overrides?: LogStormDateOverridePayload[];
date_overrides?: AttendanceDateOverridePayload[];
};
export type EmployeeWorkSchedule = LogStormSchedulePayload & {
export type EmployeeWorkSchedule = AttendanceSchedulePayload & {
id: number | null;
employee_id: number;
is_active: boolean;
@ -28,14 +28,39 @@ export type EmployeeWorkSchedule = LogStormSchedulePayload & {
updated_at?: string | null;
};
export type LogStormAttendanceAnalyzePayload = {
export type StandardWorkSchedule = AttendanceSchedulePayload & {
id: number | null;
is_default: boolean;
updated_by?: number | null;
created_at?: string | null;
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;
period_end: string;
schedule?: LogStormSchedulePayload;
schedule?: AttendanceSchedulePayload;
};
export type LogStormAttendanceRecord = {
export type AttendanceRecord = {
id?: number;
date?: string;
employee_id?: string;
@ -78,12 +103,24 @@ export type AttendanceRecordComment = {
created_at: string;
};
export type LogStormAttendanceResponse = {
records?: LogStormAttendanceRecord[];
export type AttendanceDayEvent = {
event_key: string;
time: string;
time_label: string;
caption: string;
device: string;
device_name: string;
serial_no: number;
has_photo: boolean;
photo_url: string | null;
};
export type AttendanceAnalysisResponse = {
records?: AttendanceRecord[];
[key: string]: unknown;
};
export type LogStormAttendanceRecordsQuery = {
export type AttendanceRecordsQuery = {
employee_id?: number | string;
date_from?: string;
date_to?: string;
@ -91,8 +128,8 @@ export type LogStormAttendanceRecordsQuery = {
limit?: number;
};
export type LogStormAttendanceRecordUpdatePayload = Partial<Pick<
LogStormAttendanceRecord,
export type AttendanceRecordUpdatePayload = Partial<Pick<
AttendanceRecord,
| 'arrival_time'
| 'departure_time'
| 'work_hours'
@ -110,11 +147,11 @@ export type LogStormAttendanceRecordUpdatePayload = Partial<Pick<
| 'is_absent'
>>;
export type PaginatedLogStormAttendanceRecords = {
export type PaginatedAttendanceRecords = {
count: number;
next: string | null;
previous: string | null;
results: LogStormAttendanceRecord[];
results: AttendanceRecord[];
};
export type MonthlyAttendanceMatrixCell = {
@ -124,6 +161,10 @@ export type MonthlyAttendanceMatrixCell = {
work_hours: number | string | null;
expected_hours: number | string | null;
status: 'empty' | 'technical' | 'underwork' | 'late' | 'overtime' | 'absent' | 'non_working' | 'normal' | string;
short_label?: string;
display_text?: string;
primary_label?: string;
detail_lines?: string[];
issues: string[];
is_workday: boolean | null;
effective_is_workday: boolean | null;
@ -135,6 +176,8 @@ export type MonthlyAttendanceMatrixCell = {
is_absent?: boolean;
personnel_status?: string;
personnel_status_label?: string;
is_manually_edited?: boolean;
manual_edited_at?: string | null;
comments_count: number;
};
@ -171,34 +214,124 @@ export type MonthlyAttendanceMatrixQuery = {
month: string;
};
export function createAttendanceApi(request: RequestFn) {
export type MonthlyAttendanceMatrixExportQuery = {
employee_ids: string;
period_start: string;
period_end: string;
};
export type AttendanceMatrixExportFile = {
blob: Blob;
filename: string;
};
function filenameFromContentDisposition(value: string | null): string | null {
if (!value) return null;
const utfMatch = value.match(/filename\*=UTF-8''([^;]+)/i);
if (utfMatch?.[1]) return decodeURIComponent(utfMatch[1].replace(/"/g, ''));
const match = value.match(/filename="?([^";]+)"?/i);
return match?.[1] || null;
}
export function createAttendanceApi(request: RequestFn, getToken: GetTokenFn) {
return {
analyzeLogStormAttendance: (data: LogStormAttendanceAnalyzePayload) =>
analyzeAttendance: (data: AttendanceAnalyzePayload) =>
request('/api/v1/attendance/logstorm/analyze/', {
method: 'POST',
body: JSON.stringify(data),
}) as Promise<LogStormAttendanceResponse>,
getLogStormAttendanceRecords: (params?: LogStormAttendanceRecordsQuery) =>
request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise<PaginatedLogStormAttendanceRecords>,
}) as Promise<AttendanceAnalysisResponse>,
getAttendanceRecords: (params?: AttendanceRecordsQuery) =>
request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise<PaginatedAttendanceRecords>,
getAttendanceRecord: (recordId: number) =>
request(`/api/v1/attendance/records/${recordId}/`) as Promise<AttendanceRecord>,
getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) =>
request(`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`) as Promise<MonthlyAttendanceMatrix>,
downloadMonthlyAttendanceMatrix: async (
params: MonthlyAttendanceMatrixExportQuery,
): Promise<AttendanceMatrixExportFile> => {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`/api/v1/attendance/monthly-matrix/export/${buildQuery(params)}`, {
method: 'GET',
headers,
});
if (!response.ok) {
let detail = response.statusText;
try {
const payload = await response.json();
detail = payload.detail || JSON.stringify(payload);
} catch {
detail = response.statusText;
}
throw new Error(`API Error: ${response.status} ${detail}`);
}
return {
blob: await response.blob(),
filename: filenameFromContentDisposition(
response.headers.get('content-disposition'),
) || `attendance-${params.period_start}_${params.period_end}.xlsx`,
};
},
getStandardWorkSchedule: () =>
request('/api/v1/attendance/standard-work-schedule/') as Promise<StandardWorkSchedule>,
updateStandardWorkSchedule: (data: Partial<AttendanceSchedulePayload>) =>
request('/api/v1/attendance/standard-work-schedule/', {
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: (
employeeId: number | string,
data: Partial<LogStormSchedulePayload & { is_active: boolean }>,
data: Partial<AttendanceSchedulePayload & { is_active: boolean }>,
) =>
request(`/api/v1/attendance/work-schedules/${employeeId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
}) as Promise<EmployeeWorkSchedule>,
updateAttendanceRecord: (recordId: number, data: LogStormAttendanceRecordUpdatePayload) =>
updateAttendanceRecord: (recordId: number, data: AttendanceRecordUpdatePayload) =>
request(`/api/v1/attendance/records/${recordId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
}) as Promise<LogStormAttendanceRecord>,
}) as Promise<AttendanceRecord>,
getAttendanceRecordComments: (recordId: number) =>
request(`/api/v1/attendance/records/${recordId}/comments/`) as Promise<AttendanceRecordComment[]>,
getAttendanceRecordDayEvents: (recordId: number) =>
request(`/api/v1/attendance/records/${recordId}/day-events/`) as Promise<AttendanceDayEvent[]>,
getAttendanceDayEventPhoto: async (photoUrl: string): Promise<Blob> => {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(photoUrl, { method: 'GET', headers });
if (!response.ok) {
let detail = response.statusText;
try {
const payload = await response.json();
detail = payload.detail || payload.error || JSON.stringify(payload);
} catch {
detail = response.statusText;
}
throw new Error(`API Error: ${response.status} ${detail}`);
}
return response.blob();
},
addAttendanceRecordComment: (recordId: number, text: string) =>
request(`/api/v1/attendance/records/${recordId}/comments/`, {
method: 'POST',

View File

@ -30,7 +30,7 @@ export const apiClient = Object.assign(base, {
...createRequestsApi(req, tok),
...createEquipmentApi(req, tok),
...createProcurementApi(req),
...createAttendanceApi(req),
...createAttendanceApi(req, tok),
});
export default apiClient;