Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-22 11:50:14 +03:00
commit 1e8352a400
39 changed files with 5922 additions and 243 deletions

View File

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

View File

@ -0,0 +1 @@
"""Attendance API integration endpoints."""

View File

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

View File

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

View File

@ -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/<int:record_id>/",
AttendanceRecordDetailAPIView.as_view(),
name="attendance-record-detail",
),
path(
"attendance/records/<int:record_id>/comments/",
AttendanceRecordCommentsAPIView.as_view(),
name="attendance-record-comments",
),
path(
"attendance/records/<int:record_id>/comments/<int:comment_id>/",
AttendanceRecordCommentDetailAPIView.as_view(),
name="attendance-record-comment-detail",
),
path("directory/", include("api.v1.directory.urls")),
# Notifications API (из самого модуля notifications)
path("notifications/", include("notifications.api.urls")),

View File

@ -0,0 +1 @@

View File

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

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AttendanceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "attendance"
verbose_name = "Посещаемость"

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, "Переведен"),

View File

@ -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='Кадровое событие'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, но не оба"
)

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AttendancePeriodPreset, "custom">) {
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<string, string> = {
empty: "Нет записи",
technical: "Техсбой",
underwork: "Недоработка",
late: "Опоздание",
overtime: "Переработка",
absent: "Отсутствие",
non_working: "Нерабочий",
normal: "Норма",
};
return labels[status] || status;
}
const attendanceIssueLabelMap: Record<string, string> = {
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<User[]>([]);
const [employeeSearch, setEmployeeSearch] = useState("");
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<number[]>([]);
const [periodPreset, setPeriodPreset] = useState<AttendancePeriodPreset>("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<string[]>(defaultWorkdays);
const [selectorOpen, setSelectorOpen] = useState(true);
const [loadingEmployees, setLoadingEmployees] = useState(true);
const [loadingStats, setLoadingStats] = useState(false);
const [error, setError] = useState<string | null>(null);
const [matrices, setMatrices] = useState<MonthlyAttendanceMatrix[]>([]);
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<User>((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 (
<AppShell>
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<section className="app-surface overflow-hidden rounded-2xl">
<button
type="button"
onClick={() => setSelectorOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--surface-secondary)] sm:px-5"
aria-expanded={selectorOpen}
>
<div className="min-w-0">
<h1 className="text-base font-semibold text-[var(--foreground)] sm:text-lg">
Посещаемость
</h1>
<p className="app-text-muted mt-1 text-xs sm:text-sm">
Выбор сотрудников и периода для статистики LogStorm
</p>
</div>
<ChevronDown
size={18}
className={`shrink-0 text-[var(--foreground-muted)] transition-transform ${selectorOpen ? "rotate-180" : ""}`}
/>
</button>
{selectorOpen ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4 sm:px-5">
<div className="max-w-[34rem]">
<div className="app-surface-muted rounded-2xl p-3 sm:p-4">
<div className="relative">
<Search
size={16}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
type="text"
value={employeeSearch}
onChange={(event) => setEmployeeSearch(event.target.value)}
placeholder="Выберите сотрудников для статистики"
className="app-input w-full rounded-xl py-2.5 pl-10 pr-3 text-sm"
/>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-xs">
<span className="app-text-muted">
Найдено: {loadingEmployees ? "..." : filteredEmployees.length}
</span>
<span className="flex flex-wrap items-center gap-2">
<span className="app-accent-text font-medium">
Выбрано: {selectedEmployeeIds.length}
</span>
<button
type="button"
onClick={selectVisibleEmployees}
disabled={loadingEmployees || filteredEmployees.length === 0}
className="app-accent-text font-medium disabled:cursor-not-allowed disabled:opacity-40"
>
Выбрать всех
</button>
<button
type="button"
onClick={clearSelectedEmployees}
disabled={selectedEmployeeIds.length === 0}
className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-40"
>
Снять выбор
</button>
</span>
</div>
<div className="app-surface mt-3 max-h-72 space-y-2 overflow-y-auto rounded-2xl p-2">
{loadingEmployees ? (
<div className="app-surface-muted flex items-center justify-center gap-2 rounded-xl p-5 text-sm text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
Загрузка сотрудников
</div>
) : null}
{!loadingEmployees && filteredEmployees.length === 0 ? (
<p className="app-text-muted px-2 py-3 text-sm">Сотрудники не найдены</p>
) : 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 (
<button
key={employee.id}
type="button"
onClick={() => toggleEmployee(employee.id)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${
active
? "app-selected"
: "app-surface-muted hover:bg-[var(--surface-tertiary)]"
}`}
>
{employee.avatar ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveMediaUrl(employee.avatar)}
alt={fullName}
className="app-avatar-frame h-10 w-10 shrink-0 rounded-full object-cover"
/>
</>
) : (
<span className="app-avatar-fallback flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
<span className="text-sm font-semibold">
{getInitials(employee).toUpperCase()}
</span>
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{fullName}
</span>
<span className="app-text-muted block truncate text-xs">{subtitle}</span>
</span>
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition ${
active
? "border-[var(--accent-primary)] bg-[var(--accent-primary)] text-white"
: "border-[var(--border-strong)] text-transparent"
}`}
>
{active ? <Check size={14} /> : <span className="h-2.5 w-2.5 rounded-full bg-current" />}
</span>
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{periodPresets.map((periodOption) => {
const isActive = periodPreset === periodOption.value;
return (
<button
key={periodOption.value}
type="button"
onClick={() => selectPeriodPreset(periodOption.value)}
className={isActive
? "app-action-primary rounded-lg px-3 py-2 text-xs font-medium"
: "app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
}
>
{periodOption.label}
</button>
);
})}
</div>
<p className="app-text-muted mt-2 px-1 text-xs">
{periodStart} — {periodEnd}
</p>
{periodPreset === "custom" ? (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className="app-card-caption mb-2 block">Начало</span>
<input
type="date"
value={periodStart}
onChange={(event) => {
setPeriodStart(event.target.value);
setMatrices([]);
setSelectedCell(null);
}}
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Конец</span>
<input
type="date"
value={periodEnd}
onChange={(event) => {
setPeriodEnd(event.target.value);
setMatrices([]);
setSelectedCell(null);
}}
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
/>
</label>
</div>
) : null}
</div>
<div className="mt-4 app-surface-muted rounded-2xl p-3 sm:p-4">
<label className="flex items-center gap-3 text-sm font-medium text-[var(--foreground)]">
<input
type="checkbox"
checked={useManualSchedule}
onChange={(event) => setUseManualSchedule(event.target.checked)}
className="h-4 w-4 rounded border-[var(--border-subtle)]"
/>
Ручной график
</label>
{useManualSchedule ? (
<div className="mt-3 grid gap-3">
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="app-card-caption mb-2 block">Начало</span>
<input
type="time"
value={scheduleStart}
onChange={(event) => setScheduleStart(event.target.value)}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Конец</span>
<input
type="time"
value={scheduleEnd}
onChange={(event) => setScheduleEnd(event.target.value)}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Часы</span>
<input
type="number"
min="0"
step="0.25"
value={expectedHours}
onChange={(event) => setExpectedHours(event.target.value)}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
{weekdays.map((day) => {
const active = workdays.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => toggleWorkday(day.value)}
className={`h-9 min-w-10 rounded-lg px-3 text-sm font-medium transition ${
active
? "app-selected text-[var(--accent-primary)]"
: "app-surface text-[var(--muted-foreground)] hover:bg-[var(--surface-tertiary)]"
}`}
>
{day.label}
</button>
);
})}
</div>
</div>
) : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void loadStatistics()}
disabled={loadingStats || loadingEmployees}
className="app-action-secondary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingStats ? <Loader2 size={16} className="animate-spin" /> : <CalendarCheck size={16} />}
Показать статистику
</button>
<button
type="button"
onClick={() => void loadStatistics({ refreshFromLogStorm: true })}
disabled={loadingStats || loadingEmployees}
className="app-action-primary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingStats ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
Обновить и открыть
</button>
</div>
</div>
{error ? (
<div className="mt-4 flex max-w-[34rem] items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
) : null}
</div>
) : null}
</section>
</div>
<Modal
isOpen={statsModalOpen}
onClose={() => {
setStatsModalOpen(false);
setSelectedCell(null);
}}
title="Статистика посещаемости"
size="xl"
>
<div className="space-y-4">
{!activeMatrix ? (
<div className="app-surface-muted rounded-xl p-8 text-center">
<p className="app-text-muted text-sm">Записей за выбранный период нет</p>
</div>
) : (
<>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="app-text-muted text-sm">
{periodStart} — {periodEnd}. Сотрудников: {activeMatrix.employees.length}
</p>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setActiveMonthIndex((current) => Math.max(0, current - 1));
setVisibleEmployeePage(0);
setSelectedCell(null);
}}
disabled={activeMonthIndex === 0}
className="app-icon-button rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Предыдущий месяц"
>
<ChevronLeft size={18} />
</button>
<span className="min-w-36 text-center text-sm font-semibold text-[var(--foreground)]">
{activeMatrix.month_label}
</span>
<button
type="button"
onClick={() => {
setActiveMonthIndex((current) =>
Math.min(matrices.length - 1, current + 1),
);
setVisibleEmployeePage(0);
setSelectedCell(null);
}}
disabled={activeMonthIndex >= matrices.length - 1}
className="app-icon-button rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Следующий месяц"
>
<ChevronRight size={18} />
</button>
</div>
{activeMatrix.employees.length > matrixEmployeesPerPage ? (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setVisibleEmployeePage((current) => Math.max(0, current - 1));
setSelectedCell(null);
}}
disabled={!hasPreviousEmployeePage}
className="app-icon-button rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Предыдущие сотрудники"
>
<ChevronLeft size={18} />
</button>
<span className="app-text-muted min-w-28 text-center text-xs">
{visibleEmployeeStart + 1}-{visibleEmployeeEnd} из {activeMatrix.employees.length}
</span>
<button
type="button"
onClick={() => {
setVisibleEmployeePage((current) => current + 1);
setSelectedCell(null);
}}
disabled={!hasNextEmployeePage}
className="app-icon-button rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Следующие сотрудники"
>
<ChevronRight size={18} />
</button>
</div>
) : null}
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
<table className="min-w-max border-separate border-spacing-0 text-left text-xs">
<thead>
<tr className="bg-[var(--surface-secondary)] text-[var(--muted-foreground)]">
<th
rowSpan={2}
className="sticky left-0 z-20 min-w-28 border-b border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 font-semibold"
>
Дата
</th>
{visibleEmployees.map((employee) => (
<th
key={employee.id}
colSpan={2}
className="min-w-40 border-b border-r border-[var(--border-subtle)] px-3 py-2 text-center font-semibold text-[var(--foreground)]"
>
<span className="block max-w-44 truncate">{employee.name}</span>
</th>
))}
</tr>
<tr className="bg-[var(--surface-secondary)] text-[var(--muted-foreground)]">
{visibleEmployees.map((employee) => (
<Fragment key={employee.id}>
<th
className="min-w-20 border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center font-semibold"
>
Приход
</th>
<th
className="min-w-20 border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center font-semibold"
>
Уход
</th>
</Fragment>
))}
</tr>
</thead>
<tbody>
{activeMatrix.rows.map((row) => (
<tr key={row.date}>
<th className={`sticky left-0 z-10 border-b border-r border-[var(--border-subtle)] px-3 py-2 text-left font-medium ${
row.is_weekend
? "bg-slate-500/10 text-slate-300"
: "bg-[var(--surface-secondary)] text-[var(--foreground)]"
}`}>
{row.label}
</th>
{visibleEmployees.map((employee) => {
const cell = row.cells[String(employee.id)];
const active = selectedCell?.row.date === row.date
&& selectedCell.employee.id === employee.id;
return (
<Fragment key={`${row.date}-${employee.id}`}>
<td
className="border-b border-r border-[var(--border-subtle)] p-0"
>
<button
type="button"
onClick={() => setSelectedCell({ row, employee, cell })}
className={`h-10 w-full px-2 text-center transition ${matrixCellTone(cell.status)} ${
active ? "ring-2 ring-inset ring-[var(--accent-primary)]" : ""
}`}
title={matrixStatusLabel(cell.status)}
>
{formatTime(cell.arrival_time)}
</button>
</td>
<td
className="border-b border-r border-[var(--border-subtle)] p-0"
>
<button
type="button"
onClick={() => setSelectedCell({ row, employee, cell })}
className={`h-10 w-full px-2 text-center transition ${matrixCellTone(cell.status)} ${
active ? "ring-2 ring-inset ring-[var(--accent-primary)]" : ""
}`}
title={matrixStatusLabel(cell.status)}
>
{formatTime(cell.departure_time)}
</button>
</td>
</Fragment>
);
})}
</tr>
))}
{activeMatrix.summary.map((summaryRow) => (
<tr key={summaryRow.key} className="bg-[var(--surface-secondary)]">
<th className="sticky left-0 z-10 border-b border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-2 text-left font-semibold text-[var(--foreground)]">
{summaryRow.label}
</th>
{visibleEmployees.map((employee) => (
<td
key={`${summaryRow.key}-${employee.id}`}
colSpan={2}
className="border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center font-semibold text-[var(--foreground)]"
>
{summaryRow.values[String(employee.id)] ?? 0}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{selectedCell ? (
<div className="app-surface-muted rounded-xl p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
{selectedCell.employee.name}
</p>
<p className="app-text-muted mt-1 text-xs">
{selectedCell.row.date} · {selectedCell.row.weekday}
</p>
</div>
<span className={`app-status-pill ${matrixCellTone(selectedCell.cell.status)}`}>
{matrixStatusLabel(selectedCell.cell.status)}
</span>
</div>
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-4">
<div>
<p className="app-card-caption">Приход</p>
<p className="mt-1 text-[var(--foreground)]">{formatTime(selectedCell.cell.arrival_time)}</p>
</div>
<div>
<p className="app-card-caption">Уход</p>
<p className="mt-1 text-[var(--foreground)]">{formatTime(selectedCell.cell.departure_time)}</p>
</div>
<div>
<p className="app-card-caption">Часы</p>
<p className="mt-1 text-[var(--foreground)]">
{formatHours(selectedCell.cell.work_hours)} / {formatHours(selectedCell.cell.expected_hours)}
</p>
</div>
<div>
<p className="app-card-caption">Комментарии</p>
<p className="mt-1 text-[var(--foreground)]">{selectedCell.cell.comments_count}</p>
</div>
</div>
{selectedCell.cell.personnel_status_label ? (
<p className="app-text-muted mt-3 text-sm">
Кадровое состояние: {selectedCell.cell.personnel_status_label}
</p>
) : selectedCell.cell.non_working_reason ? (
<p className="app-text-muted mt-3 text-sm">
Причина нерабочего дня: {selectedCell.cell.non_working_reason}
</p>
) : null}
{matrixCellIssueLabels(selectedCell.cell).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{matrixCellIssueLabels(selectedCell.cell).map((issue) => (
<span key={issue} className="app-status-pill bg-amber-500/15 text-amber-300">
{formatAttendanceIssueLabel(issue)}
</span>
))}
</div>
) : null}
</div>
) : (
<p className="app-text-muted text-sm">
Нажмите на ячейку месяца, чтобы открыть детали дня.
</p>
)}
</>
)}
</div>
</Modal>
</AppShell>
);
}

View File

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

View File

@ -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}
/>
<EmployeeAttendanceCard employeeActions={user.actions} employeeId={user.id} />
</div>
</AppShell>
);

