feat(finance): simplify payroll administration for pilot
This commit is contained in:
parent
bb1694c584
commit
1f18ab7848
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
|
||||
@ -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/<public_id>/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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
46
backend/finance/payroll/access.py
Normal file
46
backend/finance/payroll/access.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -331,7 +331,7 @@ export function PayrollWorkRecordFormModal({
|
||||
|
||||
<details className="app-surface-muted mt-4 rounded-xl p-3 sm:p-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-[var(--foreground)]">Контрольные суммы из Excel</summary>
|
||||
<p className="app-text-muted mt-2 text-xs leading-relaxed">Не участвуют в формуле, но остановят согласование при расхождении.</p>
|
||||
<p className="app-text-muted mt-2 text-xs leading-relaxed">Не участвуют в формуле, но заблокируют расчёт при расхождении.</p>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label><span className="app-field-label">Выработка по баллам</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_point_amount} onChange={(event) => setForm({ ...form, expected_point_amount: event.target.value })} /></label>
|
||||
<label><span className="app-field-label">Итого начислено</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_gross} onChange={(event) => setForm({ ...form, expected_gross: event.target.value })} /></label>
|
||||
|
||||
@ -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 (
|
||||
<span
|
||||
className="app-feedback-warning inline-flex w-fit rounded-full px-2 py-1 text-[10px] font-semibold leading-none"
|
||||
title={PAYROLL_SELF_APPROVAL_RECORDED}
|
||||
>
|
||||
Самоутверждение
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordStatus({
|
||||
status,
|
||||
selfApprovalOverridden,
|
||||
}: {
|
||||
status: PayrollApprovalStatus;
|
||||
selfApprovalOverridden: boolean;
|
||||
}) {
|
||||
function RecordStatus({ status }: { status: PayrollApprovalStatus }) {
|
||||
const meta = approvalStatusMeta[status];
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span className={`app-status-pill ${meta.className}`}>{meta.label}</span>
|
||||
{selfApprovalOverridden ? <SelfApprovalLabel /> : null}
|
||||
</div>
|
||||
);
|
||||
return <span className={`app-status-pill ${meta.className}`}>{meta.label}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
{canManage && isAuthor && onEdit ? <ActionButton label="Изменить" icon={<Pencil size={13} />} onClick={onEdit} /> : null}
|
||||
{canApprove && onApprove ? <ActionButton label="Утвердить" icon={<Check size={13} />} onClick={onApprove} tone="success" disabled={!approvalAllowed} tooltip={approvalTooltip} /> : null}
|
||||
{canManage && (isAuthor || fullAccess) && onEdit ? <ActionButton label="Изменить" icon={<Pencil size={13} />} onClick={onEdit} /> : null}
|
||||
{canApprove && onApprove ? <ActionButton label="Утвердить" icon={<Check size={13} />} onClick={onApprove} tone="success" disabled={!approvalAllowed} tooltip={approvalAllowed ? "Утвердить" : "Недостаточно прав для утверждения этой записи."} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Оклад</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Цена балла</span><span className="text-sm text-[var(--foreground)]">{formatPayrollMoney(record.point_rate, record.currency)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">С</span><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
|
||||
<RecordStatus status={record.status} selfApprovalOverridden={record.self_approval_overridden} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -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({
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Норма</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.target_points, record.source)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Факт</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.actual_points, record.source)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Контроль</span><span className="text-sm text-[var(--foreground)]">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</span></div>
|
||||
<RecordStatus status={record.status} selfApprovalOverridden={record.self_approval_overridden} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -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({
|
||||
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
|
||||
<div className="min-w-0"><p className="truncate text-sm text-[var(--foreground)]">{record.component.name}</p>{record.reason ? <p className="app-text-muted mt-0.5 truncate text-xs" title={record.reason}>{record.reason}</p> : null}</div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Сумма</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount)}</span></div>
|
||||
<RecordStatus status={record.status} selfApprovalOverridden={record.self_approval_overridden} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div><p className="text-sm font-semibold text-[var(--foreground)]">Ревизия {run.revision}{current ? " · текущая" : ""}</p><p className="app-text-muted mt-0.5 text-xs">{formatPayrollDateTime(run.requested_at)} · {run.requested_by.display_name}</p></div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{run.self_approval_overridden ? (
|
||||
<span
|
||||
className="app-feedback-warning inline-flex rounded-full px-2 py-1 text-[10px] font-semibold leading-none"
|
||||
title={PAYROLL_SELF_APPROVAL_RECORDED}
|
||||
>
|
||||
Самоутверждение
|
||||
</span>
|
||||
) : null}
|
||||
<span className={`app-status-pill ${meta.className}`}>{meta.label}</span>
|
||||
</div>
|
||||
<span className={`app-status-pill ${meta.className}`}>{meta.label}</span>
|
||||
</div>
|
||||
{run.payable_total != null ? (
|
||||
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-[var(--muted-foreground)]">
|
||||
@ -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<Exclude<PayrollRunAction, null>, Omit<PendingAction, "execute"> & { execute: (reason: string) => Promise<unknown> }> = {
|
||||
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
|
||||
</div>
|
||||
<aside className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex items-center gap-2"><ShieldCheck className="app-accent-text" size={18} /><h3 className="text-sm font-semibold text-[var(--foreground)]">Следующий шаг</h3></div>
|
||||
{primaryAction ? <><p className="app-text-muted mt-3 text-sm leading-relaxed">Все необходимые условия для этого этапа выполнены.</p><button type="button" className="app-action-primary mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold" onClick={() => openRunAction(primaryAction)}>{primaryAction === "calculate" ? <Calculator size={16} /> : primaryAction === "submit_review" ? <Send size={16} /> : <CheckCircle2 size={16} />}{payrollRunActionLabels[primaryAction]}</button></> : <p className="app-text-muted mt-3 text-sm leading-relaxed">Завершите отмеченные пункты или дождитесь действия сотрудника с нужными правами.</p>}
|
||||
{primaryAction ? <><p className="app-text-muted mt-3 text-sm leading-relaxed">Все необходимые условия для этого этапа выполнены.</p><button type="button" className="app-action-primary mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold" onClick={() => openRunAction(primaryAction)}>{primaryAction === "calculate" ? <Calculator size={16} /> : primaryAction === "submit_review" ? <Send size={16} /> : <CheckCircle2 size={16} />}{payrollRunActionLabels[primaryAction]}</button></> : <p className="app-text-muted mt-3 text-sm leading-relaxed">Завершите отмеченные пункты.</p>}
|
||||
{selectedPeriod.status === "open" && permissions.manage_inputs && !workspace.current_run ? <button type="button" className="app-action-secondary mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium" onClick={() => { setEditingPeriod(selectedPeriod); setPeriodModalOpen(true); }}><Pencil size={15} /> Параметры периода</button> : null}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{tab === "rates" ? <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}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
|
||||
{tab === "rates" ? <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}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
|
||||
{tab === "work" ? (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Выработка" description="Норму и факт можно ввести вручную в баллах или заполнить рабочими часами из посещаемости." />
|
||||
@ -500,13 +479,13 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
onAdd={canManageSourceData && selectedPeriod.status !== "published" ? () => { setEditingWork(null); setWorkModalOpen(true); } : undefined}
|
||||
addLabel="Добавить выработку"
|
||||
/>
|
||||
<PayrollWorkRecordsTable records={filteredWork} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} onEdit={(record) => { setEditingWork(record); setWorkModalOpen(true); }} onApprove={openApproveWork} onRevise={openReviseWork} />
|
||||
<PayrollWorkRecordsTable records={filteredWork} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingWork(record); setWorkModalOpen(true); }} onApprove={openApproveWork} onRevise={openReviseWork} />
|
||||
</section>
|
||||
) : null}
|
||||
{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}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
|
||||
{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}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(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 === "approval" ? (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Согласование" description="Проверяющий утверждает исходные данные, затем расчёт проходит проверку, утверждение и публикацию." />
|
||||
<SectionIntro title="Проверка данных" description="Здесь можно утвердить подготовленные черновики и продолжить расчёт." />
|
||||
{!canOpenInputTabs ? (
|
||||
<div className="app-surface-muted rounded-xl p-4 text-sm">
|
||||
<p className="font-semibold text-[var(--foreground)]">Очередь исходных данных недоступна для вашей роли</p>
|
||||
|
||||
@ -402,7 +402,7 @@ export function PayrollAttendanceWorkModal({
|
||||
<PreviewTable items={preview.items} mode={mode} />
|
||||
|
||||
<div className="app-surface-muted rounded-xl px-3 py-2.5 text-xs leading-relaxed text-[var(--muted-foreground)]">
|
||||
Система создаёт только черновики и не утверждает их автоматически. После импорта проверьте строки во вкладке «Выработка» и передайте их на согласование.
|
||||
Система создаёт только черновики и не утверждает их автоматически. После импорта проверьте строки во вкладке «Выработка».
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
@ -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<T> = {
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "./payroll-admin.ts";
|
||||
|
||||
const permissions = {
|
||||
full_access: false,
|
||||
manage_inputs: true,
|
||||
approve_inputs: true,
|
||||
override_approval: false,
|
||||
|
||||
@ -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<PayrollApprovalStatus, { label: string; className: string }> = {
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 || [];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user