feat(finance): simplify payroll administration for pilot

This commit is contained in:
lizerk 2026-07-18 00:00:54 +03:00
parent bb1694c584
commit 1f18ab7848
24 changed files with 490 additions and 194 deletions

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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"),
}

View File

@ -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

View File

@ -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

View 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,
)

View File

@ -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:

View File

@ -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",
)

View File

@ -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,
),
},
)

View File

@ -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"]),

View File

@ -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")

View File

@ -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,

View File

@ -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,

View File

@ -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):

View File

@ -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>

View File

@ -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);

View File

@ -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>

View File

@ -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 ? (

View File

@ -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> = {

View File

@ -10,6 +10,7 @@ import {
} from "./payroll-admin.ts";
const permissions = {
full_access: false,
manage_inputs: true,
approve_inputs: true,
override_approval: false,

View File

@ -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,

View File

@ -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: {

View File

@ -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 || [];