Merge branch 'develop'
This commit is contained in:
commit
1e8352a400
@ -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
|
||||
|
||||
1
backend/api/v1/attendance/__init__.py
Normal file
1
backend/api/v1/attendance/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Attendance API integration endpoints."""
|
||||
176
backend/api/v1/attendance/serializers.py
Normal file
176
backend/api/v1/attendance/serializers.py
Normal 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)
|
||||
360
backend/api/v1/attendance/views.py
Normal file
360
backend/api/v1/attendance/views.py
Normal 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)
|
||||
@ -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")),
|
||||
|
||||
1
backend/attendance/__init__.py
Normal file
1
backend/attendance/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
44
backend/attendance/admin.py
Normal file
44
backend/attendance/admin.py
Normal 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",)
|
||||
7
backend/attendance/apps.py
Normal file
7
backend/attendance/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AttendanceConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "attendance"
|
||||
verbose_name = "Посещаемость"
|
||||
260
backend/attendance/migrations/0001_initial.py
Normal file
260
backend/attendance/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/attendance/migrations/__init__.py
Normal file
1
backend/attendance/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
171
backend/attendance/models.py
Normal file
171
backend/attendance/models.py
Normal 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}"
|
||||
599
backend/attendance/services.py
Normal file
599
backend/attendance/services.py
Normal 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}"
|
||||
109
backend/common/logstorm_attendance.py
Normal file
109
backend/common/logstorm_attendance.py
Normal 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)
|
||||
101
backend/common/logstorm_client.py
Normal file
101
backend/common/logstorm_client.py
Normal 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
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -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, "Переведен"),
|
||||
|
||||
@ -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='Кадровое событие'),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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()})"
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, но не оба"
|
||||
)
|
||||
|
||||
695
backend/tests/api/v1/attendance/test_attendance_storage.py
Normal file
695
backend/tests/api/v1/attendance/test_attendance_storage.py
Normal 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
|
||||
112
backend/tests/integration/test_logstorm_attendance.py
Normal file
112
backend/tests/integration/test_logstorm_attendance.py
Normal 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]
|
||||
156
backend/tests/integration/test_logstorm_attendance_api.py
Normal file
156
backend/tests/integration/test_logstorm_attendance_api.py
Normal 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"
|
||||
118
backend/tests/integration/test_logstorm_client.py
Normal file
118
backend/tests/integration/test_logstorm_client.py
Normal 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
|
||||
960
frontend/src/app/attendance/page.tsx
Normal file
960
frontend/src/app/attendance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: "Закупки" },
|
||||
|
||||
1367
frontend/src/components/attendance/EmployeeAttendanceCard.tsx
Normal file
1367
frontend/src/components/attendance/EmployeeAttendanceCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
192
frontend/src/lib/api/attendance.ts
Normal file
192
frontend/src/lib/api/attendance.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user