Hide excluded employees from UI-facing APIs and payroll workflows
This commit is contained in:
parent
62faad92bb
commit
f3d2d2fe44
@ -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()
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)):
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)))
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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": "Выберите активного сотрудника."}
|
||||
|
||||
@ -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",),
|
||||
|
||||
@ -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"] = []
|
||||
|
||||
@ -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": (
|
||||
"Добавлять в задачу можно только активных сотрудников."
|
||||
"Добавлять в задачу можно только доступных "
|
||||
"активных сотрудников."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
18
backend/employees/migrations/0056_employee_hidden_in_ui.py
Normal file
18
backend/employees/migrations/0056_employee_hidden_in_ui.py
Normal 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='Скрыт в интерфейсе'),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": (
|
||||
{
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/"
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
"""Поиск находит отдел."""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user