diff --git a/.env.example b/.env.example index 7c23ead0..dd80d406 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,8 @@ NODE_ENV=production # Other Settings # SITE_URL=http://localhost:8000 + +# LogStorm attendance analyzer +LOGSTORM_API_URL=http://localhost:8000 +LOGSTORM_API_TOKEN=change-me +LOGSTORM_TIMEOUT_SECONDS=30 diff --git a/backend/api/v1/attendance/__init__.py b/backend/api/v1/attendance/__init__.py new file mode 100644 index 00000000..755e57dd --- /dev/null +++ b/backend/api/v1/attendance/__init__.py @@ -0,0 +1 @@ +"""Attendance API integration endpoints.""" diff --git a/backend/api/v1/attendance/serializers.py b/backend/api/v1/attendance/serializers.py new file mode 100644 index 00000000..d434a950 --- /dev/null +++ b/backend/api/v1/attendance/serializers.py @@ -0,0 +1,176 @@ +from rest_framework import serializers + +from attendance.models import AttendanceAnalysisRun, AttendanceRecord +from api.v1.employees.serializers import EmployeeBriefSerializer + + +class DateOverrideSerializer(serializers.Serializer): + date = serializers.DateField() + is_workday = serializers.BooleanField() + reason = serializers.CharField(required=False, allow_blank=True) + start_time = serializers.CharField(required=False, allow_blank=True) + end_time = serializers.CharField(required=False, allow_blank=True) + expected_hours = serializers.FloatField(required=False) + + +class LogStormScheduleSerializer(serializers.Serializer): + start_time = serializers.CharField() + end_time = serializers.CharField() + expected_hours = serializers.FloatField() + workdays = serializers.ListField( + child=serializers.CharField(), + required=False, + ) + date_overrides = DateOverrideSerializer(many=True, required=False) + + +class LogStormAttendanceAnalyzeSerializer(serializers.Serializer): + employee_id = serializers.IntegerField() + period_start = serializers.DateField() + period_end = serializers.DateField() + schedule = LogStormScheduleSerializer(required=False) + + 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 AttendanceMonthlyMatrixQuerySerializer(serializers.Serializer): + employee_ids = serializers.CharField() + month = serializers.RegexField(r"^\d{4}-\d{2}$") + + 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_month(self, value): + year, month = [int(part) for part in value.split("-")] + if month < 1 or month > 12: + raise serializers.ValidationError("month must be between 01 and 12") + return value + + +class AttendanceAnalysisRunSerializer(serializers.ModelSerializer): + class Meta: + model = AttendanceAnalysisRun + fields = ( + "id", + "employee", + "period_start", + "period_end", + "status", + "schedule_payload", + "request_payload", + "response_payload", + "error", + "triggered_by", + "created_at", + ) + read_only_fields = fields + + +class AttendanceRecordSerializer(serializers.ModelSerializer): + analysis_run_id = serializers.IntegerField(read_only=True) + comments_count = serializers.IntegerField(read_only=True, default=0) + non_working_reason = serializers.SerializerMethodField() + + class Meta: + model = AttendanceRecord + fields = ( + "id", + "analysis_run_id", + "employee", + "date", + "display_name", + "arrival_time", + "departure_time", + "work_hours", + "expected_hours", + "is_workday", + "effective_is_workday", + "non_working_reason", + "is_late", + "late_minutes", + "is_early_leave", + "early_leave_minutes", + "is_underwork", + "underwork_hours", + "is_overtime", + "overtime_hours", + "is_absent", + "statuses", + "employee_issues", + "technical_issues", + "personnel_status", + "personnel_status_label", + "personnel_action", + "is_manually_edited", + "manual_edit_payload", + "manual_edited_by", + "manual_edited_at", + "raw_data", + "comments_count", + "updated_at", + ) + read_only_fields = fields + + def get_non_working_reason(self, obj): + if obj.effective_is_workday: + return "" + if obj.personnel_status_label: + return obj.personnel_status_label + if not obj.is_workday: + return "Выходной по графику/календарю" + return "Нерабочий день" + + +class AttendanceRecordUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = AttendanceRecord + fields = ( + "arrival_time", + "departure_time", + "work_hours", + "expected_hours", + "is_workday", + "effective_is_workday", + "is_late", + "late_minutes", + "is_early_leave", + "early_leave_minutes", + "is_underwork", + "underwork_hours", + "is_overtime", + "overtime_hours", + "is_absent", + ) + extra_kwargs = { + "arrival_time": {"required": False, "allow_blank": True, "allow_null": True}, + "departure_time": {"required": False, "allow_blank": True, "allow_null": True}, + } + + +class AttendanceRecordCommentSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + record = serializers.IntegerField(read_only=True) + author = EmployeeBriefSerializer(read_only=True) + text = serializers.CharField() + created_at = serializers.DateTimeField(read_only=True) diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py new file mode 100644 index 00000000..42a64cef --- /dev/null +++ b/backend/api/v1/attendance/views.py @@ -0,0 +1,360 @@ +import logging +from calendar import monthrange +from datetime import date + +from attendance.models import AttendanceRecord +from attendance.services import ( + build_monthly_attendance_matrix, + normalize_attendance_record_manual_issues, + save_logstorm_attendance_result, +) +from communications import comments_helpers +from communications.models import Message +from common.logstorm_attendance import ( + analyze_employee_attendance, + build_logstorm_attendance_payload, +) +from common.logstorm_client import LogStormClient, LogStormClientError +from django.contrib.contenttypes.models import ContentType +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 +from django.utils import timezone +from employees.models import Employee +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import ListAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .serializers import ( + AttendanceMonthlyMatrixQuerySerializer, + AttendanceRecordCommentSerializer, + AttendanceRecordSerializer, + AttendanceRecordUpdateSerializer, + LogStormAttendanceAnalyzeSerializer, +) + +logger = logging.getLogger(__name__) + + +def _can_access_employee(user, employee_id) -> bool: + try: + normalized_employee_id = int(employee_id) + except (TypeError, ValueError): + return False + + return bool( + user + and user.is_authenticated + and (user.is_staff or normalized_employee_id == int(user.id)) + ) + + +def _check_employee_access(user, employee_id) -> None: + if not _can_access_employee(user, employee_id): + raise PermissionDenied( + "You don't have permission to access this attendance data" + ) + + +def _records_visible_to(user): + queryset = AttendanceRecord.objects.all() + if user.is_staff: + return queryset + return queryset.filter(employee_id=user.id) + + +def _comments_count_annotation(): + record_ct = ContentType.objects.get_for_model(AttendanceRecord) + comments_subquery = ( + Message.objects.filter( + chat__type="comments", + chat__context_content_type=record_ct, + chat__context_object_id=OuterRef("pk"), + is_deleted=False, + ) + .values("chat") + .annotate(count=Count("id")) + .values("count") + ) + return { + "comments_count": Coalesce( + Subquery(comments_subquery, output_field=IntegerField()), + Value(0), + ) + } + + +class LogStormAttendanceAnalyzeAPIView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = LogStormAttendanceAnalyzeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + _check_employee_access(request.user, data["employee_id"]) + + try: + employee = Employee.objects.get(pk=data["employee_id"]) + except Employee.DoesNotExist: + return Response( + {"detail": "Employee not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # TODO: когда schedule UI сможет явно хранить рабочие/выходные дни, + # формировать schedule/date_overrides здесь из EUSRR-календаря, а не + # полагаться на ручной payload или fallback LogStorm. + try: + result = analyze_employee_attendance( + employee=employee, + period_start=data["period_start"], + period_end=data["period_end"], + schedule=data.get("schedule"), + client=None, + ) + except LogStormClientError as exc: + logger.warning("LogStorm attendance analysis failed: %s", exc) + return Response( + {"detail": str(exc), "error": "logstorm_unavailable"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + request_payload = build_logstorm_attendance_payload( + employee=employee, + period_start=data["period_start"], + period_end=data["period_end"], + schedule=data.get("schedule"), + ) + save_logstorm_attendance_result( + employee=employee, + period_start=data["period_start"], + period_end=data["period_end"], + schedule_payload=request_payload.get("schedule"), + request_payload=request_payload, + response_payload=result, + triggered_by=request.user, + ) + + return Response(result) + + +class AttendanceRecordListAPIView(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = AttendanceRecordSerializer + + def get_queryset(self): + queryset = ( + _records_visible_to(self.request.user) + .select_related("employee", "analysis_run") + .annotate(**_comments_count_annotation()) + .all() + .order_by("-date", "-id") + ) + + employee_id = self.request.query_params.get("employee_id") + if employee_id: + _check_employee_access(self.request.user, employee_id) + queryset = queryset.filter(employee_id=employee_id) + + date_from = self.request.query_params.get("date_from") + if date_from: + queryset = queryset.filter(date__gte=date_from) + + date_to = self.request.query_params.get("date_to") + if date_to: + queryset = queryset.filter(date__lte=date_to) + + return queryset + + +class AttendanceMonthlyMatrixAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + serializer = AttendanceMonthlyMatrixQuerySerializer( + data=request.query_params, + ) + serializer.is_valid(raise_exception=True) + employee_ids = serializer.validated_data["employee_ids"] + year, month = [ + int(part) for part in serializer.validated_data["month"].split("-") + ] + + for employee_id in employee_ids: + _check_employee_access(request.user, employee_id) + + 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 + ] + + start_date = date(year, month, 1) + end_date = date(year, month, monthrange(year, month)[1]) + records = list( + _records_visible_to(request.user) + .filter( + employee_id__in=[employee.id for employee in employees], + date__gte=start_date, + date__lte=end_date, + ) + .select_related("employee") + .annotate(**_comments_count_annotation()) + .order_by("employee_id", "date", "id") + ) + + return Response( + build_monthly_attendance_matrix( + employees=employees, + records=records, + year=year, + month=month, + ) + ) + + +class AttendanceRecordDetailAPIView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, record_id): + if not request.user.is_staff: + raise PermissionDenied( + "You don't have permission to edit attendance records" + ) + + record = get_object_or_404( + _records_visible_to(request.user) + .select_related("employee", "analysis_run") + .annotate(**_comments_count_annotation()), + pk=record_id, + ) + serializer = AttendanceRecordUpdateSerializer( + record, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + manual_payload = dict(serializer.validated_data) + + try: + logstorm_override = LogStormClient().update_attendance_override( + employee_id=str(record.employee_id), + record_date=record.date, + payload={**manual_payload, "source": "eusrr"}, + ) + except LogStormClientError as exc: + logger.warning("LogStorm attendance override failed: %s", exc) + return Response( + {"detail": str(exc), "error": "logstorm_unavailable"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + record = serializer.save( + is_manually_edited=True, + manual_edit_payload=manual_payload, + manual_edited_by=request.user, + manual_edited_at=timezone.now(), + ) + normalize_attendance_record_manual_issues(record, manual_payload) + record.raw_data = { + **record.raw_data, + "manual_edited": True, + "manual_edit_payload": manual_payload, + "logstorm_override": logstorm_override, + } + record.save( + update_fields=[ + "statuses", + "employee_issues", + "raw_data", + "updated_at", + ] + ) + + record = ( + _records_visible_to(request.user) + .select_related("employee", "analysis_run") + .annotate(**_comments_count_annotation()) + .get(pk=record.pk) + ) + return Response(AttendanceRecordSerializer(record).data) + + +class AttendanceRecordCommentsAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get_record(self, request, record_id): + return get_object_or_404(_records_visible_to(request.user), pk=record_id) + + @staticmethod + def serialize_message(record, message): + payload = { + "id": message.id, + "record": record.id, + "author": message.author, + "text": message.content, + "created_at": message.created_at, + } + return AttendanceRecordCommentSerializer(payload).data + + def get(self, request, record_id): + record = self.get_record(request, record_id) + messages = comments_helpers.get_comments(record) + return Response( + [self.serialize_message(record, message) for message in messages] + ) + + def post(self, request, record_id): + record = self.get_record(request, record_id) + serializer = AttendanceRecordCommentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + message = comments_helpers.create_comment( + obj=record, + author=request.user, + content=serializer.validated_data["text"].strip(), + ) + + return Response( + self.serialize_message(record, message), + status=status.HTTP_201_CREATED, + ) + + +class AttendanceRecordCommentDetailAPIView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, record_id, comment_id): + record = get_object_or_404(_records_visible_to(request.user), pk=record_id) + chat = comments_helpers.get_comments_chat_if_exists(record) + if chat is None: + return Response( + {"detail": "Comment not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + message = Message.objects.get(id=comment_id, chat=chat) + except Message.DoesNotExist: + return Response( + {"detail": "Comment not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if message.author != request.user: + return Response( + {"detail": "You don't have permission to delete this comment"}, + status=status.HTTP_403_FORBIDDEN, + ) + + comments_helpers.delete_comment( + message=message, + deleted_by=request.user, + soft_delete=True, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 68682177..0905dcb5 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -29,6 +29,14 @@ from .employees.views import ( from .feed.views import PostViewSet from .requests_app.views import RequestViewSet from .search.views import search_api_view +from .attendance.views import ( + AttendanceMonthlyMatrixAPIView, + AttendanceRecordCommentDetailAPIView, + AttendanceRecordCommentsAPIView, + AttendanceRecordDetailAPIView, + AttendanceRecordListAPIView, + LogStormAttendanceAnalyzeAPIView, +) app_name = "v1" @@ -88,6 +96,36 @@ router.register(r"communications/polls", PollViewSet, basename="polls") urlpatterns = [ path("auth/", include("api.auth.urls")), + path( + "attendance/logstorm/analyze/", + LogStormAttendanceAnalyzeAPIView.as_view(), + name="logstorm-attendance-analyze", + ), + path( + "attendance/records/", + AttendanceRecordListAPIView.as_view(), + name="attendance-records", + ), + path( + "attendance/monthly-matrix/", + AttendanceMonthlyMatrixAPIView.as_view(), + name="attendance-monthly-matrix", + ), + path( + "attendance/records//", + AttendanceRecordDetailAPIView.as_view(), + name="attendance-record-detail", + ), + path( + "attendance/records//comments/", + AttendanceRecordCommentsAPIView.as_view(), + name="attendance-record-comments", + ), + path( + "attendance/records//comments//", + AttendanceRecordCommentDetailAPIView.as_view(), + name="attendance-record-comment-detail", + ), path("directory/", include("api.v1.directory.urls")), # Notifications API (из самого модуля notifications) path("notifications/", include("notifications.api.urls")), diff --git a/backend/attendance/__init__.py b/backend/attendance/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/attendance/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/attendance/admin.py b/backend/attendance/admin.py new file mode 100644 index 00000000..9cec6532 --- /dev/null +++ b/backend/attendance/admin.py @@ -0,0 +1,44 @@ +from django.contrib import admin + +from attendance.models import AttendanceAnalysisRun, AttendanceRecord + + +@admin.register(AttendanceAnalysisRun) +class AttendanceAnalysisRunAdmin(admin.ModelAdmin): + list_display = ( + "id", + "employee", + "period_start", + "period_end", + "status", + "triggered_by", + "created_at", + ) + list_filter = ("status", "created_at") + search_fields = ("employee__email", "employee__first_name", "employee__last_name") + readonly_fields = ("created_at",) + + +@admin.register(AttendanceRecord) +class AttendanceRecordAdmin(admin.ModelAdmin): + list_display = ( + "employee", + "date", + "is_workday", + "arrival_time", + "departure_time", + "work_hours", + "is_late", + "is_absent", + "is_overtime", + "updated_at", + ) + list_filter = ( + "is_workday", + "is_late", + "is_absent", + "is_overtime", + "date", + ) + search_fields = ("employee__email", "employee__first_name", "employee__last_name") + readonly_fields = ("updated_at",) diff --git a/backend/attendance/apps.py b/backend/attendance/apps.py new file mode 100644 index 00000000..d799e274 --- /dev/null +++ b/backend/attendance/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AttendanceConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "attendance" + verbose_name = "Посещаемость" diff --git a/backend/attendance/migrations/0001_initial.py b/backend/attendance/migrations/0001_initial.py new file mode 100644 index 00000000..104bee35 --- /dev/null +++ b/backend/attendance/migrations/0001_initial.py @@ -0,0 +1,260 @@ +# Generated by Django 5.2.4 on 2026-04-21 14:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AttendanceAnalysisRun", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("period_start", models.DateField(verbose_name="Начало периода")), + ("period_end", models.DateField(verbose_name="Конец периода")), + ( + "status", + models.CharField( + choices=[("success", "Успешно"), ("failed", "Ошибка")], + default="success", + max_length=16, + verbose_name="Статус", + ), + ), + ( + "schedule_payload", + models.JSONField(blank=True, null=True, verbose_name="График"), + ), + ( + "request_payload", + models.JSONField(blank=True, default=dict, verbose_name="Запрос"), + ), + ( + "response_payload", + models.JSONField(blank=True, default=dict, verbose_name="Ответ"), + ), + ("error", models.TextField(blank=True, verbose_name="Ошибка")), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attendance_analysis_runs", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "triggered_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_attendance_analysis_runs", + to=settings.AUTH_USER_MODEL, + verbose_name="Запустил", + ), + ), + ], + options={ + "verbose_name": "Запуск анализа посещаемости", + "verbose_name_plural": "Запуски анализа посещаемости", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="AttendanceRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateField(verbose_name="Дата")), + ( + "display_name", + models.CharField( + blank=True, max_length=255, verbose_name="Имя из анализа" + ), + ), + ( + "arrival_time", + models.CharField( + blank=True, + max_length=32, + null=True, + verbose_name="Время прихода", + ), + ), + ( + "departure_time", + models.CharField( + blank=True, max_length=32, null=True, verbose_name="Время ухода" + ), + ), + ( + "work_hours", + models.FloatField( + blank=True, null=True, verbose_name="Отработано часов" + ), + ), + ( + "expected_hours", + models.FloatField( + blank=True, null=True, verbose_name="Ожидаемо часов" + ), + ), + ( + "is_workday", + models.BooleanField(default=True, verbose_name="Рабочий день"), + ), + ( + "is_late", + models.BooleanField(default=False, verbose_name="Опоздание"), + ), + ( + "late_minutes", + models.IntegerField( + blank=True, null=True, verbose_name="Минут опоздания" + ), + ), + ( + "is_early_leave", + models.BooleanField(default=False, verbose_name="Ранний уход"), + ), + ( + "early_leave_minutes", + models.IntegerField( + blank=True, null=True, verbose_name="Минут раннего ухода" + ), + ), + ( + "is_underwork", + models.BooleanField(default=False, verbose_name="Недоработка"), + ), + ( + "underwork_hours", + models.FloatField( + blank=True, null=True, verbose_name="Часов недоработки" + ), + ), + ( + "is_overtime", + models.BooleanField(default=False, verbose_name="Переработка"), + ), + ( + "overtime_hours", + models.FloatField( + blank=True, null=True, verbose_name="Часов переработки" + ), + ), + ( + "is_absent", + models.BooleanField(default=False, verbose_name="Отсутствие"), + ), + ( + "statuses", + models.JSONField(blank=True, default=list, verbose_name="Статусы"), + ), + ( + "employee_issues", + models.JSONField( + blank=True, default=list, verbose_name="Проблемы сотрудника" + ), + ), + ( + "technical_issues", + models.JSONField( + blank=True, default=list, verbose_name="Технические проблемы" + ), + ), + ( + "raw_data", + models.JSONField( + blank=True, default=dict, verbose_name="Исходная запись анализа" + ), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "analysis_run", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="records", + to="attendance.attendanceanalysisrun", + verbose_name="Запуск анализа", + ), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attendance_records", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ], + options={ + "verbose_name": "Запись посещаемости", + "verbose_name_plural": "Записи посещаемости", + "ordering": ["-date", "-id"], + }, + ), + migrations.AddIndex( + model_name="attendanceanalysisrun", + index=models.Index( + fields=["employee", "period_start", "period_end"], + name="attendance__employe_3d4cb2_idx", + ), + ), + migrations.AddIndex( + model_name="attendanceanalysisrun", + index=models.Index( + fields=["created_at"], name="attendance__created_9a0a3e_idx" + ), + ), + migrations.AddIndex( + model_name="attendancerecord", + index=models.Index( + fields=["employee", "date"], name="attendance__employe_b6a945_idx" + ), + ), + migrations.AddIndex( + model_name="attendancerecord", + index=models.Index( + fields=["analysis_run"], name="attendance__analysi_db7fa7_idx" + ), + ), + migrations.AddConstraint( + model_name="attendancerecord", + constraint=models.UniqueConstraint( + fields=("employee", "date"), + name="attendance_record_employee_date_unique", + ), + ), + ] diff --git a/backend/attendance/migrations/0002_attendancerecord_effective_is_workday_and_more.py b/backend/attendance/migrations/0002_attendancerecord_effective_is_workday_and_more.py new file mode 100644 index 00000000..acf4649f --- /dev/null +++ b/backend/attendance/migrations/0002_attendancerecord_effective_is_workday_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.4 on 2026-04-21 17:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("attendance", "0001_initial"), + ("employees", "0048_alter_employeeaction_action_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="attendancerecord", + name="effective_is_workday", + field=models.BooleanField( + default=True, verbose_name="Рабочий день с учетом кадрового состояния" + ), + ), + migrations.AddField( + model_name="attendancerecord", + name="personnel_action", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attendance_records", + to="employees.employeeaction", + verbose_name="Кадровое событие", + ), + ), + migrations.AddField( + model_name="attendancerecord", + name="personnel_status", + field=models.CharField( + blank=True, + default="normal", + max_length=64, + verbose_name="Кадровое состояние", + ), + ), + migrations.AddField( + model_name="attendancerecord", + name="personnel_status_label", + field=models.CharField( + blank=True, + max_length=128, + verbose_name="Кадровое состояние для отображения", + ), + ), + migrations.AddIndex( + model_name="attendancerecord", + index=models.Index( + fields=["personnel_status"], name="attendance__personn_dbbd98_idx" + ), + ), + ] diff --git a/backend/attendance/migrations/0003_attendancerecord_manual_edit.py b/backend/attendance/migrations/0003_attendancerecord_manual_edit.py new file mode 100644 index 00000000..01e87c65 --- /dev/null +++ b/backend/attendance/migrations/0003_attendancerecord_manual_edit.py @@ -0,0 +1,57 @@ +# Generated by Codex on 2026-04-22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("attendance", "0002_attendancerecord_effective_is_workday_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="attendancerecord", + name="is_manually_edited", + field=models.BooleanField(default=False, verbose_name="Изменено вручную"), + ), + migrations.AddField( + model_name="attendancerecord", + name="manual_edit_payload", + field=models.JSONField( + blank=True, + default=dict, + verbose_name="Ручная корректировка", + ), + ), + migrations.AddField( + model_name="attendancerecord", + name="manual_edited_at", + field=models.DateTimeField( + blank=True, + null=True, + verbose_name="Время ручного изменения", + ), + ), + migrations.AddField( + model_name="attendancerecord", + name="manual_edited_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="manual_attendance_edits", + to=settings.AUTH_USER_MODEL, + verbose_name="Изменил вручную", + ), + ), + migrations.AddIndex( + model_name="attendancerecord", + index=models.Index( + fields=["is_manually_edited"], + name="attendance__is_manu_07f532_idx", + ), + ), + ] diff --git a/backend/attendance/migrations/__init__.py b/backend/attendance/migrations/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/attendance/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/attendance/models.py b/backend/attendance/models.py new file mode 100644 index 00000000..c4e5e9dd --- /dev/null +++ b/backend/attendance/models.py @@ -0,0 +1,171 @@ +from django.conf import settings +from django.db import models + + +class AttendanceAnalysisRun(models.Model): + STATUS_SUCCESS = "success" + STATUS_FAILED = "failed" + STATUS_CHOICES = ( + (STATUS_SUCCESS, "Успешно"), + (STATUS_FAILED, "Ошибка"), + ) + + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="attendance_analysis_runs", + verbose_name="Сотрудник", + ) + period_start = models.DateField("Начало периода") + period_end = models.DateField("Конец периода") + status = models.CharField( + "Статус", + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_SUCCESS, + ) + schedule_payload = models.JSONField("График", null=True, blank=True) + request_payload = models.JSONField("Запрос", default=dict, blank=True) + response_payload = models.JSONField("Ответ", default=dict, blank=True) + error = models.TextField("Ошибка", blank=True) + triggered_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triggered_attendance_analysis_runs", + verbose_name="Запустил", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + verbose_name = "Запуск анализа посещаемости" + verbose_name_plural = "Запуски анализа посещаемости" + indexes = [ + models.Index(fields=["employee", "period_start", "period_end"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self) -> str: + return f"{self.employee_id}: {self.period_start} - {self.period_end}" + + +class AttendanceRecord(models.Model): + analysis_run = models.ForeignKey( + AttendanceAnalysisRun, + on_delete=models.CASCADE, + related_name="records", + verbose_name="Запуск анализа", + ) + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="attendance_records", + verbose_name="Сотрудник", + ) + date = models.DateField("Дата") + display_name = models.CharField("Имя из анализа", max_length=255, blank=True) + arrival_time = models.CharField( + "Время прихода", + max_length=32, + blank=True, + null=True, + ) + departure_time = models.CharField( + "Время ухода", + max_length=32, + blank=True, + null=True, + ) + work_hours = models.FloatField("Отработано часов", null=True, blank=True) + expected_hours = models.FloatField("Ожидаемо часов", null=True, blank=True) + is_workday = models.BooleanField("Рабочий день", default=True) + is_late = models.BooleanField("Опоздание", default=False) + late_minutes = models.IntegerField("Минут опоздания", null=True, blank=True) + is_early_leave = models.BooleanField("Ранний уход", default=False) + early_leave_minutes = models.IntegerField( + "Минут раннего ухода", + null=True, + blank=True, + ) + is_underwork = models.BooleanField("Недоработка", default=False) + underwork_hours = models.FloatField("Часов недоработки", null=True, blank=True) + is_overtime = models.BooleanField("Переработка", default=False) + overtime_hours = models.FloatField("Часов переработки", null=True, blank=True) + is_absent = models.BooleanField("Отсутствие", default=False) + statuses = models.JSONField("Статусы", default=list, blank=True) + employee_issues = models.JSONField("Проблемы сотрудника", default=list, blank=True) + technical_issues = models.JSONField( + "Технические проблемы", default=list, blank=True + ) + personnel_status = models.CharField( + "Кадровое состояние", + max_length=64, + default="normal", + blank=True, + ) + personnel_status_label = models.CharField( + "Кадровое состояние для отображения", + max_length=128, + blank=True, + ) + personnel_action = models.ForeignKey( + "employees.EmployeeAction", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="attendance_records", + verbose_name="Кадровое событие", + ) + effective_is_workday = models.BooleanField( + "Рабочий день с учетом кадрового состояния", + default=True, + ) + is_manually_edited = models.BooleanField( + "Изменено вручную", + default=False, + ) + manual_edit_payload = models.JSONField( + "Ручная корректировка", + default=dict, + blank=True, + ) + manual_edited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="manual_attendance_edits", + verbose_name="Изменил вручную", + ) + manual_edited_at = models.DateTimeField( + "Время ручного изменения", + null=True, + blank=True, + ) + raw_data = models.JSONField("Исходная запись анализа", default=dict, blank=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + ordering = ["-date", "-id"] + verbose_name = "Запись посещаемости" + verbose_name_plural = "Записи посещаемости" + constraints = [ + models.UniqueConstraint( + fields=["employee", "date"], + name="attendance_record_employee_date_unique", + ), + ] + indexes = [ + models.Index(fields=["employee", "date"]), + models.Index(fields=["analysis_run"]), + models.Index(fields=["personnel_status"]), + models.Index( + fields=["is_manually_edited"], + name="attendance__is_manu_07f532_idx", + ), + ] + + def __str__(self) -> str: + return f"{self.employee_id}: {self.date}" diff --git a/backend/attendance/services.py b/backend/attendance/services.py new file mode 100644 index 00000000..79c50f1b --- /dev/null +++ b/backend/attendance/services.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from calendar import monthrange +from typing import Any + +from django.db import transaction +from django.utils.dateparse import parse_date + +from attendance.models import AttendanceAnalysisRun, AttendanceRecord +from employees.constants import ( + ACTION_DISMISSED, + ACTION_ON_DAY_OFF, + ACTION_ON_LEAVE, + ACTION_ON_MATERNITY, + ACTION_ON_SICK_LEAVE, +) +from employees.models import Employee + +PERSONNEL_STATUS_NORMAL = "normal" + +MONTHS_RU = { + 1: "Январь", + 2: "Февраль", + 3: "Март", + 4: "Апрель", + 5: "Май", + 6: "Июнь", + 7: "Июль", + 8: "Август", + 9: "Сентябрь", + 10: "Октябрь", + 11: "Ноябрь", + 12: "Декабрь", +} + +DAYS_RU = { + 0: "Пн", + 1: "Вт", + 2: "Ср", + 3: "Чт", + 4: "Пт", + 5: "Сб", + 6: "Вс", +} + +NON_WORKING_PERSONNEL_ACTIONS = { + ACTION_ON_LEAVE, + ACTION_ON_SICK_LEAVE, + ACTION_ON_DAY_OFF, + ACTION_ON_MATERNITY, + ACTION_DISMISSED, +} + +SUPPRESSED_EMPLOYEE_ISSUE_MARKERS = ( + "absence", + "absent", + "late", + "early", + "underwork", + "отсутств", + "опозд", + "ранн", + "недоработ", +) + + +@dataclass(frozen=True) +class PersonnelDayState: + status: str = PERSONNEL_STATUS_NORMAL + label: str = "" + action_id: int | None = None + is_non_working: bool = False + + +def save_logstorm_attendance_result( + *, + employee: Employee, + period_start: date, + period_end: date, + request_payload: dict[str, Any], + response_payload: dict[str, Any], + schedule_payload: dict[str, Any] | None = None, + triggered_by: Employee | None = None, +) -> AttendanceAnalysisRun: + records = response_payload.get("records") + if not isinstance(records, list): + records = [] + + personnel_actions = list( + employee.actions.filter(date__date__lte=period_end).order_by("date", "id") + ) + + with transaction.atomic(): + run = AttendanceAnalysisRun.objects.create( + employee=employee, + period_start=period_start, + period_end=period_end, + status=AttendanceAnalysisRun.STATUS_SUCCESS, + schedule_payload=schedule_payload, + request_payload=request_payload, + response_payload=response_payload, + triggered_by=triggered_by + if getattr(triggered_by, "is_authenticated", False) + else None, + ) + + for raw_record in records: + if not isinstance(raw_record, dict): + continue + + record_date = _parse_record_date(raw_record.get("date")) + if record_date is None: + continue + personnel_state = resolve_employee_day_state( + personnel_actions, + record_date, + ) + record_defaults = build_attendance_record_defaults( + raw_record, + personnel_state, + ) + existing_record = AttendanceRecord.objects.filter( + employee=employee, + date=record_date, + ).first() + if ( + existing_record + and existing_record.is_manually_edited + and not raw_record.get("manual_edited") + ): + record_defaults = apply_manual_edit_payload_to_defaults( + record_defaults, + existing_record.manual_edit_payload, + ) + record_defaults.update( + { + "is_manually_edited": True, + "manual_edit_payload": existing_record.manual_edit_payload, + "manual_edited_by": existing_record.manual_edited_by, + "manual_edited_at": existing_record.manual_edited_at, + } + ) + + AttendanceRecord.objects.update_or_create( + employee=employee, + date=record_date, + defaults={"analysis_run": run, **record_defaults}, + ) + + return run + + +def build_monthly_attendance_matrix( + *, + employees, + records, + year: int, + month: int, +) -> dict[str, Any]: + """Build an Excel-like monthly attendance matrix for API/UI usage.""" + employees = list(employees) + records_by_key = { + (record.employee_id, record.date): record + for record in records + } + days_in_month = monthrange(year, month)[1] + dates = [date(year, month, day) for day in range(1, days_in_month + 1)] + + rows = [] + for current_date in dates: + row = { + "date": current_date.isoformat(), + "label": f"{current_date.day} ({DAYS_RU[current_date.weekday()]})", + "weekday": DAYS_RU[current_date.weekday()], + "is_weekend": current_date.weekday() >= 5, + "cells": {}, + } + for employee in employees: + record = records_by_key.get((employee.id, current_date)) + row["cells"][str(employee.id)] = _monthly_matrix_cell(record) + rows.append(row) + + summary_rows = [ + ("accounted_days", "Учтено(дней)", _summary_accounted_days), + ("unaccounted_days", "Неучтено(дней)", _summary_unaccounted_days), + ("worked_hours", "Отработано(часов)", _summary_worked_hours), + ("late_days", "Опозданий", _summary_late_days), + ("early_leave_days", "Ранних уходов", _summary_early_leave_days), + ("overtime_days", "Переработок", _summary_overtime_days), + ("absent_days", "Отсутствий", _summary_absent_days), + ] + summary = [] + for key, label, counter in summary_rows: + summary.append( + { + "key": key, + "label": label, + "values": { + str(employee.id): counter( + [ + records_by_key[(employee.id, current_date)] + for current_date in dates + if (employee.id, current_date) in records_by_key + ] + ) + for employee in employees + }, + } + ) + + return { + "month": f"{year:04d}-{month:02d}", + "month_label": f"{MONTHS_RU[month]} {year}", + "employees": [ + { + "id": employee.id, + "name": _employee_display_name(employee), + "email": getattr(employee, "email", "") or "", + } + for employee in employees + ], + "rows": rows, + "summary": summary, + } + + +def resolve_employee_day_state( + personnel_actions, + record_date: date, +) -> PersonnelDayState: + day_actions = [ + action + for action in personnel_actions + if action.date and action.date.date() <= record_date + ] + if not day_actions: + return PersonnelDayState() + + action = day_actions[-1] + if action.action not in NON_WORKING_PERSONNEL_ACTIONS: + return PersonnelDayState(action_id=action.id) + + return PersonnelDayState( + status=action.action, + label=action.get_action_display(), + action_id=action.id, + is_non_working=True, + ) + + +def build_attendance_record_defaults( + raw_record: dict[str, Any], + personnel_state: PersonnelDayState, +) -> dict[str, Any]: + work_hours = _nullable_float(raw_record.get("work_hours")) + has_work = bool( + _nullable_string(raw_record.get("arrival_time")) + or _nullable_string(raw_record.get("departure_time")) + or (work_hours is not None and work_hours > 0) + ) + is_workday = bool(raw_record.get("is_workday", True)) + if "effective_is_workday" in raw_record: + effective_is_workday = bool(raw_record.get("effective_is_workday")) + else: + effective_is_workday = is_workday and not personnel_state.is_non_working + is_overtime = bool(raw_record.get("is_overtime", False)) + overtime_hours = _nullable_float(raw_record.get("overtime_hours")) + + is_late = bool(raw_record.get("is_late", False)) + late_minutes = _nullable_int(raw_record.get("late_minutes")) + is_early_leave = bool(raw_record.get("is_early_leave", False)) + early_leave_minutes = _nullable_int(raw_record.get("early_leave_minutes")) + is_underwork = bool(raw_record.get("is_underwork", False)) + underwork_hours = _nullable_float(raw_record.get("underwork_hours")) + is_absent = bool(raw_record.get("is_absent", False)) + + statuses = _list_value(raw_record.get("statuses")) + employee_issues = _list_value(raw_record.get("employee_issues")) + technical_issues = _list_value(raw_record.get("technical_issues")) + non_working_reason = _non_working_reason( + is_workday=is_workday, + effective_is_workday=effective_is_workday, + personnel_state=personnel_state, + ) + + if not effective_is_workday: + is_late = False + late_minutes = None + is_early_leave = False + early_leave_minutes = None + is_underwork = False + underwork_hours = None + is_absent = False + statuses = _suppress_personnel_non_working_issues(statuses) + employee_issues = _suppress_personnel_non_working_issues(employee_issues) + technical_issues = _suppress_personnel_non_working_issues(technical_issues) + + if has_work: + is_overtime = True + if overtime_hours is None: + overtime_hours = work_hours + statuses = _append_unique(statuses, "work_outside_personnel_schedule") + + return { + "display_name": str(raw_record.get("display_name") or ""), + "arrival_time": _nullable_string(raw_record.get("arrival_time")), + "departure_time": _nullable_string(raw_record.get("departure_time")), + "work_hours": work_hours, + "expected_hours": _nullable_float(raw_record.get("expected_hours")), + "is_workday": is_workday, + "effective_is_workday": effective_is_workday, + "is_late": is_late, + "late_minutes": late_minutes, + "is_early_leave": is_early_leave, + "early_leave_minutes": early_leave_minutes, + "is_underwork": is_underwork, + "underwork_hours": underwork_hours, + "is_overtime": is_overtime, + "overtime_hours": overtime_hours, + "is_absent": is_absent, + "statuses": statuses, + "employee_issues": employee_issues, + "technical_issues": technical_issues, + "personnel_status": personnel_state.status, + "personnel_status_label": personnel_state.label, + "personnel_action_id": personnel_state.action_id, + "is_manually_edited": bool(raw_record.get("manual_edited", False)), + "manual_edit_payload": ( + raw_record.get("manual_edit_payload") + if isinstance(raw_record.get("manual_edit_payload"), dict) + else {} + ), + "raw_data": {**raw_record, "non_working_reason": non_working_reason}, + } + + +def apply_manual_edit_payload_to_defaults( + defaults: dict[str, Any], + payload: dict[str, Any], +) -> dict[str, Any]: + """Apply saved manual fields to a freshly analyzed record payload.""" + if not isinstance(payload, dict): + return defaults + + result = dict(defaults) + editable_fields = { + "arrival_time", + "departure_time", + "work_hours", + "expected_hours", + "is_workday", + "effective_is_workday", + "is_late", + "late_minutes", + "is_early_leave", + "early_leave_minutes", + "is_underwork", + "underwork_hours", + "is_overtime", + "overtime_hours", + "is_absent", + } + for field in editable_fields: + if field in payload: + result[field] = payload[field] + + _normalize_issues_after_manual_payload(result, payload) + result["raw_data"] = { + **result.get("raw_data", {}), + "manual_edited": True, + "manual_edit_payload": { + key: value + for key, value in payload.items() + if key in editable_fields + }, + } + return result + + +def normalize_attendance_record_manual_issues( + record: AttendanceRecord, + payload: dict[str, Any], +) -> None: + fields = { + "statuses": list(record.statuses), + "employee_issues": list(record.employee_issues), + } + _normalize_issues_after_manual_payload(fields, payload) + record.statuses = fields["statuses"] + record.employee_issues = fields["employee_issues"] + + +def _normalize_issues_after_manual_payload( + defaults: dict[str, Any], + payload: dict[str, Any], +) -> None: + markers_by_flag = { + "is_late": ("late", "опозд"), + "is_early_leave": ("early", "ранн"), + "is_underwork": ("underwork", "недоработ"), + "is_absent": ("absence", "absent", "отсутств"), + } + for flag, markers in markers_by_flag.items(): + if payload.get(flag) is not False: + continue + for field in ("statuses", "employee_issues"): + defaults[field] = [ + item + for item in _list_value(defaults.get(field)) + if not any(marker in str(item).lower() for marker in markers) + ] + + +def _parse_record_date(value: Any) -> date | None: + if isinstance(value, date): + return value + if value is None: + return None + return parse_date(str(value)) + + +def _nullable_string(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value) + + +def _nullable_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _nullable_int(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _list_value(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _suppress_personnel_non_working_issues(values: list[Any]) -> list[Any]: + result = [] + for value in values: + normalized = str(value).lower() + if any(marker in normalized for marker in SUPPRESSED_EMPLOYEE_ISSUE_MARKERS): + continue + result.append(value) + return result + + +def _append_unique(values: list[Any], value: str) -> list[Any]: + if value in values: + return values + return [*values, value] + + +def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]: + if record is None: + return { + "record_id": None, + "arrival_time": None, + "departure_time": None, + "work_hours": None, + "expected_hours": None, + "status": "empty", + "issues": [], + "is_workday": None, + "effective_is_workday": None, + "non_working_reason": "", + "comments_count": 0, + } + + issues = [ + *record.statuses, + *record.employee_issues, + *record.technical_issues, + ] + comments_count = getattr(record, "comments_count", 0) or 0 + 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), + "issues": issues, + "is_workday": record.is_workday, + "effective_is_workday": record.effective_is_workday, + "non_working_reason": record.raw_data.get("non_working_reason") + or _record_non_working_reason(record), + "is_late": record.is_late, + "is_early_leave": record.is_early_leave, + "is_underwork": record.is_underwork, + "is_overtime": record.is_overtime, + "is_absent": record.is_absent, + "personnel_status": record.personnel_status, + "personnel_status_label": record.personnel_status_label, + "comments_count": comments_count, + } + + +def _monthly_matrix_status(record: AttendanceRecord) -> str: + if not record.effective_is_workday: + if record.is_overtime: + return "overtime" + return "non_working" + if record.technical_issues: + return "technical" + if record.is_underwork: + return "underwork" + if record.is_late: + return "late" + if record.is_overtime: + return "overtime" + if record.is_absent: + return "absent" + return "normal" + + +def _non_working_reason( + *, + is_workday: bool, + effective_is_workday: bool, + personnel_state: PersonnelDayState, +) -> str: + if effective_is_workday: + return "" + if personnel_state.is_non_working: + return personnel_state.label or "Кадровое нерабочее состояние" + if not is_workday: + return "Выходной по графику/календарю" + return "Нерабочий день" + + +def _record_non_working_reason(record: AttendanceRecord) -> str: + if record.effective_is_workday: + return "" + if record.personnel_status_label: + return record.personnel_status_label + if not record.is_workday: + return "Выходной по графику/календарю" + return "Нерабочий день" + + +def _is_valid_workday_record(record: AttendanceRecord) -> bool: + return bool(record.effective_is_workday and not record.technical_issues) + + +def _summary_accounted_days(records: list[AttendanceRecord]) -> int: + return sum(1 for record in records if _is_valid_workday_record(record)) + + +def _summary_unaccounted_days(records: list[AttendanceRecord]) -> int: + return sum(1 for record in records if record.effective_is_workday and record.technical_issues) + + +def _summary_worked_hours(records: list[AttendanceRecord]) -> float: + total = sum( + record.work_hours or 0 + for record in records + if _is_valid_workday_record(record) + ) + return round(total, 2) + + +def _summary_late_days(records: list[AttendanceRecord]) -> int: + return sum(1 for record in records if _is_valid_workday_record(record) and record.is_late) + + +def _summary_early_leave_days(records: list[AttendanceRecord]) -> int: + return sum( + 1 + for record in records + if _is_valid_workday_record(record) and record.is_early_leave + ) + + +def _summary_overtime_days(records: list[AttendanceRecord]) -> int: + return sum(1 for record in records if _is_valid_workday_record(record) and record.is_overtime) + + +def _summary_absent_days(records: list[AttendanceRecord]) -> int: + return sum(1 for record in records if _is_valid_workday_record(record) and record.is_absent) + + +def _employee_display_name(employee: Employee) -> str: + full_name = f"{employee.last_name or ''} {employee.first_name or ''}".strip() + return full_name or employee.email or f"#{employee.id}" diff --git a/backend/common/logstorm_attendance.py b/backend/common/logstorm_attendance.py new file mode 100644 index 00000000..da8167a8 --- /dev/null +++ b/backend/common/logstorm_attendance.py @@ -0,0 +1,109 @@ +"""Helpers for building EUSRR -> LogStorm attendance requests.""" + +from datetime import date +from typing import Any, Optional + +from employees.models import Employee + +from common.logstorm_client import LogStormClient + + +DEFAULT_WORKDAYS = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", +] + + +def build_logstorm_attendance_payload( + *, + employee: Employee, + period_start: date, + period_end: date, + schedule: Optional[dict[str, Any]] = None, + date_overrides: Optional[list[dict[str, Any]]] = None, +) -> dict[str, Any]: + """ + Build a LogStorm attendance analysis payload. + + If schedule is omitted, the payload intentionally omits it so LogStorm can + apply its configured default schedule or reject the request by policy. + """ + payload = { + "employee_id": str(employee.id), + "display_name": _employee_display_name(employee), + "period_start": period_start.isoformat(), + "period_end": period_end.isoformat(), + } + + if schedule is not None: + payload["schedule"] = normalize_logstorm_schedule( + schedule, + date_overrides=date_overrides, + ) + + return payload + + +def normalize_logstorm_schedule( + schedule: dict[str, Any], + *, + date_overrides: Optional[list[dict[str, Any]]] = None, +) -> dict[str, Any]: + return { + "start_time": schedule["start_time"], + "end_time": schedule["end_time"], + "expected_hours": schedule["expected_hours"], + "workdays": schedule.get("workdays", DEFAULT_WORKDAYS), + "date_overrides": [ + _normalize_date_override(override) + for override in ( + date_overrides + if date_overrides is not None + else schedule.get("date_overrides", []) + ) + ], + } + + +def analyze_employee_attendance( + *, + employee: Employee, + period_start: date, + period_end: date, + schedule: Optional[dict[str, Any]] = None, + date_overrides: Optional[list[dict[str, Any]]] = None, + client: Optional[LogStormClient] = None, +) -> dict[str, Any]: + payload = build_logstorm_attendance_payload( + employee=employee, + period_start=period_start, + period_end=period_end, + schedule=schedule, + date_overrides=date_overrides, + ) + return (client or LogStormClient()).analyze_attendance(payload) + + +def _normalize_date_override(override: dict[str, Any]) -> dict[str, Any]: + result = { + "date": _format_date(override["date"]), + "is_workday": bool(override["is_workday"]), + } + for key in ("reason", "start_time", "end_time", "expected_hours"): + if key in override and override[key] is not None: + result[key] = override[key] + return result + + +def _format_date(value: Any) -> str: + if isinstance(value, date): + return value.isoformat() + return str(value) + + +def _employee_display_name(employee: Employee) -> str: + full_name = employee.get_full_name().strip() + return full_name or str(employee) diff --git a/backend/common/logstorm_client.py b/backend/common/logstorm_client.py new file mode 100644 index 00000000..d6946729 --- /dev/null +++ b/backend/common/logstorm_client.py @@ -0,0 +1,101 @@ +"""Client for the external LogStorm attendance analyzer API.""" + +from dataclasses import dataclass +from datetime import date +from typing import Any, Optional + +import requests +from django.conf import settings + + +class LogStormClientError(RuntimeError): + """Raised when LogStorm cannot process an attendance request.""" + + +@dataclass(frozen=True) +class LogStormConfig: + base_url: str + token: str = "" + timeout: int = 30 + + +def load_logstorm_config() -> LogStormConfig: + return LogStormConfig( + base_url=getattr( + settings, "LOGSTORM_API_URL", "http://127.0.0.1:8000" + ).rstrip("/"), + token=getattr(settings, "LOGSTORM_API_TOKEN", ""), + timeout=getattr(settings, "LOGSTORM_TIMEOUT_SECONDS", 30), + ) + + +class LogStormClient: + """Small synchronous client for EUSRR -> LogStorm calls.""" + + def __init__( + self, + config: Optional[LogStormConfig] = None, + session: Optional[requests.Session] = None, + ): + self.config = config or load_logstorm_config() + self.session = session or requests.Session() + + def analyze_attendance(self, payload: dict[str, Any]) -> dict[str, Any]: + response = self.session.post( + self._url("/attendance/analyze"), + json=payload, + headers=self._headers(), + timeout=self.config.timeout, + ) + if not response.ok: + raise LogStormClientError( + f"LogStorm API returned HTTP {response.status_code}: " + f"{response.text}" + ) + try: + return response.json() + except ValueError as exc: + raise LogStormClientError("LogStorm API returned invalid JSON") from exc + + def update_attendance_override( + self, + *, + employee_id: str, + record_date: date | str, + payload: dict[str, Any], + ) -> dict[str, Any]: + response = self.session.patch( + self._url(f"/attendance/overrides/{employee_id}/{record_date}"), + json=payload, + headers=self._headers(), + timeout=self.config.timeout, + ) + if not response.ok: + raise LogStormClientError( + f"LogStorm API returned HTTP {response.status_code}: " + f"{response.text}" + ) + try: + return response.json() + except ValueError as exc: + raise LogStormClientError("LogStorm API returned invalid JSON") from exc + + def health(self) -> bool: + try: + response = self.session.get( + self._url("/health"), + headers=self._headers(), + timeout=self.config.timeout, + ) + except requests.RequestException: + return False + return response.ok + + def _url(self, path: str) -> str: + return f"{self.config.base_url}/{path.lstrip('/')}" + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self.config.token: + headers["Authorization"] = f"Bearer {self.config.token}" + return headers diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index 25cf2db0..06677d0e 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -15,6 +15,7 @@ from django.contrib.auth import get_user_model # Опциональная зависимость от notifications (graceful degradation) try: from notifications.signals import notify + NOTIFICATIONS_AVAILABLE = True except ImportError: NOTIFICATIONS_AVAILABLE = False @@ -61,13 +62,12 @@ def get_users_with_notifications_enabled(chat, users) -> dict[int, bool]: user_ids = [u.id for u in users] # Один запрос для всех пользователей - settings = ChatUserSettings.objects.filter( - chat=chat, - user_id__in=user_ids - ).values('user_id', 'notifications_enabled') + settings = ChatUserSettings.objects.filter(chat=chat, user_id__in=user_ids).values( + "user_id", "notifications_enabled" + ) # Создаем словарь для быстрого доступа - settings_dict = {s['user_id']: s['notifications_enabled'] for s in settings} + settings_dict = {s["user_id"]: s["notifications_enabled"] for s in settings} # Для пользователей без настроек - по умолчанию True return {uid: settings_dict.get(uid, True) for uid in user_ids} @@ -94,12 +94,12 @@ def notify_new_message(message): content = message.content # Специальная обработка для чатов комментариев - if chat.type == 'comments': + if chat.type == "comments": _notify_comment(message) return # Получаем всех участников чата кроме автора - if chat.type in ['announcement', 'channel', 'global']: + if chat.type in ["announcement", "channel", "global"]: participants = chat.get_participants().exclude(id=author.id) else: participants = chat.participants.exclude(id=author.id) @@ -110,7 +110,8 @@ def notify_new_message(message): return # Нет получателей notification_settings = get_users_with_notifications_enabled( - chat, participants_list) + chat, participants_list + ) # Отслеживаем, кому уже отправили уведомления notified_user_ids = set() @@ -133,12 +134,12 @@ def notify_new_message(message): description=truncate_message(content, 100), action_url=ActionURLs.chat_detail(chat.id), data={ - 'title': MessageTemplates.mention(author_name), - 'chat_id': chat.id, - 'chat_name': get_chat_name(chat), - 'message_id': message.id, - 'author_id': author.id, - } + "title": MessageTemplates.mention(author_name), + "chat_id": chat.id, + "chat_name": get_chat_name(chat), + "message_id": message.id, + "author_id": author.id, + }, ) notified_user_ids.add(user.id) except User.DoesNotExist: @@ -158,29 +159,28 @@ def notify_new_message(message): description=truncate_message(content, 100), action_url=ActionURLs.chat_detail(chat.id), data={ - 'title': MessageTemplates.reply(author_name), - 'chat_id': chat.id, - 'chat_name': get_chat_name(chat), - 'message_id': message.id, - 'reply_to_id': message.reply_to.id, - 'author_id': author.id, - } + "title": MessageTemplates.reply(author_name), + "chat_id": chat.id, + "chat_name": get_chat_name(chat), + "message_id": message.id, + "reply_to_id": message.reply_to.id, + "author_id": author.id, + }, ) notified_user_ids.add(original_author.id) # 3. ОБЫЧНЫЕ УВЕДОМЛЕНИЯ О НОВОМ СООБЩЕНИИ - if chat.type == 'announcement': + if chat.type == "announcement": notification_verb = NotificationVerbs.ANNOUNCEMENT title = MessageTemplates.announcement(author_name) max_length = 150 is_announcement = True else: notification_verb = NotificationVerbs.NEW_MESSAGE - if chat.type == 'private': + if chat.type == "private": title = MessageTemplates.private_message(author_name) else: - title = MessageTemplates.group_message( - author_name, get_chat_name(chat)) + title = MessageTemplates.group_message(author_name, get_chat_name(chat)) max_length = 100 is_announcement = False @@ -194,13 +194,13 @@ def notify_new_message(message): continue metadata = { - 'chat_id': chat.id, - 'chat_name': get_chat_name(chat), - 'message_id': message.id, - 'author_id': author.id, + "chat_id": chat.id, + "chat_name": get_chat_name(chat), + "message_id": message.id, + "author_id": author.id, } if is_announcement: - metadata['is_announcement'] = True + metadata["is_announcement"] = True _send_notification( sender=author, @@ -209,7 +209,7 @@ def notify_new_message(message): action_object=message, description=truncate_message(content, max_length), action_url=ActionURLs.chat_detail(chat.id), - data={**metadata, 'title': title}, + data={**metadata, "title": title}, ) @@ -244,15 +244,15 @@ def _notify_comment(message): # Проверяем тип объекта через модель model_name = context_obj.__class__.__name__ - if model_name == 'Post': - object_author = getattr(context_obj, 'author', None) + if model_name == "Post": + object_author = getattr(context_obj, "author", None) object_url = f"/?post={context_obj.id}" object_type = "публикации" - elif model_name == 'Document': - object_author = getattr(context_obj, 'uploaded_by', None) + elif model_name == "Document": + object_author = getattr(context_obj, "uploaded_by", None) object_url = f"/documents?document={context_obj.id}" object_type = "документа" - elif model_name == 'Department': + elif model_name == "Department": from employees.models import EmployeeDepartment, RoleAssignment object_url = ActionURLs.chat_detail(chat.id) @@ -269,19 +269,19 @@ def _notify_comment(message): is_active=True, ).values_list("employee_id", flat=True) recipient_ids.update(role_only_ids) - elif hasattr(context_obj, 'employee'): # Request - object_author = getattr(context_obj, 'employee', None) + elif model_name == "AttendanceRecord": + object_author = getattr(context_obj, "employee", None) + object_url = f"/users/{context_obj.employee_id}" + object_type = "записи посещаемости" + elif hasattr(context_obj, "employee"): # Request + object_author = getattr(context_obj, "employee", None) object_url = f"/requests?request={context_obj.id}" object_type = "заявки" recipient_ids.update( - context_obj.recipients.filter(is_active=True).values_list( - "id", flat=True - ) + context_obj.recipients.filter(is_active=True).values_list("id", flat=True) ) recipient_ids.update( - context_obj.cc_users.filter(is_active=True).values_list( - "id", flat=True - ) + context_obj.cc_users.filter(is_active=True).values_list("id", flat=True) ) if getattr(context_obj, "approver_id", None): recipient_ids.add(context_obj.approver_id) @@ -316,19 +316,19 @@ def _notify_comment(message): _send_notification( sender=author, recipient=recipient, - verb='commented', + verb="commented", action_object=message, target=context_obj, description=truncate_message(content, 100), action_url=object_url or f"/chat/{chat.id}/", data={ - 'title': f'{author_name} оставил комментарий к {object_type}', - 'chat_id': chat.id, - 'message_id': message.id, - 'author_id': author.id, - 'object_type': model_name, - 'object_id': context_obj.id, - } + "title": f"{author_name} оставил комментарий к {object_type}", + "chat_id": chat.id, + "message_id": message.id, + "author_id": author.id, + "object_type": model_name, + "object_id": context_obj.id, + }, ) @@ -342,8 +342,7 @@ def notify_chat_added(chat, new_users, added_by=None): added_by: Пользователь, который добавил (опционально) """ # ОПТИМИЗАЦИЯ: Загружаем настройки одним запросом - notification_settings = get_users_with_notifications_enabled( - chat, new_users) + notification_settings = get_users_with_notifications_enabled(chat, new_users) # Создаем уведомления для новых участников for user in new_users: @@ -363,9 +362,9 @@ def notify_chat_added(chat, new_users, added_by=None): description=MessageTemplates.added_to_chat(get_chat_name(chat)), action_url=ActionURLs.chat_detail(chat.id), data={ - 'title': MessageTemplates.added_to_chat_title(), - 'chat_id': chat.id, - 'chat_name': get_chat_name(chat), - 'added_by_id': added_by.id if added_by else None, - } + "title": MessageTemplates.added_to_chat_title(), + "chat_id": chat.id, + "chat_name": get_chat_name(chat), + "added_by_id": added_by.id if added_by else None, + }, ) diff --git a/backend/employees/constants.py b/backend/employees/constants.py index 5ad14439..bfd2ddb4 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -10,6 +10,10 @@ ACTION_HIRED = "hired" ACTION_DISMISSED = "dismissed" ACTION_ON_LEAVE = "on_leave" ACTION_RETURNED_FROM_LEAVE = "returned_from_leave" +ACTION_ON_SICK_LEAVE = "on_sick_leave" +ACTION_RETURNED_FROM_SICK_LEAVE = "returned_from_sick_leave" +ACTION_ON_DAY_OFF = "on_day_off" +ACTION_RETURNED_FROM_DAY_OFF = "returned_from_day_off" ACTION_ON_MATERNITY = "on_maternity" ACTION_RETURNED_FROM_MATERNITY = "returned_from_maternity" ACTION_TRANSFERRED = "transferred" @@ -20,6 +24,10 @@ ACTION_CHOICES = [ (ACTION_DISMISSED, "Уволен"), (ACTION_ON_LEAVE, "В отпуске"), (ACTION_RETURNED_FROM_LEAVE, "Вернулся из отпуска"), + (ACTION_ON_SICK_LEAVE, "На больничном"), + (ACTION_RETURNED_FROM_SICK_LEAVE, "Вернулся с больничного"), + (ACTION_ON_DAY_OFF, "В отгуле"), + (ACTION_RETURNED_FROM_DAY_OFF, "Вернулся из отгула"), (ACTION_ON_MATERNITY, "В декрете"), (ACTION_RETURNED_FROM_MATERNITY, "Вернулся из декрета"), (ACTION_TRANSFERRED, "Переведен"), diff --git a/backend/employees/migrations/0048_alter_employeeaction_action_and_more.py b/backend/employees/migrations/0048_alter_employeeaction_action_and_more.py new file mode 100644 index 00000000..9c3a7d45 --- /dev/null +++ b/backend/employees/migrations/0048_alter_employeeaction_action_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2026-04-21 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0047_alter_departmentpermission_code'), + ] + + operations = [ + migrations.AlterField( + model_name='employeeaction', + name='action', + field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_sick_leave', 'На больничном'), ('returned_from_sick_leave', 'Вернулся с больничного'), ('on_day_off', 'В отгуле'), ('returned_from_day_off', 'Вернулся из отгула'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'), + ), + migrations.AlterField( + model_name='historicalemployeeaction', + name='action', + field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_sick_leave', 'На больничном'), ('returned_from_sick_leave', 'Вернулся с больничного'), ('on_day_off', 'В отгуле'), ('returned_from_day_off', 'Вернулся из отгула'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'), + ), + ] diff --git a/backend/employees/signals/__init__.py b/backend/employees/signals/__init__.py index 5afa6294..0ede9700 100644 --- a/backend/employees/signals/__init__.py +++ b/backend/employees/signals/__init__.py @@ -14,13 +14,33 @@ from importlib import import_module from django.conf import settings from . import common, birthday +from .common import ( + IMMEDIATE_ACTION_MAPPING, + SCHEDULED_ACTION_MAPPING, + _apply_action_effects, + _schedule_delayed_action, +) -if getattr(settings, 'LDAP_ENABLED', False): - import_module('employees.signals.ldap.employee') - import_module('employees.signals.ldap.department') - import_module('employees.signals.ldap.group') - import_module('employees.signals.ldap.position') - import_module('employees.signals.ldap.role') - __all__ = ['common', 'birthday'] +if getattr(settings, "LDAP_ENABLED", False): + import_module("employees.signals.ldap.employee") + import_module("employees.signals.ldap.department") + import_module("employees.signals.ldap.group") + import_module("employees.signals.ldap.position") + import_module("employees.signals.ldap.role") + __all__ = [ + "common", + "birthday", + "IMMEDIATE_ACTION_MAPPING", + "SCHEDULED_ACTION_MAPPING", + "_apply_action_effects", + "_schedule_delayed_action", + ] else: - __all__ = ['common', 'birthday'] + __all__ = [ + "common", + "birthday", + "IMMEDIATE_ACTION_MAPPING", + "SCHEDULED_ACTION_MAPPING", + "_apply_action_effects", + "_schedule_delayed_action", + ] diff --git a/backend/employees/signals/common.py b/backend/employees/signals/common.py index 5009b583..b01d6ccd 100644 --- a/backend/employees/signals/common.py +++ b/backend/employees/signals/common.py @@ -7,13 +7,18 @@ """ import logging -from datetime import datetime +from datetime import date, datetime, time from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils import timezone -from employees.constants import ACTION_HIRED +from employees.constants import ( + ACTION_HIRED, + ACTION_ON_DAY_OFF, + ACTION_ON_LEAVE, + ACTION_ON_SICK_LEAVE, +) from employees.models import Employee, EmployeeAction logger = logging.getLogger(__name__) @@ -25,14 +30,15 @@ logger = logging.getLogger(__name__) # Немедленные события (создаются сразу при одобрении) IMMEDIATE_ACTION_MAPPING = { - "transfer": "transferred", # Перевод → Переведен - "dismissal": "dismissed", # Увольнение → Уволен + "transfer": "transferred", # Перевод → Переведен + "dismissal": "dismissed", # Увольнение → Уволен } # Отложенные события (создаются по Celery в date_from) SCHEDULED_ACTION_MAPPING = { - "vacation": "on_leave", # Отпуск → В отпуске - "sick_leave": "on_sick_leave", # Больничный → На больничном + "vacation": ACTION_ON_LEAVE, # Отпуск → В отпуске + "sick_leave": ACTION_ON_SICK_LEAVE, # Больничный → На больничном + "day_off": ACTION_ON_DAY_OFF, # Отгул → В отгуле } @@ -54,7 +60,8 @@ def create_hired_action(sender, instance: Employee, created, **kwargs): # Обработчики заявок (requests_app) # ============================================================ -@receiver(pre_save, sender='requests_app.Request') + +@receiver(pre_save, sender="requests_app.Request") def track_request_status_change(sender, instance, **kwargs): """Сохраняем старый статус перед обновлением для отслеживания изменений.""" if instance.pk: @@ -66,7 +73,7 @@ def track_request_status_change(sender, instance, **kwargs): instance._old_status = None -@receiver(post_save, sender='requests_app.Request') +@receiver(post_save, sender="requests_app.Request") def create_action_on_request_approval(sender, instance, created, **kwargs): """ Автоматически создает EmployeeAction при одобрении заявок. @@ -87,7 +94,7 @@ def create_action_on_request_approval(sender, instance, created, **kwargs): return # Проверяем изменение статуса - old_status = getattr(instance, '_old_status', None) + old_status = getattr(instance, "_old_status", None) if old_status == RequestStatus.APPROVED: return # Уже была одобрена @@ -117,16 +124,15 @@ def _create_immediate_action(request): # Проверяем дубли if EmployeeAction.objects.filter( - extra__request_id=request.id, - action=action_type + extra__request_id=request.id, action=action_type ).exists(): - logger.warning( - f"EmployeeAction already exists for Request #{request.id}" - ) + logger.warning(f"EmployeeAction already exists for Request #{request.id}") return # Создаем событие - action_date = request.date_from or request.decided_at or timezone.now() + action_date = _as_action_datetime( + request.date_from or request.decided_at or timezone.now() + ) action_comment = f"Заявление #{request.id}" if request.comment: action_comment += f": {request.comment[:200]}" @@ -138,12 +144,10 @@ def _create_immediate_action(request): date=action_date, comment=action_comment, extra={ - 'request_id': request.id, - 'approved_by': ( - request.approver.id if request.approver else None - ), - 'immediate': True - } + "request_id": request.id, + "approved_by": (request.approver.id if request.approver else None), + "immediate": True, + }, ) # Применяем эффекты (деактивация, LDAP sync) @@ -157,7 +161,7 @@ def _create_immediate_action(request): except Exception as e: logger.error( f"Failed to create EmployeeAction for Request #{request.id}: {e}", - exc_info=True + exc_info=True, ) @@ -188,34 +192,37 @@ def _schedule_delayed_action(request): return # Иначе планируем на date_from в 00:00 - eta = timezone.make_aware( - datetime.combine(request.date_from, datetime.min.time()) - ) + eta = timezone.make_aware(datetime.combine(request.date_from, datetime.min.time())) try: - task = create_scheduled_action.apply_async( - args=[request.id], - eta=eta - ) + task = create_scheduled_action.apply_async(args=[request.id], eta=eta) logger.info( f"Scheduled EmployeeAction creation for Request #{request.id} " f"at {eta} (task_id={task.id})" ) except Exception as e: logger.error( - f"Failed to schedule action for Request #{request.id}: {e}", - exc_info=True + f"Failed to schedule action for Request #{request.id}: {e}", exc_info=True ) +def _as_action_datetime(value): + if isinstance(value, datetime): + return value if timezone.is_aware(value) else timezone.make_aware(value) + if isinstance(value, date): + return timezone.make_aware(datetime.combine(value, time(hour=12))) + return value + + def _apply_action_effects(action): """Применяет эффекты кадрового события (как в EmployeeActionViewSet).""" try: from api.v1.employees.views.actions import EmployeeActionViewSet + viewset = EmployeeActionViewSet() viewset._apply_effects(action) except Exception as e: logger.error( f"Failed to apply effects for EmployeeAction #{action.id}: {e}", - exc_info=True + exc_info=True, ) diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index efdb968c..613736e4 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ "scheduling.apps.SchedulingConfig", "documents.apps.DocumentsConfig", "requests_app.apps.RequestsAppConfig", + "attendance.apps.AttendanceConfig", "feed.apps.FeedConfig", "realtime.apps.RealtimeConfig", # WebSocket consumers для real-time "communications.apps.CommunicationsConfig", @@ -203,6 +204,11 @@ LOGIN_REDIRECT_URL = "/admin/" # Используем локальный адрес для избежания проблем с SSL API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api") +# LogStorm attendance analyzer integration. +LOGSTORM_API_URL = os.getenv("LOGSTORM_API_URL", "http://127.0.0.1:8010") +LOGSTORM_API_TOKEN = os.getenv("LOGSTORM_API_TOKEN", "") +LOGSTORM_TIMEOUT_SECONDS = int(os.getenv("LOGSTORM_TIMEOUT_SECONDS", "30")) + PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU") PHONENUMBER_DEFAULT_REGION = "RU" diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index 03aa818c..4ea61dd0 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -95,9 +95,7 @@ class Request(models.Model): verbose_name=_("Отделы-получатели"), blank=True, related_name="received_requests", - help_text=_( - "Заявка будет видна всем уполномоченным сотрудникам этих отделов" - ), + help_text=_("Заявка будет видна всем уполномоченным сотрудникам этих отделов"), ) recipients = models.ManyToManyField( @@ -113,9 +111,7 @@ class Request(models.Model): verbose_name=_("Копия (CC)"), blank=True, related_name="requests_cc", - help_text=_( - "Сотрудники в копии (уведомления без обязанности рассмотрения)" - ), + help_text=_("Сотрудники в копии (уведомления без обязанности рассмотрения)"), ) sent_to_all_department = models.BooleanField( @@ -153,9 +149,7 @@ class Request(models.Model): created_at = models.DateTimeField(_("Создано"), auto_now_add=True) updated_at = models.DateTimeField(_("Обновлено"), auto_now=True) - decided_at = models.DateTimeField( - _("Решение принято"), null=True, blank=True - ) + decided_at = models.DateTimeField(_("Решение принято"), null=True, blank=True) class Meta: permissions = [ @@ -203,9 +197,11 @@ class Request(models.Model): ] def clean(self): - if self.type in {RequestType.VACATION, RequestType.SICK_LEAVE} and not ( - self.date_from and self.date_to - ): + if self.type in { + RequestType.VACATION, + RequestType.SICK_LEAVE, + RequestType.DAY_OFF, + } and not (self.date_from and self.date_to): raise ValidationError( _("Для выбранного типа укажите даты начала и окончания.") ) @@ -213,14 +209,10 @@ class Request(models.Model): self.type in {RequestType.TRANSFER, RequestType.DISMISSAL} and not self.date_from ): - raise ValidationError( - _("Для перевода/увольнения укажите дату начала.") - ) + raise ValidationError(_("Для перевода/увольнения укажите дату начала.")) if self.approver_id and self.approver_id == self.employee_id: - raise ValidationError( - _("Согласующий не может совпадать с автором заявки.") - ) + raise ValidationError(_("Согласующий не может совпадать с автором заявки.")) @property def display_title(self) -> str: @@ -281,26 +273,20 @@ class Request(models.Model): self.status = RequestStatus.APPROVED self.approver = by_user self.decided_at = timezone.now() - self.save( - update_fields=["status", "approver", "decided_at", "updated_at"] - ) + self.save(update_fields=["status", "approver", "decided_at", "updated_at"]) def reject(self, by_user): self.status = RequestStatus.REJECTED self.approver = by_user self.decided_at = timezone.now() - self.save( - update_fields=["status", "approver", "decided_at", "updated_at"] - ) + self.save(update_fields=["status", "approver", "decided_at", "updated_at"]) def cancel(self): self.status = RequestStatus.CANCELLED if self.approver_id is not None: self.approver = None self.decided_at = timezone.now() - self.save( - update_fields=["status", "approver", "decided_at", "updated_at"] - ) + self.save(update_fields=["status", "approver", "decided_at", "updated_at"]) def save(self, *args, **kwargs) -> None: """Синхронизация decided_at/approver с текущим статусом. @@ -323,6 +309,6 @@ class Request(models.Model): super().save(*args, **kwargs) def __str__(self): - return f"{self.get_type_display()} — {self.employee} ({ - self.get_status_display() - })" + return ( + f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})" + ) diff --git a/backend/requests_app/tasks.py b/backend/requests_app/tasks.py index 1a651774..de7d8c34 100644 --- a/backend/requests_app/tasks.py +++ b/backend/requests_app/tasks.py @@ -1,6 +1,7 @@ """ Celery tasks для автоматизации кадровых событий из заявок. """ + import logging from datetime import date, timedelta @@ -15,13 +16,18 @@ def create_scheduled_action(self, request_id): """ Создать кадровое событие для заявки по расписанию. - Вызывается в date_from для vacation/sick_leave. + Вызывается в date_from для vacation/sick_leave/day_off. Args: request_id: ID заявки """ from .models import Request from .enums import RequestStatus + from employees.constants import ( + ACTION_ON_DAY_OFF, + ACTION_ON_LEAVE, + ACTION_ON_SICK_LEAVE, + ) from employees.models import EmployeeAction try: @@ -37,8 +43,9 @@ def create_scheduled_action(self, request_id): # Определяем тип события action_mapping = { - 'vacation': 'on_leave', - 'sick_leave': 'on_sick_leave', + "vacation": ACTION_ON_LEAVE, + "sick_leave": ACTION_ON_SICK_LEAVE, + "day_off": ACTION_ON_DAY_OFF, } action_type = action_mapping.get(request.type) @@ -51,12 +58,9 @@ def create_scheduled_action(self, request_id): # Проверяем, что событие еще не создано if EmployeeAction.objects.filter( - extra__request_id=request_id, - action=action_type + extra__request_id=request_id, action=action_type ).exists(): - logger.info( - f"Action already exists for Request #{request_id}, skipping" - ) + logger.info(f"Action already exists for Request #{request_id}, skipping") return # Создаем событие @@ -70,12 +74,10 @@ def create_scheduled_action(self, request_id): date=timezone.now(), comment=action_comment, extra={ - 'request_id': request.id, - 'approved_by': ( - request.approver.id if request.approver else None - ), - 'scheduled': True - } + "request_id": request.id, + "approved_by": (request.approver.id if request.approver else None), + "scheduled": True, + }, ) logger.info( @@ -90,9 +92,9 @@ def create_scheduled_action(self, request_id): eta=timezone.make_aware( timezone.datetime.combine( request.date_to + timedelta(days=1), - timezone.datetime.min.time() + timezone.datetime.min.time(), ) - ) + ), ) return f"Created action {action.id}" @@ -103,7 +105,7 @@ def create_scheduled_action(self, request_id): except Exception as e: logger.error( f"Failed to create scheduled action for Request #{request_id}: {e}", - exc_info=True + exc_info=True, ) # Retry через 5 минут raise self.retry(exc=e, countdown=300) @@ -112,7 +114,7 @@ def create_scheduled_action(self, request_id): @shared_task(bind=True, max_retries=3) def schedule_auto_return(self, request_id): """ - Автоматически создать событие возврата из отпуска/больничного. + Автоматически создать событие возврата из отпуска/больничного/отгула. Вызывается в date_to + 1 день. @@ -121,6 +123,11 @@ def schedule_auto_return(self, request_id): """ from .models import Request from .enums import RequestStatus + from employees.constants import ( + ACTION_RETURNED_FROM_DAY_OFF, + ACTION_RETURNED_FROM_LEAVE, + ACTION_RETURNED_FROM_SICK_LEAVE, + ) from employees.models import EmployeeAction try: @@ -129,15 +136,15 @@ def schedule_auto_return(self, request_id): # Проверяем, что заявка все еще одобрена if request.status != RequestStatus.APPROVED: logger.info( - f"Request #{request_id} is no longer approved, " - f"skipping auto-return" + f"Request #{request_id} is no longer approved, skipping auto-return" ) return # Определяем тип возврата return_mapping = { - 'vacation': 'returned_from_leave', - 'sick_leave': 'returned_from_leave', + "vacation": ACTION_RETURNED_FROM_LEAVE, + "sick_leave": ACTION_RETURNED_FROM_SICK_LEAVE, + "day_off": ACTION_RETURNED_FROM_DAY_OFF, } return_action = return_mapping.get(request.type) @@ -150,12 +157,9 @@ def schedule_auto_return(self, request_id): # Проверяем, что событие возврата еще не создано if EmployeeAction.objects.filter( - extra__request_id=request_id, - action=return_action + extra__request_id=request_id, action=return_action ).exists(): - logger.info( - f"Return action already exists for Request #{request_id}" - ) + logger.info(f"Return action already exists for Request #{request_id}") return # Создаем событие возврата @@ -170,10 +174,7 @@ def schedule_auto_return(self, request_id): action=return_action, date=timezone.now(), comment=return_comment, - extra={ - 'request_id': request.id, - 'auto_return': True - } + extra={"request_id": request.id, "auto_return": True}, ) logger.info( @@ -189,7 +190,7 @@ def schedule_auto_return(self, request_id): except Exception as e: logger.error( f"Failed to create auto-return for Request #{request_id}: {e}", - exc_info=True + exc_info=True, ) raise self.retry(exc=e, countdown=300) @@ -214,9 +215,9 @@ def cleanup_missed_returns(): # Ищем одобренные заявки, которые закончились вчера или раньше ended_requests = Request.objects.filter( status=RequestStatus.APPROVED, - type__in=['vacation', 'sick_leave'], + type__in=["vacation", "sick_leave", "day_off"], date_to__lt=today, - date_to__gte=yesterday - timedelta(days=7) # За последнюю неделю + date_to__gte=yesterday - timedelta(days=7), # За последнюю неделю ) created_count = 0 @@ -224,8 +225,9 @@ def cleanup_missed_returns(): for req in ended_requests: # Определяем тип возврата return_action = { - 'vacation': 'returned_from_leave', - 'sick_leave': 'returned_from_leave', + "vacation": "returned_from_leave", + "sick_leave": "returned_from_sick_leave", + "day_off": "returned_from_day_off", }.get(req.type) if not return_action: @@ -233,8 +235,7 @@ def cleanup_missed_returns(): # Проверяем, что события возврата нет if EmployeeAction.objects.filter( - extra__request_id=req.id, - action=return_action + extra__request_id=req.id, action=return_action ).exists(): continue @@ -248,23 +249,14 @@ def cleanup_missed_returns(): f"Автоматически восстановлено: окончание " f"{req.get_type_display().lower()} (заявка #{req.id})" ), - extra={ - 'request_id': req.id, - 'auto_return': True, - 'cleanup': True - } + extra={"request_id": req.id, "auto_return": True, "cleanup": True}, ) created_count += 1 logger.info( - f"Cleanup: created return action #{action.id} " - f"for Request #{req.id}" + f"Cleanup: created return action #{action.id} for Request #{req.id}" ) except Exception as e: - logger.error( - f"Cleanup: failed to create return for Request #{req.id}: {e}" - ) + logger.error(f"Cleanup: failed to create return for Request #{req.id}: {e}") - logger.info( - f"cleanup_missed_returns completed: {created_count} actions created" - ) + logger.info(f"cleanup_missed_returns completed: {created_count} actions created") return created_count diff --git a/backend/requests_app/tests/test_signals.py b/backend/requests_app/tests/test_signals.py index 0154931e..10a100f5 100644 --- a/backend/requests_app/tests/test_signals.py +++ b/backend/requests_app/tests/test_signals.py @@ -1,6 +1,7 @@ """ Тесты для автоматического создания EmployeeAction из заявок (signals). """ + import pytest from datetime import date, timedelta from unittest.mock import patch @@ -24,16 +25,14 @@ class TestRequestSignals: # Создаём заявку на увольнение request = Request.objects.create( employee=employee, - type='dismissal', + type="dismissal", status=RequestStatus.PENDING, date_from=date.today(), - comment='Увольнение по собственному желанию' + comment="Увольнение по собственному желанию", ) # Проверяем что события ещё нет - assert not EmployeeAction.objects.filter( - extra__request_id=request.id - ).exists() + assert not EmployeeAction.objects.filter(extra__request_id=request.id).exists() # Одобряем заявку request.status = RequestStatus.APPROVED @@ -43,16 +42,14 @@ class TestRequestSignals: # Проверяем что событие создалось action = EmployeeAction.objects.get( - employee=employee, - action='dismissed', - extra__request_id=request.id + employee=employee, action="dismissed", extra__request_id=request.id ) - assert action.action == 'dismissed' - assert action.extra['request_id'] == request.id - assert action.extra['approved_by'] == approver.id - assert action.extra['immediate'] is True - assert 'Заявление #' in action.comment + assert action.action == "dismissed" + assert action.extra["request_id"] == request.id + assert action.extra["approved_by"] == approver.id + assert action.extra["immediate"] is True + assert "Заявление #" in action.comment def test_immediate_action_transfer(self, user_factory): """Перевод создаёт EmployeeAction немедленно при одобрении.""" @@ -61,7 +58,7 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='transfer', + type="transfer", status=RequestStatus.PENDING, date_from=date.today(), ) @@ -73,15 +70,13 @@ class TestRequestSignals: # Проверяем событие action = EmployeeAction.objects.get( - employee=employee, - action='transferred', - extra__request_id=request.id + employee=employee, action="transferred", extra__request_id=request.id ) - assert action.action == 'transferred' - assert action.extra['immediate'] is True + assert action.action == "transferred" + assert action.extra["immediate"] is True - @patch('employees.signals._schedule_delayed_action') + @patch("employees.signals.common._schedule_delayed_action") def test_scheduled_action_vacation(self, mock_schedule, user_factory): """Отпуск запускает планирование через Celery.""" employee = user_factory() @@ -90,7 +85,7 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='vacation', + type="vacation", status=RequestStatus.PENDING, date_from=future_date, date_to=future_date + timedelta(days=10), @@ -106,7 +101,7 @@ class TestRequestSignals: called_request = mock_schedule.call_args[0][0] assert called_request.id == request.id - @patch('employees.signals._schedule_delayed_action') + @patch("employees.signals.common._schedule_delayed_action") def test_scheduled_action_sick_leave(self, mock_schedule, user_factory): """Больничный запускает планирование через Celery.""" employee = user_factory() @@ -115,7 +110,7 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='sick_leave', + type="sick_leave", status=RequestStatus.PENDING, date_from=future_date, ) @@ -133,7 +128,7 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='dismissal', + type="dismissal", status=RequestStatus.PENDING, date_from=date.today(), ) @@ -144,22 +139,24 @@ class TestRequestSignals: request.save() # Первое событие создалось - assert EmployeeAction.objects.filter( - employee=employee, - action='dismissed', - extra__request_id=request.id - ).count() == 1 + assert ( + EmployeeAction.objects.filter( + employee=employee, action="dismissed", extra__request_id=request.id + ).count() + == 1 + ) # Меняем комментарий и сохраняем снова - request.comment = 'Обновлённый комментарий' + request.comment = "Обновлённый комментарий" request.save() # Дубликат не создался - assert EmployeeAction.objects.filter( - employee=employee, - action='dismissed', - extra__request_id=request.id - ).count() == 1 + assert ( + EmployeeAction.objects.filter( + employee=employee, action="dismissed", extra__request_id=request.id + ).count() + == 1 + ) def test_signal_not_fired_on_creation(self, user_factory): """Сигнал не срабатывает при создании (только при одобрении).""" @@ -167,14 +164,12 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='dismissal', + type="dismissal", status=RequestStatus.PENDING, ) # События нет - assert not EmployeeAction.objects.filter( - extra__request_id=request.id - ).exists() + assert not EmployeeAction.objects.filter(extra__request_id=request.id).exists() def test_signal_not_fired_on_rejection(self, user_factory): """Сигнал не срабатывает при отклонении.""" @@ -183,7 +178,7 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='dismissal', + type="dismissal", status=RequestStatus.PENDING, ) @@ -193,9 +188,7 @@ class TestRequestSignals: request.save() # События нет - assert not EmployeeAction.objects.filter( - extra__request_id=request.id - ).exists() + assert not EmployeeAction.objects.filter(extra__request_id=request.id).exists() def test_unknown_request_type_no_action(self, user_factory): """Неизвестный тип заявки не создаёт события.""" @@ -205,7 +198,7 @@ class TestRequestSignals: # Используем тип, который не в маппингах request = Request.objects.create( employee=employee, - type='other', # Не в IMMEDIATE/SCHEDULED маппингах + type="other", # Не в IMMEDIATE/SCHEDULED маппингах status=RequestStatus.PENDING, ) @@ -216,8 +209,7 @@ class TestRequestSignals: # События нет assert not EmployeeAction.objects.filter( - employee=employee, - extra__request_id=request.id + employee=employee, extra__request_id=request.id ).exists() def test_action_date_from_request_date_from(self, user_factory): @@ -228,7 +220,7 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='dismissal', + type="dismissal", status=RequestStatus.PENDING, date_from=specific_date, ) @@ -239,16 +231,16 @@ class TestRequestSignals: request.save() action = EmployeeAction.objects.get( - employee=employee, - action='dismissed', - extra__request_id=request.id + employee=employee, action="dismissed", extra__request_id=request.id ) # Проверяем дату (может быть datetime, поэтому берём .date()) - action_date = action.date.date() if hasattr(action.date, 'date') else action.date + action_date = ( + action.date.date() if hasattr(action.date, "date") else action.date + ) assert action_date == specific_date - @patch('employees.signals._apply_action_effects') + @patch("employees.signals.common._apply_action_effects") def test_effects_applied_for_immediate_actions(self, mock_apply, user_factory): """Эффекты применяются для немедленных действий.""" employee = user_factory() @@ -256,7 +248,7 @@ class TestRequestSignals: request = Request.objects.create( employee=employee, - type='dismissal', + type="dismissal", status=RequestStatus.PENDING, ) @@ -268,7 +260,7 @@ class TestRequestSignals: # Проверяем что _apply_action_effects была вызвана mock_apply.assert_called_once() created_action = mock_apply.call_args[0][0] - assert created_action.action == 'dismissed' + assert created_action.action == "dismissed" @pytest.mark.django_db @@ -278,15 +270,16 @@ class TestRequestMappings: def test_immediate_mapping_complete(self): """Проверка что все немедленные маппинги правильные.""" assert IMMEDIATE_ACTION_MAPPING == { - 'transfer': 'transferred', - 'dismissal': 'dismissed', + "transfer": "transferred", + "dismissal": "dismissed", } def test_scheduled_mapping_complete(self): """Проверка что все отложенные маппинги правильные.""" assert SCHEDULED_ACTION_MAPPING == { - 'vacation': 'on_leave', - 'sick_leave': 'on_sick_leave', + "vacation": "on_leave", + "sick_leave": "on_sick_leave", + "day_off": "on_day_off", } def test_no_overlap_in_mappings(self): @@ -294,5 +287,6 @@ class TestRequestMappings: immediate_keys = set(IMMEDIATE_ACTION_MAPPING.keys()) scheduled_keys = set(SCHEDULED_ACTION_MAPPING.keys()) - assert immediate_keys.isdisjoint(scheduled_keys), \ + assert immediate_keys.isdisjoint(scheduled_keys), ( "Request types должны быть либо immediate, либо scheduled, но не оба" + ) diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py new file mode 100644 index 00000000..79799c34 --- /dev/null +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -0,0 +1,695 @@ +from unittest.mock import patch +from datetime import datetime + +import pytest +from django.urls import reverse +from django.utils import timezone + +from attendance.models import AttendanceAnalysisRun, AttendanceRecord +from employees.constants import ACTION_ON_LEAVE, ACTION_RETURNED_FROM_LEAVE +from employees.models import EmployeeAction + +pytestmark = pytest.mark.django_db + + +def _analyze_url(): + return reverse("api:v1:logstorm-attendance-analyze") + + +def _records_url(): + return reverse("api:v1:attendance-records") + + +def _monthly_matrix_url(): + return reverse("api:v1:attendance-monthly-matrix") + + +def _record_detail_url(record_id): + return reverse("api:v1:attendance-record-detail", args=[record_id]) + + +def _record_comments_url(record_id): + return reverse("api:v1:attendance-record-comments", args=[record_id]) + + +def _record_comment_detail_url(record_id, comment_id): + return reverse( + "api:v1:attendance-record-comment-detail", args=[record_id, comment_id] + ) + + +def _payload(employee_id, **overrides): + payload = { + "employee_id": employee_id, + "period_start": "2026-04-20", + "period_end": "2026-04-21", + } + payload.update(overrides) + return payload + + +def _logstorm_result(): + return { + "records": [ + { + "date": "2026-04-20", + "employee_id": "42", + "display_name": "Ivan Petrov", + "arrival_time": "09:15", + "departure_time": "18:00", + "work_hours": 8.75, + "expected_hours": 9, + "is_workday": True, + "is_late": True, + "late_minutes": 15, + "is_early_leave": False, + "early_leave_minutes": None, + "is_underwork": True, + "underwork_hours": 0.25, + "is_overtime": False, + "overtime_hours": None, + "is_absent": False, + "statuses": ["late", "underwork"], + "employee_issues": ["late"], + "technical_issues": [], + }, + { + "date": "2026-04-21", + "employee_id": "42", + "display_name": "Ivan Petrov", + "arrival_time": None, + "departure_time": None, + "work_hours": 0, + "expected_hours": 9, + "is_workday": True, + "is_absent": True, + "statuses": ["absence"], + "employee_issues": ["absence"], + "technical_issues": [], + }, + ] + } + + +def _aware_datetime(year, month, day): + return timezone.make_aware(datetime(year, month, day)) + + +def _move_auto_hired_before_test_period(employee): + employee.actions.filter(action="hired").update(date=_aware_datetime(2026, 1, 1)) + + +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") + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + assert AttendanceAnalysisRun.objects.count() == 1 + assert AttendanceRecord.objects.count() == 2 + + run = AttendanceAnalysisRun.objects.get() + assert run.employee == employee + assert run.triggered_by == staff + assert run.period_start.isoformat() == "2026-04-20" + assert run.period_end.isoformat() == "2026-04-21" + assert run.request_payload["employee_id"] == str(employee.id) + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + assert record.analysis_run == run + assert record.arrival_time == "09:15" + assert record.is_late is True + assert record.effective_is_workday is True + assert record.personnel_status == "normal" + assert record.statuses == ["late", "underwork"] + assert record.raw_data["employee_id"] == "42" + + +def test_analyze_updates_existing_daily_record(auth_client_factory, user_factory): + staff = user_factory(staff=True) + employee = user_factory() + _move_auto_hired_before_test_period(employee) + client = auth_client_factory(staff) + + first_result = _logstorm_result() + second_result = { + "records": [ + { + **first_result["records"][0], + "arrival_time": "08:55", + "is_late": False, + "late_minutes": None, + "statuses": [], + "employee_issues": [], + } + ] + } + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=first_result, + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=second_result, + ): + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + assert AttendanceAnalysisRun.objects.count() == 2 + assert ( + AttendanceRecord.objects.filter(employee=employee, date="2026-04-20").count() + == 1 + ) + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + assert record.arrival_time == "08:55" + assert record.is_late is False + assert record.statuses == [] + + +def test_records_endpoint_returns_saved_records(auth_client_factory, user_factory): + staff = user_factory(staff=True) + employee = user_factory() + other = 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") + client.post(_analyze_url(), _payload(other.id), format="json") + + response = client.get( + _records_url(), + { + "employee_id": employee.id, + "date_from": "2026-04-20", + "date_to": "2026-04-21", + }, + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + assert {item["employee"] for item in results} == {employee.id} + assert {item["date"] for item in results} == {"2026-04-20", "2026-04-21"} + + +def test_monthly_matrix_endpoint_returns_excel_like_month( + 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") + + response = client.get( + _monthly_matrix_url(), + { + "employee_ids": f"{employee.id},{other.id}", + "month": "2026-04", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["month"] == "2026-04" + assert payload["month_label"] == "Апрель 2026" + assert [item["id"] for item in payload["employees"]] == [employee.id, other.id] + assert len(payload["rows"]) == 30 + + target_row = next(row for row in payload["rows"] if row["date"] == "2026-04-20") + employee_cell = target_row["cells"][str(employee.id)] + assert employee_cell["arrival_time"] == "09:15" + assert employee_cell["departure_time"] == "18:00" + assert employee_cell["status"] == "underwork" + + summary_by_key = {item["key"]: item for item in payload["summary"]} + assert summary_by_key["late_days"]["values"][str(employee.id)] == 1 + assert summary_by_key["absent_days"]["values"][str(employee.id)] == 1 + assert summary_by_key["worked_hours"]["values"][str(employee.id)] == 8.75 + + +def test_user_can_read_own_attendance_records(auth_client_factory, user_factory): + user = user_factory(staff=False) + other = user_factory() + 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") + staff_client.post(_analyze_url(), _payload(other.id), format="json") + + response = client.get(_records_url()) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + assert {item["employee"] for item in results} == {user.id} + + +def test_user_cannot_read_other_employee_attendance_records( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + other = user_factory() + client = auth_client_factory(user) + + response = client.get(_records_url(), {"employee_id": other.id}) + assert response.status_code == 403 + + +def test_staff_can_update_attendance_record(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") + patch_payload = { + "arrival_time": "09:00", + "departure_time": "18:05", + "work_hours": 9.08, + "is_late": False, + "late_minutes": None, + "is_underwork": False, + "underwork_hours": None, + } + with patch( + "api.v1.attendance.views.LogStormClient.update_attendance_override", + return_value={"id": 1, "patch": patch_payload}, + ) as logstorm_update: + response = client.patch( + _record_detail_url(record.id), + patch_payload, + format="json", + ) + + assert response.status_code == 200 + logstorm_update.assert_called_once() + record.refresh_from_db() + assert record.arrival_time == "09:00" + assert record.work_hours == 9.08 + assert record.is_late is False + assert record.is_manually_edited is True + assert record.manual_edit_payload["arrival_time"] == "09:00" + assert "late" not in record.statuses + assert response.json()["arrival_time"] == "09:00" + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + record.refresh_from_db() + assert record.arrival_time == "09:00" + assert record.is_manually_edited is True + assert record.manual_edit_payload["arrival_time"] == "09:00" + + +def test_user_cannot_update_attendance_record(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.patch( + _record_detail_url(record.id), + {"arrival_time": "09:00"}, + format="json", + ) + + assert response.status_code == 403 + + +def test_user_can_analyze_own_attendance(auth_client_factory, user_factory): + user = user_factory(staff=False) + client = auth_client_factory(user) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + response = client.post(_analyze_url(), _payload(user.id), format="json") + + assert response.status_code == 200 + assert AttendanceRecord.objects.filter(employee=user).count() == 2 + + +def test_user_cannot_analyze_other_employee_attendance( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + other = user_factory() + client = auth_client_factory(user) + + response = client.post(_analyze_url(), _payload(other.id), format="json") + + assert response.status_code == 403 + + +def test_personnel_non_working_state_is_saved_and_suppresses_absence( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + _move_auto_hired_before_test_period(employee) + client = auth_client_factory(staff) + leave_action = EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + ) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + absent_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-21") + assert absent_record.personnel_status == ACTION_ON_LEAVE + assert absent_record.personnel_action == leave_action + assert absent_record.effective_is_workday is False + assert absent_record.is_absent is False + assert absent_record.employee_issues == [] + assert absent_record.statuses == [] + + +def test_calendar_non_working_day_suppresses_absence_without_personnel_state( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + weekend_absence_result = { + "records": [ + { + "date": "2026-04-19", + "employee_id": str(employee.id), + "display_name": "Weekend User", + "arrival_time": None, + "departure_time": None, + "work_hours": 0, + "expected_hours": 9, + "is_workday": False, + "is_absent": True, + "statuses": ["День критического отсутствия (100% отсутствуют)"], + "employee_issues": ["absence"], + "technical_issues": [ + "День критического отсутствия (100% отсутствуют)" + ], + } + ] + } + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=weekend_absence_result, + ): + response = client.post( + _analyze_url(), + _payload(employee.id, period_start="2026-04-19", period_end="2026-04-19"), + format="json", + ) + + assert response.status_code == 200 + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-19") + assert record.is_workday is False + assert record.effective_is_workday is False + assert record.is_absent is False + assert record.statuses == [] + assert record.employee_issues == [] + assert record.technical_issues == [] + + records_response = client.get( + _records_url(), + { + "employee_id": employee.id, + "date_from": "2026-04-19", + "date_to": "2026-04-19", + }, + ) + assert records_response.status_code == 200 + assert records_response.json()["results"][0]["non_working_reason"] == ( + "Выходной по графику/календарю" + ) + + 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-19" + ) + matrix_cell = matrix_row["cells"][str(employee.id)] + assert matrix_cell["status"] == "non_working" + assert matrix_cell["issues"] == [] + + +def test_personnel_non_working_state_counts_work_as_overtime( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + _move_auto_hired_before_test_period(employee) + client = auth_client_factory(staff) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + ) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + worked_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + assert worked_record.personnel_status == ACTION_ON_LEAVE + assert worked_record.effective_is_workday is False + assert worked_record.is_late is False + assert worked_record.is_underwork is False + assert worked_record.is_overtime is True + assert worked_record.overtime_hours == worked_record.work_hours + assert worked_record.employee_issues == [] + assert worked_record.statuses == ["work_outside_personnel_schedule"] + + +def test_personnel_state_is_recalculated_when_actions_change( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + _move_auto_hired_before_test_period(employee) + client = auth_client_factory(staff) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + ) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + first_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-21") + assert first_record.personnel_status == ACTION_ON_LEAVE + assert first_record.is_absent is False + + EmployeeAction.objects.create( + employee=employee, + action=ACTION_RETURNED_FROM_LEAVE, + date=_aware_datetime(2026, 4, 21), + ) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + updated_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-21") + assert updated_record.personnel_status == "normal" + assert updated_record.effective_is_workday is True + assert updated_record.is_absent is True + assert updated_record.employee_issues == ["absence"] + + +def test_attendance_record_comments_use_communications( + auth_client_factory, user_factory +): + staff = user_factory(staff=True, first_name="Admin", last_name="User") + 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") + + create_response = client.post( + _record_comments_url(record.id), + {"text": "Проверить причину опоздания"}, + format="json", + ) + assert create_response.status_code == 201 + comment = create_response.json() + assert comment["record"] == record.id + assert comment["text"] == "Проверить причину опоздания" + assert comment["author"]["id"] == staff.id + + list_response = client.get(_record_comments_url(record.id)) + assert list_response.status_code == 200 + assert [item["text"] for item in list_response.json()] == [ + "Проверить причину опоздания" + ] + + records_response = client.get(_records_url(), {"employee_id": employee.id}) + assert records_response.status_code == 200 + target = next( + item for item in records_response.json()["results"] if item["id"] == record.id + ) + assert target["comments_count"] == 1 + + +def test_attendance_record_comment_delete_is_author_only( + auth_client_factory, + user_factory, +): + author = user_factory(staff=True) + other_staff = user_factory(staff=True) + employee = user_factory() + author_client = auth_client_factory(author) + other_client = auth_client_factory(other_staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + author_client.post(_analyze_url(), _payload(employee.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + comment_id = author_client.post( + _record_comments_url(record.id), + {"text": "Комментарий автора"}, + format="json", + ).json()["id"] + + forbidden_response = other_client.delete( + _record_comment_detail_url(record.id, comment_id) + ) + assert forbidden_response.status_code == 403 + + delete_response = author_client.delete( + _record_comment_detail_url(record.id, comment_id) + ) + assert delete_response.status_code == 204 + assert author_client.get(_record_comments_url(record.id)).json() == [] + + +def test_user_can_comment_own_attendance_record(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") + + create_response = client.post( + _record_comments_url(record.id), + {"text": "Мой комментарий"}, + format="json", + ) + + assert create_response.status_code == 201 + assert create_response.json()["author"]["id"] == user.id + assert client.get(_record_comments_url(record.id)).json()[0]["text"] == ( + "Мой комментарий" + ) + + +def test_user_cannot_comment_other_employee_attendance_record( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + other = user_factory() + 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(other.id), format="json") + + record = AttendanceRecord.objects.get(employee=other, date="2026-04-20") + + response = client.post( + _record_comments_url(record.id), + {"text": "Чужая запись"}, + format="json", + ) + + assert response.status_code == 404 diff --git a/backend/tests/integration/test_logstorm_attendance.py b/backend/tests/integration/test_logstorm_attendance.py new file mode 100644 index 00000000..c063e6bf --- /dev/null +++ b/backend/tests/integration/test_logstorm_attendance.py @@ -0,0 +1,112 @@ +from datetime import date + +import pytest + +from common.logstorm_attendance import ( + analyze_employee_attendance, + build_logstorm_attendance_payload, + normalize_logstorm_schedule, +) + +pytestmark = pytest.mark.django_db + + +class FakeLogStormClient: + def __init__(self): + self.payloads = [] + + def analyze_attendance(self, payload): + self.payloads.append(payload) + return {"records": []} + + +def test_payload_omits_schedule_when_eusrr_has_no_schedule(user_factory): + employee = user_factory(first_name="Ivan", last_name="Petrov") + + payload = build_logstorm_attendance_payload( + employee=employee, + period_start=date(2026, 4, 20), + period_end=date(2026, 4, 21), + ) + + assert payload["employee_id"] == str(employee.id) + assert payload["display_name"] == "Ivan Petrov" + assert payload["period_start"] == "2026-04-20" + assert payload["period_end"] == "2026-04-21" + assert "schedule" not in payload + + +def test_payload_includes_schedule_and_date_overrides(user_factory): + employee = user_factory(first_name="Ivan", last_name="Petrov") + + payload = build_logstorm_attendance_payload( + employee=employee, + period_start=date(2026, 4, 20), + period_end=date(2026, 4, 26), + schedule={ + "start_time": "09:00", + "end_time": "18:00", + "expected_hours": 9, + "workdays": ["Monday"], + }, + date_overrides=[ + { + "date": date(2026, 4, 25), + "is_workday": True, + "reason": "transferred_workday", + "start_time": "10:00", + "end_time": "16:00", + "expected_hours": 6, + } + ], + ) + + assert payload["schedule"] == { + "start_time": "09:00", + "end_time": "18:00", + "expected_hours": 9, + "workdays": ["Monday"], + "date_overrides": [ + { + "date": "2026-04-25", + "is_workday": True, + "reason": "transferred_workday", + "start_time": "10:00", + "end_time": "16:00", + "expected_hours": 6, + } + ], + } + + +def test_schedule_defaults_to_regular_workweek(): + schedule = normalize_logstorm_schedule({ + "start_time": "09:00", + "end_time": "18:00", + "expected_hours": 9, + }) + + assert schedule["workdays"] == [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + ] + assert schedule["date_overrides"] == [] + + +def test_analyze_employee_attendance_calls_logstorm_client(user_factory): + employee = user_factory(first_name="Ivan", last_name="Petrov") + client = FakeLogStormClient() + + result = analyze_employee_attendance( + employee=employee, + period_start=date(2026, 4, 20), + period_end=date(2026, 4, 20), + client=client, + ) + + assert result == {"records": []} + assert client.payloads[0]["employee_id"] == str(employee.id) + assert "schedule" not in client.payloads[0] diff --git a/backend/tests/integration/test_logstorm_attendance_api.py b/backend/tests/integration/test_logstorm_attendance_api.py new file mode 100644 index 00000000..ca1c833a --- /dev/null +++ b/backend/tests/integration/test_logstorm_attendance_api.py @@ -0,0 +1,156 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from common.logstorm_client import LogStormClientError + +pytestmark = pytest.mark.django_db + + +def _url(): + return reverse("api:v1:logstorm-attendance-analyze") + + +def _payload(employee_id, **overrides): + payload = { + "employee_id": employee_id, + "period_start": "2026-04-20", + "period_end": "2026-04-20", + } + payload.update(overrides) + return payload + + +def test_logstorm_attendance_api_allows_user_self_analysis( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + client = auth_client_factory(user) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value={"records": []}, + ): + response = client.post(_url(), _payload(user.id), format="json") + + assert response.status_code == 200 + + +def test_logstorm_attendance_api_rejects_other_employee_for_user( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + other = user_factory() + client = auth_client_factory(user) + + response = client.post(_url(), _payload(other.id), format="json") + + assert response.status_code == 403 + + +def test_logstorm_attendance_api_calls_builder_without_schedule( + 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={"records": []}, + ) as analyze: + response = client.post(_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + assert response.json() == {"records": []} + kwargs = analyze.call_args.kwargs + assert kwargs["employee"] == employee + assert kwargs["schedule"] is None + + +def test_logstorm_attendance_api_passes_schedule( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + schedule = { + "start_time": "09:00", + "end_time": "18:00", + "expected_hours": 9, + "workdays": ["Monday"], + "date_overrides": [ + {"date": "2026-04-20", "is_workday": False, "reason": "holiday"} + ], + } + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value={"records": []}, + ) as analyze: + response = client.post( + _url(), + _payload(employee.id, schedule=schedule), + format="json", + ) + + assert response.status_code == 200 + assert analyze.call_args.kwargs["schedule"]["start_time"] == "09:00" + assert ( + analyze.call_args.kwargs["schedule"]["date_overrides"][0]["is_workday"] is False + ) + + +def test_logstorm_attendance_api_rejects_invalid_period( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.post( + _url(), + { + "employee_id": staff.id, + "period_start": "2026-04-21", + "period_end": "2026-04-20", + }, + format="json", + ) + + assert response.status_code == 400 + + +def test_logstorm_attendance_api_returns_404_for_unknown_employee( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.post(_url(), _payload(999999), format="json") + + assert response.status_code == 404 + + +def test_logstorm_attendance_api_maps_logstorm_errors_to_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", + side_effect=LogStormClientError("timeout"), + ): + response = client.post(_url(), _payload(employee.id), format="json") + + assert response.status_code == 502 + assert response.json()["error"] == "logstorm_unavailable" diff --git a/backend/tests/integration/test_logstorm_client.py b/backend/tests/integration/test_logstorm_client.py new file mode 100644 index 00000000..c15e972f --- /dev/null +++ b/backend/tests/integration/test_logstorm_client.py @@ -0,0 +1,118 @@ +import pytest + +from common.logstorm_client import ( + LogStormClient, + LogStormClientError, + LogStormConfig, + load_logstorm_config, +) + + +class FakeResponse: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload + self.text = text + self.ok = 200 <= status_code < 400 + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class FakeSession: + def __init__(self, response): + self.response = response + self.requests = [] + + def post(self, url, **kwargs): + self.requests.append(("POST", url, kwargs)) + return self.response + + def get(self, url, **kwargs): + self.requests.append(("GET", url, kwargs)) + return self.response + + +def _payload(): + return { + "employee_id": "100", + "period_start": "2026-04-20", + "period_end": "2026-04-20", + "schedule": { + "start_time": "09:00", + "end_time": "18:00", + "expected_hours": 9, + "workdays": ["Monday"], + "date_overrides": [], + }, + } + + +def test_load_logstorm_config_reads_settings(settings): + settings.LOGSTORM_API_URL = "http://logstorm:8000/" + settings.LOGSTORM_API_TOKEN = "secret" + settings.LOGSTORM_TIMEOUT_SECONDS = 12 + + config = load_logstorm_config() + + assert config.base_url == "http://logstorm:8000" + assert config.token == "secret" + assert config.timeout == 12 + + +def test_analyze_attendance_posts_payload_with_bearer_token(): + session = FakeSession(FakeResponse(payload={"records": []})) + client = LogStormClient( + LogStormConfig( + base_url="http://logstorm:8000", + token="secret", + timeout=7, + ), + session=session, + ) + + result = client.analyze_attendance(_payload()) + + assert result == {"records": []} + method, url, kwargs = session.requests[0] + assert method == "POST" + assert url == "http://logstorm:8000/attendance/analyze" + assert kwargs["json"]["employee_id"] == "100" + assert kwargs["headers"]["Authorization"] == "Bearer secret" + assert kwargs["timeout"] == 7 + + +def test_analyze_attendance_raises_on_http_error(): + client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(status_code=500, text="boom")), + ) + + with pytest.raises(LogStormClientError, match="HTTP 500"): + client.analyze_attendance(_payload()) + + +def test_analyze_attendance_raises_on_invalid_json(): + client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(payload=ValueError("bad json"))), + ) + + with pytest.raises(LogStormClientError, match="invalid JSON"): + client.analyze_attendance(_payload()) + + +def test_health_returns_boolean(): + ok_client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(status_code=200, payload={})), + ) + bad_client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(status_code=503, payload={})), + ) + + assert ok_client.health() is True + assert bad_client.health() is False diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx new file mode 100644 index 00000000..f59e14a5 --- /dev/null +++ b/frontend/src/app/attendance/page.tsx @@ -0,0 +1,960 @@ +"use client"; + +import { Fragment, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + CalendarCheck, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + Loader2, + RefreshCw, + Search, +} from "lucide-react"; + +import { AppShell } from "@/components/AppShell"; +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import { loadAllPages, displayUserName } from "@/lib/shared"; +import { resolveMediaUrl } from "@/lib/url"; +import type { + MonthlyAttendanceMatrix, + MonthlyAttendanceMatrixCell, + MonthlyAttendanceMatrixEmployee, + MonthlyAttendanceMatrixRow, + LogStormSchedulePayload, +} from "@/lib/api/attendance"; +import type { User } from "@/types/api"; + +const weekdays = [ + { value: "Monday", label: "Пн" }, + { value: "Tuesday", label: "Вт" }, + { value: "Wednesday", label: "Ср" }, + { value: "Thursday", label: "Чт" }, + { value: "Friday", label: "Пт" }, + { value: "Saturday", label: "Сб" }, + { value: "Sunday", label: "Вс" }, +]; + +const defaultWorkdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; +const matrixDateColumnWidth = 140; +const matrixEmployeeColumnWidth = 160; +const matrixModalHorizontalPadding = 96; + +type AttendancePeriodPreset = "week" | "month" | "year" | "custom"; + +const periodPresets: Array<{ value: AttendancePeriodPreset; label: string }> = [ + { value: "week", label: "За неделю" }, + { value: "month", label: "За месяц" }, + { value: "year", label: "За год" }, + { value: "custom", label: "Свой период" }, +]; + +function toDateInputValue(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function getPeriodStart(preset: Exclude) { + const date = new Date(); + if (preset === "week") date.setDate(date.getDate() - 6); + if (preset === "month") date.setMonth(date.getMonth() - 1); + if (preset === "year") date.setFullYear(date.getFullYear() - 1); + return toDateInputValue(date); +} + +function getInitials(employee: User) { + const first = employee.first_name?.[0] || ""; + const last = employee.last_name?.[0] || ""; + const fallback = employee.email?.[0] || String(employee.id)[0] || ""; + return `${last}${first}` || fallback; +} + +function formatTime(value: unknown) { + if (!value) return "-"; + return String(value); +} + +function formatHours(value: unknown) { + if (value === null || value === undefined || value === "") return "0"; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return numeric.toLocaleString("ru-RU", { maximumFractionDigits: 2 }); + } + return String(value); +} + +function getErrorMessage(error: unknown, fallback: string) { + return String((error as Error)?.message || fallback); +} + +function monthKeyFromDate(value: string) { + return value.slice(0, 7); +} + +function getMonthKeysBetween(start: string, end: string) { + const result: string[] = []; + const [startYear, startMonth] = monthKeyFromDate(start).split("-").map(Number); + const [endYear, endMonth] = monthKeyFromDate(end).split("-").map(Number); + let year = startYear; + let month = startMonth; + + while (year < endYear || (year === endYear && month <= endMonth)) { + result.push(`${year}-${String(month).padStart(2, "0")}`); + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + + return result; +} + +function matrixCellTone(status: string) { + if (status === "technical") return "bg-red-500/15 text-red-200 hover:bg-red-500/20"; + if (status === "underwork" || status === "late" || status === "absent") { + return "bg-amber-500/15 text-amber-100 hover:bg-amber-500/20"; + } + if (status === "overtime") return "bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/20"; + if (status === "non_working") return "bg-slate-500/15 text-slate-300 hover:bg-slate-500/20"; + if (status === "normal") return "bg-[var(--surface-primary)] text-[var(--foreground)] hover:bg-[var(--surface-tertiary)]"; + return "bg-transparent text-[var(--muted-foreground)] hover:bg-[var(--surface-secondary)]"; +} + +function matrixStatusLabel(status: string) { + const labels: Record = { + empty: "Нет записи", + technical: "Техсбой", + underwork: "Недоработка", + late: "Опоздание", + overtime: "Переработка", + absent: "Отсутствие", + non_working: "Нерабочий", + normal: "Норма", + }; + return labels[status] || status; +} + +const attendanceIssueLabelMap: Record = { + absence: "Отсутствие", + absent: "Отсутствие", + late: "Опоздание", + early_leave: "Ранний уход", + early: "Ранний уход", + underwork: "Недоработка", + overtime: "Переработка", + technical: "Техническая проблема", + work_outside_personnel_schedule: "Работа вне графика", +}; + +function formatAttendanceIssueLabel(label: string) { + const normalized = label.trim().toLowerCase(); + return attendanceIssueLabelMap[normalized] || label; +} + +function matrixCellIssueLabels(cell: MonthlyAttendanceMatrixCell) { + const result = [...cell.issues.map(String)]; + if (cell.effective_is_workday !== false && cell.is_workday !== false) { + if (cell.is_absent) result.push("absence"); + if (cell.is_late) result.push("late"); + if (cell.is_early_leave) result.push("early_leave"); + if (cell.is_underwork) result.push("underwork"); + } + if (cell.is_overtime) result.push("overtime"); + return Array.from(new Set(result)); +} + +function calculateMatrixEmployeesPerPage(width: number) { + const availableWidth = Math.max( + 0, + width - matrixModalHorizontalPadding - matrixDateColumnWidth, + ); + return Math.min( + 8, + Math.max(1, Math.floor(availableWidth / matrixEmployeeColumnWidth)), + ); +} + +export default function AttendancePage() { + const [employees, setEmployees] = useState([]); + const [employeeSearch, setEmployeeSearch] = useState(""); + const [selectedEmployeeIds, setSelectedEmployeeIds] = useState([]); + const [periodPreset, setPeriodPreset] = useState("week"); + const [periodStart, setPeriodStart] = useState(() => getPeriodStart("week")); + const [periodEnd, setPeriodEnd] = useState(() => toDateInputValue(new Date())); + const [useManualSchedule, setUseManualSchedule] = useState(false); + const [scheduleStart, setScheduleStart] = useState("09:00"); + const [scheduleEnd, setScheduleEnd] = useState("18:00"); + const [expectedHours, setExpectedHours] = useState("9"); + const [workdays, setWorkdays] = useState(defaultWorkdays); + const [selectorOpen, setSelectorOpen] = useState(true); + const [loadingEmployees, setLoadingEmployees] = useState(true); + const [loadingStats, setLoadingStats] = useState(false); + const [error, setError] = useState(null); + const [matrices, setMatrices] = useState([]); + const [activeMonthIndex, setActiveMonthIndex] = useState(0); + const [visibleEmployeePage, setVisibleEmployeePage] = useState(0); + const [matrixEmployeesPerPage, setMatrixEmployeesPerPage] = useState(4); + const [selectedCell, setSelectedCell] = useState<{ + row: MonthlyAttendanceMatrixRow; + employee: MonthlyAttendanceMatrixEmployee; + cell: MonthlyAttendanceMatrixCell; + } | null>(null); + const [statsModalOpen, setStatsModalOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function loadEmployees() { + try { + setLoadingEmployees(true); + setError(null); + const nextEmployees = await loadAllPages((params) => + apiClient.getEmployees({ + ...params, + is_active: true, + ordering: "last_name", + }), + ); + if (!cancelled) setEmployees(nextEmployees); + } catch (loadError) { + if (!cancelled) { + setError(getErrorMessage(loadError, "Не удалось загрузить сотрудников")); + } + } finally { + if (!cancelled) setLoadingEmployees(false); + } + } + + void loadEmployees(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + function updateMatrixEmployeesPerPage() { + setMatrixEmployeesPerPage( + calculateMatrixEmployeesPerPage(window.innerWidth), + ); + } + + updateMatrixEmployeesPerPage(); + window.addEventListener("resize", updateMatrixEmployeesPerPage); + return () => window.removeEventListener("resize", updateMatrixEmployeesPerPage); + }, []); + + const filteredEmployees = useMemo(() => { + const query = employeeSearch.trim().toLowerCase(); + if (!query) return employees; + return employees.filter((employee) => { + const fullName = displayUserName(employee).toLowerCase(); + const position = (employee.position?.name || "").toLowerCase(); + return ( + fullName.includes(query) + || position.includes(query) + || (employee.email || "").toLowerCase().includes(query) + || String(employee.id).includes(query) + ); + }); + }, [employees, employeeSearch]); + + const selectedEmployees = useMemo(() => { + const selectedIds = new Set(selectedEmployeeIds); + return employees.filter((employee) => selectedIds.has(employee.id)); + }, [employees, selectedEmployeeIds]); + + const activeMatrix = matrices[activeMonthIndex] || null; + const visibleEmployeeStart = visibleEmployeePage * matrixEmployeesPerPage; + const visibleEmployees = activeMatrix + ? activeMatrix.employees.slice( + visibleEmployeeStart, + visibleEmployeeStart + matrixEmployeesPerPage, + ) + : []; + const visibleEmployeeEnd = Math.min( + visibleEmployeeStart + visibleEmployees.length, + activeMatrix?.employees.length || 0, + ); + const hasPreviousEmployeePage = visibleEmployeePage > 0; + const hasNextEmployeePage = activeMatrix + ? visibleEmployeeEnd < activeMatrix.employees.length + : false; + + useEffect(() => { + if (!activeMatrix) { + setVisibleEmployeePage(0); + return; + } + + const maxPage = Math.max( + 0, + Math.ceil(activeMatrix.employees.length / matrixEmployeesPerPage) - 1, + ); + setVisibleEmployeePage((current) => Math.min(current, maxPage)); + }, [activeMatrix, matrixEmployeesPerPage]); + + function toggleEmployee(employeeId: number) { + setSelectedEmployeeIds((current) => + current.includes(employeeId) + ? current.filter((currentId) => currentId !== employeeId) + : [...current, employeeId], + ); + } + + function selectVisibleEmployees() { + setSelectedEmployeeIds((current) => { + const next = new Set(current); + filteredEmployees.forEach((employee) => next.add(employee.id)); + return Array.from(next); + }); + } + + function clearSelectedEmployees() { + setSelectedEmployeeIds([]); + setMatrices([]); + setSelectedCell(null); + } + + function toggleWorkday(day: string) { + setWorkdays((current) => + current.includes(day) + ? current.filter((currentDay) => currentDay !== day) + : [...current, day], + ); + } + + function selectPeriodPreset(nextPreset: AttendancePeriodPreset) { + setPeriodPreset(nextPreset); + setMatrices([]); + setSelectedCell(null); + if (nextPreset !== "custom") { + setPeriodStart(getPeriodStart(nextPreset)); + setPeriodEnd(toDateInputValue(new Date())); + } + } + + function getSchedulePayload(): LogStormSchedulePayload | undefined { + if (!useManualSchedule) return undefined; + return { + start_time: scheduleStart, + end_time: scheduleEnd, + expected_hours: Number(expectedHours), + workdays, + // TODO: Send date_overrides from EUSRR when schedule UI can store working/weekend days. + date_overrides: [], + }; + } + + function validateStatsRequest() { + if (selectedEmployees.length === 0) { + setError("Выберите хотя бы одного сотрудника"); + return false; + } + + if (!periodStart || !periodEnd || periodStart > periodEnd) { + setError("Проверьте период анализа"); + return false; + } + + return true; + } + + async function loadStatistics(options?: { refreshFromLogStorm?: boolean }) { + setError(null); + setMatrices([]); + setSelectedCell(null); + + if (!validateStatsRequest()) return; + + try { + setLoadingStats(true); + const schedule = getSchedulePayload(); + const employeeIds = selectedEmployees.map((employee) => employee.id).join(","); + + for (const employee of selectedEmployees) { + if (options?.refreshFromLogStorm) { + await apiClient.analyzeLogStormAttendance({ + employee_id: employee.id, + period_start: periodStart, + period_end: periodEnd, + schedule, + }); + } + } + + const monthKeys = getMonthKeysBetween(periodStart, periodEnd); + const nextMatrices = await Promise.all( + monthKeys.map((month) => + apiClient.getMonthlyAttendanceMatrix({ + employee_ids: employeeIds, + month, + }), + ), + ); + + setMatrices(nextMatrices); + setActiveMonthIndex(Math.max(0, nextMatrices.length - 1)); + setVisibleEmployeePage(0); + setStatsModalOpen(true); + } catch (statsError) { + setError(getErrorMessage(statsError, "Не удалось получить статистику посещаемости")); + } finally { + setLoadingStats(false); + } + } + + return ( + +
+
+ + + {selectorOpen ? ( +
+
+
+
+ + setEmployeeSearch(event.target.value)} + placeholder="Выберите сотрудников для статистики" + className="app-input w-full rounded-xl py-2.5 pl-10 pr-3 text-sm" + /> +
+ +
+ + Найдено: {loadingEmployees ? "..." : filteredEmployees.length} + + + + Выбрано: {selectedEmployeeIds.length} + + + + +
+ +
+ {loadingEmployees ? ( +
+ + Загрузка сотрудников +
+ ) : null} + + {!loadingEmployees && filteredEmployees.length === 0 ? ( +

Сотрудники не найдены

+ ) : null} + + {!loadingEmployees && filteredEmployees.map((employee) => { + const active = selectedEmployeeIds.includes(employee.id); + const fullName = displayUserName(employee); + const subtitle = employee.email || employee.position?.name || `ID ${employee.id}`; + + return ( + + ); + })} +
+ +
+ {periodPresets.map((periodOption) => { + const isActive = periodPreset === periodOption.value; + return ( + + ); + })} +
+ +

+ {periodStart} — {periodEnd} +

+ + {periodPreset === "custom" ? ( +
+ + +
+ ) : null} +
+ +
+ + + {useManualSchedule ? ( +
+
+ + + +
+ +
+ {weekdays.map((day) => { + const active = workdays.includes(day.value); + return ( + + ); + })} +
+
+ ) : null} +
+ +
+ + +
+
+ + {error ? ( +
+ + {error} +
+ ) : null} +
+ ) : null} +
+
+ + { + setStatsModalOpen(false); + setSelectedCell(null); + }} + title="Статистика посещаемости" + size="xl" + > +
+ {!activeMatrix ? ( +
+

Записей за выбранный период нет

+
+ ) : ( + <> +
+

+ {periodStart} — {periodEnd}. Сотрудников: {activeMatrix.employees.length} +

+
+
+ + + {activeMatrix.month_label} + + +
+ {activeMatrix.employees.length > matrixEmployeesPerPage ? ( +
+ + + {visibleEmployeeStart + 1}-{visibleEmployeeEnd} из {activeMatrix.employees.length} + + +
+ ) : null} +
+
+ +
+ + + + + {visibleEmployees.map((employee) => ( + + ))} + + + {visibleEmployees.map((employee) => ( + + + + + ))} + + + + {activeMatrix.rows.map((row) => ( + + + {visibleEmployees.map((employee) => { + const cell = row.cells[String(employee.id)]; + const active = selectedCell?.row.date === row.date + && selectedCell.employee.id === employee.id; + return ( + + + + + ); + })} + + ))} + {activeMatrix.summary.map((summaryRow) => ( + + + {visibleEmployees.map((employee) => ( + + ))} + + ))} + +
+ Дата + + {employee.name} +
+ Приход + + Уход +
+ {row.label} + + + + +
+ {summaryRow.label} + + {summaryRow.values[String(employee.id)] ?? 0} +
+
+ + {selectedCell ? ( +
+
+
+

+ {selectedCell.employee.name} +

+

+ {selectedCell.row.date} · {selectedCell.row.weekday} +

+
+ + {matrixStatusLabel(selectedCell.cell.status)} + +
+
+
+

Приход

+

{formatTime(selectedCell.cell.arrival_time)}

+
+
+

Уход

+

{formatTime(selectedCell.cell.departure_time)}

+
+
+

Часы

+

+ {formatHours(selectedCell.cell.work_hours)} / {formatHours(selectedCell.cell.expected_hours)} +

+
+
+

Комментарии

+

{selectedCell.cell.comments_count}

+
+
+ {selectedCell.cell.personnel_status_label ? ( +

+ Кадровое состояние: {selectedCell.cell.personnel_status_label} +

+ ) : selectedCell.cell.non_working_reason ? ( +

+ Причина нерабочего дня: {selectedCell.cell.non_working_reason} +

+ ) : null} + {matrixCellIssueLabels(selectedCell.cell).length > 0 ? ( +
+ {matrixCellIssueLabels(selectedCell.cell).map((issue) => ( + + {formatAttendanceIssueLabel(issue)} + + ))} +
+ ) : null} +
+ ) : ( +

+ Нажмите на ячейку месяца, чтобы открыть детали дня. +

+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 8f642d57..0e968166 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -310,10 +310,16 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { display: inline-flex; align-items: center; justify-content: center; + min-width: 0; + max-width: 100%; + flex-wrap: wrap; border-radius: 9999px; border: 1px solid var(--border-subtle); background: var(--surface-secondary); color: var(--muted-foreground); + overflow-wrap: anywhere; + text-align: center; + white-space: normal; } .app-badge-accent { @@ -333,14 +339,18 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { align-items: center; justify-content: center; width: fit-content; + max-width: 100%; + min-width: 0; min-height: 1.75rem; - flex-shrink: 0; + flex-shrink: 1; border-radius: 9999px; padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 500; - line-height: 1; - white-space: nowrap; + line-height: 1.2; + overflow-wrap: anywhere; + text-align: center; + white-space: normal; } .app-avatar-frame { @@ -453,6 +463,11 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { border: 1px solid transparent; background: var(--surface-secondary); color: var(--foreground); + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + text-align: center; + white-space: normal; } .app-pill:hover { @@ -463,6 +478,11 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { border-color: color-mix(in srgb, var(--accent-primary) 22%, var(--border-subtle)); background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary)); color: var(--foreground); + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + text-align: center; + white-space: normal; } .app-pill-count { diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 60ce08d7..0b8a7949 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { Mail, MessageCircle, Phone, RefreshCw } from "lucide-react"; import { AppShell } from "@/components/AppShell"; +import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard"; import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline"; import { ProfileContactsPanel, @@ -186,6 +187,8 @@ export default function ProfilePage() { truncateCommentLength={120} expandedCommentLength={240} /> + + ); diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index c4c78f6a..e2216966 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; +import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard"; import ChangeUserPositionModal from "@/components/users/ChangeUserPositionModal"; import EditUserProfileModal from "@/components/users/EditUserProfileModal"; import EmployeeActionModal from "@/components/users/EmployeeActionModal"; @@ -523,6 +524,8 @@ export default function UserDetailPage() { truncateCommentLength={120} expandedCommentLength={240} /> + + ) : null} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index a97900fb..522473e2 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,6 +1,6 @@ "use client"; -import { Building2, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react"; +import { Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react"; @@ -43,6 +43,7 @@ const navItems = [ { href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" }, { href: "/employees", label: "Сотрудники", icon: Users }, { href: "/departments", label: "Отделы", icon: Building2 }, + { href: "/attendance", label: "Посещаемость", icon: CalendarCheck }, { href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" }, { href: "/equipment", label: "Оборудование", icon: Monitor }, { href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки" }, diff --git a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx new file mode 100644 index 00000000..6a4bb6e0 --- /dev/null +++ b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx @@ -0,0 +1,1367 @@ +"use client"; + +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { + AlertTriangle, + CalendarCheck, + CalendarClock, + ChevronDown, + ChevronRight, + Clock, + Edit3, + Loader2, + LogIn, + LogOut, + MessageSquare, + RefreshCw, +} from "lucide-react"; + +import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; +import { Modal } from "@/components/ui"; +import { useUser } from "@/contexts/UserContext"; +import { apiClient } from "@/lib/api"; +import { displayUserName, formatDate } from "@/lib/shared"; +import type { + AttendanceRecordComment, + LogStormAttendanceRecord, + LogStormAttendanceRecordUpdatePayload, + LogStormAttendanceResponse, +} from "@/lib/api/attendance"; +import type { EmployeeAction } from "@/types/api"; + +type EmployeeAttendanceCardProps = { + employeeId: number; + employeeActions?: EmployeeAction[] | null; +}; + +const attendancePeriods = [ + { value: "week", label: "За неделю" }, + { value: "month", label: "За месяц" }, + { value: "year", label: "За год" }, + { value: "custom", label: "Свой период" }, +] as const; + +type AttendancePeriod = (typeof attendancePeriods)[number]["value"]; + +const scheduleWeekdays = [ + { value: "Monday", label: "Пн" }, + { value: "Tuesday", label: "Вт" }, + { value: "Wednesday", label: "Ср" }, + { value: "Thursday", label: "Чт" }, + { value: "Friday", label: "Пт" }, + { value: "Saturday", label: "Сб" }, + { value: "Sunday", label: "Вс" }, +]; + +const defaultWorkdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; + +function toDateInputValue(date: Date) { + return date.toISOString().slice(0, 10); +} + +function getPeriodRange(period: Exclude) { + const end = new Date(); + const start = new Date(end); + + if (period === "week") { + start.setDate(start.getDate() - 6); + } else if (period === "month") { + start.setMonth(start.getMonth() - 1); + } else { + start.setFullYear(start.getFullYear() - 1); + } + + return { + start: toDateInputValue(start), + end: toDateInputValue(end), + }; +} + +function formatTime(value: unknown) { + if (!value) return "Нет"; + return String(value); +} + +function formatHours(value: unknown) { + if (value === null || value === undefined || value === "") return "0"; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return numeric.toLocaleString("ru-RU", { maximumFractionDigits: 2 }); + } + return String(value); +} + +function formatDateLabel(value: unknown) { + if (!value) return "Нет даты"; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleDateString("ru-RU", { + day: "2-digit", + month: "short", + weekday: "short", + }); +} + +type PersonnelDayMeta = { + action: EmployeeAction; + className: string; + label: string; + nonWorking: boolean; +}; + +type AttendanceRecordEditForm = { + recordId: number; + arrival_time: string; + departure_time: string; + work_hours: string; + expected_hours: string; + is_workday: boolean; + effective_is_workday: boolean; + is_late: boolean; + late_minutes: string; + is_early_leave: boolean; + early_leave_minutes: string; + is_underwork: boolean; + underwork_hours: string; + is_overtime: boolean; + overtime_hours: string; + is_absent: boolean; +}; + +type AttendanceRecordBooleanEditField = { + [Key in keyof AttendanceRecordEditForm]: AttendanceRecordEditForm[Key] extends boolean ? Key : never; +}[keyof AttendanceRecordEditForm]; + +const attendanceRecordBooleanFields: Array<{ + key: AttendanceRecordBooleanEditField; + label: string; +}> = [ + { key: "is_workday", label: "Рабочий по календарю" }, + { key: "effective_is_workday", label: "Учитывать как рабочий" }, + { key: "is_absent", label: "Отсутствие" }, + { key: "is_late", label: "Опоздание" }, + { key: "is_early_leave", label: "Ранний уход" }, + { key: "is_underwork", label: "Недоработка" }, + { key: "is_overtime", label: "Переработка" }, +]; + +const suppressedByPersonnelStateMarkers = [ + "absence", + "absent", + "late", + "early", + "underwork", + "отсутств", + "опозд", + "ранн", + "недоработ", +]; + +const attendanceIssueLabelMap: Record = { + absence: "Отсутствие", + absent: "Отсутствие", + late: "Опоздание", + early_leave: "Ранний уход", + early: "Ранний уход", + underwork: "Недоработка", + overtime: "Переработка", + technical: "Техническая проблема", + work_outside_personnel_schedule: "Работа вне графика", +}; + +function formatAttendanceIssueLabel(label: string) { + const normalized = label.trim().toLowerCase(); + return attendanceIssueLabelMap[normalized] || label; +} + +function hasWorked(record: LogStormAttendanceRecord) { + const hours = Number(record.work_hours || 0); + return Boolean(record.arrival_time || record.departure_time || hours > 0); +} + +function getDateEnd(value: unknown) { + if (!value) return null; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return null; + date.setHours(23, 59, 59, 999); + return date; +} + +function getPersonnelDayMeta( + actions: EmployeeAction[] | null | undefined, + dateValue: unknown, + record?: LogStormAttendanceRecord, +): PersonnelDayMeta | null { + if (record?.personnel_status && record.personnel_status !== "normal") { + return { + action: { + id: Number(record.personnel_action || 0), + employee: 0, + action: record.personnel_status, + action_display: record.personnel_status_label || record.personnel_status, + date: String(dateValue || ""), + }, + className: record.personnel_status === "dismissed" ? "text-red-400" : "text-amber-500", + label: record.personnel_status_label || record.personnel_status, + nonWorking: record.effective_is_workday === false, + }; + } + + if (!actions?.length) return null; + + const dayEnd = getDateEnd(dateValue); + if (!dayEnd) return null; + + const activeAction = actions + .filter((action) => { + const actionDate = new Date(action.date); + return !Number.isNaN(actionDate.getTime()) && actionDate <= dayEnd; + }) + .sort((left, right) => new Date(right.date).getTime() - new Date(left.date).getTime())[0]; + + if (!activeAction) return null; + + if (activeAction.action === "on_leave") { + return { + action: activeAction, + className: "text-amber-500", + label: activeAction.action_display || "В отпуске", + nonWorking: true, + }; + } + + if (activeAction.action === "on_sick_leave") { + return { + action: activeAction, + className: "text-amber-500", + label: activeAction.action_display || "На больничном", + nonWorking: true, + }; + } + + if (activeAction.action === "on_day_off") { + return { + action: activeAction, + className: "text-amber-500", + label: activeAction.action_display || "В отгуле", + nonWorking: true, + }; + } + + if (activeAction.action === "on_maternity") { + return { + action: activeAction, + className: "text-amber-500", + label: activeAction.action_display || "В декрете", + nonWorking: true, + }; + } + + if (activeAction.action === "dismissed") { + return { + action: activeAction, + className: "text-red-400", + label: activeAction.action_display || "Уволен", + nonWorking: true, + }; + } + + return null; +} + +function issueLabels(record: LogStormAttendanceRecord, personnelMeta?: PersonnelDayMeta | null) { + const labels = [ + ...(record.statuses || []), + ...(record.employee_issues || []), + ...(record.technical_issues || []), + ].map(String); + const nonWorking = personnelMeta?.nonWorking || record.effective_is_workday === false || record.is_workday === false; + + const effectiveLabels = nonWorking + ? labels.filter((label) => { + const normalized = label.toLowerCase(); + return !suppressedByPersonnelStateMarkers.some((marker) => normalized.includes(marker)); + }) + : labels; + + const result = [...effectiveLabels]; + if (!nonWorking) { + if (record.is_absent) result.push("absence"); + if (record.is_late) result.push("late"); + if (record.is_early_leave) result.push("early_leave"); + if (record.is_underwork) result.push("underwork"); + } + if (record.is_overtime) result.push("overtime"); + return Array.from(new Set(result)); +} + +function getRecordTone(record: LogStormAttendanceRecord, personnelMeta?: PersonnelDayMeta | null) { + if (personnelMeta?.nonWorking && hasWorked(record)) { + return { + dotClassName: "bg-sky-500", + pillClassName: "app-selected", + label: "Работа вне графика", + }; + } + + const labels = issueLabels(record, personnelMeta); + + if (labels.length > 0) { + return { + dotClassName: "bg-amber-500", + pillClassName: "app-feedback-warning", + label: "Есть замечания", + }; + } + + if (record.is_workday === false && record.is_overtime) { + return { + dotClassName: "bg-sky-500", + pillClassName: "app-selected", + label: "Работа вне графика", + }; + } + + if (record.is_workday === false) { + return { + dotClassName: "bg-slate-400", + pillClassName: "app-badge", + label: "Нерабочий", + }; + } + + return { + dotClassName: "bg-emerald-500", + pillClassName: "app-feedback-success", + label: "Без замечаний", + }; +} + +function getWorkdayMeta(record: LogStormAttendanceRecord, personnelMeta?: PersonnelDayMeta | null) { + if (personnelMeta?.nonWorking) { + return { + className: personnelMeta.className, + label: personnelMeta.label, + reason: record.non_working_reason || personnelMeta.label, + }; + } + + if (record.is_workday) { + return { + className: "text-amber-600", + label: "Рабочий день", + reason: "", + }; + } + + return { + className: "text-gray-500", + label: "Нерабочий день", + reason: record.non_working_reason || "Выходной по графику/календарю", + }; +} + +function getIssueBadgeClassName(label: string) { + const normalized = label.toLowerCase(); + + if (normalized.includes("overtime")) return "app-selected"; + if (normalized.includes("technical")) return "app-badge"; + if (normalized.includes("absence") || normalized.includes("late") || normalized.includes("early") || normalized.includes("underwork")) { + return "app-feedback-warning"; + } + + return "app-badge"; +} + +function getErrorMessage(error: unknown, fallback: string) { + return String((error as Error)?.message || fallback); +} + +function getRecordId(record: LogStormAttendanceRecord) { + const value = Number(record.id); + return Number.isFinite(value) && value > 0 ? value : null; +} + +function valueToEditString(value: unknown) { + if (value === null || value === undefined) return ""; + return String(value); +} + +function recordToEditForm(record: LogStormAttendanceRecord): AttendanceRecordEditForm | null { + const recordId = getRecordId(record); + if (!recordId) return null; + + return { + recordId, + arrival_time: valueToEditString(record.arrival_time), + departure_time: valueToEditString(record.departure_time), + work_hours: valueToEditString(record.work_hours), + expected_hours: valueToEditString(record.expected_hours), + is_workday: Boolean(record.is_workday), + effective_is_workday: record.effective_is_workday ?? Boolean(record.is_workday), + is_late: Boolean(record.is_late), + late_minutes: valueToEditString(record.late_minutes), + is_early_leave: Boolean(record.is_early_leave), + early_leave_minutes: valueToEditString(record.early_leave_minutes), + is_underwork: Boolean(record.is_underwork), + underwork_hours: valueToEditString(record.underwork_hours), + is_overtime: Boolean(record.is_overtime), + overtime_hours: valueToEditString(record.overtime_hours), + is_absent: Boolean(record.is_absent), + }; +} + +function nullableNumber(value: string) { + if (value.trim() === "") return null; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function nullableTime(value: string) { + const trimmed = value.trim(); + return trimmed || null; +} + +export default function EmployeeAttendanceCard({ + employeeActions, + employeeId, +}: EmployeeAttendanceCardProps) { + const { user: currentUser } = useUser(); + const defaultRange = useMemo(() => getPeriodRange("week"), []); + const [period, setPeriod] = useState("week"); + const [periodStart, setPeriodStart] = useState(defaultRange.start); + const [periodEnd, setPeriodEnd] = useState(defaultRange.end); + const [loading, setLoading] = useState(false); + const [loadingMode, setLoadingMode] = useState<"saved" | "analyze" | null>(null); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [expandedRecordKey, setExpandedRecordKey] = useState(null); + const [expandedComments, setExpandedComments] = useState>({}); + const [commentsMap, setCommentsMap] = useState>({}); + const [commentsLoadingMap, setCommentsLoadingMap] = useState>({}); + const [commentDrafts, setCommentDrafts] = useState>({}); + const [actionsOpen, setActionsOpen] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [scheduleModalOpen, setScheduleModalOpen] = useState(false); + const [editForm, setEditForm] = useState(null); + const [useCustomSchedule, setUseCustomSchedule] = useState(false); + const [scheduleStart, setScheduleStart] = useState("08:00"); + const [scheduleEnd, setScheduleEnd] = useState("17:00"); + const [scheduleExpectedHours, setScheduleExpectedHours] = useState("9"); + const [scheduleWorkdays, setScheduleWorkdays] = useState(defaultWorkdays); + + const canManageAttendance = Boolean( + currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser, + ); + + const records = useMemo(() => { + if (!result?.records || !Array.isArray(result.records)) return []; + return result.records; + }, [result]); + + const summary = useMemo(() => { + const issueDays = records.filter((record) => { + const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record); + return issueLabels(record, personnelMeta).length > 0; + }).length; + return { + total: records.length, + workdays: records.filter((record) => { + const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record); + return (record.effective_is_workday ?? record.is_workday) && !personnelMeta?.nonWorking; + }).length, + issueDays, + overtime: records.filter((record) => record.is_overtime).length, + }; + }, [employeeActions, records]); + + async function analyzeAttendance() { + setError(null); + + if (!employeeId) { + setError("Некорректный сотрудник"); + return; + } + + if (!periodStart || !periodEnd || periodStart > periodEnd) { + setError("Проверьте период анализа"); + return; + } + + try { + setLoading(true); + setLoadingMode("analyze"); + await apiClient.analyzeLogStormAttendance({ + employee_id: employeeId, + period_start: periodStart, + period_end: periodEnd, + schedule: useCustomSchedule + ? { + start_time: scheduleStart, + end_time: scheduleEnd, + expected_hours: Number(scheduleExpectedHours) || 0, + workdays: scheduleWorkdays, + date_overrides: [], + } + : undefined, + }); + const savedRecords = await apiClient.getLogStormAttendanceRecords({ + employee_id: employeeId, + date_from: periodStart, + date_to: periodEnd, + }); + setResult({ records: savedRecords.results }); + setExpandedRecordKey(null); + setExpandedComments({}); + setCommentsMap({}); + } catch (analysisError) { + setError(getErrorMessage(analysisError, "Не удалось выполнить анализ посещаемости")); + } finally { + setLoading(false); + setLoadingMode(null); + } + } + + function handlePeriodChange(nextPeriod: AttendancePeriod) { + setPeriod(nextPeriod); + setError(null); + setResult(null); + setExpandedRecordKey(null); + setExpandedComments({}); + + if (nextPeriod === "custom") return; + + const range = getPeriodRange(nextPeriod); + setPeriodStart(range.start); + setPeriodEnd(range.end); + } + + useEffect(() => { + let cancelled = false; + + async function loadSavedRecords() { + if (!employeeId || !periodStart || !periodEnd || periodStart > periodEnd) { + setResult(null); + return; + } + + try { + setLoading(true); + setLoadingMode("saved"); + setError(null); + const response = await apiClient.getLogStormAttendanceRecords({ + employee_id: employeeId, + date_from: periodStart, + date_to: periodEnd, + }); + if (!cancelled) { + setResult({ records: response.results }); + setExpandedRecordKey(null); + } + } catch (loadError) { + if (!cancelled) { + setError(getErrorMessage(loadError, "Не удалось загрузить сохраненную посещаемость")); + setResult(null); + } + } finally { + if (!cancelled) { + setLoading(false); + setLoadingMode(null); + } + } + } + + void loadSavedRecords(); + + return () => { + cancelled = true; + }; + }, [employeeId, periodEnd, periodStart]); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + void analyzeAttendance(); + } + + function updateRecordCommentCount(recordId: number, count: number) { + setResult((current) => { + if (!current?.records) return current; + return { + ...current, + records: current.records.map((record) => + getRecordId(record) === recordId + ? { ...record, comments_count: count } + : record, + ), + }; + }); + } + + async function ensureCommentsLoaded(recordId: number) { + if (commentsMap[recordId] || commentsLoadingMap[recordId]) return; + + try { + setCommentsLoadingMap((current) => ({ ...current, [recordId]: true })); + const comments = await apiClient.getAttendanceRecordComments(recordId); + setCommentsMap((current) => ({ ...current, [recordId]: comments })); + updateRecordCommentCount(recordId, comments.length); + } catch (commentError) { + setError(getErrorMessage(commentError, "Не удалось загрузить комментарии")); + } finally { + setCommentsLoadingMap((current) => ({ ...current, [recordId]: false })); + } + } + + async function toggleComments(recordId: number) { + const isOpen = Boolean(expandedComments[recordId]); + setExpandedComments((current) => ({ ...current, [recordId]: !isOpen })); + + if (!isOpen) { + await ensureCommentsLoaded(recordId); + } + } + + async function addComment(recordId: number) { + const text = (commentDrafts[recordId] || "").trim(); + if (!text) return; + + try { + setBusyKey(`comment-${recordId}`); + const saved = await apiClient.addAttendanceRecordComment(recordId, text); + const nextCount = (commentsMap[recordId]?.length || 0) + 1; + setCommentsMap((current) => { + return { ...current, [recordId]: [...(current[recordId] || []), saved] }; + }); + updateRecordCommentCount(recordId, nextCount); + setCommentDrafts((current) => ({ ...current, [recordId]: "" })); + } catch (commentError) { + setError(getErrorMessage(commentError, "Не удалось добавить комментарий")); + } finally { + setBusyKey(null); + } + } + + async function deleteComment(recordId: number, commentId: number) { + try { + setBusyKey(`delete-comment-${commentId}`); + await apiClient.deleteAttendanceRecordComment(recordId, commentId); + const nextCount = Math.max((commentsMap[recordId]?.length || 1) - 1, 0); + setCommentsMap((current) => { + return { + ...current, + [recordId]: (current[recordId] || []).filter((comment) => comment.id !== commentId), + }; + }); + updateRecordCommentCount(recordId, nextCount); + } catch (commentError) { + setError(getErrorMessage(commentError, "Не удалось удалить комментарий")); + } finally { + setBusyKey(null); + } + } + + function editableRecords() { + return records.filter((record) => getRecordId(record)); + } + + function openEditModal() { + const firstRecord = editableRecords()[0]; + const form = firstRecord ? recordToEditForm(firstRecord) : null; + + if (!form) { + setError("Нет сохраненных записей для редактирования"); + setActionsOpen(false); + return; + } + + setEditForm(form); + setEditModalOpen(true); + setActionsOpen(false); + } + + function openScheduleModal() { + setScheduleModalOpen(true); + setActionsOpen(false); + } + + function handleEditRecordSelect(recordId: string) { + const selectedRecord = records.find((record) => getRecordId(record) === Number(recordId)); + const form = selectedRecord ? recordToEditForm(selectedRecord) : null; + if (form) setEditForm(form); + } + + function updateEditForm( + key: K, + value: AttendanceRecordEditForm[K], + ) { + setEditForm((current) => current ? { ...current, [key]: value } : current); + } + + async function saveEditedRecord() { + if (!editForm) return; + + const payload: LogStormAttendanceRecordUpdatePayload = { + arrival_time: nullableTime(editForm.arrival_time), + departure_time: nullableTime(editForm.departure_time), + work_hours: nullableNumber(editForm.work_hours), + expected_hours: nullableNumber(editForm.expected_hours), + is_workday: editForm.is_workday, + effective_is_workday: editForm.effective_is_workday, + is_late: editForm.is_late, + late_minutes: nullableNumber(editForm.late_minutes), + is_early_leave: editForm.is_early_leave, + early_leave_minutes: nullableNumber(editForm.early_leave_minutes), + is_underwork: editForm.is_underwork, + underwork_hours: nullableNumber(editForm.underwork_hours), + is_overtime: editForm.is_overtime, + overtime_hours: nullableNumber(editForm.overtime_hours), + is_absent: editForm.is_absent, + }; + + try { + setBusyKey("edit-record"); + const saved = await apiClient.updateAttendanceRecord(editForm.recordId, payload); + setResult((current) => { + if (!current?.records) return current; + return { + ...current, + records: current.records.map((record) => + getRecordId(record) === editForm.recordId + ? { ...record, ...saved } + : record, + ), + }; + }); + setEditModalOpen(false); + } catch (saveError) { + setError(getErrorMessage(saveError, "Не удалось сохранить запись посещаемости")); + } finally { + setBusyKey(null); + } + } + + function toggleScheduleWorkday(day: string) { + setScheduleWorkdays((current) => + current.includes(day) + ? current.filter((item) => item !== day) + : [...current, day], + ); + } + + return ( + <> +
+
+
+
+
+ + Посещаемость +
+

Анализ проходов LogStorm по сотруднику

+
+ +
+ + + {canManageAttendance ? ( +
+ + + {actionsOpen ? ( +
+ + +
+ ) : null} +
+ ) : null} +
+
+ +
+
+ {attendancePeriods.map((periodOption) => { + const active = period === periodOption.value; + return ( + + ); + })} +
+ + {period === "custom" ? ( +
+ + +
+ ) : ( +

+ {periodStart} — {periodEnd} +

+ )} +
+
+ +
+
+

Записей

+

{summary.total}

+
+
+

Рабочих

+

{summary.workdays}

+
+
+

Проблем

+

{summary.issueDays}

+
+
+

Переработок

+

{summary.overtime}

+
+
+ + {error ? ( +
+ + {error} +
+ ) : null} + +
+ {loading && !result ? ( +
+ + {loadingMode === "analyze" ? "Выполняем анализ" : "Загружаем сохраненную посещаемость"} +
+ ) : null} + + {!loading && result && records.length === 0 ? ( +
+

+ Сохраненных записей за период нет. Нажмите «Обновить», чтобы выполнить анализ. +

+
+ ) : null} + + {records.length > 0 ? ( +
+ {records.map((record, index) => { + const recordId = getRecordId(record); + const key = recordId ? `record-${recordId}` : `${record.date || "record"}-${index}`; + const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record); + const labels = issueLabels(record, personnelMeta); + const tone = getRecordTone(record, personnelMeta); + const workdayMeta = getWorkdayMeta(record, personnelMeta); + const expanded = expandedRecordKey === key; + const commentsOpen = recordId ? Boolean(expandedComments[recordId]) : false; + const comments = recordId ? (commentsMap[recordId] || []) : []; + const commentsCount = comments.length || Number(record.comments_count || 0); + const commentsLoading = recordId ? Boolean(commentsLoadingMap[recordId]) : false; + const commentDraft = recordId ? (commentDrafts[recordId] || "") : ""; + + return ( +
+
+
+ + +
+ + +
+ + {(expanded || commentsOpen) ? ( +
+ {commentsOpen ? ( +
+
+ {commentsLoading ? ( +

Загружаем комментарии

+ ) : comments.length === 0 ? ( +

Комментариев пока нет

+ ) : ( + comments.map((comment) => ( +
+
+ {displayUserName(comment.author)} +
+ {formatDate(comment.created_at)} + {Boolean(comment.author?.id && currentUser?.id === comment.author.id) ? ( + recordId ? deleteComment(recordId, comment.id) : undefined} + /> + ) : null} +
+
+

{comment.text}

+
+ )) + )} +
+ {recordId ? ( +
+ setCommentDrafts((current) => ({ ...current, [recordId]: value }))} + onSubmit={() => addComment(recordId)} + disabled={busyKey === `comment-${recordId}`} + /> +
+ ) : null} +
+ ) : null} + + {expanded ? ( +
+

Детали

+
+
+ {personnelMeta?.nonWorking ? ( +
+

Кадровое состояние

+

+ {personnelMeta.label} +

+
+ ) : null} +
+

Опоздание

+

+ {record.is_late && !personnelMeta?.nonWorking ? `${record.late_minutes ?? 0} мин.` : "Нет"} +

+
+
+

Ранний уход

+

+ {record.is_early_leave && !personnelMeta?.nonWorking ? `${record.early_leave_minutes ?? 0} мин.` : "Нет"} +

+
+
+

Недоработка

+

+ {record.is_underwork && !personnelMeta?.nonWorking ? `${formatHours(record.underwork_hours)} ч.` : "Нет"} +

+
+
+

Переработка

+

+ {record.is_overtime ? `${formatHours(record.overtime_hours)} ч.` : "Нет"} +

+
+
+ + {labels.length > 0 ? ( +
+ {labels.map((label) => ( + + {formatAttendanceIssueLabel(label)} + + ))} +
+ ) : ( +

Замечаний нет

+ )} +
+
+ ) : null} +
+ ) : null} +
+ ); + })} +
+ ) : null} +
+
+ + setEditModalOpen(false)} + title="Редактировать посещаемость" + size="md" + footer={( +
+ + +
+ )} + > + {!editForm ? ( +
+ Нет сохраненных записей для редактирования. +
+ ) : ( +
+ + +
+ + + + +
+ +
+

Признаки дня

+
+ {attendanceRecordBooleanFields.map(({ key, label }) => ( + + ))} +
+
+ +
+ + + + +
+
+ )} +
+ + setScheduleModalOpen(false)} + title="График анализа" + size="md" + footer={( +
+ +
+ )} + > +
+ + +
+ + + +
+ +
+

Рабочие дни

+
+ {scheduleWeekdays.map((day) => { + const active = scheduleWorkdays.includes(day.value); + return ( + + ); + })} +
+
+ +

+ Этот график применяется к следующему анализу LogStorm и не заменяет производственный календарь EUSRR. +

+
+
+ + ); +} diff --git a/frontend/src/lib/api/attendance.ts b/frontend/src/lib/api/attendance.ts new file mode 100644 index 00000000..cd483dd7 --- /dev/null +++ b/frontend/src/lib/api/attendance.ts @@ -0,0 +1,192 @@ +import { buildQuery, type RequestFn } from './utils'; +import type { User } from '@/types/api'; + +export type LogStormDateOverridePayload = { + date: string; + is_workday: boolean; + reason?: string; + start_time?: string; + end_time?: string; + expected_hours?: number; +}; + +export type LogStormSchedulePayload = { + start_time: string; + end_time: string; + expected_hours: number; + workdays: string[]; + date_overrides?: LogStormDateOverridePayload[]; +}; + +export type LogStormAttendanceAnalyzePayload = { + employee_id: number; + period_start: string; + period_end: string; + schedule?: LogStormSchedulePayload; +}; + +export type LogStormAttendanceRecord = { + id?: number; + date?: string; + employee_id?: string; + display_name?: string; + arrival_time?: string | null; + departure_time?: string | null; + work_hours?: number | string | null; + expected_hours?: number | string | null; + is_workday?: boolean; + effective_is_workday?: boolean; + non_working_reason?: string; + is_late?: boolean; + late_minutes?: number | null; + is_early_leave?: boolean; + early_leave_minutes?: number | null; + is_underwork?: boolean; + underwork_hours?: number | string | null; + is_overtime?: boolean; + overtime_hours?: number | string | null; + is_absent?: boolean; + employee_issues?: string[]; + technical_issues?: string[]; + statuses?: string[]; + comments_count?: number; + personnel_status?: string; + personnel_status_label?: string; + personnel_action?: number | null; + is_manually_edited?: boolean; + manual_edit_payload?: Record; + manual_edited_by?: number | null; + manual_edited_at?: string | null; + [key: string]: unknown; +}; + +export type AttendanceRecordComment = { + id: number; + record: number; + author: User; + text: string; + created_at: string; +}; + +export type LogStormAttendanceResponse = { + records?: LogStormAttendanceRecord[]; + [key: string]: unknown; +}; + +export type LogStormAttendanceRecordsQuery = { + employee_id?: number | string; + date_from?: string; + date_to?: string; + page?: number; + limit?: number; +}; + +export type LogStormAttendanceRecordUpdatePayload = Partial>; + +export type PaginatedLogStormAttendanceRecords = { + count: number; + next: string | null; + previous: string | null; + results: LogStormAttendanceRecord[]; +}; + +export type MonthlyAttendanceMatrixCell = { + record_id: number | null; + arrival_time: string | null; + departure_time: string | null; + work_hours: number | string | null; + expected_hours: number | string | null; + status: 'empty' | 'technical' | 'underwork' | 'late' | 'overtime' | 'absent' | 'non_working' | 'normal' | string; + issues: string[]; + is_workday: boolean | null; + effective_is_workday: boolean | null; + non_working_reason?: string; + is_late?: boolean; + is_early_leave?: boolean; + is_underwork?: boolean; + is_overtime?: boolean; + is_absent?: boolean; + personnel_status?: string; + personnel_status_label?: string; + comments_count: number; +}; + +export type MonthlyAttendanceMatrixEmployee = { + id: number; + name: string; + email: string; +}; + +export type MonthlyAttendanceMatrixRow = { + date: string; + label: string; + weekday: string; + is_weekend: boolean; + cells: Record; +}; + +export type MonthlyAttendanceMatrixSummaryRow = { + key: string; + label: string; + values: Record; +}; + +export type MonthlyAttendanceMatrix = { + month: string; + month_label: string; + employees: MonthlyAttendanceMatrixEmployee[]; + rows: MonthlyAttendanceMatrixRow[]; + summary: MonthlyAttendanceMatrixSummaryRow[]; +}; + +export type MonthlyAttendanceMatrixQuery = { + employee_ids: string; + month: string; +}; + +export function createAttendanceApi(request: RequestFn) { + return { + analyzeLogStormAttendance: (data: LogStormAttendanceAnalyzePayload) => + request('/api/v1/attendance/logstorm/analyze/', { + method: 'POST', + body: JSON.stringify(data), + }) as Promise, + getLogStormAttendanceRecords: (params?: LogStormAttendanceRecordsQuery) => + request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise, + getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) => + request(`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`) as Promise, + updateAttendanceRecord: (recordId: number, data: LogStormAttendanceRecordUpdatePayload) => + request(`/api/v1/attendance/records/${recordId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }) as Promise, + getAttendanceRecordComments: (recordId: number) => + request(`/api/v1/attendance/records/${recordId}/comments/`) as Promise, + addAttendanceRecordComment: (recordId: number, text: string) => + request(`/api/v1/attendance/records/${recordId}/comments/`, { + method: 'POST', + body: JSON.stringify({ text }), + }) as Promise, + deleteAttendanceRecordComment: (recordId: number, commentId: number): Promise => + request(`/api/v1/attendance/records/${recordId}/comments/${commentId}/`, { + method: 'DELETE', + }), + }; +} diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 34c1ccd1..bfdf5969 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -13,6 +13,7 @@ import { createFeedApi } from './feed'; import { createRequestsApi } from './requests'; import { createEquipmentApi } from './equipment'; import { createProcurementApi } from './procurement'; +import { createAttendanceApi } from './attendance'; const base = new ApiClientBase(); const req = base.request.bind(base); @@ -29,6 +30,7 @@ export const apiClient = Object.assign(base, { ...createRequestsApi(req, tok), ...createEquipmentApi(req, tok), ...createProcurementApi(req), + ...createAttendanceApi(req), }); export default apiClient; diff --git a/frontend/src/lib/pwa.ts b/frontend/src/lib/pwa.ts index cd938d47..9fff8193 100644 --- a/frontend/src/lib/pwa.ts +++ b/frontend/src/lib/pwa.ts @@ -20,11 +20,30 @@ export function isStandaloneDisplayMode(): boolean { return Boolean(standaloneMatch || navigatorStandalone); } +async function unregisterDevelopmentServiceWorker(): Promise { + const registration = await navigator.serviceWorker.getRegistration("/"); + await registration?.unregister().catch(() => undefined); + + if ("caches" in window) { + const cacheNames = await caches.keys().catch(() => []); + await Promise.all( + cacheNames + .filter((cacheName) => cacheName.startsWith("eusrr-")) + .map((cacheName) => caches.delete(cacheName)), + ); + } +} + export async function registerAppServiceWorker(): Promise { if (typeof window === "undefined" || !("serviceWorker" in navigator)) { return null; } + if (process.env.NODE_ENV !== "production") { + await unregisterDevelopmentServiceWorker(); + return null; + } + const existingRegistration = await navigator.serviceWorker.getRegistration("/"); if (existingRegistration) { await existingRegistration.update().catch(() => undefined); diff --git a/frontend/src/lib/users/userDetailUtils.ts b/frontend/src/lib/users/userDetailUtils.ts index 34a8e4b3..d7fa7f93 100644 --- a/frontend/src/lib/users/userDetailUtils.ts +++ b/frontend/src/lib/users/userDetailUtils.ts @@ -42,6 +42,10 @@ export const employeeActionTypes = [ { value: "dismissed", label: "Уволен" }, { value: "on_leave", label: "В отпуске" }, { value: "returned_from_leave", label: "Вернулся из отпуска" }, + { value: "on_sick_leave", label: "На больничном" }, + { value: "returned_from_sick_leave", label: "Вернулся с больничного" }, + { value: "on_day_off", label: "В отгуле" }, + { value: "returned_from_day_off", label: "Вернулся из отгула" }, { value: "on_maternity", label: "В декрете" }, { value: "returned_from_maternity", label: "Вернулся из декрета" }, { value: "transferred", label: "Переведен" }, @@ -217,6 +221,8 @@ export function getEmployeeActionTone(actionType?: string): { } { switch (actionType) { case "on_leave": + case "on_sick_leave": + case "on_day_off": case "on_maternity": return { badgeClass: "app-feedback-warning",