View File

@ -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}
/>
<EmployeeAttendanceCard employeeActions={person.actions} employeeId={person.id} />
</>
) : null}
</div>

View File

@ -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: "Закупки" },

File diff suppressed because it is too large Load Diff

View File

@ -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<string, unknown>;
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<Pick<
LogStormAttendanceRecord,
| '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'
>>;
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<string, MonthlyAttendanceMatrixCell>;
};
export type MonthlyAttendanceMatrixSummaryRow = {
key: string;
label: string;
values: Record<string, number>;
};
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<LogStormAttendanceResponse>,
getLogStormAttendanceRecords: (params?: LogStormAttendanceRecordsQuery) =>
request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise<PaginatedLogStormAttendanceRecords>,
getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) =>
request(`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`) as Promise<MonthlyAttendanceMatrix>,
updateAttendanceRecord: (recordId: number, data: LogStormAttendanceRecordUpdatePayload) =>
request(`/api/v1/attendance/records/${recordId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
}) as Promise<LogStormAttendanceRecord>,
getAttendanceRecordComments: (recordId: number) =>
request(`/api/v1/attendance/records/${recordId}/comments/`) as Promise<AttendanceRecordComment[]>,
addAttendanceRecordComment: (recordId: number, text: string) =>
request(`/api/v1/attendance/records/${recordId}/comments/`, {
method: 'POST',
body: JSON.stringify({ text }),
}) as Promise<AttendanceRecordComment>,
deleteAttendanceRecordComment: (recordId: number, commentId: number): Promise<void> =>
request(`/api/v1/attendance/records/${recordId}/comments/${commentId}/`, {
method: 'DELETE',
}),
};
}

View File

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

View File

@ -20,11 +20,30 @@ export function isStandaloneDisplayMode(): boolean {
return Boolean(standaloneMatch || navigatorStandalone);
}
async function unregisterDevelopmentServiceWorker(): Promise<void> {
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<ServiceWorkerRegistration | null> {
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);

View File

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