Hide excluded employees from UI-facing APIs and payroll workflows

This commit is contained in:
lizerk 2026-08-25 10:55:38 +03:00
parent 62faad92bb
commit f3d2d2fe44
51 changed files with 773 additions and 90 deletions

View File

@ -34,6 +34,7 @@ class AttendanceRecordListAPIView(ListAPIView):
def get_queryset(self):
queryset = (
_records_visible_to(self.request.user)
.filter(employee__is_hidden_in_ui=False)
.select_related("employee", "analysis_run")
.annotate(**_comments_count_annotation())
.all()

View File

@ -49,6 +49,7 @@ class AttendanceWeeklySummaryAPIView(APIView):
_records_visible_to(request.user)
.filter(
employee__is_active=True,
employee__is_hidden_in_ui=False,
date__gte=week_start,
date__lte=week_end,
)
@ -141,7 +142,9 @@ class AttendanceMonthlyMatrixAPIView(APIView):
for employee_id in employee_ids:
_check_employee_access(request.user, employee_id)
employees = list(Employee.objects.filter(id__in=employee_ids))
employees = list(
Employee.objects.visible_in_ui().filter(id__in=employee_ids)
)
employees_by_id = {employee.id: employee for employee in employees}
employees = [
employees_by_id[employee_id]
@ -197,7 +200,9 @@ class AttendanceMonthlyMatrixExportAPIView(APIView):
period_start = data["period_start"]
period_end = data["period_end"]
employees = list(Employee.objects.filter(id__in=employee_ids))
employees = list(
Employee.objects.visible_in_ui().filter(id__in=employee_ids)
)
employees_by_id = {employee.id: employee for employee in employees}
employees = [
employees_by_id[employee_id]

View File

@ -108,7 +108,8 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
else set()
)
allowed_ids = set(
User.objects.filter(id__in=requested_ids)
User.objects.visible_in_ui(include_ids=existing_ids)
.filter(id__in=requested_ids)
.filter(Q(is_active=True) | Q(id__in=existing_ids))
.values_list("id", flat=True)
)

View File

@ -14,7 +14,7 @@ class DepartmentSerializer(serializers.ModelSerializer):
head = EmployeeBriefSerializer(read_only=True)
head_id = serializers.PrimaryKeyRelatedField(
source="head",
queryset=Employee.objects.all(),
queryset=Employee.objects.visible_in_ui(),
write_only=True,
required=False,
allow_null=True,

View File

@ -101,7 +101,10 @@ class DepartmentMembershipActionsMixin:
role_id = payload.validated_data.get("role_id")
employee_model = Department._meta.get_field("head").remote_field.model
employee = get_object_or_404(employee_model, id=emp_id)
employee = get_object_or_404(
employee_model.objects.visible_in_ui(),
id=emp_id,
)
role = None
if role_id is not None:
role = get_object_or_404(DepartmentRole, id=role_id)

View File

@ -245,7 +245,10 @@ class DepartmentViewSet(
new_head = None
if desired_head_id is not None:
employee_model = Department._meta.get_field("head").remote_field.model
new_head = get_object_or_404(employee_model, id=desired_head_id)
new_head = get_object_or_404(
employee_model.objects.visible_in_ui(),
id=desired_head_id,
)
require_verified = not (
instance.head_id

View File

@ -142,6 +142,8 @@ class EmployeeQueryMixin:
queryset = self._with_last_attendance_status(
self._with_last_activity(self._base_queryset().order_by(*self.ordering))
)
if getattr(self, "action", None) == "list":
queryset = queryset.visible_in_ui()
query_params = self.request.query_params
department = query_params.get("department")

View File

@ -38,7 +38,7 @@ class EmployeeStatisticsMixin:
"""Краткая статистика по активным сотрудникам для списка сотрудников."""
today = timezone.localdate()
employees = list(
Employee.objects.filter(is_active=True)
Employee.objects.visible_in_ui().filter(is_active=True)
.only(
"id",
"email",

View File

@ -288,7 +288,10 @@ class GroupViewSet(viewsets.ModelViewSet):
)
requested_ids = set(member_ids)
users = Employee.objects.filter(id__in=requested_ids, is_active=True)
users = Employee.objects.visible_in_ui().filter(
id__in=requested_ids,
is_active=True,
)
if requested_ids - set(users.values_list("id", flat=True)):
return Response(
{"member_ids": ["Добавлять можно только активных сотрудников."]},
@ -346,7 +349,9 @@ class GroupViewSet(viewsets.ModelViewSet):
requested_ids = set(member_ids)
existing_ids = set(grp.user_set.values_list("id", flat=True))
users = Employee.objects.filter(id__in=requested_ids).filter(
users = Employee.objects.visible_in_ui(include_ids=existing_ids).filter(
id__in=requested_ids
).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
if requested_ids - set(users.values_list("id", flat=True)):

View File

@ -7,6 +7,7 @@ from django.utils import timezone
from rest_framework.exceptions import ValidationError
from employees.constants import ACTION_DISMISSED
from employees.models import Employee
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import ApprovalStatus, InputSource
from finance.models import (
@ -45,6 +46,7 @@ def _copyable_input_lines(source_period):
return (
PayrollInputLine.objects.filter(
period=source_period,
employee__is_hidden_in_ui=False,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
component_code__in=PAYROLL_COMPONENTS_BY_CODE,
)
@ -89,12 +91,17 @@ def _input_copy_configuration_payload(actor):
"existing_mode": "skip",
"auto_apply_on_period_creation": False,
}
visible_employee_ids = list(
Employee.objects.visible_in_ui()
.filter(pk__in=configuration.employee_ids)
.values_list("pk", flat=True)
)
return {
"saved": True,
"component_codes": configuration.component_codes,
"component_ids": configuration.component_codes,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"employee_ids": visible_employee_ids,
"existing_mode": configuration.existing_mode,
"auto_apply_on_period_creation": (configuration.auto_apply_on_period_creation),
}

View File

@ -81,7 +81,10 @@ class DraftCollectionView(PayrollAdminAPIView):
return queryset
def get(self, request):
queryset = self.filter_queryset(request, self.get_queryset())
queryset = self.filter_queryset(
request,
self.get_queryset().filter(employee__is_hidden_in_ui=False),
)
return Response(self.read_serializer_class(queryset, many=True).data)
def validate_create(self, validated_data):
@ -148,13 +151,18 @@ class PayrollInputHistoryView(PayrollAdminAPIView):
queryset=EmployeePayRate.objects.select_related(
"employee",
"employee__position",
).prefetch_related("employee__departments_links__department"),
)
.prefetch_related("employee__departments_links__department")
.filter(employee__is_hidden_in_ui=False),
)
object_type = EmployeePayRate._meta.label_lower
subjects = {record.pk: "Ставка" for record in records}
elif kind == "work":
records = (
PayrollWorkRecord.objects.filter(period=period)
PayrollWorkRecord.objects.filter(
period=period,
employee__is_hidden_in_ui=False,
)
.select_related(
"employee",
"employee__position",
@ -165,7 +173,10 @@ class PayrollInputHistoryView(PayrollAdminAPIView):
subjects = {record.pk: "Выработка" for record in records}
elif kind == "inputs":
records = (
PayrollInputLine.objects.filter(period=period)
PayrollInputLine.objects.filter(
period=period,
employee__is_hidden_in_ui=False,
)
.select_related(
"employee",
"employee__position",

View File

@ -48,7 +48,7 @@ class PayrollAdminEmployeeListView(PayrollAdminAPIView):
def get(self, request):
employees = (
Employee.objects.filter(is_active=True)
Employee.objects.visible_in_ui().filter(is_active=True)
.select_related("position")
.prefetch_related("departments_links__department")
.order_by("last_name", "first_name", "id")

View File

@ -28,7 +28,10 @@ from ..admin_serializers import (
def _cell_comment_summaries(period):
threads = list(
PayrollCellCommentThread.objects.filter(period=period).values(
PayrollCellCommentThread.objects.filter(
period=period,
subject_employee__is_hidden_in_ui=False,
).values(
"id",
"subject_employee_id",
"column_id",
@ -78,6 +81,7 @@ def _carryover_summary(period):
statements = (
PayrollStatement.objects.filter(
run=period.current_run,
employee__is_hidden_in_ui=False,
)
if period.current_run_id
else PayrollStatement.objects.none()
@ -112,6 +116,7 @@ def _carryover_ledger(period):
PayrollInputLine.objects.filter(
period__currency=period.currency,
period__date_to__lte=period.date_to,
employee__is_hidden_in_ui=False,
component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"),
)
.exclude(

View File

@ -56,7 +56,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
@staticmethod
def _employee_queryset(*, active_only: bool):
queryset = Employee.objects.all()
queryset = Employee.objects.visible_in_ui()
if active_only:
queryset = queryset.filter(is_active=True)
return (
@ -169,7 +169,7 @@ class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView):
selected_ids = list(
finance_settings.management_users.values_list("id", flat=True)
)
candidates = Employee.objects.filter(
candidates = Employee.objects.visible_in_ui(include_ids=selected_ids).filter(
Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True))
| Q(id__in=selected_ids)
).order_by("last_name", "first_name", "id")
@ -209,7 +209,9 @@ class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView):
existing_ids = set(
finance_settings.management_users.values_list("id", flat=True)
)
eligible_users = Employee.objects.filter(id__in=requested_ids).filter(
eligible_users = Employee.objects.visible_in_ui(
include_ids=existing_ids
).filter(id__in=requested_ids).filter(
Q(id__in=existing_ids)
| (Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True)))
)

View File

@ -74,12 +74,14 @@ def _period_input_state(period) -> PeriodInputState:
approved_employee_ids = list(
PayrollWorkRecord.objects.filter(
period=period,
employee__is_hidden_in_ui=False,
status=ApprovalStatus.APPROVED,
).values_list("employee_id", flat=True)
)
draft_work_employee_ids = list(
PayrollWorkRecord.objects.filter(
period=period,
employee__is_hidden_in_ui=False,
status=ApprovalStatus.DRAFT,
).values_list("employee_id", flat=True)
)
@ -89,6 +91,7 @@ def _period_input_state(period) -> PeriodInputState:
employees_with_rate = set(
EmployeePayRate.objects.filter(
employee_id__in=roster_employee_ids,
employee__is_hidden_in_ui=False,
rate_code=base_rate_code(),
status=ApprovalStatus.APPROVED,
effective_from__lte=period.date_from,
@ -101,14 +104,17 @@ def _period_input_state(period) -> PeriodInputState:
]
approved_input_count = PayrollInputLine.objects.filter(
period=period,
employee__is_hidden_in_ui=False,
status=ApprovalStatus.APPROVED,
).count()
draft_inputs = PayrollInputLine.objects.filter(
period=period,
employee__is_hidden_in_ui=False,
status=ApprovalStatus.DRAFT,
)
pending_rates = EmployeePayRate.objects.filter(
employee_id__in=roster_employee_ids,
employee__is_hidden_in_ui=False,
rate_code=base_rate_code(),
status=ApprovalStatus.DRAFT,
effective_from__lte=period.date_to,

View File

@ -104,7 +104,7 @@ class PayrollInputLineWriteSerializer(
)
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.all(),
queryset=Employee.objects.visible_in_ui(),
)
component_code = serializers.ChoiceField(
choices=PAYROLL_COMPONENT_CODE_CHOICES,

View File

@ -74,7 +74,7 @@ class EmployeePayRateWriteSerializer(
expected_lock_version = DraftWriteSerializerMixin.expected_lock_version
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.all(),
queryset=Employee.objects.visible_in_ui(),
)
class Meta:

View File

@ -105,7 +105,7 @@ class PayrollCarryoverSerializer(serializers.ModelSerializer):
class PayrollCarryoverWriteSerializer(serializers.Serializer):
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.all(),
queryset=Employee.objects.visible_in_ui(),
)
direction = serializers.ChoiceField(choices=PayrollCarryoverDirection.choices)
amount = serializers.DecimalField(

View File

@ -84,7 +84,7 @@ class PayrollWorkRecordWriteSerializer(
)
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.all(),
queryset=Employee.objects.visible_in_ui(),
)
target_points = serializers.DecimalField(
max_digits=19,

View File

@ -28,7 +28,7 @@ class CreateEquipmentFromItemSerializer(serializers.Serializer):
)
warranty_until = serializers.DateField(required=False, allow_null=True)
responsible_person = serializers.PrimaryKeyRelatedField(
queryset=Employee.objects.filter(is_active=True),
queryset=Employee.objects.visible_in_ui().filter(is_active=True),
required=False,
allow_null=True,
)

View File

@ -23,7 +23,7 @@ class ProcurementManualApprovalStepSerializer(serializers.Serializer):
priority = serializers.IntegerField(min_value=1)
approver = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(is_active=True),
queryset=User.objects.visible_in_ui().filter(is_active=True),
)
step_name = serializers.CharField(
required=False,

View File

@ -169,7 +169,9 @@ class RequestWriteSerializer(
return
existing_ids = set(relation.values_list("id", flat=True))
users = User.objects.filter(id__in=recipient_ids).filter(
users = User.objects.visible_in_ui(include_ids=existing_ids).filter(
id__in=recipient_ids
).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
relation.set(users)
@ -184,7 +186,8 @@ class RequestWriteSerializer(
else set()
)
allowed_ids = set(
User.objects.filter(id__in=requested_ids)
User.objects.visible_in_ui(include_ids=existing_ids)
.filter(id__in=requested_ids)
.filter(Q(is_active=True) | Q(id__in=existing_ids))
.values_list("id", flat=True)
)

View File

@ -70,7 +70,10 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(id=value, is_active=True).exists():
if not User.objects.visible_in_ui().filter(
id=value,
is_active=True,
).exists():
raise serializers.ValidationError("Выберите активного сотрудника.")
return value
@ -90,7 +93,7 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
distinction = validated_data.get("distinction", "viewer")
inheritable = validated_data.get("inheritable", True)
user = User.objects.get(id=user_id, is_active=True)
user = User.objects.visible_in_ui().get(id=user_id, is_active=True)
# Используем встроенный метод create_relation
calendar.create_relation(user, distinction=distinction, inheritable=inheritable)

View File

@ -101,7 +101,9 @@ class EventRelationSerializer(serializers.ModelSerializer):
if (
relation_changed
and content_type == user_content_type
and not User.objects.filter(id=object_id, is_active=True).exists()
and not User.objects.visible_in_ui()
.filter(id=object_id, is_active=True)
.exists()
):
raise serializers.ValidationError(
{"object_id": "Выберите активного сотрудника."}

View File

@ -70,6 +70,8 @@ def _search_queryset_for_model(model: Any):
"""Подгружает связи, нужные форматтерам и проверкам доступа."""
queryset = model._default_manager.all()
model_name = model.__name__.lower()
if model_name == "employee":
queryset = queryset.visible_in_ui()
select_related = {
"employee": ("position",),
"post": ("author",),

View File

@ -196,11 +196,20 @@ class TaskBoardSerializer(serializers.ModelSerializer):
else set()
)
if any(
not member.is_active and member.id not in existing_member_ids
(
not member.is_active
or member.is_hidden_in_ui
)
and member.id not in existing_member_ids
for member in attrs["members"]
):
raise serializers.ValidationError(
{"members": "Добавлять на доску можно только активных сотрудников."}
{
"members": (
"Добавлять на доску можно только доступных "
"активных сотрудников."
)
}
)
if access_scope != TaskBoardAccessScope.RESTRICTED:
attrs["members"] = []

View File

@ -357,9 +357,16 @@ class TaskSerializer(serializers.ModelSerializer):
assignee = attrs.get("assignee")
if "assignee" in attrs and assignee is not None:
assignee_changed = assignee.id != existing_assignee_id
if assignee_changed and not assignee.is_active:
if assignee_changed and (
not assignee.is_active or assignee.is_hidden_in_ui
):
raise serializers.ValidationError(
{"assignee_id": "Исполнителем может быть только активный сотрудник."}
{
"assignee_id": (
"Исполнителем можно выбрать только доступного "
"активного сотрудника."
)
}
)
should_check_assignee_access = assignee_changed or (
board_changed and assignee.is_active
@ -392,11 +399,15 @@ class TaskSerializer(serializers.ModelSerializer):
for participant in participants
if participant.id not in existing_participant_ids
]
if any(not participant.is_active for participant in new_participants):
if any(
not participant.is_active or participant.is_hidden_in_ui
for participant in new_participants
):
raise serializers.ValidationError(
{
"participant_ids": (
"Добавлять в задачу можно только активных сотрудников."
"Добавлять в задачу можно только доступных "
"активных сотрудников."
)
}
)

View File

@ -39,7 +39,7 @@ class TaskBoardActionsMixin:
def participant_candidates(self, request, pk=None):
board = self.get_object()
recipient_ids = get_task_board_recipient_ids(board)
candidates = Employee.objects.filter(
candidates = Employee.objects.visible_in_ui().filter(
id__in=recipient_ids,
is_active=True,
).order_by("last_name", "first_name", "id")

View File

@ -294,9 +294,9 @@ class ChatViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
valid_participant_ids = set(
User.objects.filter(id__in=participant_ids, is_active=True).values_list(
"id", flat=True
)
User.objects.visible_in_ui()
.filter(id__in=participant_ids, is_active=True)
.values_list("id", flat=True)
)
if participant_ids - valid_participant_ids:
return Response(
@ -474,7 +474,7 @@ class ChatViewSet(viewsets.ModelViewSet):
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
user_to_add = User.objects.filter(pk=user_id).first()
user_to_add = User.objects.visible_in_ui().filter(pk=user_id).first()
if user_to_add is None:
return Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND

View File

@ -51,9 +51,9 @@ def filter_mentionable_users(users: Iterable, query: str) -> list:
def get_mentionable_users(chat, *, author=None) -> QuerySet:
"""Return active users who can see the chat, including legacy memberships."""
if chat.type == "global" or chat.include_all_users:
users = User.objects.filter(is_active=True)
users = User.objects.visible_in_ui().filter(is_active=True)
else:
users = User.objects.filter(
users = User.objects.visible_in_ui().filter(
Q(chat_memberships__chat=chat, chat_memberships__is_active=True)
| Q(chats=chat)
).filter(is_active=True).distinct()

View File

@ -43,7 +43,9 @@ def _set_departments(document: Document, department_ids: Sequence[int]) -> None:
def _set_recipients(document: Document, recipient_ids: Sequence[int]) -> None:
existing_ids = set(document.recipients.values_list("id", flat=True))
recipients = User.objects.filter(id__in=set(recipient_ids)).filter(
recipients = User.objects.visible_in_ui(include_ids=existing_ids).filter(
id__in=set(recipient_ids)
).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
document.recipients.set(recipients)
@ -74,7 +76,9 @@ def _set_acknowledgement_recipients(
existing_ids = set(
document.acknowledgement_recipients.values_list("id", flat=True)
)
recipients = User.objects.filter(id__in=set(recipient_ids)).filter(
recipients = User.objects.visible_in_ui(include_ids=existing_ids).filter(
id__in=set(recipient_ids)
).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
document.acknowledgement_recipients.set(recipients)

View File

@ -147,6 +147,7 @@ class EmployeeChangeForm(forms.ModelForm):
"position",
"skills",
"is_active",
"is_hidden_in_ui",
"email_verified",
"is_staff",
"is_superuser",
@ -221,6 +222,7 @@ class EmployeeAdmin(DjangoUserAdmin):
"phone_number",
"position",
"is_active",
"is_hidden_in_ui",
"email_verified",
"is_ldap_managed",
"ldap_sync_status",
@ -228,6 +230,7 @@ class EmployeeAdmin(DjangoUserAdmin):
)
list_filter = (
"is_active",
"is_hidden_in_ui",
"email_verified",
"is_staff",
"is_superuser",
@ -397,6 +400,7 @@ class EmployeeAdmin(DjangoUserAdmin):
{
"fields": (
"is_active",
"is_hidden_in_ui",
"email_verified",
"email_activation_code",
"sms_activation_code",

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-08-24 13:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0055_employee_email_activation_sent_at'),
]
operations = [
migrations.AddField(
model_name='employee',
name='is_hidden_in_ui',
field=models.BooleanField(default=False, help_text='Сотрудник сможет входить и пользоваться своими правами, но не будет отображаться в каталогах, поиске и селекторах сотрудников.', verbose_name='Скрыт в интерфейсе'),
),
]

View File

@ -44,7 +44,23 @@ class DateRangeMixin(models.Model):
)
class EmployeeManager(BaseUserManager):
class EmployeeQuerySet(models.QuerySet):
def visible_in_ui(self, *, include_ids=()):
"""Сотрудники для UI-каталогов с явным сохранением связанных записей.
``include_ids`` используется только карточками уже существующих сущностей:
скрытый сотрудник не попадает в общий выбор, но ранее сохранённая связь не
исчезает при чтении или повторном сохранении формы.
"""
include_ids = tuple(include_ids)
if not include_ids:
return self.filter(is_hidden_in_ui=False)
return self.filter(
models.Q(is_hidden_in_ui=False) | models.Q(pk__in=include_ids)
)
class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)):
use_in_migrations = True
def create_user(self, email, password=None, **extra_fields):
@ -247,6 +263,14 @@ class Employee(AbstractUser):
"Skill", related_name="employees", blank=True
)
is_active = models.BooleanField(default=False)
is_hidden_in_ui = models.BooleanField(
"Скрыт в интерфейсе",
default=False,
help_text=(
"Сотрудник сможет входить и пользоваться своими правами, но не "
"будет отображаться в каталогах, поиске и селекторах сотрудников."
),
)
sms_activation_code = models.CharField(
max_length=6, blank=True, null=True
) # Для будущей SMS-активации в bots (fallback к email)

View File

@ -66,6 +66,8 @@ class UpsertBirthdayEventService(Service):
return False, "email_not_verified"
if not employee.is_active:
return False, "not_active"
if employee.is_hidden_in_ui:
return False, "hidden_in_ui"
if not employee.is_actually_active:
return False, "not_actually_active"
return True, None
@ -199,7 +201,7 @@ class BulkSyncBirthdaysService(Service):
"""
def process(self):
employees = Employee.objects.all().select_related()
employees = Employee.objects.visible_in_ui().select_related()
results = {
"total": 0,

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from collections import defaultdict
from dataclasses import replace
from datetime import timedelta
from datetime import date, timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
@ -13,7 +13,8 @@ from django.utils import timezone
from payroll_core import DeterministicPayrollCalculator
from attendance.models import AttendanceRecord
from employees.constants import ACTIVATING_MARKER_ACTIONS
from attendance.services import ABSENCE_ISSUE_MARKERS
from employees.constants import ACTION_REMOTE, ACTIVATING_MARKER_ACTIONS
from employees.models import EmployeeAction
from employees.services.personnel_state import resolve_employee_personnel_state
from requests_app.enums import RequestStatus
@ -221,6 +222,90 @@ def _row_money_total(rows, field):
return str(sum(values, Decimal("0"))) if values else None
def _attendance_record_has_absence(record: AttendanceRecord) -> bool:
if record.is_absent:
return True
values = [*(record.statuses or []), *(record.employee_issues or [])]
return any(
marker in str(value).strip().lower()
for value in values
for marker in ABSENCE_ISSUE_MARKERS
)
def _first_employment_date(actions) -> date | None:
dates = [
timezone.localtime(action.date).date()
for action in actions
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
]
return min(dates) if dates else None
def _attendance_alert(
period: PayrollPeriod,
*,
employee,
actions,
records,
schedule,
target_points,
actual_points,
):
"""Describe explicit absences not covered by a personnel event.
Missing attendance rows are deliberately ignored: without a saved analysis
result the system cannot distinguish an absence from an incomplete sync.
Remote days and dates covered by personnel state never become warnings.
"""
workdates = set(period_workdates(period, schedule))
today = timezone.localdate()
employment_start = _first_employment_date(actions)
absence_dates = []
for record in records:
if record.date not in workdates or record.date >= today:
continue
if employment_start is not None and record.date < employment_start:
continue
if not _attendance_record_has_absence(record):
continue
state = resolve_employee_personnel_state(
employee,
record.date,
actions=actions,
)
if (
record.personnel_status == ACTION_REMOTE
or state.status == ACTION_REMOTE
or not state.expects_attendance
):
continue
absence_dates.append(record.date)
if not absence_dates:
return None
target = Decimal(str(target_points)) if target_points not in (None, "") else None
actual = Decimal(str(actual_points)) if actual_points not in (None, "") else None
points_in_norm = bool(
target is not None
and actual is not None
and target > 0
and actual >= target
)
ordered_dates = sorted(set(absence_dates))
return {
"kind": (
"compensated_absence" if points_in_norm else "unconfirmed_absence"
),
"severity": "warning" if points_in_norm else "danger",
"count": len(ordered_dates),
"dates": [item.isoformat() for item in ordered_dates],
"points_in_norm": points_in_norm,
}
def build_payroll_period_table(period: PayrollPeriod):
"""Include every active employee and inactive employees with period work."""
@ -244,9 +329,19 @@ def build_payroll_period_table(period: PayrollPeriod):
).values_list("employee_id", flat=True)
)
employee_ids = (
set(Employee.objects.filter(is_active=True).values_list("id", flat=True))
set(
Employee.objects.visible_in_ui()
.filter(is_active=True)
.values_list("id", flat=True)
)
| period_employee_ids
)
visible_employee_ids = set(
Employee.objects.visible_in_ui()
.filter(id__in=employee_ids)
.values_list("id", flat=True)
)
employee_ids = visible_employee_ids
employees = list(
Employee.objects.filter(id__in=employee_ids)
.select_related("position")
@ -440,6 +535,15 @@ def build_payroll_period_table(period: PayrollPeriod):
period_target_points=projection_target_points,
schedule=schedule,
)
attendance_alert = _attendance_alert(
period,
employee=employee,
actions=employee.payroll_personnel_actions,
records=attendance_records_for_employee,
schedule=schedule,
target_points=target_points,
actual_points=actual_points,
)
point_base_amount = _point_base_amount(rate_amount, component_amounts)
in_norm_point_rate = _in_norm_point_rate(
@ -461,6 +565,7 @@ def build_payroll_period_table(period: PayrollPeriod):
"target_points_automatic": target_points_automatic,
"target_points_source": target_points_source,
"attendance_points": _decimal_text(attendance_points),
"attendance_alert": attendance_alert,
"personnel_points": _decimal_text(personnel_points),
"actual_points": actual_points,
"point_delta": point_delta,
@ -505,6 +610,14 @@ def build_payroll_period_table(period: PayrollPeriod):
"draft_count": status_counts["draft"],
"incomplete_count": status_counts["incomplete"],
"preliminary_count": sum(1 for row in rows if row["totals_preliminary"]),
"attendance_alert_employee_count": sum(
1 for row in rows if row["attendance_alert"] is not None
),
"attendance_alert_day_count": sum(
row["attendance_alert"]["count"]
for row in rows
if row["attendance_alert"] is not None
),
"gross_total": _row_money_total(rows, "gross_total"),
"deduction_total": _row_money_total(rows, "deduction_total"),
"payable_total": _row_money_total(rows, "payable"),
@ -527,14 +640,7 @@ def build_payroll_employee_point_breakdown(
.select_related("source_request")
.order_by("date", "id")
)
employment_start_dates = [
timezone.localtime(action.date).date()
for action in actions
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
]
first_employment_date = (
min(employment_start_dates) if employment_start_dates else None
)
first_employment_date = _first_employment_date(actions)
requests = list(
Request.objects.filter(employee=employee)
.filter(
@ -587,6 +693,18 @@ def build_payroll_employee_point_breakdown(
date__range=(period.date_from, period.date_to),
).select_related("analysis_run")
}
attendance_alert = _attendance_alert(
period,
employee=employee,
actions=actions,
records=attendance_records.values(),
schedule=schedule,
target_points=target_points,
actual_points=actual_points,
)
attendance_alert_dates = set(
attendance_alert["dates"] if attendance_alert is not None else []
)
personnel_points = calculate_period_personnel_daily_points(
period,
employee=employee,
@ -746,6 +864,11 @@ def build_payroll_employee_point_breakdown(
"personnel_points": _decimal_text(personnel_points.get(current)),
"work_points": _decimal_text(work_points.get(current)),
"attendance_record": attendance_payload,
"attendance_alert": (
"unconfirmed_absence"
if current.isoformat() in attendance_alert_dates
else None
),
"personnel_detail": {
"state": (
{
@ -791,6 +914,7 @@ def build_payroll_employee_point_breakdown(
"employee": _employee_payload(employee),
"target_points": _decimal_text(target_points),
"actual_points": _decimal_text(actual_points),
"attendance_alert": attendance_alert,
"undated_work_points": _decimal_text(undated_work_points),
"undated_work_record": (
{

View File

@ -87,10 +87,10 @@ class RequestSettings(models.Model):
def get_allowed_recipients_queryset(self):
User = self.allowed_recipients.model
configured = self.allowed_recipients.filter(is_active=True)
configured = self.allowed_recipients.visible_in_ui().filter(is_active=True)
if self.allowed_recipients.exists():
return configured
return User.objects.filter(is_active=True)
return User.objects.visible_in_ui().filter(is_active=True)
def recipients_are_restricted(self) -> bool:
return self.allowed_recipients.exists()

View File

@ -350,6 +350,30 @@ def test_login_by_email_allowed_after_verify(api):
assert "access" in body and "refresh" in body
def test_hidden_from_ui_user_can_still_login(api):
user = User.objects.create_user(
email="hidden-login@example.com",
password="Str0ngPass!",
phone_number="+79990000071",
first_name="Скрытый",
last_name="Пользователь",
is_active=True,
is_hidden_in_ui=True,
send_activation_email=False,
)
user.email_verified = True
user.save(update_fields=["email_verified"])
response = jwt_obtain(
api,
email=user.email,
password="Str0ngPass!",
)
assert response.status_code == status.HTTP_200_OK
assert {"access", "refresh"} <= set(response.json())
def test_login_by_phone_allowed_after_verify(api):
# Регистрируем с телефоном и подтверждаем
payload = register_payload()

View File

@ -1343,6 +1343,54 @@ def test_monthly_matrix_infers_absence_from_issue_when_logstorm_omits_flag(
assert "Недоработка" not in " ".join(matrix_cell["detail_lines"])
def test_hidden_employee_is_absent_from_attendance_lists(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
hidden = user_factory(is_hidden_in_ui=True)
run = AttendanceAnalysisRun.objects.create(
employee=hidden,
period_start=date(2026, 4, 20),
period_end=date(2026, 4, 20),
triggered_by=staff,
)
AttendanceRecord.objects.create(
analysis_run=run,
employee=hidden,
date=date(2026, 4, 20),
arrival_time="09:00:00",
departure_time="18:00:00",
work_hours=9,
)
client = auth_client_factory(staff)
records_response = client.get(_records_url())
matrix_response = client.get(
_monthly_matrix_url(),
{"employee_ids": str(hidden.id), "month": "2026-04"},
)
weekly_response = client.get(
_weekly_summary_url(),
{"week_start": "2026-04-20"},
)
assert records_response.status_code == 200
assert hidden.id not in {
item["employee"]["id"] for item in records_response.json()["results"]
}
assert matrix_response.status_code == 200
assert matrix_response.json()["employees"] == []
assert weekly_response.status_code == 200
assert all(
hidden.id not in {
person["id"]
for person in [*day["present_people"], *day["absent_people"]]
}
for day in weekly_response.json()["days"]
)
def test_non_staff_cannot_export_monthly_matrix_xlsx(
auth_client_factory,
user_factory,

View File

@ -247,6 +247,26 @@ class TestChatViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "participants" in response.data
def test_cannot_create_chat_with_hidden_participant(
self, auth_client, user1, user2
):
"""Служебная учётка не появляется даже при передаче её ID вручную."""
user2.is_hidden_in_ui = True
user2.save(update_fields=["is_hidden_in_ui"])
response = auth_client.post(
"/api/v1/communications/chats/",
{
"type": "private",
"name": "Hidden participant",
"participants": [user1.id, user2.id],
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "participants" in response.data
def test_pin_chat(self, auth_client, private_chat, user1):
"""Закрепление чата"""
url = f"/api/v1/communications/chats/{private_chat.pk}/pin/"

View File

@ -90,6 +90,31 @@ def test_private_chat_candidates_only_include_counterpart(
}
def test_hidden_employee_is_not_mentionable(
auth_client,
private_chat,
mention_users,
):
author, recipient, _outsider = mention_users
recipient.is_hidden_in_ui = True
recipient.save(update_fields=["is_hidden_in_ui"])
response = auth_client.get(
f"/api/v1/communications/chats/{private_chat.id}/mention-candidates/"
)
assert response.status_code == 200
assert recipient.id not in {
candidate["id"] for candidate in response.data["results"]
}
assert build_message_mentions(
private_chat,
author,
f"Привет @{mention_display_name(recipient)}",
[recipient.id],
) == []
def test_global_chat_candidates_are_not_truncated(auth_client, mention_users):
author, _recipient, _outsider = mention_users
extra_users = [create_user(index) for index in range(10, 35)]

View File

@ -95,6 +95,51 @@ def test_list_ok_for_authenticated(api_client: APIClient):
assert e1.id in ids and e2.id in ids
def test_hidden_employee_is_excluded_from_directory_but_keeps_own_profile(
api_client: APIClient,
):
viewer = make_user("directory-viewer@example.com")
hidden = make_user(
"hidden-directory-user@example.com",
is_hidden_in_ui=True,
)
api_client.force_authenticate(user=viewer)
response = api_client.get(reverse("api:v1:employees-list"))
assert response.status_code == status.HTTP_200_OK
assert hidden.id not in {
item["id"] for item in extract_results(response.json())
}
api_client.force_authenticate(user=hidden)
profile_response = api_client.get(reverse("api:v1:employees-me"))
assert profile_response.status_code == status.HTTP_200_OK
assert profile_response.json()["id"] == hidden.id
def test_hidden_in_ui_flag_cannot_be_changed_through_profile_api(
api_client: APIClient,
):
hidden = make_user(
"hidden-profile-user@example.com",
is_hidden_in_ui=True,
)
api_client.force_authenticate(user=hidden)
response = api_client.patch(
reverse("api:v1:employees-me"),
{"is_hidden_in_ui": False, "first_name": "Обновлён"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
hidden.refresh_from_db()
assert hidden.first_name == "Обновлён"
assert hidden.is_hidden_in_ui is True
def test_stats_requires_auth(api_client: APIClient):
response = api_client.get(reverse("api:v1:employees-stats"))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@ -139,6 +184,12 @@ def test_stats_returns_active_employee_demographics(api_client: APIClient, monke
gender=2,
birth_date=dt.date(1970, 1, 1),
)
make_user(
"stats-hidden@example.com",
is_hidden_in_ui=True,
gender=2,
birth_date=dt.date(1985, 1, 1),
)
experienced.date_joined = dt.datetime(
2026, 1, 1, tzinfo=dt.timezone.utc

View File

@ -634,6 +634,63 @@ def test_empty_recipient_settings_allow_all_active_employees(
assert create_resp.status_code == 201
def test_hidden_employee_is_absent_from_request_recipients_and_cc(
auth_client, regular_user: models.Model, make_user
) -> None:
"""Служебную учётку нельзя найти или добавить в адресацию заявления."""
hidden = make_user(email="request-hidden-service@example.com")
hidden.is_hidden_in_ui = True
hidden.save(update_fields=["is_hidden_in_ui"])
RequestSettings.get_solo()
options_response = auth_client(regular_user).get(
f"{API_BASE}recipient-options/"
)
create_response = auth_client(regular_user).post(
API_BASE,
data={
"type": RequestType.OTHER,
"recipient_ids": [hidden.id],
"cc_user_ids": [hidden.id],
},
format="json",
)
assert options_response.status_code == 200
assert hidden.id not in _result_ids(options_response.json())
assert create_response.status_code == 400
assert "recipient_ids" in create_response.json()
def test_existing_hidden_cc_relation_is_preserved_on_request_update(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
"""Явная историческая связь остаётся читаемой и не ломает правку."""
hidden = make_user(email="request-hidden-existing-cc@example.com")
recipient = make_user(email="request-visible-recipient@example.com")
employee_request = make_request(
employee=regular_user,
type_=RequestType.OTHER,
)
employee_request.recipients.add(recipient)
employee_request.cc_users.add(hidden)
hidden.is_hidden_in_ui = True
hidden.save(update_fields=["is_hidden_in_ui"])
response = auth_client(regular_user).patch(
f"{API_BASE}{employee_request.id}/",
{
"comment": "Связь сохранена",
"recipient_ids": [recipient.id],
"cc_user_ids": [hidden.id],
},
format="json",
)
assert response.status_code == 200
assert employee_request.cc_users.filter(id=hidden.id).exists()
def test_configured_recipient_settings_restrict_primary_recipients(
auth_client, regular_user: models.Model, make_user
) -> None:

View File

@ -3324,14 +3324,21 @@ def test_task_participant_candidates_follow_board_access(api_client):
owner = make_user("task-candidates-owner@example.com", "+79994440097")
member = make_user("task-candidates-member@example.com", "+79994440098")
outsider = make_user("task-candidates-outsider@example.com", "+79994440099")
hidden_member = make_user(
"task-candidates-hidden@example.com",
"+79994440100",
is_hidden_in_ui=True,
)
board = TaskBoard.objects.create(
name="Кандидаты в участники",
created_by=owner,
access_scope="restricted",
)
board.members.add(member)
board.members.add(member, hidden_member)
api_client.force_authenticate(user=owner)
assert hidden_member.id in get_task_board_recipient_ids(board)
response = api_client.get(
reverse(
"api:v1:tasks:task-board-participant-candidates",
@ -3343,6 +3350,7 @@ def test_task_participant_candidates_follow_board_access(api_client):
candidate_ids = {candidate["id"] for candidate in response.data}
assert owner.id in candidate_ids
assert member.id in candidate_ids
assert hidden_member.id not in candidate_ids
assert outsider.id not in candidate_ids

View File

@ -186,6 +186,27 @@ class TestSearchAPI:
employees = [r for r in response.data['results'] if r['model_name'] == 'employee']
assert len(employees) > 0
assert 'ivanov@test.com' in employees[0]['meta']['email'].lower()
def test_search_omits_employee_hidden_from_ui_without_revealing_count(
self,
api_client,
sample_data,
):
employee = sample_data["employees"][0]
employee.is_hidden_in_ui = True
employee.save(update_fields=["is_hidden_in_ui"])
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=ivanov@test.com")
assert response.status_code == status.HTTP_200_OK
assert not [
result
for result in response.data["results"]
if result["model_name"] == "employee"
]
assert response.data["counts"].get("employee", 0) == 0
assert response.data["hidden_counts"].get("employee", 0) == 0
def test_search_finds_department(self, api_client, sample_data):
"""Поиск находит отдел."""

View File

@ -133,6 +133,19 @@ def test_birthday_event_deleted_when_employee_is_dismissed(user_factory):
assert not _birthday_events(employee).exists()
def test_birthday_event_deleted_when_employee_is_hidden_from_ui(user_factory):
employee = user_factory(
birth_date=date(1990, 5, 20),
verified=True,
)
assert _birthday_events(employee).exists()
employee.is_hidden_in_ui = True
employee.save(update_fields=["is_hidden_in_ui"])
assert not _birthday_events(employee).exists()
def test_birthday_event_recreated_when_employee_is_rehired(user_factory):
employee = user_factory(birth_date=date(1990, 5, 6))
now = timezone.now()

View File

@ -10,7 +10,13 @@ from django.utils import timezone
from django.utils.dateparse import parse_datetime
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from employees.constants import ACTION_DISMISSED, ACTION_HIRED, ACTION_REHIRED
from employees.constants import (
ACTION_DISMISSED,
ACTION_HIRED,
ACTION_ON_LEAVE,
ACTION_REHIRED,
ACTION_REMOTE,
)
from employees.models import EmployeeAction
from finance.models import (
EmployeePayRate,
@ -582,6 +588,10 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all(
email="workspace.dismissed@example.test",
active=False,
)
hidden_employee = user_factory(
email="workspace.hidden-admin@example.test",
is_hidden_in_ui=True,
)
grant(manager, "manage_payroll_inputs")
response = auth_client_factory(manager).get(api_url("admin-workspace"))
@ -618,6 +628,10 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all(
}
assert inactive_employee.pk not in {item["id"] for item in body["employees"]}
assert inactive_employee.pk in {item["id"] for item in body["rate_employees"]}
assert hidden_employee.pk not in {item["id"] for item in body["employees"]}
assert hidden_employee.pk not in {
item["id"] for item in body["rate_employees"]
}
def test_workspace_readiness_identifies_affected_employees_and_draft_counts(
@ -724,6 +738,10 @@ def test_period_table_lists_all_employees_and_requires_view_all(
last_name="Итогова",
)
missing_employee = user_factory(email="table.missing@example.test")
hidden_employee = user_factory(
email="table.hidden-admin@example.test",
is_hidden_in_ui=True,
)
grant(manager, "manage_payroll_inputs")
grant(viewer, "view_all_payroll")
period = make_period(creator=manager)
@ -741,6 +759,13 @@ def test_period_table_lists_all_employees_and_requires_view_all(
actual_points="115",
created_by=manager,
)
PayrollWorkRecord.objects.create(
period=period,
employee=hidden_employee,
target_points="110",
actual_points="110",
created_by=manager,
)
bonus = PayrollComponent.objects.get(code="BONUS")
PayrollInputLine.objects.create(
period=period,
@ -761,6 +786,9 @@ def test_period_table_lists_all_employees_and_requires_view_all(
assert response.status_code == 200, response.content
assert_no_store(response)
body = response.json()
assert hidden_employee.pk not in {
item["employee"]["id"] for item in body["rows"]
}
assert body["period_id"] == period.pk
assert body["calculation_rules"]["point_policy"] == "proportional_with_excess"
assert body["calculation_rules"]["money_quantum"] == "0.01"
@ -1073,6 +1101,101 @@ def test_period_table_personnel_points_respect_employment_boundaries(
assert row["personnel_points"] == "150.0000"
def test_period_table_flags_compensated_absence_but_excludes_remote_and_leave(
user_factory,
auth_client_factory,
monkeypatch,
):
manager = user_factory(email="attendance.alert.manager@example.test")
office_employee = user_factory(email="attendance.alert.office@example.test")
remote_employee = user_factory(email="attendance.alert.remote@example.test")
leave_employee = user_factory(email="attendance.alert.leave@example.test")
grant(manager, "view_all_payroll")
period = make_period(creator=manager)
for employee in (office_employee, remote_employee, leave_employee):
PayrollWorkRecord.objects.create(
period=period,
employee=employee,
target_points="20",
target_points_overridden=True,
actual_points="25",
created_by=manager,
)
run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=period.date_from,
period_end=period.date_to,
status=AttendanceAnalysisRun.STATUS_SUCCESS,
)
AttendanceRecord.objects.create(
analysis_run=run,
employee=employee,
date=date(2026, 6, 2),
work_hours=0,
expected_hours=9,
is_workday=True,
effective_is_workday=True,
is_absent=True,
statuses=["absence"],
)
EmployeeAction.objects.create(
employee=office_employee,
action=ACTION_HIRED,
date=timezone.make_aware(datetime(2026, 6, 1)),
)
EmployeeAction.objects.create(
employee=remote_employee,
action=ACTION_REMOTE,
date=timezone.make_aware(datetime(2026, 6, 1)),
)
EmployeeAction.objects.create(
employee=leave_employee,
action=ACTION_ON_LEAVE,
date=timezone.make_aware(datetime(2026, 6, 2)),
date_to=date(2026, 6, 2),
)
monkeypatch.setattr(
"finance.payroll.reporting.timezone.localdate",
lambda: date(2026, 6, 3),
)
response = auth_client_factory(manager).get(
api_url("admin-period-table", pk=period.pk),
)
assert response.status_code == 200, response.content
body = response.json()
rows = {item["employee"]["id"]: item for item in body["rows"]}
assert rows[office_employee.pk]["attendance_alert"] == {
"kind": "compensated_absence",
"severity": "warning",
"count": 1,
"dates": ["2026-06-02"],
"points_in_norm": True,
}
assert rows[remote_employee.pk]["attendance_alert"] is None
assert rows[leave_employee.pk]["attendance_alert"] is None
assert body["summary"]["attendance_alert_employee_count"] == 1
assert body["summary"]["attendance_alert_day_count"] == 1
breakdown = auth_client_factory(manager).get(
api_url(
"admin-period-point-breakdown",
pk=period.pk,
employee_id=office_employee.pk,
)
)
assert breakdown.status_code == 200, breakdown.content
breakdown_body = breakdown.json()
assert breakdown_body["attendance_alert"]["kind"] == "compensated_absence"
absence_day = next(
item for item in breakdown_body["dates"] if item["date"] == "2026-06-02"
)
assert absence_day["attendance_alert"] == "unconfirmed_absence"
def test_period_point_breakdown_returns_daily_sources(
user_factory,
auth_client_factory,

View File

@ -22,7 +22,6 @@ import {
Send,
ShieldCheck,
UsersRound,
WalletCards,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -1255,28 +1254,6 @@ export function PayrollAdminWorkspace({
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
{tab === "summary" ? (
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<div className="mb-4 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--foreground)]">Итоговая таблица</h2>
</div>
<button
type="button"
className="app-action-secondary relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
onClick={() => {
setCarryoversEmployeeId(null);
setCarryoversModalOpen(true);
}}
aria-label="Переходящие остатки"
title="Переходящие остатки"
>
<WalletCards size={16} />
{(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0) ? (
<span className="app-counter absolute -right-1 -top-1 inline-flex h-5 min-w-5 px-1 text-[10px]">
{(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0)}
</span>
) : null}
</button>
</div>
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
</section>
) : null}

View File

@ -16,7 +16,7 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react";
import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react";
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
@ -586,6 +586,15 @@ function formatEmployeeName(employee: PayrollPeriodTableRow["employee"]): string
.join(" ") || employee.display_name;
}
function absenceDaysLabel(count: number): string {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return `${count} дней`;
if (mod10 === 1) return `${count} день`;
if (mod10 >= 2 && mod10 <= 4) return `${count} дня`;
return `${count} дней`;
}
function CalculatedMoney({ value, currency, preliminary }: { value: string | null; currency: string; preliminary: boolean }) {
return (
<span
@ -1614,6 +1623,20 @@ export function PayrollPeriodTableView({
</button>
</div>
<p className="app-text-muted mt-0.5 max-w-full truncate text-[10px]">{row.employee.position || row.employee.department || "Сотрудник"}{row.employee.is_active ? "" : " · неактивен"}</p>
{row.attendance_alert ? (
<button
type="button"
className={`mt-1 inline-flex max-w-full items-center gap-1 rounded-md border px-1.5 py-0.5 text-[9px] font-medium leading-3 transition-colors ${row.attendance_alert.points_in_norm ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20" : "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"}`}
onClick={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })}
title={row.attendance_alert.points_in_norm
? `Баллы за период в норме, но обнаружено неподтверждённое отсутствие: ${absenceDaysLabel(row.attendance_alert.count)}. Открыть даты.`
: `Обнаружено неподтверждённое отсутствие: ${absenceDaysLabel(row.attendance_alert.count)}. Открыть даты.`}
aria-label={`Неподтверждённое отсутствие, ${absenceDaysLabel(row.attendance_alert.count)}. Открыть детализацию`}
>
<AlertTriangle className="shrink-0" size={10} />
<span className="truncate">{absenceDaysLabel(row.attendance_alert.count)} без посещения</span>
</button>
) : null}
</td>
{visibleColumns.map((column) => (
<Fragment key={column.id}>{renderBodyCell(column, row)}</Fragment>

View File

@ -1,6 +1,6 @@
"use client";
import { CalendarDays, Loader2, RefreshCw } from "lucide-react";
import { AlertTriangle, CalendarDays, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
@ -139,6 +139,10 @@ export function PayrollPointBreakdownModal({
() => 190 + ((data?.dates.length || 0) + (data?.undated_work_points ? 1 : 0)) * 68,
[data?.dates.length, data?.undated_work_points],
);
const attendanceAlertDays = useMemo(
() => data?.dates.filter((day) => day.attendance_alert === "unconfirmed_absence") || [],
[data?.dates],
);
const attendancePreview = useMemo<AttendanceDayEventsPreview | null>(() => {
const record = attendanceDay?.attendance_record;
if (!attendanceDay || !record) return null;
@ -227,6 +231,22 @@ export function PayrollPointBreakdownModal({
</div>
</div>
{data.attendance_alert ? (
<div className={`flex items-start gap-2.5 rounded-xl border px-3 py-2.5 text-sm ${data.attendance_alert.points_in_norm ? "border-amber-500/40 bg-amber-500/10 text-amber-200" : "border-red-500/40 bg-red-500/10 text-red-200"}`}>
<AlertTriangle className="mt-0.5 shrink-0" size={17} />
<div className="min-w-0">
<p className="font-semibold">
{data.attendance_alert.points_in_norm
? "Баллы в норме, но есть дни без подтверждённого посещения"
: "Есть дни без подтверждённого посещения"}
</p>
<p className="mt-1 text-xs opacity-85">
{attendanceAlertDays.map((day) => formatDate(day.date)).join(", ")}. Удалённые дни и отсутствия, закрытые кадровыми событиями, сюда не входят.
</p>
</div>
</div>
) : null}
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span className="text-[var(--foreground)]">Нажмите на значение, чтобы открыть исходные данные дня.</span>
<span>Подсвечивается любое дневное расхождение с выработкой:</span>
@ -254,13 +274,16 @@ export function PayrollPointBreakdownModal({
{data.dates.map((day) => {
const parts = dateParts(day.date);
const hasActivity = hasAttendanceActivity(day) || Boolean(day.work_entry);
const hasAttendanceAlert = day.attendance_alert === "unconfirmed_absence";
return (
<th
key={day.date}
className={`app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold ${day.is_workday || hasActivity ? "text-[var(--muted-foreground)]" : "opacity-55 text-[var(--muted-foreground)]"}`}
title={day.is_workday ? "Плановый рабочий день" : hasActivity ? "Нерабочий день с фактической активностью" : "Нерабочий день"}
className={`app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold ${hasAttendanceAlert ? "!bg-amber-500/15 !text-amber-300" : day.is_workday || hasActivity ? "text-[var(--muted-foreground)]" : "opacity-55 text-[var(--muted-foreground)]"}`}
title={hasAttendanceAlert ? "Неподтверждённое отсутствие" : day.is_workday ? "Плановый рабочий день" : hasActivity ? "Нерабочий день с фактической активностью" : "Нерабочий день"}
>
<span className="block tabular-nums">{parts.date}</span>
<span className="inline-flex items-center gap-0.5 tabular-nums">
{hasAttendanceAlert ? <AlertTriangle size={9} /> : null}{parts.date}
</span>
<span className="mt-0.5 block font-normal uppercase">{parts.weekday}</span>
</th>
);

View File

@ -398,6 +398,14 @@ export type PayrollCellCommentSummary = {
latest_at: string;
};
export type PayrollAttendanceAlert = {
kind: "compensated_absence" | "unconfirmed_absence";
severity: "warning" | "danger";
count: number;
dates: string[];
points_in_norm: boolean;
};
export type PayrollPeriodTableRow = {
employee: PayrollAdminEmployee & {
first_name: string;
@ -415,6 +423,7 @@ export type PayrollPeriodTableRow = {
target_points_automatic: boolean;
target_points_source: string;
attendance_points: string | null;
attendance_alert: PayrollAttendanceAlert | null;
personnel_points: string;
actual_points: string | null;
point_delta: string | null;
@ -470,6 +479,8 @@ export type PayrollPeriodTable = {
draft_count: number;
incomplete_count: number;
preliminary_count: number;
attendance_alert_employee_count: number;
attendance_alert_day_count: number;
gross_total: string | null;
deduction_total: string | null;
payable_total: string | null;
@ -488,6 +499,7 @@ export type PayrollPointBreakdownDay = {
date: string;
is_workday: boolean;
attendance_points: string | null;
attendance_alert: "unconfirmed_absence" | null;
personnel_points: string | null;
work_points: string | null;
attendance_record: {
@ -547,6 +559,7 @@ export type PayrollPointBreakdown = {
employee: PayrollAdminEmployee & { is_active: boolean };
target_points: string | null;
actual_points: string | null;
attendance_alert: PayrollAttendanceAlert | null;
undated_work_points: string | null;
undated_work_record: {
id: number;