diff --git a/backend/api/v1/attendance/endpoints/records.py b/backend/api/v1/attendance/endpoints/records.py index 5370029a..73fe859a 100644 --- a/backend/api/v1/attendance/endpoints/records.py +++ b/backend/api/v1/attendance/endpoints/records.py @@ -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() diff --git a/backend/api/v1/attendance/endpoints/reports.py b/backend/api/v1/attendance/endpoints/reports.py index a528be39..2a09c946 100644 --- a/backend/api/v1/attendance/endpoints/reports.py +++ b/backend/api/v1/attendance/endpoints/reports.py @@ -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] diff --git a/backend/api/v1/documents/serializers/write.py b/backend/api/v1/documents/serializers/write.py index 1f452cc9..4188572b 100644 --- a/backend/api/v1/documents/serializers/write.py +++ b/backend/api/v1/documents/serializers/write.py @@ -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) ) diff --git a/backend/api/v1/employees/serializers/department.py b/backend/api/v1/employees/serializers/department.py index 74f8e2f6..5ba97b8b 100644 --- a/backend/api/v1/employees/serializers/department.py +++ b/backend/api/v1/employees/serializers/department.py @@ -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, diff --git a/backend/api/v1/employees/views/department_mixins/membership.py b/backend/api/v1/employees/views/department_mixins/membership.py index 7e4aae5e..25e83767 100644 --- a/backend/api/v1/employees/views/department_mixins/membership.py +++ b/backend/api/v1/employees/views/department_mixins/membership.py @@ -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) diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py index 78f15cd0..52b7f04d 100644 --- a/backend/api/v1/employees/views/departments.py +++ b/backend/api/v1/employees/views/departments.py @@ -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 diff --git a/backend/api/v1/employees/views/employee_mixins/query.py b/backend/api/v1/employees/views/employee_mixins/query.py index b1f206ad..cd582065 100644 --- a/backend/api/v1/employees/views/employee_mixins/query.py +++ b/backend/api/v1/employees/views/employee_mixins/query.py @@ -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") diff --git a/backend/api/v1/employees/views/employee_mixins/statistics.py b/backend/api/v1/employees/views/employee_mixins/statistics.py index 6d2197e6..025d5e34 100644 --- a/backend/api/v1/employees/views/employee_mixins/statistics.py +++ b/backend/api/v1/employees/views/employee_mixins/statistics.py @@ -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", diff --git a/backend/api/v1/employees/views/groups.py b/backend/api/v1/employees/views/groups.py index 3cb3bedf..d81c8f69 100644 --- a/backend/api/v1/employees/views/groups.py +++ b/backend/api/v1/employees/views/groups.py @@ -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)): diff --git a/backend/api/v1/finance/admin/input_copy.py b/backend/api/v1/finance/admin/input_copy.py index 5958d6cf..29bb6caa 100644 --- a/backend/api/v1/finance/admin/input_copy.py +++ b/backend/api/v1/finance/admin/input_copy.py @@ -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), } diff --git a/backend/api/v1/finance/admin/input_views/base.py b/backend/api/v1/finance/admin/input_views/base.py index c9b7da8a..8972efd0 100644 --- a/backend/api/v1/finance/admin/input_views/base.py +++ b/backend/api/v1/finance/admin/input_views/base.py @@ -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", diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 187702d4..7f8430ce 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -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") diff --git a/backend/api/v1/finance/admin/queries.py b/backend/api/v1/finance/admin/queries.py index a422e3cd..a1ca2628 100644 --- a/backend/api/v1/finance/admin/queries.py +++ b/backend/api/v1/finance/admin/queries.py @@ -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( diff --git a/backend/api/v1/finance/admin/workspace.py b/backend/api/v1/finance/admin/workspace.py index 623542c0..92f7095c 100644 --- a/backend/api/v1/finance/admin/workspace.py +++ b/backend/api/v1/finance/admin/workspace.py @@ -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))) ) diff --git a/backend/api/v1/finance/admin/workspace_payload.py b/backend/api/v1/finance/admin/workspace_payload.py index a6a07ec3..cd7126a0 100644 --- a/backend/api/v1/finance/admin/workspace_payload.py +++ b/backend/api/v1/finance/admin/workspace_payload.py @@ -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, diff --git a/backend/api/v1/finance/payroll_serializers/input_lines.py b/backend/api/v1/finance/payroll_serializers/input_lines.py index 47ac8ce1..02da1f25 100644 --- a/backend/api/v1/finance/payroll_serializers/input_lines.py +++ b/backend/api/v1/finance/payroll_serializers/input_lines.py @@ -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, diff --git a/backend/api/v1/finance/payroll_serializers/pay_rates.py b/backend/api/v1/finance/payroll_serializers/pay_rates.py index e04564ea..24bdd1c2 100644 --- a/backend/api/v1/finance/payroll_serializers/pay_rates.py +++ b/backend/api/v1/finance/payroll_serializers/pay_rates.py @@ -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: diff --git a/backend/api/v1/finance/payroll_serializers/periods.py b/backend/api/v1/finance/payroll_serializers/periods.py index 492d2a84..f817d3a6 100644 --- a/backend/api/v1/finance/payroll_serializers/periods.py +++ b/backend/api/v1/finance/payroll_serializers/periods.py @@ -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( diff --git a/backend/api/v1/finance/payroll_serializers/work_records.py b/backend/api/v1/finance/payroll_serializers/work_records.py index 00cac1d2..3fcee6dc 100644 --- a/backend/api/v1/finance/payroll_serializers/work_records.py +++ b/backend/api/v1/finance/payroll_serializers/work_records.py @@ -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, diff --git a/backend/api/v1/procurement/action_serializers.py b/backend/api/v1/procurement/action_serializers.py index 9c6fb66f..12954f5f 100644 --- a/backend/api/v1/procurement/action_serializers.py +++ b/backend/api/v1/procurement/action_serializers.py @@ -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, ) diff --git a/backend/api/v1/procurement/serializers/approvals.py b/backend/api/v1/procurement/serializers/approvals.py index 4cbc6ebf..9bdfc0a5 100644 --- a/backend/api/v1/procurement/serializers/approvals.py +++ b/backend/api/v1/procurement/serializers/approvals.py @@ -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, diff --git a/backend/api/v1/requests_app/request_serializers/write.py b/backend/api/v1/requests_app/request_serializers/write.py index 69a369ab..758386cc 100644 --- a/backend/api/v1/requests_app/request_serializers/write.py +++ b/backend/api/v1/requests_app/request_serializers/write.py @@ -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) ) diff --git a/backend/api/v1/schedule/schedule_serializers/calendars.py b/backend/api/v1/schedule/schedule_serializers/calendars.py index c9de897c..2a0d5dcb 100644 --- a/backend/api/v1/schedule/schedule_serializers/calendars.py +++ b/backend/api/v1/schedule/schedule_serializers/calendars.py @@ -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) diff --git a/backend/api/v1/schedule/schedule_serializers/relations.py b/backend/api/v1/schedule/schedule_serializers/relations.py index d5a34275..98207137 100644 --- a/backend/api/v1/schedule/schedule_serializers/relations.py +++ b/backend/api/v1/schedule/schedule_serializers/relations.py @@ -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": "Выберите активного сотрудника."} diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index c03178e3..987fdc55 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -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",), diff --git a/backend/api/v1/tasks/serializers/boards.py b/backend/api/v1/tasks/serializers/boards.py index b92f3ef5..ec3269fb 100644 --- a/backend/api/v1/tasks/serializers/boards.py +++ b/backend/api/v1/tasks/serializers/boards.py @@ -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"] = [] diff --git a/backend/api/v1/tasks/serializers/tasks.py b/backend/api/v1/tasks/serializers/tasks.py index cd42e2fb..12cd39fb 100644 --- a/backend/api/v1/tasks/serializers/tasks.py +++ b/backend/api/v1/tasks/serializers/tasks.py @@ -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": ( - "Добавлять в задачу можно только активных сотрудников." + "Добавлять в задачу можно только доступных " + "активных сотрудников." ) } ) diff --git a/backend/api/v1/tasks/viewsets/board_mixins/actions.py b/backend/api/v1/tasks/viewsets/board_mixins/actions.py index b61dab6b..0cc797dd 100644 --- a/backend/api/v1/tasks/viewsets/board_mixins/actions.py +++ b/backend/api/v1/tasks/viewsets/board_mixins/actions.py @@ -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") diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 2ee2218b..23c9ec33 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -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 diff --git a/backend/communications/mentions.py b/backend/communications/mentions.py index 27d2fa2e..7b2ade1a 100644 --- a/backend/communications/mentions.py +++ b/backend/communications/mentions.py @@ -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() diff --git a/backend/documents/application/documents.py b/backend/documents/application/documents.py index 7ac0307b..8c2c43f7 100644 --- a/backend/documents/application/documents.py +++ b/backend/documents/application/documents.py @@ -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) diff --git a/backend/employees/admin.py b/backend/employees/admin.py index c1e9de2b..f83eb418 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -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", diff --git a/backend/employees/migrations/0056_employee_hidden_in_ui.py b/backend/employees/migrations/0056_employee_hidden_in_ui.py new file mode 100644 index 00000000..5e3eb7cb --- /dev/null +++ b/backend/employees/migrations/0056_employee_hidden_in_ui.py @@ -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='Скрыт в интерфейсе'), + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index 0d56126b..af27072f 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -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) diff --git a/backend/employees/services/birthday_events.py b/backend/employees/services/birthday_events.py index 8e0e0c04..90224e8d 100644 --- a/backend/employees/services/birthday_events.py +++ b/backend/employees/services/birthday_events.py @@ -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, diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 30329b0c..4807efce 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -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": ( { diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index 0055918a..e2f9733b 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -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() diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py index 5aade1cd..1f6808d2 100644 --- a/backend/tests/api/auth/test_auth_and_registration.py +++ b/backend/tests/api/auth/test_auth_and_registration.py @@ -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() diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index a1215b41..fdc49cd6 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -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, diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 9924a89f..a87d1cb5 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -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/" diff --git a/backend/tests/api/v1/communications/test_mentions.py b/backend/tests/api/v1/communications/test_mentions.py index 6f79db2a..7897be84 100644 --- a/backend/tests/api/v1/communications/test_mentions.py +++ b/backend/tests/api/v1/communications/test_mentions.py @@ -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)] diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index 5bcbe695..8a23fe82 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -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 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 7f5654ef..fcc4dc26 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -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: diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 68b676c9..815964ab 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -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 diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py index c2bf60a4..1ab94537 100644 --- a/backend/tests/api/v1/test_search.py +++ b/backend/tests/api/v1/test_search.py @@ -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): """Поиск находит отдел.""" diff --git a/backend/tests/employees/test_birthday_events.py b/backend/tests/employees/test_birthday_events.py index 2fd43585..4763ae23 100644 --- a/backend/tests/employees/test_birthday_events.py +++ b/backend/tests/employees/test_birthday_events.py @@ -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() diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index c41aff8f..b5dbb240 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -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, diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 0e53ce62..e2087f07 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -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" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} {tab === "summary" ? (
-
-
-

