diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py
index d74e2bb5..2d95079b 100644
--- a/backend/api/v1/requests_app/views.py
+++ b/backend/api/v1/requests_app/views.py
@@ -1,12 +1,16 @@
from __future__ import annotations
+import calendar
+from datetime import date
from typing import Any # было: Any, Dict, List, Type
+from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquery, Value
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404 # раскомментируйте импорт
-from requests_app.enums import RequestStatus
+from django.utils import timezone
+from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request as EmployeeRequest
from communications import comments_helpers
from drf_spectacular.utils import OpenApiParameter, extend_schema
@@ -27,7 +31,6 @@ from rest_framework.serializers import (
BaseSerializer,
) # <- для типизации get_serializer_class
-from ..permissions import IsSelfOrStaff
from .permissions import (
CanViewRequest,
CommentsPermission,
@@ -41,6 +44,185 @@ from .serializers import (
)
+UNPAID_VACATION_MARKERS = (
+ "за свой счет",
+ "за свой счёт",
+ "без сохранения заработной платы",
+ "без сохранения зарплаты",
+ "без сохранения зп",
+ "неоплачиваем",
+)
+
+
+def _request_duration_days(req: EmployeeRequest) -> int:
+ """Возвращает длительность заявки в днях, включая обе даты."""
+ if req.date_from and req.date_to:
+ return max((req.date_to - req.date_from).days + 1, 0)
+ if req.date_from:
+ return 1
+ return 0
+
+
+def _is_unpaid_vacation(req: EmployeeRequest) -> bool:
+ """Определяет отпуск за свой счёт по текстовым маркерам.
+
+ Явного поля в модели пока нет, поэтому используем временную эвристику
+ по title/comment.
+ """
+ haystack = " ".join(
+ [
+ str(getattr(req, "title", "") or ""),
+ str(getattr(req, "comment", "") or ""),
+ ]
+ ).lower()
+ return any(marker in haystack for marker in UNPAID_VACATION_MARKERS)
+
+
+def _resolve_stats_window(period: str) -> tuple[timezone.datetime.date, timezone.datetime.date] | tuple[None, None]:
+ """Возвращает границы окна статистики для month/year/all."""
+ today = timezone.localdate()
+ if period == "month":
+ _, last_day = calendar.monthrange(today.year, today.month)
+ return today.replace(day=1), today.replace(day=last_day)
+ if period == "year":
+ return today.replace(month=1, day=1), today.replace(month=12, day=31)
+ return None, None
+
+
+def _parse_iso_date(raw_value: str, field_name: str) -> date:
+ """Парсит ISO-даты для custom period."""
+ try:
+ return date.fromisoformat(raw_value)
+ except ValueError as exc:
+ raise ValidationError(
+ {field_name: ["Ожидается дата в формате YYYY-MM-DD."]}
+ ) from exc
+
+
+def _resolve_stats_window_from_query(
+ period: str, request: DRFRequest
+) -> tuple[date | None, date | None]:
+ """Возвращает окно статистики, включая custom range."""
+ if period != "custom":
+ return _resolve_stats_window(period)
+
+ date_from_raw = (request.query_params.get("date_from") or "").strip()
+ date_to_raw = (request.query_params.get("date_to") or "").strip()
+ if not date_from_raw or not date_to_raw:
+ raise ValidationError(
+ {
+ "date_from": ["Обязательное поле для custom периода."],
+ "date_to": ["Обязательное поле для custom периода."],
+ }
+ )
+
+ start_date = _parse_iso_date(date_from_raw, "date_from")
+ end_date = _parse_iso_date(date_to_raw, "date_to")
+ if start_date > end_date:
+ raise ValidationError(
+ {"date_to": ["Дата окончания не может быть раньше даты начала."]}
+ )
+ return start_date, end_date
+
+
+def _request_duration_days_in_window(
+ req: EmployeeRequest,
+ start_date,
+ end_date,
+) -> int:
+ """Возвращает число дней заявки внутри заданного окна."""
+ if start_date is None or end_date is None:
+ return _request_duration_days(req)
+
+ if req.date_from and req.date_to:
+ overlap_start = max(req.date_from, start_date)
+ overlap_end = min(req.date_to, end_date)
+ if overlap_start > overlap_end:
+ return 0
+ return (overlap_end - overlap_start).days + 1
+
+ if req.date_from and start_date <= req.date_from <= end_date:
+ return 1
+
+ return 0
+
+
+def _can_view_request_statistics(user) -> bool:
+ return bool(
+ getattr(user, "is_staff", False)
+ or getattr(user, "is_superuser", False)
+ or user.has_perm("requests_app.view_statistics")
+ or user.has_perm("requests_app.can_view_all_requests")
+ )
+
+
+def _build_employee_statistics(
+ employee_id: int,
+ employee_name: str,
+ period: str,
+ start_date: date | None = None,
+ end_date: date | None = None,
+):
+ if period != "custom":
+ start_date, end_date = _resolve_stats_window(period)
+
+ base_qs = EmployeeRequest.objects.filter(employee_id=employee_id).exclude(
+ status=RequestStatus.DRAFT
+ )
+ if start_date and end_date:
+ base_qs = base_qs.filter(
+ created_at__date__gte=start_date,
+ created_at__date__lte=end_date,
+ )
+
+ approved_qs = EmployeeRequest.objects.filter(
+ employee_id=employee_id,
+ status=RequestStatus.APPROVED,
+ )
+
+ sick_leave_days = 0
+ day_off_days = 0
+ paid_vacation_days = 0
+ unpaid_vacation_days = 0
+
+ for req in approved_qs.only(
+ "type", "date_from", "date_to", "title", "comment"
+ ):
+ duration = _request_duration_days_in_window(
+ req,
+ start_date,
+ end_date,
+ )
+ if req.type == RequestType.SICK_LEAVE:
+ sick_leave_days += duration
+ elif req.type == RequestType.DAY_OFF:
+ day_off_days += duration
+ elif req.type == RequestType.VACATION:
+ if _is_unpaid_vacation(req):
+ unpaid_vacation_days += duration
+ else:
+ paid_vacation_days += duration
+
+ return {
+ "employee_id": employee_id,
+ "employee_name": employee_name,
+ "period": period,
+ "date_from": start_date.isoformat() if start_date else None,
+ "date_to": end_date.isoformat() if end_date else None,
+ "total_submitted_requests": base_qs.count(),
+ "sick_leave_requests_count": base_qs.filter(
+ type=RequestType.SICK_LEAVE
+ ).count(),
+ "day_off_requests_count": base_qs.filter(
+ type=RequestType.DAY_OFF
+ ).count(),
+ "sick_leave_days": sick_leave_days,
+ "day_off_days": day_off_days,
+ "paid_vacation_days": paid_vacation_days,
+ "unpaid_vacation_days": unpaid_vacation_days,
+ }
+
+
class RequestViewSet(viewsets.ModelViewSet):
"""ViewSet для заявок сотрудников.
@@ -80,7 +262,11 @@ class RequestViewSet(viewsets.ModelViewSet):
return [IsRecipientOfRequest()]
if self.action == "cancel":
return [IsRequestAuthor()]
- if self.action == "retrieve":
+ if self.action == "statistics":
+ from rest_framework.permissions import IsAuthenticated
+
+ return [IsAuthenticated()]
+ if self.action in {"retrieve", "employee_statistics"}:
return [CanViewRequest()]
if self.action == "destroy":
return [IsRequestAuthor(), NotFinalOrStaff()]
@@ -210,6 +396,7 @@ class RequestViewSet(viewsets.ModelViewSet):
"comments",
"cancel",
"retrieve",
+ "employee_statistics",
}:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
@@ -480,6 +667,71 @@ class RequestViewSet(viewsets.ModelViewSet):
).data
)
+ @action(detail=True, methods=["get"], url_path="employee-statistics")
+ def employee_statistics(
+ self, request: DRFRequest, pk: int | str | None = None
+ ) -> Response:
+ """Возвращает сводную статистику по заявлениям сотрудника.
+
+ Доступно администраторам и пользователям с явным правом
+ ``requests_app.view_statistics`` или ``requests_app.can_view_all_requests``.
+ """
+ obj = self.get_object()
+ user = request.user
+
+ if not _can_view_request_statistics(user):
+ raise PermissionDenied(
+ "У вас нет доступа к статистике по заявлениям."
+ )
+
+ period = (request.query_params.get("period") or "all").strip().lower()
+ if period not in {"all", "year", "month", "custom"}:
+ raise ValidationError(
+ "Параметр period должен быть one of: all, year, month, custom."
+ )
+ start_date, end_date = _resolve_stats_window_from_query(period, request)
+
+ return Response(
+ _build_employee_statistics(
+ employee_id=obj.employee_id,
+ employee_name=str(obj.employee),
+ period=period,
+ start_date=start_date,
+ end_date=end_date,
+ )
+ )
+
+ @action(detail=False, methods=["get"], url_path="statistics")
+ def statistics(self, request: DRFRequest) -> Response:
+ """Возвращает статистику по выбранному сотруднику для страницы."""
+ if not _can_view_request_statistics(request.user):
+ raise PermissionDenied(
+ "У вас нет доступа к статистике по заявлениям."
+ )
+
+ employee_id_raw = (request.query_params.get("employee_id") or "").strip()
+ if not employee_id_raw.isdigit():
+ raise ValidationError("Параметр employee_id обязателен.")
+
+ period = (request.query_params.get("period") or "all").strip().lower()
+ if period not in {"all", "year", "month", "custom"}:
+ raise ValidationError(
+ "Параметр period должен быть one of: all, year, month, custom."
+ )
+ start_date, end_date = _resolve_stats_window_from_query(period, request)
+
+ UserModel = get_user_model()
+ employee = get_object_or_404(UserModel, pk=int(employee_id_raw))
+ return Response(
+ _build_employee_statistics(
+ employee_id=employee.id,
+ employee_name=str(employee),
+ period=period,
+ start_date=start_date,
+ end_date=end_date,
+ )
+ )
+
def create(self, request, *args, **kwargs):
"""
Создание заявки: валидируем write-сериализатором,
diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py
index 11d16e9e..7563bbe2 100644
--- a/backend/tests/api/v1/requests_app/test_requests_api.py
+++ b/backend/tests/api/v1/requests_app/test_requests_api.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+from datetime import datetime
from typing import Any
import pytest
from django.db import models
+from django.utils import timezone
from notifications.models import Notification
from rest_framework.test import APIClient
@@ -31,6 +33,122 @@ def test_list_unauth_401(api_client: APIClient) -> None:
assert resp.status_code == 401
+def test_statistics_requires_privileged_access(
+ auth_client, regular_user: models.Model, make_user
+) -> None:
+ """Статистика недоступна обычному пользователю без спецправ."""
+ employee = make_user(email="stats-target@example.com")
+
+ resp = auth_client(regular_user).get(
+ f"{API_BASE}statistics/?employee_id={employee.id}&period=all"
+ )
+
+ assert resp.status_code == 403
+
+
+def test_statistics_counts_all_employee_requests_for_admin(
+ auth_client,
+ admin_user: models.Model,
+ make_user,
+ make_request,
+) -> None:
+ """Админ получает полную статистику по сотруднику, а не только видимые заявки."""
+ employee = make_user(email="stats-admin-target@example.com")
+ make_request(
+ employee=employee,
+ type_=RequestType.SICK_LEAVE,
+ status=RequestStatus.APPROVED,
+ )
+ make_request(
+ employee=employee,
+ type_=RequestType.DAY_OFF,
+ status=RequestStatus.PENDING,
+ )
+
+ resp = auth_client(admin_user).get(
+ f"{API_BASE}statistics/?employee_id={employee.id}&period=all"
+ )
+
+ assert resp.status_code == 200
+ payload = resp.json()
+ assert payload["employee_id"] == employee.id
+ assert payload["period"] == "all"
+ assert payload["date_from"] is None
+ assert payload["date_to"] is None
+ assert payload["total_submitted_requests"] == 2
+ assert payload["sick_leave_requests_count"] == 1
+ assert payload["day_off_requests_count"] == 1
+
+
+def test_statistics_custom_period_filters_request_counts_and_absence_days(
+ auth_client,
+ admin_user: models.Model,
+ make_user,
+ make_request,
+) -> None:
+ """Custom период фильтрует заявки по дате отправки и дни по пересечению периода."""
+ employee = make_user(email="stats-custom-target@example.com")
+
+ matching = make_request(
+ employee=employee,
+ type_=RequestType.VACATION,
+ status=RequestStatus.APPROVED,
+ comment="отпуск за свой счет",
+ )
+ matching.created_at = datetime(
+ 2026, 4, 10, 12, 0, tzinfo=timezone.get_current_timezone()
+ )
+ matching.date_from = datetime(2026, 4, 5).date()
+ matching.date_to = datetime(2026, 4, 12).date()
+ matching.save(
+ update_fields=["created_at", "date_from", "date_to", "comment"]
+ )
+
+ outside_count = make_request(
+ employee=employee,
+ type_=RequestType.DAY_OFF,
+ status=RequestStatus.APPROVED,
+ )
+ outside_count.created_at = datetime(
+ 2026, 3, 1, 12, 0, tzinfo=timezone.get_current_timezone()
+ )
+ outside_count.date_from = datetime(2026, 4, 11).date()
+ outside_count.date_to = datetime(2026, 4, 11).date()
+ outside_count.save(update_fields=["created_at", "date_from", "date_to"])
+
+ resp = auth_client(admin_user).get(
+ f"{API_BASE}statistics/?employee_id={employee.id}&period=custom"
+ "&date_from=2026-04-10&date_to=2026-04-15"
+ )
+
+ assert resp.status_code == 200
+ payload = resp.json()
+ assert payload["period"] == "custom"
+ assert payload["date_from"] == "2026-04-10"
+ assert payload["date_to"] == "2026-04-15"
+ assert payload["total_submitted_requests"] == 1
+ assert payload["day_off_requests_count"] == 0
+ assert payload["unpaid_vacation_days"] == 3
+
+
+def test_statistics_custom_period_requires_valid_dates(
+ auth_client, admin_user: models.Model, make_user
+) -> None:
+ """Custom период требует обе даты и валидный диапазон."""
+ employee = make_user(email="stats-custom-validation@example.com")
+
+ missing_resp = auth_client(admin_user).get(
+ f"{API_BASE}statistics/?employee_id={employee.id}&period=custom"
+ )
+ invalid_resp = auth_client(admin_user).get(
+ f"{API_BASE}statistics/?employee_id={employee.id}&period=custom"
+ "&date_from=2026-04-20&date_to=2026-04-10"
+ )
+
+ assert missing_resp.status_code == 400
+ assert invalid_resp.status_code == 400
+
+
def test_list_only_participants_see_requests(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
index 9edff1c7..c4b7818f 100644
--- a/frontend/next-env.d.ts
+++ b/frontend/next-env.d.ts
@@ -1,6 +1,6 @@
///
{h.error}
Описание отсутствует
- )} +Описание отсутствует
+ )} +Комментарии
+ + {commentCount} + ++ Комментарии будут доступны после отправки заявления. +
+ ) : ( ++ {displayUserName(comment.author)} +
++ {formatDateTime(comment.created_at) || formatDate(comment.created_at)} +
++ {comment.text} +
+Комментариев пока нет
+ )} +Даты
Отделы
- {(request.departments || []).length > 0 ? ( -Отделы не указаны
- )} +Отделы
+ {(request.departments || []).length > 0 ? ( +Отделы не указаны
+ )} +Получатели
+ {(request.recipients || []).length > 0 ? ( +Получатели не указаны
+ )} +Получатели
- {(request.recipients || []).length > 0 ? ( -Получатели не указаны
- )} -В копии
+ {(request.cc_users || []).length > 0 ? ( +Копия не указана
+ )} +В копии
- {(request.cc_users || []).length > 0 ? ( -Копия не указана
- )} -Вложение
- {attachmentUrl ? ( - - ) : ( -Вложение отсутствует
- )} +Вложение
+ {attachmentUrl ? ( + + ) : ( +Вложение отсутствует
+ )} +Сотрудники не найдены
+ )} ++ Выберите сотрудника, чтобы посмотреть статистику. +
+ ) : period === "custom" && (!dateFrom || !dateTo) ? ( ++ Укажите даты начала и окончания для выбранного периода. +
+ ) : loading || (!stats && !error) ? ( ++ {period === "all" + ? "Период: за всё время" + : period === "year" + ? "Период: текущий календарный год" + : period === "month" + ? "Период: текущий календарный месяц" + : `Период: ${stats?.date_from || dateFrom} — ${stats?.date_to || dateTo}`} +
+{item.label}
+{item.value}
+