From 1f18ab784827f004ee935488b8e1d7f5f573e1ad Mon Sep 17 00:00:00 2001 From: lizerk Date: Sat, 18 Jul 2026 00:00:54 +0300 Subject: [PATCH] feat(finance): simplify payroll administration for pilot --- .env.example | 2 + backend/api/v1/finance/admin_serializers.py | 4 - backend/api/v1/finance/admin_views.py | 26 +++-- backend/eusrr_backend/settings.py | 3 + backend/finance/README.md | 38 +++++--- backend/finance/admin.py | 83 ++++++++++------ backend/finance/payroll/access.py | 46 +++++++++ backend/finance/payroll/attendance.py | 6 +- backend/finance/payroll/config.py | 12 +++ backend/finance/payroll/services.py | 63 +++++++------ .../tests/finance/test_payroll_admin_api.py | 94 ++++++++++++++++++- .../finance/test_payroll_admin_permissions.py | 29 ++++++ .../finance/test_payroll_attendance_import.py | 46 +++++++++ backend/tests/finance/test_payroll_config.py | 48 +++++++++- .../tests/finance/test_payroll_services.py | 36 ++++++- .../finance/admin/PayrollAdminModals.tsx | 2 +- .../finance/admin/PayrollAdminTables.tsx | 75 +++++---------- .../finance/admin/PayrollAdminWorkspace.tsx | 41 ++------ .../admin/PayrollAttendanceWorkModal.tsx | 2 +- frontend/src/lib/api/finance.ts | 5 +- frontend/src/lib/payroll-admin.test.js | 1 + frontend/src/lib/payroll-admin.ts | 13 +-- frontend/src/lib/permissions-payroll.test.js | 4 +- frontend/src/lib/permissions.ts | 5 +- 24 files changed, 490 insertions(+), 194 deletions(-) create mode 100644 backend/finance/payroll/access.py diff --git a/.env.example b/.env.example index 0c3e8b82..76d24d4c 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,5 @@ PAYROLL_MONEY_QUANTUM=0.01 PAYROLL_ROUNDING=half_up PAYROLL_ALLOW_NEGATIVE_PAYABLE=false PAYROLL_BASE_RATE_CODE=BASE +# Temporary: staff/superuser receive full payroll access during the pilot. +PAYROLL_SIMPLE_ADMIN_ACCESS=true diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py index b686b9b3..473b9b42 100644 --- a/backend/api/v1/finance/admin_serializers.py +++ b/backend/api/v1/finance/admin_serializers.py @@ -236,7 +236,6 @@ class EmployeePayRateSerializer(serializers.ModelSerializer): "approved_by_id", "approved_by", "approved_at", - "self_approval_overridden", "voided_by_id", "voided_at", ] @@ -318,7 +317,6 @@ class PayrollWorkRecordSerializer(serializers.ModelSerializer): "approved_by_id", "approved_by", "approved_at", - "self_approval_overridden", "voided_by_id", "voided_at", ] @@ -431,7 +429,6 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): "approved_by_id", "approved_by", "approved_at", - "self_approval_overridden", "voided_by_id", "voided_at", ] @@ -546,7 +543,6 @@ class PayrollRunSerializer(serializers.ModelSerializer): "approved_by_id", "approved_by", "approved_at", - "self_approval_overridden", "published_by_id", "published_by", "published_at", diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 503dd91c..64bf9641 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -35,6 +35,7 @@ from finance.payroll.config import ( ruleset_not_effective_message, ruleset_period_details, ) +from finance.payroll.access import has_payroll_permission, has_simple_admin_access from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.services import ( approve_input_line, @@ -171,15 +172,21 @@ class PayrollAdminAPIView(NoStorePayrollResponseMixin, APIView): def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) - if not any(request.user.has_perm(code) for code in self.allowed_permissions): + if not any( + has_payroll_permission(request.user, code) + for code in self.allowed_permissions + ): _permission_error(self.allowed_permissions[0]) def require_permission(self, request, code): - if not request.user.has_perm(code): + if not has_payroll_permission(request.user, code): _permission_error(code) def include_amounts(self, request): - return request.user.has_perm(PAYROLL_PERMISSIONS["view_all"]) + return has_payroll_permission( + request.user, + PAYROLL_PERMISSIONS["view_all"], + ) def run_payload(self, request, run): return PayrollRunSerializer( @@ -212,9 +219,10 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): def get(self, request): permissions_payload = { - key: request.user.has_perm(permission) + key: has_payroll_permission(request.user, permission) for key, permission in PAYROLL_PERMISSIONS.items() } + permissions_payload["full_access"] = has_simple_admin_access(request.user) periods = list( PayrollPeriod.objects.select_related("current_run").order_by( "-date_from", "-id" @@ -661,15 +669,17 @@ class DraftDetailView(PayrollAdminAPIView): scoped_queryset = self.get_queryset().filter( pk=pk, status=ApprovalStatus.DRAFT, - created_by=request.user, ) + if not has_simple_admin_access(request.user): + scoped_queryset = scoped_queryset.filter(created_by=request.user) if self.period_scoped: - reference = get_object_or_404( - self.model.objects.only("period_id"), + reference_queryset = self.model.objects.only("period_id").filter( pk=pk, status=ApprovalStatus.DRAFT, - created_by=request.user, ) + if not has_simple_admin_access(request.user): + reference_queryset = reference_queryset.filter(created_by=request.user) + reference = get_object_or_404(reference_queryset) period = _lock_period_accepting_draft_edits(reference.period_id) else: period = None diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 41399f3f..fcde7771 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -859,4 +859,7 @@ FINANCE_PAYROLL = { "PAYROLL_ALLOW_NEGATIVE_PAYABLE", "false" ), "BASE_RATE_CODE": os.getenv("PAYROLL_BASE_RATE_CODE", "BASE"), + # Temporary pilot mode. Set to false after the granular finance roles and + # maker-checker workflow have been fully tested. + "SIMPLE_ADMIN_ACCESS": os.getenv("PAYROLL_SIMPLE_ADMIN_ACCESS", "true"), } diff --git a/backend/finance/README.md b/backend/finance/README.md index c2e936ee..a2e2d7e0 100644 --- a/backend/finance/README.md +++ b/backend/finance/README.md @@ -54,13 +54,25 @@ into the calculation. ## Workflow -Input records and rates are created as drafts and normally approved by another -actor. An approver cannot approve their own record or edit another user's draft -in the admin. A deliberately assigned self-approval override can waive only -the first restriction; the actor must still hold the ordinary input or run -approval permission. Every use persists `self_approval_overridden=true` on the -approved object and in audit metadata. Database constraints reject both an -unflagged self-approval and a flag without an actual self-approval. +During the pilot, `PAYROLL_SIMPLE_ADMIN_ACCESS=true` is the default. Active +`staff` and `superuser` accounts receive every payroll operation without extra +Finance roles, may edit any draft, and may approve records and runs they +created. The legacy `self_approval_overridden` value remains internal to +satisfy existing database constraints; it is not exposed by the payroll API or +shown in the normal payroll workspace and Django model forms. With the pilot +mode disabled, privileged auditors can still inspect the preserved approval +metadata in the immutable audit log. + +Set `PAYROLL_SIMPLE_ADMIN_ACCESS=false` to restore the preserved granular +policy: input records and rates are created as drafts and normally approved by +another actor; an approver cannot approve their own record or edit another +user's draft without the dedicated override policy. + +> TODO(payroll-access-hardening): disable the temporary full-access mode only +> after the Finance role matrix and maker-checker workflow have passed a full +> pilot with representative payroll periods. In the same deployment, remove +> the temporary `staff` shortcut from the frontend navigation so it follows +> the granular Finance permissions again. The approval screen captures the exact draft version the checker reviewed; any concurrent maker edit increments that version and makes the approval fail @@ -82,7 +94,9 @@ legacy Excel mismatch blocks submission for review. ## Permissions -Use the custom Finance permissions rather than ordinary Django model access: +In the current pilot, active `staff` and `superuser` accounts receive all of +the following permissions automatically. When simple access is disabled, use +the custom Finance permissions rather than ordinary Django model access: - `finance.manage_payroll_inputs` — create and maintain own drafts; - `finance.approve_payroll_inputs` — approve another actor's inputs; @@ -107,8 +121,8 @@ POST /api/v1/finance/payroll/me/statements//acknowledge/ Payroll operators use the permission-gated native workspace under `/api/v1/finance/payroll/admin/`. It exposes explicit commands rather than generic model CRUD: draft edits require the version that the operator opened, -approvals require a different actor unless the explicit audited override is -used, and run transitions call the payroll services. The portal entry point is +the granular fallback can require a different actor, and run transitions call +the payroll services. The portal entry point is `/finances/payroll/manage`; Django Admin is not required for the normal payroll workflow. @@ -141,9 +155,9 @@ unit. It is accepted only while the payroll point policy is `disabled`, so hours cannot accidentally become paid production points. Missing days, technical issues, open shifts, unverified remote work and work outside the effective schedule block the affected employee. The apply command never -auto-approves: it creates a draft, updates only the operator's own draft, or +auto-approves: it creates a draft, updates an administrator-accessible draft, or creates a draft revision that leaves the approved record intact until the -normal maker-checker approval succeeds. +record is explicitly approved. ## Operational boundaries diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 75794ee5..52cc2a92 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -20,6 +20,7 @@ from .models import ( PayrollStatementLine, PayrollWorkRecord, ) +from .payroll.access import has_payroll_permission, has_simple_admin_access from .payroll.exceptions import PayrollOperationError from .payroll.services import ( approve_input_line, @@ -110,7 +111,8 @@ class PayrollSensitiveAdminMixin: def _has_payroll_access(self, request): return any( - request.user.has_perm(permission) for permission in self.view_permissions + has_payroll_permission(request.user, permission) + for permission in self.view_permissions ) def has_module_permission(self, request): @@ -137,9 +139,15 @@ class ImmutableApprovedAdminMixin(PayrollSensitiveAdminMixin): if "status" not in readonly: readonly.append("status") if obj is not None and ( - not request.user.has_perm("finance.manage_payroll_inputs") + not has_payroll_permission( + request.user, + "finance.manage_payroll_inputs", + ) or obj.status != ApprovalStatus.DRAFT - or obj.created_by_id != request.user.pk + or ( + obj.created_by_id != request.user.pk + and not has_simple_admin_access(request.user) + ) ): readonly.extend( field.name @@ -152,14 +160,20 @@ class ImmutableApprovedAdminMixin(PayrollSensitiveAdminMixin): return False def has_add_permission(self, request): - return request.user.has_perm("finance.manage_payroll_inputs") + return has_payroll_permission( + request.user, + "finance.manage_payroll_inputs", + ) def has_change_permission(self, request, obj=None): return self._has_payroll_access(request) @transaction.atomic def save_model(self, request, obj, form, change): - if not request.user.has_perm("finance.manage_payroll_inputs"): + if not has_payroll_permission( + request.user, + "finance.manage_payroll_inputs", + ): raise PermissionDenied("Нет права изменять входные данные зарплаты.") if change: if form is None or form.cleaned_data.get("expected_lock_version") is None: @@ -168,11 +182,11 @@ class ImmutableApprovedAdminMixin(PayrollSensitiveAdminMixin): ) expected_lock_version = form.cleaned_data["expected_lock_version"] current = obj.__class__.objects.select_for_update().get(pk=obj.pk) - if ( + if current.status != ApprovalStatus.DRAFT or ( current.created_by_id != request.user.pk - or current.status != ApprovalStatus.DRAFT + and not has_simple_admin_access(request.user) ): - raise PermissionDenied("Изменять можно только собственный черновик.") + raise PermissionDenied("Этот черновик нельзя изменить.") if current.lock_version != expected_lock_version: raise PermissionDenied( "Черновик уже изменён другой операцией; обновите страницу." @@ -198,12 +212,18 @@ class PayrollComponentAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): ordering = ["display_order", "code"] def has_add_permission(self, request): - return request.user.has_perm("finance.manage_payroll_inputs") + return has_payroll_permission( + request.user, + "finance.manage_payroll_inputs", + ) def has_change_permission(self, request, obj=None): if obj is None: return self._has_payroll_access(request) - return request.user.has_perm("finance.manage_payroll_inputs") + return has_payroll_permission( + request.user, + "finance.manage_payroll_inputs", + ) def has_delete_permission(self, request, obj=None): return False @@ -249,19 +269,25 @@ class PayrollPeriodAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): super().save_model(request, obj, form, change) def has_add_permission(self, request): - return request.user.has_perm("finance.manage_payroll_inputs") + return has_payroll_permission( + request.user, + "finance.manage_payroll_inputs", + ) def has_change_permission(self, request, obj=None): if obj is None: return self._has_payroll_access(request) - return request.user.has_perm("finance.manage_payroll_inputs") + return has_payroll_permission( + request.user, + "finance.manage_payroll_inputs", + ) def has_delete_permission(self, request, obj=None): return False @admin.action(description="Рассчитать выбранные периоды") def calculate_selected(self, request, queryset): - if not request.user.has_perm("finance.calculate_payroll"): + if not has_payroll_permission(request.user, "finance.calculate_payroll"): self.message_user(request, "Недостаточно прав.", level=messages.ERROR) return reason_required = any(period.current_run_id for period in queryset) @@ -311,6 +337,7 @@ class PayrollPeriodAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): @admin.register(EmployeePayRate) class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): + exclude = ["self_approval_overridden"] list_display = [ "employee", "rate_code", @@ -319,11 +346,9 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): "effective_from", "revision", "status", - "self_approval_overridden", ] list_filter = [ "status", - "self_approval_overridden", "currency", "source", "effective_from", @@ -340,7 +365,6 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): "created_at", "approved_by", "approved_at", - "self_approval_overridden", "voided_by", "voided_at", ] @@ -365,7 +389,10 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): action_name, title, ): - if not request.user.has_perm("finance.approve_payroll_inputs"): + if not has_payroll_permission( + request.user, + "finance.approve_payroll_inputs", + ): self.message_user(request, "Недостаточно прав.", level=messages.ERROR) return if "confirm_payroll_approval" not in request.POST: @@ -404,6 +431,7 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): @admin.register(PayrollWorkRecord) class PayrollWorkRecordAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): + exclude = ["self_approval_overridden"] list_display = [ "period", "employee", @@ -411,9 +439,8 @@ class PayrollWorkRecordAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): "actual_points", "revision", "status", - "self_approval_overridden", ] - list_filter = ["period", "status", "self_approval_overridden", "source"] + list_filter = ["period", "status", "source"] search_fields = [ "employee__last_name", "employee__first_name", @@ -426,7 +453,6 @@ class PayrollWorkRecordAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): "updated_at", "approved_by", "approved_at", - "self_approval_overridden", "voided_by", "voided_at", ] @@ -446,7 +472,7 @@ class PayrollWorkRecordAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): @admin.register(PayrollInputLine) class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): - exclude = ["reversal_of"] + exclude = ["reversal_of", "self_approval_overridden"] list_display = [ "period", "employee", @@ -454,12 +480,10 @@ class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): "amount", "relates_to_period", "status", - "self_approval_overridden", ] list_filter = [ "period", "status", - "self_approval_overridden", "component", "source", ] @@ -476,7 +500,6 @@ class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): "created_at", "approved_by", "approved_at", - "self_approval_overridden", "voided_by", "voided_at", ] @@ -507,6 +530,7 @@ class PayrollStatementLineInline(admin.TabularInline): @admin.register(PayrollRun) class PayrollRunAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): + exclude = ["self_approval_overridden"] view_permissions = ( "finance.calculate_payroll", "finance.approve_payroll", @@ -521,11 +545,14 @@ class PayrollRunAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): "gross_total", "payable_total", "requested_by", - "self_approval_overridden", ] - list_filter = ["status", "self_approval_overridden", "period"] + list_filter = ["status", "period"] search_fields = ["period__code", "input_hash", "result_hash"] - readonly_fields = [field.name for field in PayrollRun._meta.concrete_fields] + readonly_fields = [ + field.name + for field in PayrollRun._meta.concrete_fields + if field.name != "self_approval_overridden" + ] actions = [ "request_review", "return_selected", @@ -686,7 +713,7 @@ class PayrollAuditEventAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): return False def has_change_permission(self, request, obj=None): - return request.user.has_perm("finance.audit_payroll") + return has_payroll_permission(request.user, "finance.audit_payroll") def has_delete_permission(self, request, obj=None): return False diff --git a/backend/finance/payroll/access.py b/backend/finance/payroll/access.py new file mode 100644 index 00000000..8052f60f --- /dev/null +++ b/backend/finance/payroll/access.py @@ -0,0 +1,46 @@ +"""Temporary host access policy for payroll administration. + +The deterministic payroll core is intentionally unaware of portal roles. This +adapter keeps the current pilot simple without deleting the granular permission +model that will be needed after the workflow has been tested in production. +""" + +from __future__ import annotations + +from .config import simple_admin_access_enabled + +SELF_APPROVAL_OVERRIDE_PERMISSION = "finance.override_payroll_approval" + + +def has_simple_admin_access(user) -> bool: + """Grant temporary full payroll access to authenticated portal admins.""" + + return bool( + simple_admin_access_enabled() + and user is not None + and getattr(user, "is_authenticated", False) + and getattr(user, "is_active", False) + and (getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)) + ) + + +def has_payroll_permission(user, permission: str) -> bool: + """Check the temporary admin bypass, then the preserved granular policy.""" + + if user is None or not getattr(user, "is_authenticated", False): + return False + if has_simple_admin_access(user): + return True + return user.has_perm(permission) + + +def can_self_approve_payroll(user) -> bool: + """Allow admins to approve directly while retaining the old override role.""" + + # TODO(payroll-access-hardening): disable SIMPLE_ADMIN_ACCESS and require the + # maker-checker/override policy again after roles and approval flows have + # passed a full pilot with finance administrators. + return has_simple_admin_access(user) or has_payroll_permission( + user, + SELF_APPROVAL_OVERRIDE_PERMISSION, + ) diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index 61e59749..9e2880b4 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -32,6 +32,7 @@ from finance.enums import ( ) from finance.models import PayrollAuditEvent, PayrollPeriod, PayrollWorkRecord +from .access import has_payroll_permission, has_simple_admin_access from .config import build_rules from .exceptions import PayrollOperationError, PayrollPermissionDenied @@ -361,7 +362,7 @@ def _actions_for_item( if existing.status == ApprovalStatus.DRAFT: replacement = ( "blocked" - if existing.created_by_id != actor.pk + if existing.created_by_id != actor.pk and not has_simple_admin_access(actor) else ("unchanged" if _same_metrics(existing, metrics) else "update") ) return {"missing_only": "skip", "replace_existing": replacement} @@ -470,6 +471,7 @@ def build_attendance_work_preview( existing is not None and existing.status == ApprovalStatus.DRAFT and existing.created_by_id != actor.pk + and not has_simple_admin_access(actor) ): metrics["warnings"].append( _issue( @@ -576,7 +578,7 @@ def apply_attendance_work_preview( if ( not actor or not actor.is_authenticated - or not actor.has_perm("finance.manage_payroll_inputs") + or not has_payroll_permission(actor, "finance.manage_payroll_inputs") ): raise PayrollPermissionDenied("finance.manage_payroll_inputs") if mode not in ATTENDANCE_IMPORT_MODES: diff --git a/backend/finance/payroll/config.py b/backend/finance/payroll/config.py index 36d565fc..70e53ce8 100644 --- a/backend/finance/payroll/config.py +++ b/backend/finance/payroll/config.py @@ -23,6 +23,9 @@ DEFAULT_CONFIG = { "ROUNDING": RoundingPolicy.HALF_UP.value, "ALLOW_NEGATIVE_PAYABLE": False, "BASE_RATE_CODE": "BASE", + # Temporary pilot mode: portal administrators receive every payroll + # operation while the granular role matrix is being tested. + "SIMPLE_ADMIN_ACCESS": True, } @@ -182,3 +185,12 @@ def ruleset_period_details( def base_rate_code() -> str: return str(get_config()["BASE_RATE_CODE"]) + + +def simple_admin_access_enabled() -> bool: + """Return whether staff administrators bypass the granular payroll matrix.""" + + return _parse_bool( + get_config()["SIMPLE_ADMIN_ACCESS"], + "SIMPLE_ADMIN_ACCESS", + ) diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 31ca6d94..63e12561 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -37,6 +37,12 @@ from finance.models import ( ) from .adapter import build_core_request, employee_snapshot +from .access import ( + SELF_APPROVAL_OVERRIDE_PERMISSION, + can_self_approve_payroll, + has_payroll_permission, + has_simple_admin_access, +) from .config import ( base_rate_code, build_rules, @@ -45,18 +51,33 @@ from .config import ( ) from .exceptions import PayrollOperationError, PayrollPermissionDenied -SELF_APPROVAL_OVERRIDE_PERMISSION = "finance.override_payroll_approval" - def _require_permission(actor, permission: str) -> None: if ( actor is None or not getattr(actor, "is_authenticated", False) - or not actor.has_perm(permission) + or not has_payroll_permission(actor, permission) ): raise PayrollPermissionDenied(permission) +def _approval_audit_metadata(actor, *, self_approval: bool) -> dict[str, object]: + """Keep legacy separation metadata only outside the temporary admin mode.""" + + if has_simple_admin_access(actor): + return {} + if not self_approval: + return { + "self_approval_overridden": False, + "approval_mode": "maker_checker", + } + return { + "self_approval_overridden": True, + "approval_mode": "self_override", + "override_permission": SELF_APPROVAL_OVERRIDE_PERMISSION, + } + + def _hash_items(items: list[str]) -> str: payload = json.dumps(sorted(items), separators=(",", ":")).encode("utf-8") return hashlib.sha256(payload).hexdigest() @@ -349,7 +370,7 @@ def calculate_period( }: _operation_error( "PERIOD_UNDER_APPROVAL", - "Сначала верните текущую ревизию из согласования.", + "Сначала верните текущую ревизию на исправление.", period_id=period.pk, ) if previous_current is not None and not recalculation_reason.strip(): @@ -618,9 +639,7 @@ def approve_run(run_id: int, *, actor) -> PayrollRun: _operation_error("INVALID_RUN_STATE", "Расчёт не находится на проверке.") _ensure_run_is_fresh(run, period) self_approval_overridden = run.requested_by_id == actor.pk - if self_approval_overridden and not actor.has_perm( - SELF_APPROVAL_OVERRIDE_PERMISSION - ): + if self_approval_overridden and not can_self_approve_payroll(actor): _operation_error( "SELF_APPROVAL_FORBIDDEN", "Автор расчёта не может сам его утвердить.", @@ -644,17 +663,10 @@ def approve_run(run_id: int, *, actor) -> PayrollRun: action="payroll.approved", instance=run, period=period, - metadata={ - "self_approval_overridden": self_approval_overridden, - "approval_mode": ( - "self_override" if self_approval_overridden else "maker_checker" - ), - **( - {"override_permission": SELF_APPROVAL_OVERRIDE_PERMISSION} - if self_approval_overridden - else {} - ), - }, + metadata=_approval_audit_metadata( + actor, + self_approval=self_approval_overridden, + ), ) return run @@ -746,9 +758,7 @@ def _approve_record( if record.status != ApprovalStatus.DRAFT: _operation_error("INVALID_INPUT_STATE", "Утвердить можно только черновик.") self_approval_overridden = record.created_by_id == actor.pk - if self_approval_overridden and not actor.has_perm( - SELF_APPROVAL_OVERRIDE_PERMISSION - ): + if self_approval_overridden and not can_self_approve_payroll(actor): _operation_error( "SELF_APPROVAL_FORBIDDEN", "Автор входных данных не может сам их утвердить.", @@ -814,14 +824,9 @@ def _approve_record( period=period, metadata={ "approved_lock_version": record.lock_version, - "self_approval_overridden": self_approval_overridden, - "approval_mode": ( - "self_override" if self_approval_overridden else "maker_checker" - ), - **( - {"override_permission": SELF_APPROVAL_OVERRIDE_PERMISSION} - if self_approval_overridden - else {} + **_approval_audit_metadata( + actor, + self_approval=self_approval_overridden, ), }, ) diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 3e8fea9d..b1923594 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -97,6 +97,7 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all( assert_no_store(response) body = response.json() assert set(body["permissions"]) == { + "full_access", "manage_inputs", "approve_inputs", "calculate", @@ -107,6 +108,7 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all( "audit", } assert body["permissions"]["manage_inputs"] is True + assert body["permissions"]["full_access"] is False assert body["permissions"]["override_approval"] is False assert body["summary"] is None assert set(body["readiness"]) == { @@ -124,6 +126,90 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all( } +def test_staff_admin_gets_full_pilot_access_and_can_manage_any_draft( + user_factory, + auth_client_factory, +): + administrator = user_factory( + email="simple.payroll.admin@example.test", + staff=True, + ) + creator = user_factory(email="simple.payroll.creator@example.test") + first_employee = user_factory(email="simple.payroll.employee.one@example.test") + second_employee = user_factory(email="simple.payroll.employee.two@example.test") + client = auth_client_factory(administrator) + + workspace = client.get(api_url("admin-workspace")) + + assert workspace.status_code == 200 + assert workspace.json()["permissions"] == { + "manage_inputs": True, + "approve_inputs": True, + "calculate": True, + "approve_run": True, + "override_approval": True, + "publish": True, + "view_all": True, + "audit": True, + "full_access": True, + } + + foreign_rate = EmployeePayRate.objects.create( + employee=first_employee, + amount="80000", + effective_from="2026-01-01", + created_by=creator, + ) + patched = client.patch( + api_url("admin-pay-rate-detail", pk=foreign_rate.pk), + {"amount": "81000.0000", "expected_lock_version": 0}, + format="json", + ) + assert patched.status_code == 200 + assert patched.json()["amount"] == "81000.0000" + + own_rate = client.post( + api_url("admin-pay-rate-list"), + { + "employee_id": second_employee.pk, + "rate_code": "BASE", + "amount": "90000.0000", + "point_rate": "0.0000", + "currency": "RUB", + "effective_from": "2026-01-01", + "reason": "", + }, + format="json", + ) + assert own_rate.status_code == 201 + approved = client.post( + api_url("admin-pay-rate-approve", pk=own_rate.json()["id"]), + {"expected_lock_version": own_rate.json()["lock_version"]}, + format="json", + ) + assert approved.status_code == 200 + assert approved.json()["approved_by_id"] == administrator.pk + assert "self_approval_overridden" not in approved.json() + + +def test_staff_without_finance_permissions_is_denied_when_simple_mode_is_disabled( + settings, + user_factory, + auth_client_factory, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": False} + administrator = user_factory( + email="granular.payroll.admin@example.test", + staff=True, + ) + + response = auth_client_factory(administrator).get(api_url("admin-workspace")) + + assert response.status_code == 403 + assert response.json()["code"] == "PERMISSION_DENIED" + assert_no_store(response) + + def test_workspace_blocks_period_outside_ruleset_effective_dates( settings, user_factory, @@ -453,7 +539,7 @@ def test_native_api_runs_complete_service_workflow_and_redacts_aggregate_money( ) assert approval.status_code == 200 assert approval.json()["status"] == "approved" - assert approval.json()["self_approval_overridden"] is False + assert "self_approval_overridden" not in approval.json() operator_client = auth_client_factory(operator) workspace_before = operator_client.get( @@ -523,7 +609,7 @@ def test_native_api_runs_complete_service_workflow_and_redacts_aggregate_money( assert approved.status_code == 200 assert approved.json()["status"] == "approved" assert approved.json()["approved_by"]["id"] == run_approver.pk - assert approved.json()["self_approval_overridden"] is False + assert "self_approval_overridden" not in approved.json() published = auth_client_factory(publisher).post( api_url("admin-run-publish", pk=run_id), @@ -638,7 +724,7 @@ def test_override_holder_can_self_approve_every_serialized_payroll_object( ) assert approval.status_code == 200 assert approval.json()["approved_by_id"] == operator.pk - assert approval.json()["self_approval_overridden"] is True + assert "self_approval_overridden" not in approval.json() approved_objects.append(approval.json()) calculation = client.post( @@ -657,7 +743,7 @@ def test_override_holder_can_self_approve_every_serialized_payroll_object( run_approval = client.post(api_url("admin-run-approve", pk=run_id), {}) assert run_approval.status_code == 200 assert run_approval.json()["approved_by_id"] == operator.pk - assert run_approval.json()["self_approval_overridden"] is True + assert "self_approval_overridden" not in run_approval.json() records = ( (EmployeePayRate, approved_objects[0]["id"]), diff --git a/backend/tests/finance/test_payroll_admin_permissions.py b/backend/tests/finance/test_payroll_admin_permissions.py index a4fdae72..38c50a47 100644 --- a/backend/tests/finance/test_payroll_admin_permissions.py +++ b/backend/tests/finance/test_payroll_admin_permissions.py @@ -16,6 +16,13 @@ from finance.models import EmployeePayRate, PayrollAuditEvent, PayrollStatement pytestmark = pytest.mark.django_db +@pytest.fixture(autouse=True) +def use_granular_policy_by_default(settings): + """Keep legacy permission assertions explicit while the app defaults simple.""" + + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": False} + + def grant(user, *codenames): permissions = Permission.objects.filter( content_type__app_label="finance", @@ -34,6 +41,28 @@ def admin_request(user): return request +def test_simple_mode_staff_can_open_and_edit_any_payroll_draft( + settings, + user_factory, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + administrator = user_factory(email="simple.admin@example.test", staff=True) + creator = user_factory(email="simple.creator@example.test") + employee = user_factory(email="simple.employee@example.test") + rate = EmployeePayRate.objects.create( + employee=employee, + amount="80000", + effective_from="2026-01-01", + created_by=creator, + ) + model_admin = EmployeePayRateAdmin(EmployeePayRate, admin.site) + request = admin_request(administrator) + + assert model_admin.has_module_permission(request) is True + assert model_admin.has_add_permission(request) is True + assert "amount" not in model_admin.get_readonly_fields(request, rate) + + def test_standard_model_permission_does_not_expose_all_statements(user_factory): staff = user_factory(email="generic.admin@example.test", staff=True) grant(staff, "view_payrollstatement") diff --git a/backend/tests/finance/test_payroll_attendance_import.py b/backend/tests/finance/test_payroll_attendance_import.py index 93d258b7..ec4f1032 100644 --- a/backend/tests/finance/test_payroll_attendance_import.py +++ b/backend/tests/finance/test_payroll_attendance_import.py @@ -296,6 +296,52 @@ def test_replace_updates_only_own_draft_and_protects_foreign_draft( assert foreign.lock_version == 0 +def test_staff_admin_can_refresh_a_foreign_attendance_draft( + user_factory, + auth_client_factory, +): + administrator = user_factory( + email="attendance.simple.admin@example.test", + staff=True, + ) + creator = user_factory(email="attendance.simple.creator@example.test") + employee = user_factory(email="attendance.simple.employee@example.test") + period = make_period(administrator, suffix="simple-admin") + make_attendance(employee) + record = PayrollWorkRecord.objects.create( + period=period, + employee=employee, + target_points="1", + actual_points="1", + created_by=creator, + ) + client = auth_client_factory(administrator) + preview = client.get(endpoint(period)).json() + + assert preview["items"][0]["actions"]["replace_existing"] == "update" + assert not any( + issue["code"] == "FOREIGN_DRAFT_PROTECTED" + for issue in preview["items"][0]["warnings"] + ) + + response = client.post( + endpoint(period), + apply_payload( + preview, + period, + mode="replace_existing", + reason="Обновление администратором", + ), + format="json", + ) + + assert response.status_code == 200 + assert response.json()["summary"]["updated"] == 1 + record.refresh_from_db() + assert record.target_points == Decimal("16.0000") + assert record.lock_version == 1 + + def test_preview_blocks_technical_days_and_rejects_stale_source( user_factory, auth_client_factory, diff --git a/backend/tests/finance/test_payroll_config.py b/backend/tests/finance/test_payroll_config.py index 5e56a540..eefac4ce 100644 --- a/backend/tests/finance/test_payroll_config.py +++ b/backend/tests/finance/test_payroll_config.py @@ -3,7 +3,8 @@ from decimal import Decimal import pytest from django.core.exceptions import ImproperlyConfigured -from finance.payroll.config import build_rules +from finance.payroll.access import has_payroll_permission, has_simple_admin_access +from finance.payroll.config import build_rules, simple_admin_access_enabled from payroll_core import PointPolicy, RoundingPolicy @@ -39,6 +40,51 @@ def test_payroll_adapter_is_safe_by_default(settings): assert rules.allow_negative_payable is False +def test_simple_admin_access_defaults_to_enabled_and_accepts_boolean_strings(settings): + settings.FINANCE_PAYROLL = {} + assert simple_admin_access_enabled() is True + + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": "false"} + assert simple_admin_access_enabled() is False + + +@pytest.mark.django_db +def test_simple_admin_access_grants_staff_operational_permissions( + settings, + user_factory, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + administrator = user_factory(email="payroll.admin.access@example.test", staff=True) + regular_user = user_factory(email="payroll.regular.access@example.test") + + assert has_simple_admin_access(administrator) is True + assert ( + has_payroll_permission( + administrator, + "finance.publish_payroll", + ) + is True + ) + assert has_simple_admin_access(regular_user) is False + assert ( + has_payroll_permission( + regular_user, + "finance.publish_payroll", + ) + is False + ) + + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": False} + assert has_simple_admin_access(administrator) is False + assert ( + has_payroll_permission( + administrator, + "finance.publish_payroll", + ) + is False + ) + + def test_config_accepts_enum_values(settings): settings.FINANCE_PAYROLL = { "POINT_POLICY": PointPolicy.DISABLED, diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index f2d7d740..0353eef0 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -483,7 +483,7 @@ def test_mid_period_rate_change_fails_closed_until_policy_is_defined( assert PayrollRun.objects.count() == 0 -def test_staff_flag_alone_does_not_grant_payroll_calculation( +def test_staff_admin_can_calculate_without_dedicated_finance_permissions( june_period, payroll_users, user_factory, @@ -494,11 +494,37 @@ def test_staff_flag_alone_does_not_grant_payroll_calculation( staff=True, ) - with pytest.raises(PayrollOperationError) as error: - calculate_period(june_period.pk, actor=unrelated_staff) + run = calculate_period(june_period.pk, actor=unrelated_staff) - assert error.value.code == "PERMISSION_DENIED" - assert PayrollRun.objects.count() == 0 + assert run.requested_by == unrelated_staff + assert run.status == PayrollRunStatus.CALCULATED + assert PayrollRun.objects.count() == 1 + + +def test_staff_admin_can_approve_own_run_without_override_role( + june_period, + payroll_users, + user_factory, +): + create_approved_excel_inputs(june_period, payroll_users) + administrator = user_factory( + email="staff.direct.approval@example.test", + staff=True, + ) + run = calculate_period(june_period.pk, actor=administrator) + submit_run_for_review(run.pk, actor=administrator) + + approved = approve_run(run.pk, actor=administrator) + + assert approved.status == PayrollRunStatus.APPROVED + assert approved.approved_by == administrator + assert approved.self_approval_overridden is True + event = PayrollAuditEvent.objects.get( + action="payroll.approved", + object_id=str(run.pk), + ) + assert "self_approval_overridden" not in event.metadata + assert "approval_mode" not in event.metadata def test_every_new_revision_requires_a_reason(june_period, payroll_users): diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx index 4aabc1e8..1ba8f7f8 100644 --- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -331,7 +331,7 @@ export function PayrollWorkRecordFormModal({
Контрольные суммы из Excel -