Итоговая таблица

-
- -
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} />
) : null} diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index d7caf5a4..853f50e6 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -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 (

{row.employee.position || row.employee.department || "Сотрудник"}{row.employee.is_active ? "" : " · неактивен"}

+ {row.attendance_alert ? ( + + ) : null} {visibleColumns.map((column) => ( {renderBodyCell(column, row)} diff --git a/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx index 3a8a36c4..6f1c2641 100644 --- a/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx +++ b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx @@ -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(() => { const record = attendanceDay?.attendance_record; if (!attendanceDay || !record) return null; @@ -227,6 +231,22 @@ export function PayrollPointBreakdownModal({ + {data.attendance_alert ? ( +
+ +
+

+ {data.attendance_alert.points_in_norm + ? "Баллы в норме, но есть дни без подтверждённого посещения" + : "Есть дни без подтверждённого посещения"} +

+

+ {attendanceAlertDays.map((day) => formatDate(day.date)).join(", ")}. Удалённые дни и отсутствия, закрытые кадровыми событиями, сюда не входят. +

+
+
+ ) : null} +
Нажмите на значение, чтобы открыть исходные данные дня. Подсвечивается любое дневное расхождение с выработкой: @@ -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 ( - {parts.date} + + {hasAttendanceAlert ? : null}{parts.date} + {parts.weekday} ); diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index 31056e2e..180ce5c8 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -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;