Не участвуют в формуле, но остановят согласование при расхождении.

+

Не участвуют в формуле, но заблокируют расчёт при расхождении.

diff --git a/frontend/src/components/finance/admin/PayrollAdminTables.tsx b/frontend/src/components/finance/admin/PayrollAdminTables.tsx index 1f918a91..35769116 100644 --- a/frontend/src/components/finance/admin/PayrollAdminTables.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminTables.tsx @@ -13,35 +13,11 @@ import { formatPayrollWorkMetric, isAttendancePayrollWorkRecord } from "@/lib/pa import { approvalStatusMeta, canApprovePayrollDraft, - PAYROLL_SELF_APPROVAL_RECORDED, - PAYROLL_SELF_APPROVAL_WARNING, } from "@/lib/payroll-admin"; -function SelfApprovalLabel() { - return ( - - Самоутверждение - - ); -} - -function RecordStatus({ - status, - selfApprovalOverridden, -}: { - status: PayrollApprovalStatus; - selfApprovalOverridden: boolean; -}) { +function RecordStatus({ status }: { status: PayrollApprovalStatus }) { const meta = approvalStatusMeta[status]; - return ( -
- {meta.label} - {selfApprovalOverridden ? : null} -
- ); + return {meta.label}; } function ListState({ loading, error, empty }: { loading: boolean; error: string | null; empty: boolean }) { @@ -92,6 +68,7 @@ function RecordActions({ canManage, canApprove, canOverrideApproval, + fullAccess, onEdit, onApprove, onRevise, @@ -101,21 +78,17 @@ function RecordActions({ canManage: boolean; canApprove: boolean; canOverrideApproval: boolean; + fullAccess: boolean; onEdit?: () => void; onApprove?: () => void; onRevise?: () => void; }) { if (status === "draft") { const approvalAllowed = canApprovePayrollDraft(isAuthor, canApprove, canOverrideApproval); - const approvalTooltip = isAuthor - ? canOverrideApproval - ? PAYROLL_SELF_APPROVAL_WARNING - : "Автор записи не может утвердить её без права на самоутверждение." - : "Утвердить"; return (
- {canManage && isAuthor && onEdit ? } onClick={onEdit} /> : null} - {canApprove && onApprove ? } onClick={onApprove} tone="success" disabled={!approvalAllowed} tooltip={approvalTooltip} /> : null} + {canManage && (isAuthor || fullAccess) && onEdit ? } onClick={onEdit} /> : null} + {canApprove && onApprove ? } onClick={onApprove} tone="success" disabled={!approvalAllowed} tooltip={approvalAllowed ? "Утвердить" : "Недостаточно прав для утверждения этой записи."} /> : null}
); } @@ -133,6 +106,7 @@ export function PayrollRatesTable({ canManage, canApprove, canOverrideApproval, + fullAccess, onEdit, onApprove, onRevise, @@ -144,6 +118,7 @@ export function PayrollRatesTable({ canManage: boolean; canApprove: boolean; canOverrideApproval: boolean; + fullAccess: boolean; onEdit: (record: PayrollAdminPayRate) => void; onApprove: (record: PayrollAdminPayRate) => void; onRevise: (record: PayrollAdminPayRate) => void; @@ -164,8 +139,8 @@ export function PayrollRatesTable({
Оклад{formatPayrollMoney(record.amount, record.currency)}
Цена балла{formatPayrollMoney(record.point_rate, record.currency)}
С{formatPayrollDate(record.effective_from)}
- - onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> + + onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
))} @@ -182,6 +157,7 @@ export function PayrollWorkRecordsTable({ canManage, canApprove, canOverrideApproval, + fullAccess, onEdit, onApprove, onRevise, @@ -193,6 +169,7 @@ export function PayrollWorkRecordsTable({ canManage: boolean; canApprove: boolean; canOverrideApproval: boolean; + fullAccess: boolean; onEdit: (record: PayrollAdminWorkRecord) => void; onApprove: (record: PayrollAdminWorkRecord) => void; onRevise: (record: PayrollAdminWorkRecord) => void; @@ -223,8 +200,8 @@ export function PayrollWorkRecordsTable({
Норма{formatPayrollWorkMetric(record.target_points, record.source)}
Факт{formatPayrollWorkMetric(record.actual_points, record.source)}
Контроль{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}
- - onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> + + onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> ); })} @@ -242,6 +219,7 @@ export function PayrollInputLinesTable({ canManage, canApprove, canOverrideApproval, + fullAccess, onEdit, onApprove, }: { @@ -252,6 +230,7 @@ export function PayrollInputLinesTable({ canManage: boolean; canApprove: boolean; canOverrideApproval: boolean; + fullAccess: boolean; onEdit: (record: PayrollAdminInputLine) => void; onApprove: (record: PayrollAdminInputLine) => void; }) { @@ -270,8 +249,8 @@ export function PayrollInputLinesTable({

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

{record.component.name}

{record.reason ?

{record.reason}

: null}
Сумма{formatPayrollMoney(record.amount)}
- - onEdit(record)} onApprove={() => onApprove(record)} /> + + onEdit(record)} onApprove={() => onApprove(record)} /> ))} @@ -323,20 +302,10 @@ export function PayrollApprovalQueue({ {items.map((item) => { const isAuthor = item.createdById === currentUserId; const approvalAllowed = canApprovePayrollDraft(isAuthor, canApprove, canOverrideApproval); - const approvalLabel = !canApprove - ? "Только просмотр" - : isAuthor && !canOverrideApproval - ? "Нужен другой проверяющий" - : isAuthor - ? "Утвердить самому" - : "Утвердить"; - const approvalTooltip = !canApprove - ? "Для утверждения необходимо обычное право согласования." - : isAuthor - ? canOverrideApproval - ? PAYROLL_SELF_APPROVAL_WARNING - : "Автор записи не может утвердить её без права на самоутверждение." - : "Утвердить"; + const approvalLabel = canApprove ? "Утвердить" : "Только просмотр"; + const approvalTooltip = approvalAllowed + ? "Утвердить" + : "Недостаточно прав для утверждения этой записи."; const approve = () => { if (item.group === "rate") onApproveRate(item.raw as PayrollAdminPayRate); else if (item.group === "work") onApproveWork(item.raw as PayrollAdminWorkRecord); diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 71fa4ab2..c05bef49 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -57,8 +57,6 @@ import { getPrimaryPayrollRunAction, normalizePayrollAdminList, PAYROLL_ADMIN_TABS, - PAYROLL_SELF_APPROVAL_RECORDED, - PAYROLL_SELF_APPROVAL_WARNING, payrollRunActionLabels, periodStatusMeta, runStatusMeta, @@ -155,17 +153,7 @@ function RunCard({ run, current = false }: { run: PayrollAdminRun; current?: boo

Ревизия {run.revision}{current ? " · текущая" : ""}

{formatPayrollDateTime(run.requested_at)} · {run.requested_by.display_name}

-
- {run.self_approval_overridden ? ( - - Самоутверждение - - ) : null} - {meta.label} -
+ {meta.label}
{run.payable_total != null ? (
@@ -313,20 +301,14 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade return (!status || record.status === status) && (!needle || `${record.employee.display_name} ${record.component.name} ${record.reason}`.toLocaleLowerCase("ru-RU").includes(needle)); }), [inputLines, search, status]); - const approvalWarning = (createdById: number, standardWarning: string) => ( - createdById === user?.id && workspace?.permissions.override_approval - ? `${PAYROLL_SELF_APPROVAL_WARNING} ${standardWarning}` - : standardWarning - ); - const openApproveRate = (record: PayrollAdminPayRate) => setPendingAction({ - title: "Утвердить ставку", description: `${record.employee.display_name}: ${formatPayrollMoney(record.amount, record.currency)}.`, confirmLabel: record.created_by.id === user?.id ? "Утвердить самому" : "Утвердить", warning: approvalWarning(record.created_by.id, "После утверждения ставку нельзя редактировать — только создать новую версию."), successMessage: "Ставка утверждена.", execute: () => apiClient.approvePayrollAdminPayRate(record.id, record.lock_version), + title: "Утвердить ставку", description: `${record.employee.display_name}: ${formatPayrollMoney(record.amount, record.currency)}.`, confirmLabel: "Утвердить", warning: "После утверждения ставку нельзя редактировать — только создать новую версию.", successMessage: "Ставка утверждена.", execute: () => apiClient.approvePayrollAdminPayRate(record.id, record.lock_version), }); const openApproveWork = (record: PayrollAdminWorkRecord) => setPendingAction({ - title: "Утвердить выработку", description: `${record.employee.display_name}: ${record.actual_points} из ${record.target_points} баллов.`, confirmLabel: record.created_by.id === user?.id ? "Утвердить самому" : "Утвердить", warning: approvalWarning(record.created_by.id, "Обычно автор записи и проверяющий должны быть разными сотрудниками."), successMessage: "Выработка утверждена.", execute: () => apiClient.approvePayrollAdminWorkRecord(record.id, record.lock_version), + title: "Утвердить выработку", description: `${record.employee.display_name}: ${record.actual_points} из ${record.target_points} баллов.`, confirmLabel: "Утвердить", warning: "Проверьте норму и фактическую выработку перед утверждением.", successMessage: "Выработка утверждена.", execute: () => apiClient.approvePayrollAdminWorkRecord(record.id, record.lock_version), }); const openApproveInput = (record: PayrollAdminInputLine) => setPendingAction({ - title: "Утвердить операцию", description: `${record.component.name} для ${record.employee.display_name}: ${formatPayrollMoney(record.amount, selectedPeriod?.currency)}.`, confirmLabel: record.created_by.id === user?.id ? "Утвердить самому" : "Утвердить", warning: approvalWarning(record.created_by.id, "Утверждённая операция станет входом следующего расчёта."), successMessage: "Операция утверждена.", execute: () => apiClient.approvePayrollAdminInputLine(record.id, record.lock_version), + title: "Утвердить операцию", description: `${record.component.name} для ${record.employee.display_name}: ${formatPayrollMoney(record.amount, selectedPeriod?.currency)}.`, confirmLabel: "Утвердить", warning: "Утверждённая операция станет входом следующего расчёта.", successMessage: "Операция утверждена.", execute: () => apiClient.approvePayrollAdminInputLine(record.id, record.lock_version), }); const openReviseRate = (record: PayrollAdminPayRate) => setPendingAction({ title: "Создать новую версию ставки", description: `Будет создан отдельный черновик ставки для ${record.employee.display_name}.`, confirmLabel: "Создать черновик", reasonLabel: "Основание изменения", reasonRequired: true, successMessage: "Новая версия ставки создана в черновиках.", execute: (reason) => apiClient.revisePayrollAdminPayRate(record.id, reason), @@ -338,9 +320,6 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade const openRunAction = (action: PayrollRunAction) => { if (!action || !selectedPeriod || !workspace) return; const run = workspace.current_run; - const isRunSelfApproval = action === "approve" - && run?.requested_by.id === user?.id - && workspace.permissions.override_approval; const configs: Record, Omit & { execute: (reason: string) => Promise }> = { calculate: { title: run ? "Запустить перерасчёт" : "Запустить расчёт", @@ -353,7 +332,7 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade execute: (reason) => apiClient.calculatePayrollAdminPeriod(selectedPeriod.id, { idempotency_key: globalThis.crypto.randomUUID(), recalculation_reason: reason, expected_lock_version: selectedPeriod.lock_version }), }, submit_review: { title: "Передать расчёт на проверку", description: "Система повторно проверит актуальность входных данных и контрольные суммы.", confirmLabel: "Передать", successMessage: "Расчёт передан на проверку.", execute: () => apiClient.submitPayrollAdminRunForReview(run!.id) }, - approve: { title: "Утвердить расчёт", description: "Проверьте итоговые суммы и состав сотрудников перед решением.", confirmLabel: isRunSelfApproval ? "Утвердить самому" : "Утвердить", warning: isRunSelfApproval ? PAYROLL_SELF_APPROVAL_WARNING : "Расчёт утверждает сотрудник, который не запускал эту ревизию.", successMessage: "Расчёт утверждён.", execute: () => apiClient.approvePayrollAdminRun(run!.id) }, + approve: { title: "Утвердить расчёт", description: "Проверьте итоговые суммы и состав сотрудников перед решением.", confirmLabel: "Утвердить", successMessage: "Расчёт утверждён.", execute: () => apiClient.approvePayrollAdminRun(run!.id) }, publish: { title: "Опубликовать расчётные листки", description: "После публикации сотрудники увидят суммы в разделе «Финансы».", confirmLabel: "Опубликовать", warning: "Проверьте дату выплаты и итог к выплате. Публикация сразу открывает листки сотрудникам.", successMessage: "Расчётные листки опубликованы.", execute: () => apiClient.publishPayrollAdminRun(run!.id) }, close: { title: "Закрыть период", description: "Закрытый период больше нельзя пересчитывать.", confirmLabel: "Закрыть период", warning: "Это финальное действие для расчётного периода.", successMessage: "Период закрыт.", execute: () => apiClient.closePayrollAdminPeriod(selectedPeriod.id) }, }; @@ -479,14 +458,14 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
) : null} - {tab === "rates" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingRate(null); setRateModalOpen(true); } : undefined} addLabel="Добавить ставку" /> { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} />
: null} + {tab === "rates" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingRate(null); setRateModalOpen(true); } : undefined} addLabel="Добавить ставку" /> { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} />
: null} {tab === "work" ? (
@@ -500,13 +479,13 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade onAdd={canManageSourceData && selectedPeriod.status !== "published" ? () => { setEditingWork(null); setWorkModalOpen(true); } : undefined} addLabel="Добавить выработку" /> - { setEditingWork(record); setWorkModalOpen(true); }} onApprove={openApproveWork} onRevise={openReviseWork} /> + { setEditingWork(record); setWorkModalOpen(true); }} onApprove={openApproveWork} onRevise={openReviseWork} />
) : null} - {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} + {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} {tab === "approval" ? (
- + {!canOpenInputTabs ? (

Очередь исходных данных недоступна для вашей роли

diff --git a/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx b/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx index 06b98678..27afe05b 100644 --- a/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx +++ b/frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx @@ -402,7 +402,7 @@ export function PayrollAttendanceWorkModal({
- Система создаёт только черновики и не утверждает их автоматически. После импорта проверьте строки во вкладке «Выработка» и передайте их на согласование. + Система создаёт только черновики и не утверждает их автоматически. После импорта проверьте строки во вкладке «Выработка».
{error ? ( diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index 272c4835..76b6f4f3 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -98,10 +98,10 @@ export type PayrollAdminRun = { published_by: PayrollAdminUserRef | null; published_at: string | null; recalculation_reason: string; - self_approval_overridden: boolean; }; export type PayrollAdminPermissions = { + full_access: boolean; manage_inputs: boolean; approve_inputs: boolean; override_approval: boolean; @@ -183,7 +183,6 @@ export type PayrollAdminPayRate = { created_at: string; approved_by: PayrollAdminUserRef | null; approved_at: string | null; - self_approval_overridden: boolean; }; export type PayrollAdminWorkRecord = { @@ -207,7 +206,6 @@ export type PayrollAdminWorkRecord = { created_at: string; approved_by: PayrollAdminUserRef | null; approved_at: string | null; - self_approval_overridden: boolean; }; export type PayrollAttendanceWorkMode = "missing_only" | "replace_existing"; @@ -314,7 +312,6 @@ export type PayrollAdminInputLine = { created_at: string; approved_by: PayrollAdminUserRef | null; approved_at: string | null; - self_approval_overridden: boolean; }; export type PayrollAdminPage = { diff --git a/frontend/src/lib/payroll-admin.test.js b/frontend/src/lib/payroll-admin.test.js index 44add36f..49b673ad 100644 --- a/frontend/src/lib/payroll-admin.test.js +++ b/frontend/src/lib/payroll-admin.test.js @@ -10,6 +10,7 @@ import { } from "./payroll-admin.ts"; const permissions = { + full_access: false, manage_inputs: true, approve_inputs: true, override_approval: false, diff --git a/frontend/src/lib/payroll-admin.ts b/frontend/src/lib/payroll-admin.ts index 9d42e68d..9c2ef2b2 100644 --- a/frontend/src/lib/payroll-admin.ts +++ b/frontend/src/lib/payroll-admin.ts @@ -14,7 +14,7 @@ export const PAYROLL_ADMIN_TABS: Array<{ value: PayrollAdminTab; label: string } { value: "rates", label: "Ставки" }, { value: "work", label: "Выработка" }, { value: "inputs", label: "Начисления и выплаты" }, - { value: "approval", label: "Согласование" }, + { value: "approval", label: "Проверка" }, ]; export const approvalStatusMeta: Record = { @@ -154,13 +154,10 @@ export function isPayrollAdminStaleConflict(error: unknown): boolean { export type PayrollRunAction = "calculate" | "submit_review" | "approve" | "publish" | "close" | null; -export const PAYROLL_SELF_APPROVAL_WARNING = - "Вы утверждаете собственные данные. Самоутверждение будет зафиксировано в журнале аудита."; - -export const PAYROLL_SELF_APPROVAL_RECORDED = - "Объект утверждён его автором с особым правом. Самоутверждение зафиксировано в журнале аудита."; - -/** An override only bypasses separation of duties; it never grants approval by itself. */ +/** + * TODO(payroll-access-hardening): restore maker-checker UI after the finance + * role matrix has passed the pilot. Admins currently receive override=true. + */ export function canApprovePayrollDraft( isAuthor: boolean, canApprove: boolean, diff --git a/frontend/src/lib/permissions-payroll.test.js b/frontend/src/lib/permissions-payroll.test.js index 9431b803..390f4b33 100644 --- a/frontend/src/lib/permissions-payroll.test.js +++ b/frontend/src/lib/permissions-payroll.test.js @@ -3,9 +3,9 @@ import assert from "node:assert/strict"; import { canOpenPayrollAdmin } from "./permissions.ts"; -test("payroll management requires an operational payroll permission", () => { +test("staff administrators can open payroll management without extra roles", () => { assert.equal(canOpenPayrollAdmin(null), false); - assert.equal(canOpenPayrollAdmin({ auth: { is_staff: true, permissions: [] } }), false); + assert.equal(canOpenPayrollAdmin({ auth: { is_staff: true, permissions: [] } }), true); assert.equal( canOpenPayrollAdmin({ auth: { diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 4ef0725f..9f5207c3 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -13,7 +13,10 @@ const PAYROLL_ADMIN_PERMISSIONS = [ export function canOpenPayrollAdmin(user?: User | null): boolean { const auth = user?.auth; if (!auth) return false; - if (auth.is_superuser) return true; + // TODO(payroll-access-hardening): when SIMPLE_ADMIN_ACCESS is disabled on + // the backend, remove this staff shortcut in the same deployment and use + // the granular Finance permissions below again. + if (auth.is_staff || auth.is_superuser) return true; const permissions = auth.permissions || []; const financePermissions = auth.permissions_by_app?.finance || [];