feat(finance): add payroll core and management workspace

This commit is contained in:
lizerk 2026-07-17 23:35:29 +03:00
parent de48eefdcf
commit bb1694c584
68 changed files with 16914 additions and 25 deletions

View File

@ -47,3 +47,16 @@ NODE_ENV=production
LOGSTORM_API_URL=http://localhost:8000
LOGSTORM_API_TOKEN=change-me-logstorm-token
LOGSTORM_TIMEOUT_SECONDS=30
# Finance / Payroll
# Change RULESET_VERSION whenever calculation semantics change.
PAYROLL_RULESET_ID=eusrr-standard
PAYROLL_RULESET_VERSION=2026.07.1
PAYROLL_EFFECTIVE_FROM=2026-01-01
# PAYROLL_EFFECTIVE_TO=2026-12-31
# Keep disabled until the point formula is reconciled against real Excel rows.
PAYROLL_POINT_POLICY=disabled
PAYROLL_MONEY_QUANTUM=0.01
PAYROLL_ROUNDING=half_up
PAYROLL_ALLOW_NEGATIVE_PAYABLE=false
PAYROLL_BASE_RATE_CODE=BASE

View File

View File

@ -0,0 +1,561 @@
"""Serializers for the native payroll administration workspace.
Workflow fields, hashes, source references and idempotency keys intentionally
never form part of a writable/read payload here. State transitions are owned
by :mod:`finance.payroll.services`.
"""
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from finance.enums import ApprovalStatus, InputSource
from finance.models import (
EmployeePayRate,
PayrollComponent,
PayrollInputLine,
PayrollPeriod,
PayrollRun,
PayrollWorkRecord,
)
Employee = get_user_model()
def _validation_detail(exc: DjangoValidationError):
if hasattr(exc, "message_dict"):
return exc.message_dict
return {"non_field_errors": exc.messages}
class PayrollEmployeeMiniSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
position = serializers.SerializerMethodField()
department = serializers.SerializerMethodField()
class Meta:
model = Employee
fields = ["id", "display_name", "position", "department"]
read_only_fields = fields
def get_display_name(self, obj):
return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}"
def get_position(self, obj):
position = getattr(obj, "position", None)
return position.name if position is not None else None
def get_department(self, obj):
prefetched = getattr(obj, "_prefetched_objects_cache", {}).get(
"departments_links"
)
if prefetched is None:
link = (
obj.departments_links.filter(is_active=True)
.select_related("department")
.order_by("department__name", "id")
.first()
)
else:
link = next(
(
item
for item in sorted(
prefetched,
key=lambda item: (item.department.name, item.pk),
)
if item.is_active
),
None,
)
return link.department.name if link is not None else None
class PayrollUserRefSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
class Meta:
model = Employee
fields = ["id", "display_name"]
read_only_fields = fields
def get_display_name(self, obj):
return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}"
class PayrollComponentSerializer(serializers.ModelSerializer):
kind_label = serializers.CharField(source="get_kind_display", read_only=True)
class Meta:
model = PayrollComponent
fields = [
"id",
"code",
"name",
"kind",
"kind_label",
"requires_reason",
"is_active",
"display_order",
]
read_only_fields = fields
class PayrollPeriodSerializer(serializers.ModelSerializer):
status_label = serializers.CharField(source="get_status_display", read_only=True)
current_run_id = serializers.IntegerField(read_only=True, allow_null=True)
created_by_id = serializers.IntegerField(read_only=True)
class Meta:
model = PayrollPeriod
fields = [
"id",
"code",
"name",
"date_from",
"date_to",
"pay_date",
"currency",
"status",
"status_label",
"current_run_id",
"lock_version",
"created_by_id",
"created_at",
"updated_at",
]
read_only_fields = fields
class PayrollPeriodWriteSerializer(serializers.ModelSerializer):
expected_lock_version = serializers.IntegerField(
min_value=0,
required=False,
write_only=True,
)
class Meta:
model = PayrollPeriod
fields = [
"code",
"name",
"date_from",
"date_to",
"pay_date",
"currency",
"expected_lock_version",
]
def validate(self, attrs):
if self.instance is not None and "expected_lock_version" not in attrs:
raise serializers.ValidationError(
{"expected_lock_version": "Укажите открытую версию периода."}
)
return attrs
class DraftWriteSerializerMixin:
"""Create manual drafts and update them through model optimistic locking."""
expected_lock_version = serializers.IntegerField(
min_value=0,
required=False,
write_only=True,
)
def validate(self, attrs):
attrs = super().validate(attrs)
if self.instance is not None and "expected_lock_version" not in attrs:
raise serializers.ValidationError(
{"expected_lock_version": "Укажите открытую версию черновика."}
)
return attrs
def create(self, validated_data):
validated_data.pop("expected_lock_version", None)
instance = self.Meta.model(
**validated_data,
status=ApprovalStatus.DRAFT,
source=InputSource.MANUAL,
created_by=self.context["actor"],
)
try:
instance.full_clean()
except DjangoValidationError as exc:
raise serializers.ValidationError(_validation_detail(exc)) from exc
instance.save(force_insert=True)
return instance
def update(self, instance, validated_data):
expected_lock_version = validated_data.pop("expected_lock_version")
for field_name, value in validated_data.items():
setattr(instance, field_name, value)
instance._expected_lock_version = expected_lock_version
try:
instance.full_clean()
instance.save()
except DjangoValidationError as exc:
raise serializers.ValidationError(_validation_detail(exc)) from exc
return instance
class EmployeePayRateSerializer(serializers.ModelSerializer):
employee = PayrollEmployeeMiniSerializer(read_only=True)
employee_id = serializers.IntegerField(read_only=True)
status_label = serializers.CharField(source="get_status_display", read_only=True)
created_by_id = serializers.IntegerField(read_only=True)
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
voided_by_id = serializers.IntegerField(read_only=True, allow_null=True)
replaces_id = serializers.IntegerField(read_only=True, allow_null=True)
created_by = PayrollUserRefSerializer(read_only=True)
approved_by = PayrollUserRefSerializer(read_only=True)
class Meta:
model = EmployeePayRate
fields = [
"id",
"employee_id",
"employee",
"rate_code",
"amount",
"point_rate",
"currency",
"effective_from",
"revision",
"status",
"status_label",
"lock_version",
"replaces_id",
"reason",
"source",
"created_by_id",
"created_by",
"created_at",
"approved_by_id",
"approved_by",
"approved_at",
"self_approval_overridden",
"voided_by_id",
"voided_at",
]
read_only_fields = fields
class EmployeePayRateWriteSerializer(
DraftWriteSerializerMixin,
serializers.ModelSerializer,
):
expected_lock_version = DraftWriteSerializerMixin.expected_lock_version
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.filter(is_active=True),
)
class Meta:
model = EmployeePayRate
fields = [
"employee_id",
"rate_code",
"amount",
"point_rate",
"currency",
"effective_from",
"reason",
"expected_lock_version",
]
class EmployeePayRateRevisionSerializer(serializers.Serializer):
amount = serializers.DecimalField(max_digits=19, decimal_places=4, required=False)
point_rate = serializers.DecimalField(
max_digits=19,
decimal_places=4,
min_value=0,
required=False,
)
currency = serializers.CharField(max_length=3, required=False)
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
class PayrollWorkRecordSerializer(serializers.ModelSerializer):
employee = PayrollEmployeeMiniSerializer(read_only=True)
employee_id = serializers.IntegerField(read_only=True)
period_id = serializers.IntegerField(read_only=True)
status_label = serializers.CharField(source="get_status_display", read_only=True)
created_by_id = serializers.IntegerField(read_only=True)
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
voided_by_id = serializers.IntegerField(read_only=True, allow_null=True)
replaces_id = serializers.IntegerField(read_only=True, allow_null=True)
created_by = PayrollUserRefSerializer(read_only=True)
approved_by = PayrollUserRefSerializer(read_only=True)
class Meta:
model = PayrollWorkRecord
fields = [
"id",
"period_id",
"employee_id",
"employee",
"target_points",
"actual_points",
"expected_point_amount",
"expected_gross",
"expected_recalculated_gross",
"expected_payable",
"revision",
"status",
"status_label",
"lock_version",
"replaces_id",
"reason",
"source",
"created_by_id",
"created_by",
"created_at",
"updated_at",
"approved_by_id",
"approved_by",
"approved_at",
"self_approval_overridden",
"voided_by_id",
"voided_at",
]
read_only_fields = fields
class PayrollWorkRecordWriteSerializer(
DraftWriteSerializerMixin,
serializers.ModelSerializer,
):
expected_lock_version = DraftWriteSerializerMixin.expected_lock_version
period_id = serializers.PrimaryKeyRelatedField(
source="period",
queryset=PayrollPeriod.objects.all(),
)
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.filter(is_active=True),
)
class Meta:
model = PayrollWorkRecord
fields = [
"period_id",
"employee_id",
"target_points",
"actual_points",
"expected_point_amount",
"expected_gross",
"expected_recalculated_gross",
"expected_payable",
"reason",
"expected_lock_version",
]
class PayrollWorkRecordRevisionSerializer(serializers.Serializer):
target_points = serializers.DecimalField(
max_digits=19,
decimal_places=4,
min_value=0,
required=False,
)
actual_points = serializers.DecimalField(
max_digits=19,
decimal_places=4,
min_value=0,
required=False,
)
expected_point_amount = serializers.DecimalField(
max_digits=19,
decimal_places=2,
allow_null=True,
required=False,
)
expected_gross = serializers.DecimalField(
max_digits=19,
decimal_places=2,
allow_null=True,
required=False,
)
expected_recalculated_gross = serializers.DecimalField(
max_digits=19,
decimal_places=2,
allow_null=True,
required=False,
)
expected_payable = serializers.DecimalField(
max_digits=19,
decimal_places=2,
allow_null=True,
required=False,
)
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
class PayrollInputLineSerializer(serializers.ModelSerializer):
employee = PayrollEmployeeMiniSerializer(read_only=True)
employee_id = serializers.IntegerField(read_only=True)
period_id = serializers.IntegerField(read_only=True)
component = PayrollComponentSerializer(read_only=True)
component_id = serializers.IntegerField(read_only=True)
relates_to_period_id = serializers.IntegerField(read_only=True, allow_null=True)
status_label = serializers.CharField(source="get_status_display", read_only=True)
created_by_id = serializers.IntegerField(read_only=True)
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
voided_by_id = serializers.IntegerField(read_only=True, allow_null=True)
created_by = PayrollUserRefSerializer(read_only=True)
approved_by = PayrollUserRefSerializer(read_only=True)
class Meta:
model = PayrollInputLine
fields = [
"id",
"period_id",
"employee_id",
"employee",
"component_id",
"component",
"amount",
"relates_to_period_id",
"reason",
"status",
"status_label",
"lock_version",
"source",
"created_by_id",
"created_by",
"created_at",
"approved_by_id",
"approved_by",
"approved_at",
"self_approval_overridden",
"voided_by_id",
"voided_at",
]
read_only_fields = fields
class PayrollInputLineWriteSerializer(
DraftWriteSerializerMixin,
serializers.ModelSerializer,
):
expected_lock_version = DraftWriteSerializerMixin.expected_lock_version
period_id = serializers.PrimaryKeyRelatedField(
source="period",
queryset=PayrollPeriod.objects.all(),
)
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.filter(is_active=True),
)
component_id = serializers.PrimaryKeyRelatedField(
source="component",
queryset=PayrollComponent.objects.all(),
)
relates_to_period_id = serializers.PrimaryKeyRelatedField(
source="relates_to_period",
queryset=PayrollPeriod.objects.all(),
allow_null=True,
required=False,
)
class Meta:
model = PayrollInputLine
fields = [
"period_id",
"employee_id",
"component_id",
"amount",
"relates_to_period_id",
"reason",
"expected_lock_version",
]
class ApprovalCommandSerializer(serializers.Serializer):
expected_lock_version = serializers.IntegerField(min_value=0)
class CalculatePayrollCommandSerializer(serializers.Serializer):
expected_lock_version = serializers.IntegerField(min_value=0)
recalculation_reason = serializers.CharField(
allow_blank=True,
required=False,
default="",
trim_whitespace=True,
)
idempotency_key = serializers.UUIDField(required=False, allow_null=True)
class AttendanceWorkImportCommandSerializer(serializers.Serializer):
mode = serializers.ChoiceField(
choices=("missing_only", "replace_existing"),
)
preview_token = serializers.CharField(min_length=64, max_length=64)
expected_period_lock_version = serializers.IntegerField(min_value=0)
reason = serializers.CharField(
allow_blank=True,
required=False,
default="",
trim_whitespace=True,
)
def validate(self, attrs):
if attrs["mode"] == "replace_existing" and not attrs["reason"]:
raise serializers.ValidationError(
{"reason": "Для пересчёта существующих записей укажите причину."}
)
return attrs
class ReturnPayrollCommandSerializer(serializers.Serializer):
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
class PayrollRunSerializer(serializers.ModelSerializer):
period_id = serializers.IntegerField(read_only=True)
status_label = serializers.CharField(source="get_status_display", read_only=True)
requested_by_id = serializers.IntegerField(read_only=True)
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
published_by_id = serializers.IntegerField(read_only=True, allow_null=True)
supersedes_id = serializers.IntegerField(read_only=True, allow_null=True)
requested_by = PayrollUserRefSerializer(read_only=True)
approved_by = PayrollUserRefSerializer(read_only=True)
published_by = PayrollUserRefSerializer(read_only=True)
class Meta:
model = PayrollRun
fields = [
"id",
"period_id",
"revision",
"status",
"status_label",
"supersedes_id",
"recalculation_reason",
"employee_count",
"gross_total",
"deduction_total",
"payable_total",
"requested_by_id",
"requested_by",
"requested_at",
"approved_by_id",
"approved_by",
"approved_at",
"self_approval_overridden",
"published_by_id",
"published_by",
"published_at",
]
read_only_fields = fields
def to_representation(self, instance):
data = super().to_representation(instance)
if not self.context.get("include_amounts", False):
for field_name in ("gross_total", "deduction_total", "payable_total"):
data[field_name] = None
return data

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,116 @@
from rest_framework import serializers
from finance.models import (
PayrollStatement,
PayrollStatementAcknowledgement,
PayrollStatementLine,
)
class PayrollStatementLineSerializer(serializers.ModelSerializer):
class Meta:
model = PayrollStatementLine
fields = [
"code",
"label",
"kind",
"amount",
"source_period_from",
"source_period_to",
"is_retro",
"calculated",
]
class PayrollStatementAcknowledgementSerializer(serializers.ModelSerializer):
class Meta:
model = PayrollStatementAcknowledgement
fields = ["viewed_at", "acknowledged_at", "disputed_at"]
def period_payload(statement):
period = statement.run.period
return {
"code": period.code,
"name": period.name,
"date_from": period.date_from,
"date_to": period.date_to,
"pay_date": period.pay_date,
}
def acknowledgement_payload(statement):
try:
acknowledgement = statement.acknowledgement
except PayrollStatementAcknowledgement.DoesNotExist:
return None
return PayrollStatementAcknowledgementSerializer(acknowledgement).data
class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer):
"""List metadata only; detailed amounts/lines require the audited endpoint."""
period = serializers.SerializerMethodField()
revision = serializers.IntegerField(source="run.revision", read_only=True)
published_at = serializers.DateTimeField(
source="run.published_at",
read_only=True,
)
acknowledgement = serializers.SerializerMethodField()
class Meta:
model = PayrollStatement
fields = [
"public_id",
"period",
"revision",
"published_at",
"currency",
"payable",
"acknowledgement",
]
read_only_fields = fields
def get_period(self, obj):
return period_payload(obj)
def get_acknowledgement(self, obj):
return acknowledgement_payload(obj)
class OwnPayrollStatementSerializer(serializers.ModelSerializer):
period = serializers.SerializerMethodField()
revision = serializers.IntegerField(source="run.revision", read_only=True)
published_at = serializers.DateTimeField(
source="run.published_at",
read_only=True,
)
lines = PayrollStatementLineSerializer(many=True, read_only=True)
acknowledgement = serializers.SerializerMethodField()
class Meta:
model = PayrollStatement
fields = [
"public_id",
"period",
"revision",
"published_at",
"currency",
"point_delta",
"gross_before_adjustments",
"adjustment_total",
"gross_total",
"deduction_total",
"net_pay",
"payment_total",
"payable",
"lines",
"acknowledgement",
]
read_only_fields = fields
def get_period(self, obj):
return period_payload(obj)
def get_acknowledgement(self, obj):
return acknowledgement_payload(obj)

View File

@ -0,0 +1,173 @@
from django.urls import path
from .views import (
OwnPayrollStatementAcknowledgeView,
OwnPayrollStatementDetailView,
OwnPayrollStatementListView,
)
from .admin_views import (
EmployeePayRateApproveView,
EmployeePayRateDetailView,
EmployeePayRateListCreateView,
EmployeePayRateReviseView,
PayrollAdminComponentListView,
PayrollAdminEmployeeListView,
PayrollAdminPeriodDetailView,
PayrollAdminPeriodListCreateView,
PayrollAdminWorkspaceView,
PayrollAttendanceWorkRecordsView,
PayrollInputLineApproveView,
PayrollInputLineDetailView,
PayrollInputLineListCreateView,
PayrollPeriodCalculateView,
PayrollPeriodCloseView,
PayrollRunApproveView,
PayrollRunDetailView,
PayrollRunPublishView,
PayrollRunReturnView,
PayrollRunSubmitReviewView,
PayrollWorkRecordApproveView,
PayrollWorkRecordDetailView,
PayrollWorkRecordListCreateView,
PayrollWorkRecordReviseView,
)
app_name = "finance-payroll"
urlpatterns = [
path(
"payroll/admin/workspace/",
PayrollAdminWorkspaceView.as_view(),
name="admin-workspace",
),
path(
"payroll/admin/employees/",
PayrollAdminEmployeeListView.as_view(),
name="admin-employee-list",
),
path(
"payroll/admin/components/",
PayrollAdminComponentListView.as_view(),
name="admin-component-list",
),
path(
"payroll/admin/periods/",
PayrollAdminPeriodListCreateView.as_view(),
name="admin-period-list",
),
path(
"payroll/admin/periods/<int:pk>/",
PayrollAdminPeriodDetailView.as_view(),
name="admin-period-detail",
),
path(
"payroll/admin/periods/<int:pk>/calculate/",
PayrollPeriodCalculateView.as_view(),
name="admin-period-calculate",
),
path(
"payroll/admin/periods/<int:pk>/attendance-work-records/",
PayrollAttendanceWorkRecordsView.as_view(),
name="admin-period-attendance-work-records",
),
path(
"payroll/admin/periods/<int:pk>/close/",
PayrollPeriodCloseView.as_view(),
name="admin-period-close",
),
path(
"payroll/admin/pay-rates/",
EmployeePayRateListCreateView.as_view(),
name="admin-pay-rate-list",
),
path(
"payroll/admin/pay-rates/<int:pk>/",
EmployeePayRateDetailView.as_view(),
name="admin-pay-rate-detail",
),
path(
"payroll/admin/pay-rates/<int:pk>/approve/",
EmployeePayRateApproveView.as_view(),
name="admin-pay-rate-approve",
),
path(
"payroll/admin/pay-rates/<int:pk>/revise/",
EmployeePayRateReviseView.as_view(),
name="admin-pay-rate-revise",
),
path(
"payroll/admin/work-records/",
PayrollWorkRecordListCreateView.as_view(),
name="admin-work-record-list",
),
path(
"payroll/admin/work-records/<int:pk>/",
PayrollWorkRecordDetailView.as_view(),
name="admin-work-record-detail",
),
path(
"payroll/admin/work-records/<int:pk>/approve/",
PayrollWorkRecordApproveView.as_view(),
name="admin-work-record-approve",
),
path(
"payroll/admin/work-records/<int:pk>/revise/",
PayrollWorkRecordReviseView.as_view(),
name="admin-work-record-revise",
),
path(
"payroll/admin/input-lines/",
PayrollInputLineListCreateView.as_view(),
name="admin-input-line-list",
),
path(
"payroll/admin/input-lines/<int:pk>/",
PayrollInputLineDetailView.as_view(),
name="admin-input-line-detail",
),
path(
"payroll/admin/input-lines/<int:pk>/approve/",
PayrollInputLineApproveView.as_view(),
name="admin-input-line-approve",
),
path(
"payroll/admin/runs/<int:pk>/",
PayrollRunDetailView.as_view(),
name="admin-run-detail",
),
path(
"payroll/admin/runs/<int:pk>/submit-review/",
PayrollRunSubmitReviewView.as_view(),
name="admin-run-submit-review",
),
path(
"payroll/admin/runs/<int:pk>/return/",
PayrollRunReturnView.as_view(),
name="admin-run-return",
),
path(
"payroll/admin/runs/<int:pk>/approve/",
PayrollRunApproveView.as_view(),
name="admin-run-approve",
),
path(
"payroll/admin/runs/<int:pk>/publish/",
PayrollRunPublishView.as_view(),
name="admin-run-publish",
),
path(
"payroll/me/statements/",
OwnPayrollStatementListView.as_view(),
name="own-statement-list",
),
path(
"payroll/me/statements/<uuid:public_id>/",
OwnPayrollStatementDetailView.as_view(),
name="own-statement-detail",
),
path(
"payroll/me/statements/<uuid:public_id>/acknowledge/",
OwnPayrollStatementAcknowledgeView.as_view(),
name="own-statement-acknowledge",
),
]

View File

@ -0,0 +1,132 @@
from django.http import Http404
from django.utils.cache import patch_vary_headers
from rest_framework import generics, permissions, status
from rest_framework.exceptions import APIException
from rest_framework.response import Response
from rest_framework.views import APIView
from finance.enums import PayrollRunStatus
from finance.models import PayrollAuditEvent, PayrollStatement
from finance.payroll.exceptions import PayrollOperationError
from finance.payroll.services import acknowledge_statement
from .serializers import (
OwnPayrollStatementSerializer,
OwnPayrollStatementSummarySerializer,
PayrollStatementAcknowledgementSerializer,
)
class PayrollConflict(APIException):
status_code = status.HTTP_409_CONFLICT
default_code = "payroll_conflict"
class NoStorePayrollResponseMixin:
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
response["Cache-Control"] = "private, no-store, max-age=0"
response["Pragma"] = "no-cache"
patch_vary_headers(response, ("Authorization", "Cookie"))
return response
def own_published_statements(user):
return (
PayrollStatement.objects.filter(
employee=user,
run__status=PayrollRunStatus.PUBLISHED,
)
.select_related("run", "run__period")
.prefetch_related("lines", "acknowledgement")
.order_by("-run__period__date_from", "-run__revision")
)
class OwnPayrollStatementListView(
NoStorePayrollResponseMixin,
generics.ListAPIView,
):
permission_classes = [permissions.IsAuthenticated]
serializer_class = OwnPayrollStatementSummarySerializer
def get_queryset(self):
return own_published_statements(self.request.user)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
statements = list(page) if page is not None else list(queryset)
PayrollAuditEvent.objects.bulk_create(
[
PayrollAuditEvent(
actor=request.user,
action="payroll.statement_summary_viewed",
object_type=statement._meta.label_lower,
object_id=str(statement.pk),
period=statement.run.period,
after_hash=statement.result_hash,
metadata={"channel": "employee_api"},
)
for statement in statements
]
)
serializer = self.get_serializer(statements, many=True)
if page is not None:
return self.get_paginated_response(serializer.data)
return Response(serializer.data)
class OwnPayrollStatementDetailView(
NoStorePayrollResponseMixin,
generics.RetrieveAPIView,
):
permission_classes = [permissions.IsAuthenticated]
serializer_class = OwnPayrollStatementSerializer
lookup_field = "public_id"
lookup_url_kwarg = "public_id"
def get_queryset(self):
return own_published_statements(self.request.user)
def retrieve(self, request, *args, **kwargs):
statement = self.get_object()
PayrollAuditEvent.objects.create(
actor=request.user,
action="payroll.statement_viewed",
object_type=statement._meta.label_lower,
object_id=str(statement.pk),
period=statement.run.period,
after_hash=statement.result_hash,
metadata={"channel": "employee_api"},
)
return Response(self.get_serializer(statement).data)
class OwnPayrollStatementAcknowledgeView(
NoStorePayrollResponseMixin,
APIView,
):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, public_id):
statement = (
own_published_statements(request.user).filter(public_id=public_id).first()
)
if statement is None:
raise Http404
try:
acknowledgement = acknowledge_statement(
statement.pk,
actor=request.user,
)
except PayrollOperationError as exc:
if exc.code == "STATEMENT_NOT_FOUND":
raise Http404 from exc
raise PayrollConflict(
detail={"code": exc.code, "message": exc.message}
) from exc
return Response(
PayrollStatementAcknowledgementSerializer(acknowledgement).data,
status=status.HTTP_200_OK,
)

View File

@ -179,6 +179,8 @@ urlpatterns = [
path("notifications/", include("notifications.api.urls")),
# Procurement API
path("procurement/", include("api.v1.procurement.urls")),
# Finance / payroll API
path("finance/", include("api.v1.finance.urls")),
# Tasks API
path("tasks/", include("api.v1.tasks.urls")),
# Guests API

View File

@ -838,3 +838,25 @@ CORS_ALLOW_CREDENTIALS = True
COMMUNICATIONS_PARTICIPANT_RESOLVER = (
"employees.utils.resolve_chat_participants"
)
# -----------------------------------------------------------------------------
# FINANCE / PAYROLL
# -----------------------------------------------------------------------------
# Rules are versioned inputs of the deterministic payroll core. Changing a
# value creates a different ruleset fingerprint; do not silently reuse a
# version after changing calculation semantics.
FINANCE_PAYROLL = {
"RULESET_ID": os.getenv("PAYROLL_RULESET_ID", "eusrr-standard"),
"RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.07.1"),
"EFFECTIVE_FROM": os.getenv("PAYROLL_EFFECTIVE_FROM", "2026-01-01"),
"EFFECTIVE_TO": os.getenv("PAYROLL_EFFECTIVE_TO") or None,
# Disabled until the point formula is confirmed against representative
# rows from the company's actual spreadsheets.
"POINT_POLICY": os.getenv("PAYROLL_POINT_POLICY", "disabled"),
"MONEY_QUANTUM": os.getenv("PAYROLL_MONEY_QUANTUM", "0.01"),
"ROUNDING": os.getenv("PAYROLL_ROUNDING", "half_up"),
"ALLOW_NEGATIVE_PAYABLE": os.getenv(
"PAYROLL_ALLOW_NEGATIVE_PAYABLE", "false"
),
"BASE_RATE_CODE": os.getenv("PAYROLL_BASE_RATE_CODE", "BASE"),
}

178
backend/finance/README.md Normal file
View File

@ -0,0 +1,178 @@
# Finance / Payroll
The Finance app is the EUSRR adapter around the project-independent
`payroll_core` package. The dependency points in one direction:
```text
Django models, permissions, workflow and API
|
v
immutable payroll_core DTOs
|
v
deterministic calculation result
```
The core does not know about Django, users, databases, Excel or EUSRR. The
adapter resolves approved inputs, persists snapshots and implements the
company workflow.
## Implemented slice
- effective-dated, revisioned employee rates;
- period roster, point facts and legacy Excel control totals;
- typed bonuses, corrections, one-time accruals, deductions and advances;
- maker-checker approval for rates and input records;
- deterministic period calculation with input, ruleset and result hashes;
- immutable run revisions and employee statements;
- review, approval, publication, correction return and republication;
- native payroll workspace API for periods, rates, work records, input lines
and run workflow actions;
- employee-only statement API and idempotent receipt acknowledgement;
- payroll audit events and dedicated Django permissions.
The June 2026 example is covered by an integration test:
```text
base 80,000 + bonus 15,000 + correction 20,000 = payable 115,000 RUB
target points 110, actual points 110, point amount 0
```
This proves the photographed row only. It does not prove the formula for
employees above or below the point target.
| Spreadsheet column | Finance source |
| --- | --- |
| Оклад | approved `EmployeePayRate` |
| Норма баллов / баллы | approved `PayrollWorkRecord` |
| Отпускные / премия / коррекция / разовая | typed `PayrollInputLine` |
| Аванс | payment input line, reducing payable |
| Итого / выработка / перерасчёт / к выплате | reconciliation checkpoints |
Checkpoint columns can block review on mismatch but never feed money back
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.
The approval screen captures the exact draft version the checker reviewed; any
concurrent maker edit increments that version and makes the approval fail
closed until the checker reviews it again.
```text
inputs: draft -> approved
run: calculated -> review -> approved -> published
| |
+-> returned <-+
|
calculate new revision
```
Every revision after the first requires a reason. Publishing a new revision
supersedes the previous published run without changing its snapshots. A
legacy Excel mismatch blocks submission for review.
## Permissions
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;
- `finance.calculate_payroll` — calculate, submit or withdraw a calculated run;
- `finance.approve_payroll` — return, review and approve runs;
- `finance.override_payroll_approval` — additionally allow an input or run
approver to approve an object they created themselves; this permission grants
no workspace access and never replaces either ordinary approval permission;
- `finance.publish_payroll` — publish employee statements;
- `finance.view_all_payroll` — see payroll data for all employees;
- `finance.audit_payroll` — inspect the audit journal.
Ordinary employees need none of these. The self-service API always filters by
the authenticated employee and by the currently published run:
```text
GET /api/v1/finance/payroll/me/statements/
GET /api/v1/finance/payroll/me/statements/<public_id>/
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
`/finances/payroll/manage`; Django Admin is not required for the normal payroll
workflow.
Responses contain no internal source references and are marked
`Cache-Control: private, no-store`.
## Configuration
Rules live in `FINANCE_PAYROLL` and can be supplied through the
`PAYROLL_*` environment variables documented in `.env.example`. Increment
`PAYROLL_RULESET_VERSION` whenever calculation semantics change.
The point policy is `disabled` by default. `excess_only` is available for a
controlled shadow run and computes `max(actual - target, 0) * point_rate`, but
must not be enabled for publication until the company confirms it on a
representative workbook.
The adapter intentionally accepts only a `0.01` money quantum and nonnegative
payable amounts because the persisted schema stores two decimal places.
## Attendance work import
The EUSRR host adapter can prepare `PayrollWorkRecord` drafts from stored
attendance through the period-scoped preview/apply command. This integration
does not live in `payroll_core`: it translates verified portal attendance into
the versioned payroll input contract.
Policy `hours_as_work_units_v1` maps one verified attendance hour to one work
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
creates a draft revision that leaves the approved record intact until the
normal maker-checker approval succeeds.
## Operational boundaries
- PostgreSQL is required in production. SQLite is suitable only for
single-process development/tests because it does not provide the row locks
used by the payroll workflow.
- An acknowledgement means the employee confirmed receipt of the statement;
it is not evidence that a bank transfer completed.
- Model/admin protections are defense in depth for normal application paths.
Database administrators and direct `QuerySet.update()` calls can bypass
model validation, so production DB access must remain restricted.
- Successful workflow changes and detailed employee views are audited. Failed
command attempts are not yet written to a separate append-only command log;
add that external audit sink before treating this module as the sole
compliance record.
- Overlapping periods are rejected by application validation. A PostgreSQL
exclusion constraint should be added before periods can be written by more
than one integration.
- Linked reversal records are intentionally disabled in this slice. Use an
explicit debit/credit correction with a reason until reversal semantics are
approved and implemented atomically.
- Taxes, statutory deductions, bank files, 1C export/import and disputes are
deliberately outside this first slice.
## Rollout
1. Import rates and several representative spreadsheet periods as drafts.
2. Reconcile every employee in shadow mode; do not publish mismatched runs.
3. Confirm point, correction, deduction and rounding rules with accounting.
4. Freeze and version the approved ruleset.
5. Grant separated operator, checker, publisher and auditor roles.
6. Run one parallel payroll cycle before making the portal authoritative.

View File

@ -1 +1,692 @@
# Register your models here.
"""Restricted operational admin for the first payroll backend slice."""
from django import forms
from django.contrib import admin, messages
from django.contrib.admin import helpers
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.template.response import TemplateResponse
from .enums import ApprovalStatus
from .models import (
EmployeePayRate,
PayrollAuditEvent,
PayrollComponent,
PayrollInputLine,
PayrollPeriod,
PayrollRun,
PayrollStatement,
PayrollStatementAcknowledgement,
PayrollStatementLine,
PayrollWorkRecord,
)
from .payroll.exceptions import PayrollOperationError
from .payroll.services import (
approve_input_line,
approve_pay_rate,
approve_run,
approve_work_record,
calculate_period,
publish_run,
return_run_for_correction,
submit_run_for_review,
)
def payroll_reason_action_response(
model_admin,
request,
queryset,
*,
action_name,
title,
reason_required=True,
error="",
):
context = {
**model_admin.admin_site.each_context(request),
"title": title,
"opts": model_admin.model._meta,
"queryset": queryset,
"action_name": action_name,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
"reason_required": reason_required,
"error": error,
}
return TemplateResponse(
request,
"admin/finance/payroll_reason_action.html",
context,
)
def payroll_approval_action_response(
model_admin,
request,
queryset,
*,
action_name,
title,
error="",
):
context = {
**model_admin.admin_site.each_context(request),
"title": title,
"opts": model_admin.model._meta,
"queryset": queryset,
"action_name": action_name,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
"error": error,
}
return TemplateResponse(
request,
"admin/finance/payroll_approval_action.html",
context,
)
class VersionedDraftAdminForm(forms.ModelForm):
"""Carry the version seen on GET back to the locked admin POST."""
expected_lock_version = forms.IntegerField(
min_value=0,
required=False,
widget=forms.HiddenInput,
)
class Meta:
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.is_bound and self.instance and self.instance.pk:
self.initial["expected_lock_version"] = self.instance.lock_version
class PayrollSensitiveAdminMixin:
"""Use explicit payroll roles instead of generic Django model access."""
view_permissions = ()
def _has_payroll_access(self, request):
return any(
request.user.has_perm(permission) for permission in self.view_permissions
)
def has_module_permission(self, request):
return self._has_payroll_access(request)
def has_view_permission(self, request, obj=None):
return self._has_payroll_access(request)
class ImmutableApprovedAdminMixin(PayrollSensitiveAdminMixin):
"""Prevent editing approved/voided inputs through Django admin."""
view_permissions = (
"finance.manage_payroll_inputs",
"finance.approve_payroll_inputs",
"finance.view_all_payroll",
)
form = VersionedDraftAdminForm
def get_readonly_fields(self, request, obj=None):
readonly = list(super().get_readonly_fields(request, obj))
if "lock_version" not in readonly:
readonly.append("lock_version")
if "status" not in readonly:
readonly.append("status")
if obj is not None and (
not request.user.has_perm("finance.manage_payroll_inputs")
or obj.status != ApprovalStatus.DRAFT
or obj.created_by_id != request.user.pk
):
readonly.extend(
field.name
for field in obj._meta.concrete_fields
if field.name not in readonly
)
return readonly
def has_delete_permission(self, request, obj=None):
return False
def has_add_permission(self, request):
return request.user.has_perm("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"):
raise PermissionDenied("Нет права изменять входные данные зарплаты.")
if change:
if form is None or form.cleaned_data.get("expected_lock_version") is None:
raise PermissionDenied(
"Версия открытого черновика потеряна; обновите страницу."
)
expected_lock_version = form.cleaned_data["expected_lock_version"]
current = obj.__class__.objects.select_for_update().get(pk=obj.pk)
if (
current.created_by_id != request.user.pk
or current.status != ApprovalStatus.DRAFT
):
raise PermissionDenied("Изменять можно только собственный черновик.")
if current.lock_version != expected_lock_version:
raise PermissionDenied(
"Черновик уже изменён другой операцией; обновите страницу."
)
obj.status = current.status
obj.lock_version = current.lock_version
obj._expected_lock_version = expected_lock_version
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.register(PayrollComponent)
class PayrollComponentAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
view_permissions = (
"finance.manage_payroll_inputs",
"finance.approve_payroll_inputs",
"finance.view_all_payroll",
)
list_display = ["code", "name", "kind", "is_active", "display_order"]
list_filter = ["kind", "is_active"]
search_fields = ["code", "name"]
ordering = ["display_order", "code"]
def has_add_permission(self, request):
return request.user.has_perm("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")
def has_delete_permission(self, request, obj=None):
return False
def get_readonly_fields(self, request, obj=None):
if obj is None:
return []
return list(PayrollComponent.semantic_fields)
@admin.register(PayrollPeriod)
class PayrollPeriodAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
view_permissions = (
"finance.manage_payroll_inputs",
"finance.calculate_payroll",
"finance.approve_payroll",
"finance.publish_payroll",
"finance.view_all_payroll",
)
list_display = [
"code",
"name",
"date_from",
"date_to",
"status",
"current_run",
]
list_filter = ["status", "currency"]
search_fields = ["code", "name"]
readonly_fields = [
"status",
"current_run",
"lock_version",
"created_by",
"created_at",
"updated_at",
]
actions = ["calculate_selected"]
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)
def has_add_permission(self, request):
return request.user.has_perm("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")
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"):
self.message_user(request, "Недостаточно прав.", level=messages.ERROR)
return
reason_required = any(period.current_run_id for period in queryset)
if "confirm_payroll_action" not in request.POST:
return payroll_reason_action_response(
self,
request,
queryset,
action_name="calculate_selected",
title="Расчёт зарплатного периода",
reason_required=reason_required,
)
reason = request.POST.get("reason", "").strip()
if reason_required and not reason:
return payroll_reason_action_response(
self,
request,
queryset,
action_name="calculate_selected",
title="Расчёт зарплатного периода",
reason_required=True,
error="Для повторного расчёта обязательно укажите основание.",
)
success_count = 0
for period in queryset:
try:
calculate_period(
period.pk,
actor=request.user,
recalculation_reason=reason if period.current_run_id else "",
)
except PayrollOperationError as exc:
self.message_user(
request,
f"{period}: {exc.message} ({exc.code})",
level=messages.ERROR,
)
else:
success_count += 1
if success_count:
self.message_user(
request,
f"Рассчитано периодов: {success_count}",
level=messages.SUCCESS,
)
@admin.register(EmployeePayRate)
class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
list_display = [
"employee",
"rate_code",
"amount",
"currency",
"effective_from",
"revision",
"status",
"self_approval_overridden",
]
list_filter = [
"status",
"self_approval_overridden",
"currency",
"source",
"effective_from",
]
search_fields = [
"employee__last_name",
"employee__first_name",
"employee__email",
"rate_code",
"source_ref",
]
readonly_fields = [
"created_by",
"created_at",
"approved_by",
"approved_at",
"self_approval_overridden",
"voided_by",
"voided_at",
]
actions = ["approve_selected"]
@admin.action(description="Утвердить выбранные ставки")
def approve_selected(self, request, queryset):
return self._approve_records(
request,
queryset,
approve_pay_rate,
action_name="approve_selected",
title="Утверждение ставок",
)
def _approve_records(
self,
request,
queryset,
service,
*,
action_name,
title,
):
if not request.user.has_perm("finance.approve_payroll_inputs"):
self.message_user(request, "Недостаточно прав.", level=messages.ERROR)
return
if "confirm_payroll_approval" not in request.POST:
return payroll_approval_action_response(
self,
request,
queryset,
action_name=action_name,
title=title,
)
for record in queryset:
try:
expected_lock_version = int(request.POST[f"lock_version_{record.pk}"])
except (KeyError, TypeError, ValueError):
return payroll_approval_action_response(
self,
request,
queryset,
action_name=action_name,
title=title,
error="Версия черновика потеряна; проверьте данные ещё раз.",
)
try:
service(
record.pk,
actor=request.user,
expected_lock_version=expected_lock_version,
)
except PayrollOperationError as exc:
self.message_user(
request,
f"{record}: {exc.message} ({exc.code})",
level=messages.ERROR,
)
@admin.register(PayrollWorkRecord)
class PayrollWorkRecordAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
list_display = [
"period",
"employee",
"target_points",
"actual_points",
"revision",
"status",
"self_approval_overridden",
]
list_filter = ["period", "status", "self_approval_overridden", "source"]
search_fields = [
"employee__last_name",
"employee__first_name",
"employee__email",
"source_ref",
]
readonly_fields = [
"created_by",
"created_at",
"updated_at",
"approved_by",
"approved_at",
"self_approval_overridden",
"voided_by",
"voided_at",
]
actions = ["approve_selected"]
@admin.action(description="Утвердить выбранную выработку")
def approve_selected(self, request, queryset):
return EmployeePayRateAdmin._approve_records(
self,
request,
queryset,
approve_work_record,
action_name="approve_selected",
title="Утверждение выработки",
)
@admin.register(PayrollInputLine)
class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
exclude = ["reversal_of"]
list_display = [
"period",
"employee",
"component",
"amount",
"relates_to_period",
"status",
"self_approval_overridden",
]
list_filter = [
"period",
"status",
"self_approval_overridden",
"component",
"source",
]
search_fields = [
"employee__last_name",
"employee__first_name",
"employee__email",
"reason",
"source_ref",
]
readonly_fields = [
"idempotency_key",
"created_by",
"created_at",
"approved_by",
"approved_at",
"self_approval_overridden",
"voided_by",
"voided_at",
]
actions = ["approve_selected"]
@admin.action(description="Утвердить выбранные строки")
def approve_selected(self, request, queryset):
return EmployeePayRateAdmin._approve_records(
self,
request,
queryset,
approve_input_line,
action_name="approve_selected",
title="Утверждение начислений и удержаний",
)
class PayrollStatementLineInline(admin.TabularInline):
model = PayrollStatementLine
extra = 0
can_delete = False
fields = ["position", "code", "label", "kind", "amount", "reason"]
readonly_fields = fields
def has_add_permission(self, request, obj=None):
return False
@admin.register(PayrollRun)
class PayrollRunAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
view_permissions = (
"finance.calculate_payroll",
"finance.approve_payroll",
"finance.publish_payroll",
"finance.view_all_payroll",
)
list_display = [
"period",
"revision",
"status",
"employee_count",
"gross_total",
"payable_total",
"requested_by",
"self_approval_overridden",
]
list_filter = ["status", "self_approval_overridden", "period"]
search_fields = ["period__code", "input_hash", "result_hash"]
readonly_fields = [field.name for field in PayrollRun._meta.concrete_fields]
actions = [
"request_review",
"return_selected",
"approve_selected",
"publish_selected",
]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return self._has_payroll_access(request)
def _run_action(self, request, queryset, service):
for run in queryset:
try:
service(run.pk, actor=request.user)
except PayrollOperationError as exc:
self.message_user(
request,
f"{run}: {exc.message} ({exc.code})",
level=messages.ERROR,
)
@admin.action(description="Передать на проверку")
def request_review(self, request, queryset):
self._run_action(request, queryset, submit_run_for_review)
@admin.action(description="Вернуть расчёт на исправление")
def return_selected(self, request, queryset):
if "confirm_payroll_action" not in request.POST:
return payroll_reason_action_response(
self,
request,
queryset,
action_name="return_selected",
title="Возврат расчёта на исправление",
)
reason = request.POST.get("reason", "").strip()
if not reason:
return payroll_reason_action_response(
self,
request,
queryset,
action_name="return_selected",
title="Возврат расчёта на исправление",
error="Укажите причину возврата.",
)
for run in queryset:
try:
return_run_for_correction(
run.pk,
actor=request.user,
reason=reason,
)
except PayrollOperationError as exc:
self.message_user(
request,
f"{run}: {exc.message} ({exc.code})",
level=messages.ERROR,
)
@admin.action(description="Утвердить расчёт")
def approve_selected(self, request, queryset):
self._run_action(request, queryset, approve_run)
@admin.action(description="Опубликовать расчётные листки")
def publish_selected(self, request, queryset):
self._run_action(request, queryset, publish_run)
@admin.register(PayrollStatement)
class PayrollStatementAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
view_permissions = ("finance.view_all_payroll",)
list_display = [
"run",
"employee",
"gross_total",
"deduction_total",
"payment_total",
"payable",
]
list_filter = ["run__period", "run__status", "currency"]
search_fields = [
"employee__last_name",
"employee__first_name",
"employee__email",
"result_hash",
]
readonly_fields = [field.name for field in PayrollStatement._meta.concrete_fields]
inlines = [PayrollStatementLineInline]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return self._has_payroll_access(request)
@admin.register(PayrollStatementAcknowledgement)
class PayrollStatementAcknowledgementAdmin(
PayrollSensitiveAdminMixin,
admin.ModelAdmin,
):
view_permissions = (
"finance.view_all_payroll",
"finance.audit_payroll",
)
list_display = [
"statement",
"employee",
"viewed_at",
"acknowledged_at",
"disputed_at",
]
list_filter = ["acknowledged_at", "disputed_at"]
search_fields = [
"employee__last_name",
"employee__first_name",
"statement__result_hash",
]
readonly_fields = [
field.name for field in PayrollStatementAcknowledgement._meta.concrete_fields
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return self._has_payroll_access(request)
def has_delete_permission(self, request, obj=None):
return False
@admin.register(PayrollAuditEvent)
class PayrollAuditEventAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
view_permissions = ("finance.audit_payroll",)
list_display = [
"created_at",
"actor",
"action",
"object_type",
"object_id",
"period",
]
list_filter = ["action", "object_type", "period"]
search_fields = ["object_id", "before_hash", "after_hash"]
readonly_fields = [field.name for field in PayrollAuditEvent._meta.concrete_fields]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return request.user.has_perm("finance.audit_payroll")
def has_delete_permission(self, request, obj=None):
return False

View File

@ -2,5 +2,6 @@ from django.apps import AppConfig
class FinanceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'finance'
default_auto_field = "django.db.models.BigAutoField"
name = "finance"
verbose_name = "Финансы"

42
backend/finance/enums.py Normal file
View File

@ -0,0 +1,42 @@
"""Stable database values used by the finance payroll adapter."""
from django.db import models
class ApprovalStatus(models.TextChoices):
DRAFT = "draft", "Черновик"
APPROVED = "approved", "Утверждено"
VOIDED = "voided", "Аннулировано"
class InputSource(models.TextChoices):
MANUAL = "manual", "Вручную"
EXCEL = "excel", "Excel"
API = "api", "API"
ATTENDANCE = "attendance", "Посещаемость"
class PayrollComponentKind(models.TextChoices):
EARNING = "earning", "Начисление"
ADJUSTMENT_CREDIT = "adjustment_credit", "Коррекция +"
ADJUSTMENT_DEBIT = "adjustment_debit", "Коррекция −"
DEDUCTION = "deduction", "Удержание"
PAYMENT = "payment", "Выплата"
class PayrollPeriodStatus(models.TextChoices):
OPEN = "open", "Открыт"
CALCULATED = "calculated", "Рассчитан"
REVIEW = "review", "На проверке"
APPROVED = "approved", "Утверждён"
PUBLISHED = "published", "Опубликован"
CLOSED = "closed", "Закрыт"
class PayrollRunStatus(models.TextChoices):
CALCULATED = "calculated", "Рассчитан"
REVIEW = "review", "На проверке"
APPROVED = "approved", "Утверждён"
PUBLISHED = "published", "Опубликован"
RETURNED = "returned", "Возвращён на исправление"
SUPERSEDED = "superseded", "Заменён новой ревизией"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
# Generated by Django 5.2.4 on 2026-07-17 12:16
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name="employeepayrate",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("status", "approved"), _negated=True),
models.Q(
("approved_at__isnull", False), ("approved_by__isnull", False)
),
_connector="OR",
),
name="pay_rate_approval_complete",
),
),
migrations.AddConstraint(
model_name="payrollinputline",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("status", "approved"), _negated=True),
models.Q(
("approved_at__isnull", False), ("approved_by__isnull", False)
),
_connector="OR",
),
name="pay_input_approval_complete",
),
),
migrations.AddConstraint(
model_name="payrollrun",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("status__in", ["approved", "published"]), _negated=True),
models.Q(
("approved_at__isnull", False), ("approved_by__isnull", False)
),
_connector="OR",
),
name="pay_run_approval_complete",
),
),
migrations.AddConstraint(
model_name="payrollrun",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("status", "published"), _negated=True),
models.Q(
("published_at__isnull", False), ("published_by__isnull", False)
),
_connector="OR",
),
name="pay_run_publication_complete",
),
),
migrations.AddConstraint(
model_name="payrollworkrecord",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("status", "approved"), _negated=True),
models.Q(
("approved_at__isnull", False), ("approved_by__isnull", False)
),
_connector="OR",
),
name="pay_work_approval_complete",
),
),
]

View File

@ -0,0 +1,49 @@
from django.db import migrations
COMPONENTS = (
("VACATION_PAY", "Отпускные", "earning", False, 20),
("BONUS", "Премия", "earning", False, 30),
(
"CORRECTION_CREDIT",
"Положительная корректировка",
"adjustment_credit",
True,
40,
),
(
"CORRECTION_DEBIT",
"Отрицательная корректировка",
"adjustment_debit",
True,
41,
),
("ONE_TIME_PAYMENT", "Разовое начисление", "earning", True, 50),
("DEDUCTION", "Удержание", "deduction", True, 60),
("ADVANCE", "Аванс", "payment", False, 70),
)
def seed_components(apps, schema_editor):
component_model = apps.get_model("finance", "PayrollComponent")
for code, name, kind, requires_reason, display_order in COMPONENTS:
component_model.objects.update_or_create(
code=code,
defaults={
"name": name,
"kind": kind,
"requires_reason": requires_reason,
"is_active": True,
"display_order": display_order,
},
)
class Migration(migrations.Migration):
dependencies = [
("finance", "0002_employeepayrate_pay_rate_approval_complete_and_more")
]
operations = [
migrations.RunPython(seed_components, migrations.RunPython.noop),
]

View File

@ -0,0 +1,45 @@
import uuid
from django.db import migrations, models
def populate_statement_public_ids(apps, schema_editor):
statement_model = apps.get_model("finance", "PayrollStatement")
for statement in statement_model.objects.filter(public_id__isnull=True).iterator():
statement.public_id = uuid.uuid4()
statement.save(update_fields=["public_id"])
class Migration(migrations.Migration):
dependencies = [("finance", "0003_seed_payroll_components")]
operations = [
migrations.AddField(
model_name="payrollrun",
name="recalculation_reason",
field=models.TextField(blank=True, verbose_name="Основание перерасчёта"),
),
migrations.AddField(
model_name="payrollstatement",
name="public_id",
field=models.UUIDField(
editable=False,
null=True,
verbose_name="Публичный идентификатор",
),
),
migrations.RunPython(
populate_statement_public_ids,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="payrollstatement",
name="public_id",
field=models.UUIDField(
default=uuid.uuid4,
editable=False,
unique=True,
verbose_name="Публичный идентификатор",
),
),
]

View File

@ -0,0 +1,46 @@
# Generated by Django 5.2.4 on 2026-07-17 12:38
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0004_payroll_run_reason_statement_public_id"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="payrollstatementline",
name="is_retro",
field=models.BooleanField(
default=False, verbose_name="Перерасчёт прошлого периода"
),
),
migrations.AlterField(
model_name="payrollrun",
name="status",
field=models.CharField(
choices=[
("calculated", "Рассчитан"),
("review", "На проверке"),
("approved", "Утверждён"),
("published", "Опубликован"),
("returned", "Возвращён на исправление"),
("superseded", "Заменён новой ревизией"),
],
default="calculated",
max_length=20,
verbose_name="Статус",
),
),
migrations.AddConstraint(
model_name="payrollrun",
constraint=models.UniqueConstraint(
condition=models.Q(("status", "published")),
fields=("period",),
name="pay_run_one_published_per_period",
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.4 on 2026-07-17 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0005_payrollstatementline_is_retro_and_more"),
]
operations = [
migrations.AddField(
model_name="payrollworkrecord",
name="reason",
field=models.TextField(blank=True, verbose_name="Основание изменения"),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.4 on 2026-07-17 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0006_payrollworkrecord_reason"),
]
operations = [
migrations.AddField(
model_name="employeepayrate",
name="lock_version",
field=models.PositiveIntegerField(
default=0, verbose_name="Версия черновика"
),
),
migrations.AddField(
model_name="payrollinputline",
name="lock_version",
field=models.PositiveIntegerField(
default=0, verbose_name="Версия черновика"
),
),
migrations.AddField(
model_name="payrollworkrecord",
name="lock_version",
field=models.PositiveIntegerField(
default=0, verbose_name="Версия черновика"
),
),
]

View File

@ -0,0 +1,191 @@
# Generated by Django 5.2.4 on 2026-07-17 16:27
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0007_employeepayrate_lock_version_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name="payrollperiod",
options={
"ordering": ["-date_from", "-id"],
"permissions": [
("manage_payroll_inputs", "Может управлять данными для расчёта"),
("approve_payroll_inputs", "Может утверждать данные для расчёта"),
("calculate_payroll", "Может запускать расчёт зарплаты"),
("approve_payroll", "Может утверждать расчёт зарплаты"),
(
"override_payroll_approval",
"Может самоутверждать данные и расчёты зарплаты",
),
("publish_payroll", "Может публиковать расчётные листки"),
("view_all_payroll", "Может просматривать все расчёты"),
("audit_payroll", "Может просматривать аудит расчётов"),
],
"verbose_name": "Расчётный период",
"verbose_name_plural": "Расчётные периоды",
},
),
migrations.RemoveConstraint(
model_name="employeepayrate",
name="pay_rate_maker_not_approver",
),
migrations.RemoveConstraint(
model_name="payrollinputline",
name="pay_input_maker_not_approver",
),
migrations.RemoveConstraint(
model_name="payrollrun",
name="pay_run_maker_not_approver",
),
migrations.RemoveConstraint(
model_name="payrollworkrecord",
name="pay_work_maker_not_approver",
),
migrations.AddField(
model_name="employeepayrate",
name="self_approval_overridden",
field=models.BooleanField(
default=False,
editable=False,
verbose_name="Самоутверждение по особому праву",
),
),
migrations.AddField(
model_name="payrollinputline",
name="self_approval_overridden",
field=models.BooleanField(
default=False,
editable=False,
verbose_name="Самоутверждение по особому праву",
),
),
migrations.AddField(
model_name="payrollrun",
name="self_approval_overridden",
field=models.BooleanField(
default=False,
editable=False,
verbose_name="Самоутверждение по особому праву",
),
),
migrations.AddField(
model_name="payrollworkrecord",
name="self_approval_overridden",
field=models.BooleanField(
default=False,
editable=False,
verbose_name="Самоутверждение по особому праву",
),
),
migrations.AddConstraint(
model_name="employeepayrate",
constraint=models.CheckConstraint(
condition=models.Q(
("approved_by__isnull", True),
models.Q(("created_by", models.F("approved_by")), _negated=True),
("self_approval_overridden", True),
_connector="OR",
),
name="pay_rate_maker_not_approver",
),
),
migrations.AddConstraint(
model_name="employeepayrate",
constraint=models.CheckConstraint(
condition=models.Q(
("self_approval_overridden", False),
models.Q(
("approved_by__isnull", False),
("created_by", models.F("approved_by")),
),
_connector="OR",
),
name="pay_rate_self_override_valid",
),
),
migrations.AddConstraint(
model_name="payrollinputline",
constraint=models.CheckConstraint(
condition=models.Q(
("approved_by__isnull", True),
models.Q(("created_by", models.F("approved_by")), _negated=True),
("self_approval_overridden", True),
_connector="OR",
),
name="pay_input_maker_not_approver",
),
),
migrations.AddConstraint(
model_name="payrollinputline",
constraint=models.CheckConstraint(
condition=models.Q(
("self_approval_overridden", False),
models.Q(
("approved_by__isnull", False),
("created_by", models.F("approved_by")),
),
_connector="OR",
),
name="pay_input_self_override_valid",
),
),
migrations.AddConstraint(
model_name="payrollrun",
constraint=models.CheckConstraint(
condition=models.Q(
("approved_by__isnull", True),
models.Q(("requested_by", models.F("approved_by")), _negated=True),
("self_approval_overridden", True),
_connector="OR",
),
name="pay_run_maker_not_approver",
),
),
migrations.AddConstraint(
model_name="payrollrun",
constraint=models.CheckConstraint(
condition=models.Q(
("self_approval_overridden", False),
models.Q(
("approved_by__isnull", False),
("requested_by", models.F("approved_by")),
),
_connector="OR",
),
name="pay_run_self_override_valid",
),
),
migrations.AddConstraint(
model_name="payrollworkrecord",
constraint=models.CheckConstraint(
condition=models.Q(
("approved_by__isnull", True),
models.Q(("created_by", models.F("approved_by")), _negated=True),
("self_approval_overridden", True),
_connector="OR",
),
name="pay_work_maker_not_approver",
),
),
migrations.AddConstraint(
model_name="payrollworkrecord",
constraint=models.CheckConstraint(
condition=models.Q(
("self_approval_overridden", False),
models.Q(
("approved_by__isnull", False),
("created_by", models.F("approved_by")),
),
_connector="OR",
),
name="pay_work_self_override_valid",
),
),
]

View File

@ -0,0 +1,57 @@
# Generated by Django 5.2.4 on 2026-07-17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0008_alter_payrollperiod_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="employeepayrate",
name="source",
field=models.CharField(
choices=[
("manual", "Вручную"),
("excel", "Excel"),
("api", "API"),
("attendance", "Посещаемость"),
],
default="manual",
max_length=16,
verbose_name="Источник",
),
),
migrations.AlterField(
model_name="payrollinputline",
name="source",
field=models.CharField(
choices=[
("manual", "Вручную"),
("excel", "Excel"),
("api", "API"),
("attendance", "Посещаемость"),
],
default="manual",
max_length=16,
verbose_name="Источник",
),
),
migrations.AlterField(
model_name="payrollworkrecord",
name="source",
field=models.CharField(
choices=[
("manual", "Вручную"),
("excel", "Excel"),
("api", "API"),
("attendance", "Посещаемость"),
],
default="manual",
max_length=16,
verbose_name="Источник",
),
),
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
"""EUSRR adapter for the project-independent :mod:`payroll_core` package."""
from .services import (
acknowledge_statement,
approve_input_line,
approve_pay_rate,
approve_run,
approve_work_record,
calculate_period,
close_period,
publish_run,
return_run_for_correction,
submit_run_for_review,
)
__all__ = [
"acknowledge_statement",
"approve_input_line",
"approve_pay_rate",
"approve_run",
"approve_work_record",
"calculate_period",
"close_period",
"publish_run",
"return_run_for_correction",
"submit_run_for_review",
]

View File

@ -0,0 +1,96 @@
"""Mapping between approved Django records and payroll-core DTOs."""
from __future__ import annotations
from payroll_core import (
ExpectedTotals,
InputLine,
LineKind,
PayrollPeriod as CorePayrollPeriod,
PayrollRequest,
)
from finance.models import (
EmployeePayRate,
PayrollInputLine,
PayrollPeriod,
PayrollWorkRecord,
)
def _core_period(period: PayrollPeriod) -> CorePayrollPeriod:
return CorePayrollPeriod(start=period.date_from, end=period.date_to)
def _expected_totals(work_record: PayrollWorkRecord) -> ExpectedTotals | None:
values = (
work_record.expected_point_amount,
work_record.expected_gross,
work_record.expected_recalculated_gross,
work_record.expected_payable,
)
if all(value is None for value in values):
return None
return ExpectedTotals(
point_amount=work_record.expected_point_amount,
gross=work_record.expected_gross,
recalculated_gross=work_record.expected_recalculated_gross,
payable=work_record.expected_payable,
)
def build_core_request(
*,
period: PayrollPeriod,
rate: EmployeePayRate,
work_record: PayrollWorkRecord | None,
input_lines: list[PayrollInputLine],
) -> PayrollRequest:
core_period = _core_period(period)
mapped_lines = []
for record in input_lines:
source_period = None
if record.relates_to_period_id:
source_period = _core_period(record.relates_to_period)
mapped_lines.append(
InputLine(
line_id=f"input:{record.pk}",
code=record.component.code,
label=record.component.name,
kind=LineKind(record.component.kind),
amount=record.amount,
source_ref=(
record.source_ref or f"{record.source}:payroll-input:{record.pk}"
),
reason=record.reason,
source_period=source_period,
is_retro=source_period is not None,
)
)
return PayrollRequest(
employee_ref=f"employee:{rate.employee_id}",
period=core_period,
currency=period.currency,
base_accrual=rate.amount,
base_source_ref=f"pay-rate:{rate.pk}:revision:{rate.revision}",
target_points=(work_record.target_points if work_record else None),
actual_points=(work_record.actual_points if work_record else None),
point_rate=rate.point_rate,
lines=tuple(mapped_lines),
expected_totals=(
_expected_totals(work_record) if work_record is not None else None
),
)
def employee_snapshot(employee) -> dict[str, object]:
"""Return the minimum identity snapshot needed for a historical payslip."""
display_name = employee.get_full_name().strip() or f"Сотрудник #{employee.pk}"
position = getattr(employee, "position", None)
return {
"employee_id": employee.pk,
"display_name": display_name,
"position": ({"id": position.pk, "name": position.name} if position else None),
}

View File

@ -0,0 +1,764 @@
"""Host adapter that turns verified attendance hours into payroll work drafts.
The deterministic payroll core remains unaware of the portal's attendance
models. This adapter is deliberately conservative: it only imports complete,
unambiguous attendance and never approves a payroll record automatically.
"""
from __future__ import annotations
import hashlib
import json
import math
from collections import defaultdict
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.utils import timezone
from attendance.models import AttendanceRecord
from attendance.services import ABSENCE_ISSUE_MARKERS
from employees.constants import ACTION_REMOTE
from payroll_core import PointPolicy
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollPeriodStatus,
PayrollRunStatus,
)
from finance.models import PayrollAuditEvent, PayrollPeriod, PayrollWorkRecord
from .config import build_rules
from .exceptions import PayrollOperationError, PayrollPermissionDenied
Employee = get_user_model()
ATTENDANCE_WORK_POLICY_CODE = "hours_as_work_units_v1"
ATTENDANCE_WORK_POLICY = {
"code": ATTENDANCE_WORK_POLICY_CODE,
"label": "1 час = 1 единица выработки",
"description": (
"Норма равна сумме плановых часов, факт — сумме отработанных часов "
"по проверенным рабочим дням. Импорт создаёт только черновики."
),
}
ATTENDANCE_IMPORT_MODES = {"missing_only", "replace_existing"}
ACTION_KEYS = ("create", "update", "revise", "unchanged", "skip", "blocked")
QUANTUM = Decimal("0.0001")
def _operation_error(code: str, message: str, **details):
raise PayrollOperationError(code, message, details=details)
def _issue(code: str, message: str) -> dict[str, str]:
return {"code": code, "message": message}
def _date_range(period: PayrollPeriod) -> list:
days = (period.date_to - period.date_from).days
return [period.date_from + timedelta(days=offset) for offset in range(days + 1)]
def _finite_decimal(value) -> Decimal | None:
if value is None:
return None
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
if not number.is_finite() or not math.isfinite(float(number)):
return None
return number
def _is_explicit_absence(record: AttendanceRecord) -> bool:
if record.is_absent:
return True
values = [*record.statuses, *record.employee_issues]
return any(
marker in str(value).strip().lower()
for value in values
for marker in ABSENCE_ISSUE_MARKERS
)
def _employee_payload(employee) -> dict:
position = getattr(employee, "position", None)
links = list(getattr(employee, "departments_links", []).all())
department_link = next((link for link in links if link.is_active), None)
return {
"id": employee.pk,
"display_name": employee.get_full_name().strip() or f"Сотрудник #{employee.pk}",
"position": position.name if position is not None else None,
"department": (
department_link.department.name if department_link is not None else None
),
}
def _existing_payload(record: PayrollWorkRecord | None) -> dict | None:
if record is None:
return None
return {
"id": record.pk,
"revision": record.revision,
"status": record.status,
"source": record.source,
"target_points": str(record.target_points),
"actual_points": str(record.actual_points),
"created_by": {
"id": record.created_by_id,
"display_name": (
record.created_by.get_full_name().strip()
or f"Сотрудник #{record.created_by_id}"
),
},
}
def _attendance_snapshot_hash(records: list[AttendanceRecord]) -> str:
payload = [
{
"id": record.pk,
"run": record.analysis_run_id,
"run_status": record.analysis_run.status,
"schedule": record.analysis_run.schedule_payload,
"date": record.date.isoformat(),
"expected": record.expected_hours,
"worked": record.work_hours,
"workday": record.is_workday,
"effective_workday": record.effective_is_workday,
"arrival": record.arrival_time,
"departure": record.departure_time,
"absent": record.is_absent,
"technical": record.technical_issues,
"employee_issues": record.employee_issues,
"statuses": record.statuses,
"personnel_status": record.personnel_status,
"manual": record.is_manually_edited,
"updated_at": record.updated_at.isoformat(),
}
for record in records
]
encoded = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
return hashlib.sha256(encoded.encode()).hexdigest()
def _analyze_attendance(period: PayrollPeriod, records: list[AttendanceRecord]):
blockers: list[dict[str, str]] = []
warnings: list[dict[str, str]] = []
expected_dates = set(_date_range(period))
actual_dates = {record.date for record in records}
missing_dates = sorted(expected_dates - actual_dates)
if missing_dates:
blockers.append(
_issue(
"MISSING_ATTENDANCE_DAYS",
f"Не хватает записей посещаемости за {len(missing_dates)} дн.",
)
)
if period.date_to >= timezone.localdate():
blockers.append(
_issue(
"PERIOD_NOT_COMPLETE",
"Период ещё не завершён; итоговую выработку рассчитывать рано.",
)
)
target = Decimal("0")
actual = Decimal("0")
effective_workdays = 0
technical_issue_days = 0
absence_days = 0
manual_days = 0
overtime_days = 0
missing_schedule = False
for record in records:
if record.analysis_run.status != record.analysis_run.STATUS_SUCCESS:
blockers.append(
_issue(
"ATTENDANCE_ANALYSIS_FAILED",
"Один из дней получен из неуспешного анализа посещаемости.",
)
)
if not record.analysis_run.schedule_payload:
missing_schedule = True
worked = _finite_decimal(record.work_hours)
expected = _finite_decimal(record.expected_hours)
if worked is not None and worked > 0 and not record.effective_is_workday:
blockers.append(
_issue(
"OUTSIDE_SCHEDULE_WORK_REQUIRES_REVIEW",
"Есть работа вне рабочего графика; её нужно проверить вручную.",
)
)
continue
if not record.effective_is_workday:
continue
effective_workdays += 1
if record.technical_issues:
technical_issue_days += 1
continue
if record.personnel_status == ACTION_REMOTE and not (
record.is_manually_edited
and "work_hours" in (record.manual_edit_payload or {})
):
blockers.append(
_issue(
"REMOTE_ATTENDANCE_UNMEASURABLE",
"Удалённый рабочий день не подтверждён ручной записью часов.",
)
)
continue
if bool(record.arrival_time) != bool(record.departure_time):
blockers.append(
_issue(
"OPEN_SHIFT",
f"За {record.date:%d.%m.%Y} не закрыта смена.",
)
)
continue
if (
expected is None
or worked is None
or expected <= 0
or expected > 24
or worked < 0
or worked > 24
):
blockers.append(
_issue(
"INVALID_ATTENDANCE_HOURS",
f"За {record.date:%d.%m.%Y} указаны некорректные часы.",
)
)
continue
explicit_absence = _is_explicit_absence(record)
if worked == 0 and not record.arrival_time and not explicit_absence:
blockers.append(
_issue(
"UNCLASSIFIED_ZERO_DAY",
f"Нулевые часы за {record.date:%d.%m.%Y} не объяснены отсутствием.",
)
)
continue
target += expected
actual += worked
absence_days += int(explicit_absence)
manual_days += int(record.is_manually_edited)
overtime_days += int(record.is_overtime)
if technical_issue_days:
blockers.append(
_issue(
"TECHNICAL_ATTENDANCE_ISSUES",
f"Технические проблемы обнаружены за {technical_issue_days} дн.",
)
)
if target <= 0:
blockers.append(
_issue(
"ZERO_TARGET_WORK_UNITS",
"По посещаемости не удалось получить положительную норму часов.",
)
)
if missing_schedule:
warnings.append(
_issue(
"NO_SCHEDULE_SNAPSHOT",
"В старых данных нет снимка графика; проверьте норму часов.",
)
)
if absence_days:
warnings.append(
_issue(
"ATTENDANCE_ABSENCES_INCLUDED",
f"Подтверждённые отсутствия учтены как 0 часов: {absence_days} дн.",
)
)
if manual_days:
warnings.append(
_issue(
"MANUAL_ATTENDANCE_INCLUDED",
f"Использованы ручные исправления посещаемости: {manual_days} дн.",
)
)
if overtime_days:
warnings.append(
_issue(
"ATTENDANCE_OVERTIME_INCLUDED",
f"Факт включает переработку в рабочие дни: {overtime_days} дн.",
)
)
return {
"target_points": str(target.quantize(QUANTUM)),
"actual_points": str(actual.quantize(QUANTUM)),
"expected_hours": str(target.quantize(QUANTUM)),
"worked_hours": str(actual.quantize(QUANTUM)),
"attendance_days": len(records),
"effective_workdays": effective_workdays,
"technical_issue_days": technical_issue_days,
"warnings": _deduplicate_issues(warnings),
"blockers": _deduplicate_issues(blockers),
"source_hash": _attendance_snapshot_hash(records),
}
def _deduplicate_issues(issues: list[dict[str, str]]) -> list[dict[str, str]]:
result = []
seen = set()
for issue in issues:
key = (issue["code"], issue["message"])
if key not in seen:
seen.add(key)
result.append(issue)
return result
def _active_work_record(records: list[PayrollWorkRecord]) -> PayrollWorkRecord | None:
drafts = [record for record in records if record.status == ApprovalStatus.DRAFT]
if drafts:
return max(drafts, key=lambda record: (record.revision, record.pk))
approved = [
record for record in records if record.status == ApprovalStatus.APPROVED
]
if approved:
return max(approved, key=lambda record: (record.revision, record.pk))
return None
def _same_metrics(record: PayrollWorkRecord, metrics: dict) -> bool:
return record.target_points == Decimal(
metrics["target_points"]
) and record.actual_points == Decimal(metrics["actual_points"])
def _actions_for_item(
*,
period: PayrollPeriod,
actor,
existing: PayrollWorkRecord | None,
metrics: dict,
) -> dict[str, str]:
if metrics["blockers"]:
return {"missing_only": "blocked", "replace_existing": "blocked"}
if existing is None:
if period.status == PayrollPeriodStatus.PUBLISHED:
return {"missing_only": "blocked", "replace_existing": "blocked"}
return {"missing_only": "create", "replace_existing": "create"}
if existing.status == ApprovalStatus.DRAFT:
replacement = (
"blocked"
if existing.created_by_id != actor.pk
else ("unchanged" if _same_metrics(existing, metrics) else "update")
)
return {"missing_only": "skip", "replace_existing": replacement}
replacement = "unchanged" if _same_metrics(existing, metrics) else "revise"
return {"missing_only": "skip", "replace_existing": replacement}
def _mode_summaries(items: list[dict]) -> dict:
summaries = {}
for mode in sorted(ATTENDANCE_IMPORT_MODES):
summary = {key: 0 for key in ACTION_KEYS}
for item in items:
summary[item["actions"][mode]] += 1
summary["changes"] = summary["create"] + summary["update"] + summary["revise"]
summaries[mode] = summary
return summaries
def _preview_token(period: PayrollPeriod, actor, items: list[dict]) -> str:
canonical_items = [
{
"employee_id": item["employee"]["id"],
"target": item["target_points"],
"actual": item["actual_points"],
"source_hash": item["_source_hash"],
"blockers": item["blockers"],
"actions": item["actions"],
"existing": item["_existing_state"],
}
for item in items
]
payload = {
"policy": ATTENDANCE_WORK_POLICY_CODE,
"period": period.pk,
"period_lock_version": period.lock_version,
"period_status": period.status,
"actor": actor.pk,
"items": canonical_items,
}
encoded = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
return hashlib.sha256(encoded.encode()).hexdigest()
def build_attendance_work_preview(
period: PayrollPeriod,
*,
actor,
lock: bool = False,
) -> dict:
"""Build a deterministic, non-mutating attendance import preview."""
if build_rules().point_policy != PointPolicy.DISABLED:
_operation_error(
"ATTENDANCE_UNIT_POLICY_CONFLICT",
"Импорт часов доступен только пока начисление по баллам отключено.",
)
attendance = AttendanceRecord.objects.filter(
date__gte=period.date_from,
date__lte=period.date_to,
).select_related("analysis_run", "employee", "employee__position")
work = PayrollWorkRecord.objects.filter(period=period).select_related(
"employee", "employee__position", "created_by"
)
if lock:
attendance = attendance.select_for_update()
work = work.select_for_update()
attendance_records = list(attendance.order_by("employee_id", "date", "id"))
work_records = list(work.order_by("employee_id", "revision", "id"))
attendance_by_employee = defaultdict(list)
work_by_employee = defaultdict(list)
for record in attendance_records:
attendance_by_employee[record.employee_id].append(record)
for record in work_records:
work_by_employee[record.employee_id].append(record)
employee_ids = sorted(set(attendance_by_employee) | set(work_by_employee))
employees = {
employee.pk: employee
for employee in Employee.objects.filter(pk__in=employee_ids)
.select_related("position")
.prefetch_related("departments_links__department")
}
items = []
for employee_id in employee_ids:
records = attendance_by_employee[employee_id]
metrics = _analyze_attendance(period, records)
if not records:
metrics["blockers"].append(
_issue(
"NO_ATTENDANCE_DATA",
"За выбранный период нет данных посещаемости.",
)
)
existing = _active_work_record(work_by_employee[employee_id])
actions = _actions_for_item(
period=period,
actor=actor,
existing=existing,
metrics=metrics,
)
if (
existing is not None
and existing.status == ApprovalStatus.DRAFT
and existing.created_by_id != actor.pk
):
metrics["warnings"].append(
_issue(
"FOREIGN_DRAFT_PROTECTED",
"Черновик создан другим сотрудником и не будет перезаписан.",
)
)
if period.status == PayrollPeriodStatus.PUBLISHED and existing is None:
metrics["blockers"].append(
_issue(
"PUBLISHED_WORK_REPLACEMENT_REQUIRED",
"В опубликованный период можно добавить только ревизию существующей записи.",
)
)
existing_state = (
{
"id": existing.pk,
"status": existing.status,
"revision": existing.revision,
"lock_version": existing.lock_version,
"created_by": existing.created_by_id,
"replaces": existing.replaces_id,
"target": str(existing.target_points),
"actual": str(existing.actual_points),
}
if existing is not None
else None
)
items.append(
{
"employee": _employee_payload(employees[employee_id]),
**{
key: value for key, value in metrics.items() if key != "source_hash"
},
"existing_record": _existing_payload(existing),
"actions": actions,
"_source_hash": metrics["source_hash"],
"_existing_state": existing_state,
}
)
token = _preview_token(period, actor, items)
summaries = _mode_summaries(items)
public_items = [
{key: value for key, value in item.items() if not key.startswith("_")}
for item in items
]
return {
"period_id": period.pk,
"generated_at": timezone.now().isoformat(),
"preview_token": token,
"policy": ATTENDANCE_WORK_POLICY,
"summary": {
"attendance_employees": len(attendance_by_employee),
"existing": sum(item["existing_record"] is not None for item in items),
"blocked": sum(bool(item["blockers"]) for item in items),
"modes": summaries,
},
"items": public_items,
"_internal_items": items,
}
def _ensure_period_accepts_attendance_import(period: PayrollPeriod):
if period.status == PayrollPeriodStatus.CLOSED:
_operation_error(
"PERIOD_INPUTS_LOCKED", "Данные закрытого периода заблокированы."
)
if period.current_run is not None and period.current_run.status in {
PayrollRunStatus.CALCULATED,
PayrollRunStatus.REVIEW,
PayrollRunStatus.APPROVED,
}:
_operation_error(
"PERIOD_INPUTS_LOCKED",
"Сначала верните текущий расчёт на исправление.",
run_id=period.current_run_id,
)
def _audit_mutation(*, actor, action, record, period, metadata):
PayrollAuditEvent.objects.create(
actor=actor,
action=action,
object_type=record._meta.label_lower,
object_id=str(record.pk),
period=period,
metadata={"channel": "attendance_import", **metadata},
)
@transaction.atomic
def apply_attendance_work_preview(
period_id: int,
*,
actor,
mode: str,
preview_token: str,
expected_period_lock_version: int,
reason: str = "",
) -> dict:
"""Atomically apply a previously inspected preview as payroll drafts."""
if (
not actor
or not actor.is_authenticated
or not actor.has_perm("finance.manage_payroll_inputs")
):
raise PayrollPermissionDenied("finance.manage_payroll_inputs")
if mode not in ATTENDANCE_IMPORT_MODES:
_operation_error("INVALID_ATTENDANCE_IMPORT_MODE", "Неизвестный режим импорта.")
reason = reason.strip()
if mode == "replace_existing" and not reason:
_operation_error(
"ATTENDANCE_RECALCULATION_REASON_REQUIRED",
"Для пересчёта существующих записей укажите причину.",
)
period = (
PayrollPeriod.objects.select_for_update()
.select_related("current_run")
.get(pk=period_id)
)
_ensure_period_accepts_attendance_import(period)
if period.lock_version != expected_period_lock_version:
_operation_error(
"STALE_PERIOD",
"Период уже изменён; обновите предпросмотр.",
expected_lock_version=expected_period_lock_version,
actual_lock_version=period.lock_version,
)
preview = build_attendance_work_preview(period, actor=actor, lock=True)
if preview["preview_token"] != preview_token:
_operation_error(
"ATTENDANCE_PREVIEW_STALE",
"Посещаемость или выработка изменились; обновите предпросмотр.",
)
records_by_id = {
record.pk: record
for record in PayrollWorkRecord.objects.select_for_update().filter(
period=period
)
}
summary = {
"created": 0,
"updated": 0,
"revised": 0,
"unchanged": 0,
"skipped": 0,
"blocked": 0,
}
changed_records = []
default_reason = (
f"Заполнено по посещаемости за {period.date_from:%d.%m.%Y}–"
f"{period.date_to:%d.%m.%Y}."
)
try:
for item in preview["_internal_items"]:
action = item["actions"][mode]
if action == "blocked":
summary["blocked"] += 1
continue
if action == "skip":
summary["skipped"] += 1
continue
if action == "unchanged":
summary["unchanged"] += 1
continue
employee_id = item["employee"]["id"]
existing_data = item["existing_record"]
existing = records_by_id.get(existing_data["id"]) if existing_data else None
prior_max = max(
(
record.revision
for record in records_by_id.values()
if record.employee_id == employee_id
),
default=0,
)
revision = (
existing.revision
if action == "update"
else existing.revision + 1
if existing is not None
else prior_max + 1
)
source_ref = (
f"attendance-hours-v1:{period.pk}:{employee_id}:r{revision}:"
f"{preview_token[:24]}"
)
metrics = {
"target_points": Decimal(item["target_points"]),
"actual_points": Decimal(item["actual_points"]),
}
mutation_reason = reason or default_reason
if action == "create":
record = PayrollWorkRecord(
period=period,
employee_id=employee_id,
revision=revision,
status=ApprovalStatus.DRAFT,
source=InputSource.ATTENDANCE,
source_ref=source_ref,
reason=mutation_reason,
created_by=actor,
**metrics,
)
record.full_clean()
record.save(force_insert=True)
summary["created"] += 1
audit_action = "payroll.work_record_attendance_draft_created"
elif action == "update":
record = existing
record._expected_lock_version = record.lock_version
record.target_points = metrics["target_points"]
record.actual_points = metrics["actual_points"]
record.source = InputSource.ATTENDANCE
record.source_ref = source_ref
record.reason = mutation_reason
record.full_clean()
record.save()
summary["updated"] += 1
audit_action = "payroll.work_record_attendance_draft_updated"
else:
record = PayrollWorkRecord(
period=period,
employee_id=employee_id,
revision=existing.revision + 1,
replaces=existing,
status=ApprovalStatus.DRAFT,
source=InputSource.ATTENDANCE,
source_ref=source_ref,
reason=mutation_reason,
created_by=actor,
expected_point_amount=existing.expected_point_amount,
expected_gross=existing.expected_gross,
expected_recalculated_gross=existing.expected_recalculated_gross,
expected_payable=existing.expected_payable,
**metrics,
)
record.full_clean()
record.save(force_insert=True)
summary["revised"] += 1
audit_action = "payroll.work_record_attendance_revision_created"
records_by_id[record.pk] = record
changed_records.append(record)
_audit_mutation(
actor=actor,
action=audit_action,
record=record,
period=period,
metadata={
"policy": ATTENDANCE_WORK_POLICY_CODE,
"mode": mode,
"preview_token": preview_token,
"attendance_input_hash": item["_source_hash"],
"target_points": item["target_points"],
"actual_points": item["actual_points"],
"replaces_id": record.replaces_id,
},
)
except ValidationError as exc:
_operation_error(
"ATTENDANCE_IMPORT_VALIDATION_FAILED",
"Черновик выработки не прошёл проверку.",
errors=(exc.message_dict if hasattr(exc, "message_dict") else exc.messages),
)
except IntegrityError:
_operation_error(
"ATTENDANCE_IMPORT_CONFLICT",
"Параллельная операция уже изменила выработку; обновите предпросмотр.",
)
PayrollAuditEvent.objects.create(
actor=actor,
action="payroll.attendance_work_imported",
object_type=period._meta.label_lower,
object_id=str(period.pk),
period=period,
metadata={
"channel": "attendance_import",
"policy": ATTENDANCE_WORK_POLICY_CODE,
"mode": mode,
"preview_token": preview_token,
"summary": summary,
},
)
return {"mode": mode, "summary": summary, "records": changed_records}

View File

@ -0,0 +1,184 @@
"""Host configuration translated into immutable payroll-core rules."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import date
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from payroll_core import PayrollRules, PointPolicy, RoundingPolicy
DEFAULT_CONFIG = {
"RULESET_ID": "eusrr-standard",
"RULESET_VERSION": "2026.07.1",
"EFFECTIVE_FROM": "2026-01-01",
"EFFECTIVE_TO": None,
# The spreadsheet screenshot is not enough to prove the point formula.
# Production must opt in after reconciliation against representative rows.
"POINT_POLICY": PointPolicy.DISABLED.value,
"MONEY_QUANTUM": "0.01",
"ROUNDING": RoundingPolicy.HALF_UP.value,
"ALLOW_NEGATIVE_PAYABLE": False,
"BASE_RATE_CODE": "BASE",
}
def get_config() -> dict[str, object]:
config = DEFAULT_CONFIG.copy()
overrides = getattr(settings, "FINANCE_PAYROLL", {})
if not isinstance(overrides, Mapping):
raise ImproperlyConfigured("FINANCE_PAYROLL must be a mapping")
config.update(overrides)
return config
def _parse_date(value, key: str, *, optional: bool = False):
if value is None and optional:
return None
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value))
except (TypeError, ValueError) as exc:
raise ImproperlyConfigured(
f"FINANCE_PAYROLL[{key!r}] must be an ISO date"
) from exc
def _parse_bool(value, key: str) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes"}:
return True
if normalized in {"false", "0", "no"}:
return False
raise ImproperlyConfigured(f"FINANCE_PAYROLL[{key!r}] must be a boolean")
def _parse_rounding(value) -> RoundingPolicy:
if isinstance(value, RoundingPolicy):
return value
raw_value = str(value).strip()
aliases = {
"half_up": RoundingPolicy.HALF_UP,
"half_even": RoundingPolicy.HALF_EVEN,
"down": RoundingPolicy.DOWN,
}
try:
return aliases.get(raw_value.lower()) or RoundingPolicy(raw_value)
except ValueError as exc:
raise ImproperlyConfigured(
"FINANCE_PAYROLL['ROUNDING'] must be half_up, half_even, down "
"or a canonical Decimal rounding name"
) from exc
def _parse_point_policy(value) -> PointPolicy:
if isinstance(value, PointPolicy):
return value
try:
return PointPolicy(str(value).strip().lower())
except ValueError as exc:
raise ImproperlyConfigured(
"FINANCE_PAYROLL['POINT_POLICY'] must be disabled or excess_only"
) from exc
def build_rules() -> PayrollRules:
config = get_config()
try:
quantum = Decimal(str(config["MONEY_QUANTUM"]))
except (KeyError, ValueError, InvalidOperation) as exc:
raise ImproperlyConfigured(
"FINANCE_PAYROLL contains an unsupported calculation setting"
) from exc
point_policy = _parse_point_policy(config["POINT_POLICY"])
rounding = _parse_rounding(config["ROUNDING"])
allow_negative_payable = _parse_bool(
config["ALLOW_NEGATIVE_PAYABLE"],
"ALLOW_NEGATIVE_PAYABLE",
)
if quantum != Decimal("0.01"):
raise ImproperlyConfigured(
"The finance adapter persists two decimal places; "
"MONEY_QUANTUM must be 0.01"
)
if allow_negative_payable:
raise ImproperlyConfigured(
"The finance adapter does not persist negative payable amounts"
)
return PayrollRules(
ruleset_id=str(config["RULESET_ID"]),
version=str(config["RULESET_VERSION"]),
effective_from=_parse_date(config["EFFECTIVE_FROM"], "EFFECTIVE_FROM"),
effective_to=_parse_date(
config.get("EFFECTIVE_TO"),
"EFFECTIVE_TO",
optional=True,
),
point_policy=point_policy,
money_quantum=quantum,
rounding=rounding,
allow_negative_payable=allow_negative_payable,
)
def rules_cover_period(
rules: PayrollRules,
*,
period_from: date,
period_to: date,
) -> bool:
"""Return whether one ruleset is effective for the complete payroll period."""
return period_from >= rules.effective_from and (
rules.effective_to is None or period_to <= rules.effective_to
)
def ruleset_not_effective_message(rules: PayrollRules) -> str:
"""Build an operator-facing explanation that remains useful without details."""
effective_range = f"с {rules.effective_from:%d.%m.%Y}"
if rules.effective_to is not None:
effective_range += f" по {rules.effective_to:%d.%m.%Y}"
return (
"Для выбранного периода нет действующих правил расчёта. "
f"Набор {rules.ruleset_id}, версия {rules.version}, применяется "
f"{effective_range}. Измените период или подключите историческую "
"версию правил."
)
def ruleset_period_details(
rules: PayrollRules,
*,
period_from: date,
period_to: date,
) -> dict[str, object]:
"""Stable diagnostics shared by readiness and calculation errors."""
ruleset_details = {
"id": rules.ruleset_id,
"version": rules.version,
"effective_from": rules.effective_from.isoformat(),
}
if rules.effective_to is not None:
ruleset_details["effective_to"] = rules.effective_to.isoformat()
return {
"period": {
"date_from": period_from.isoformat(),
"date_to": period_to.isoformat(),
},
"ruleset": ruleset_details,
}
def base_rate_code() -> str:
return str(get_config()["BASE_RATE_CODE"])

View File

@ -0,0 +1,28 @@
"""Application-level errors raised by the Django payroll adapter."""
from __future__ import annotations
from typing import Mapping
class PayrollOperationError(Exception):
def __init__(
self,
code: str,
message: str,
*,
details: Mapping[str, object] | None = None,
):
self.code = code
self.message = message
self.details = dict(details or {})
super().__init__(f"{code}: {message}")
class PayrollPermissionDenied(PayrollOperationError):
def __init__(self, permission: str):
super().__init__(
"PERMISSION_DENIED",
"Недостаточно прав для операции с зарплатой.",
details={"permission": permission},
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
{% extends "admin/base_site.html" %}
{% load admin_urls %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>
Проверьте суммы, сотрудника и основание. Будут утверждены только показанные
версии; если оператор изменит черновик, операция завершится конфликтом.
</p>
<ul>
{% for object in queryset %}
<li>
{{ object }} — версия {{ object.lock_version }}
(<a
href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}"
target="_blank"
rel="noopener"
>открыть карточку</a>)
</li>
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ object.pk }}">
<input
type="hidden"
name="lock_version_{{ object.pk }}"
value="{{ object.lock_version }}"
>
{% endfor %}
</ul>
{% if error %}
<p class="errornote">{{ error }}</p>
{% endif %}
<input type="hidden" name="action" value="{{ action_name }}">
<input type="hidden" name="confirm_payroll_approval" value="1">
<input type="hidden" name="select_across" value="0">
<input type="submit" value="Утвердить показанные версии" class="default">
<a href="../" class="button cancel-link">Отмена</a>
</form>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>Операция будет применена к следующим объектам:</p>
<ul>
{% for object in queryset %}
<li>{{ object }}</li>
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ object.pk }}">
{% endfor %}
</ul>
{% if error %}
<p class="errornote">{{ error }}</p>
{% endif %}
<div class="form-row">
<label for="id_reason"{% if reason_required %} class="required"{% endif %}>
Основание:
</label>
<textarea
id="id_reason"
name="reason"
rows="4"
cols="80"
{% if reason_required %}required{% endif %}
>{{ request.POST.reason|default:"" }}</textarea>
</div>
<input type="hidden" name="action" value="{{ action_name }}">
<input type="hidden" name="confirm_payroll_action" value="1">
<input type="hidden" name="select_across" value="0">
<input type="submit" value="Продолжить" class="default">
<a href="../" class="button cancel-link">Отмена</a>
</form>
{% endblock %}

View File

@ -0,0 +1,96 @@
# Payroll Core
`payroll_core` is a deterministic, project-independent calculation package.
It contains no Django, database, network, Celery or EUSRR imports. A host
application resolves employees and approved inputs, calls the calculator, and
persists the returned immutable snapshot.
## Guarantees
- every monetary and quantity value is `Decimal`; floats are rejected;
- each displayed money line is rounded once before totals are summed;
- calculation uses a fixed local decimal context and does not depend on the
host process context;
- rules are immutable, effective-dated and versioned;
- inputs, rules and calculation output have canonical SHA-256 fingerprints;
- reconciliation warnings have a separate fingerprint, so legacy checkpoints
never alter calculation identity but cannot be changed unnoticed;
- explicit line kinds carry direction, so a negative correction cannot become
a double negative;
- legacy spreadsheet totals are reconciliation checkpoints, never inputs;
- the result is independent of the order of input lines.
## Example matching the June 2026 spreadsheet
```python
from datetime import date
from decimal import Decimal
from payroll_core import (
DeterministicPayrollCalculator,
ExpectedTotals,
InputLine,
LineKind,
PayrollPeriod,
PayrollRequest,
PayrollRules,
PointPolicy,
)
period = PayrollPeriod(date(2026, 6, 1), date(2026, 6, 30))
request = PayrollRequest(
employee_ref="employee:42",
period=period,
currency="RUB",
base_accrual=Decimal("80000"),
base_source_ref="rate:42:revision:1",
target_points=Decimal("110"),
actual_points=Decimal("110"),
point_rate=Decimal("100"),
lines=(
InputLine(
line_id="bonus:1",
code="BONUS",
label="Bonus",
kind=LineKind.EARNING,
amount=Decimal("15000"),
source_ref="excel:june:row-2:bonus",
),
InputLine(
line_id="correction:1",
code="MANUAL_CORRECTION",
label="Manual correction",
kind=LineKind.ADJUSTMENT_CREDIT,
amount=Decimal("20000"),
reason="Approved accounting correction",
source_ref="excel:june:row-2:correction",
),
),
expected_totals=ExpectedTotals(
point_amount=Decimal("0"),
gross=Decimal("115000"),
recalculated_gross=Decimal("115000"),
payable=Decimal("115000"),
),
)
rules = PayrollRules(
ruleset_id="company-standard",
version="2026.07.1",
effective_from=date(2026, 1, 1),
point_policy=PointPolicy.EXCESS_ONLY,
)
result = DeterministicPayrollCalculator().calculate(request, rules)
assert result.totals.payable == Decimal("115000.00")
```
The initial `EXCESS_ONLY` point policy pays only points above the target:
`max(actual - target, 0) * point_rate`. It never creates an implicit penalty
for a shortfall. This policy must remain in shadow/reconciliation mode until
the company confirms how points work for other rows.
## Host boundary
The core knows an employee only as an opaque `employee_ref`. The host adapter
is responsible for authorization, rate resolution, maker-checker approval,
transactions, audit, publication, acknowledgements and notifications.

View File

@ -0,0 +1,44 @@
"""Project-independent deterministic payroll calculation core.
The public API intentionally contains no Django or EUSRR imports. Host
applications are expected to resolve employees, rates and persistence before
calling the calculator.
"""
from .calculator import DeterministicPayrollCalculator, PayrollCalculator
from .domain import (
CalculationWarning,
ExpectedTotals,
InputLine,
LineKind,
PayrollLine,
PayrollPeriod,
PayrollRequest,
PayrollResult,
PayrollRules,
PayrollTotals,
PointPolicy,
RoundingPolicy,
)
from .exceptions import PayrollIssue, PayrollValidationError
__all__ = [
"CalculationWarning",
"DeterministicPayrollCalculator",
"ExpectedTotals",
"InputLine",
"LineKind",
"PayrollCalculator",
"PayrollIssue",
"PayrollLine",
"PayrollPeriod",
"PayrollRequest",
"PayrollResult",
"PayrollRules",
"PayrollTotals",
"PayrollValidationError",
"PointPolicy",
"RoundingPolicy",
]
__version__ = "0.1.0"

View File

@ -0,0 +1,820 @@
"""Deterministic reference implementation of the payroll calculator."""
from __future__ import annotations
import hashlib
import json
import re
from datetime import date, datetime
from decimal import Context, Decimal, DecimalException, localcontext
from typing import Protocol
from .domain import (
CalculationWarning,
ExpectedTotals,
InputLine,
LineKind,
PayrollLine,
PayrollPeriod,
PayrollRequest,
PayrollResult,
PayrollRules,
PayrollTotals,
PointPolicy,
RoundingPolicy,
decimal_text,
)
from .exceptions import PayrollIssue, PayrollValidationError
_CURRENCY_RE = re.compile(r"^[A-Z]{3}$")
_CODE_RE = re.compile(r"^[A-Z][A-Z0-9_.-]{0,63}$")
_SYSTEM_LINE_PREFIX = "system:"
class PayrollCalculator(Protocol):
"""Port implemented by any compatible calculation provider."""
def calculate(
self,
request: PayrollRequest,
rules: PayrollRules,
) -> PayrollResult: ...
def _canonical_hash(payload: object) -> str:
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _is_finite_decimal(value: object) -> bool:
return isinstance(value, Decimal) and value.is_finite()
class DeterministicPayrollCalculator:
"""Pure calculator with explicit Decimal and rounding semantics."""
CALCULATOR_VERSION = "1.0.0"
DECIMAL_PRECISION = 80
MAX_DECIMAL_DIGITS = 30
MAX_FRACTIONAL_PLACES = 12
MAX_INPUT_LINES = 10_000
def calculate(
self,
request: PayrollRequest,
rules: PayrollRules,
) -> PayrollResult:
issues = self._validate(request, rules)
if issues:
raise PayrollValidationError(issues)
decimal_context = Context(
prec=self.DECIMAL_PRECISION,
rounding=rules.rounding.value,
)
with localcontext(decimal_context):
quantize = self._quantizer(rules)
lines: list[PayrollLine] = [
PayrollLine(
line_id="system:base",
code="BASE",
label="Base accrual",
kind=LineKind.EARNING,
amount=quantize(request.base_accrual),
source_ref=request.base_source_ref,
calculated=True,
)
]
warnings: list[CalculationWarning] = []
point_delta: Decimal | None = None
if rules.point_policy is PointPolicy.EXCESS_ONLY:
point_delta = request.actual_points - request.target_points
eligible_points = max(point_delta, Decimal("0"))
point_amount = quantize(eligible_points * request.point_rate)
lines.append(
PayrollLine(
line_id="system:point-excess",
code="POINT_EXCESS",
label="Points above target",
kind=LineKind.EARNING,
amount=point_amount,
source_ref="calculation:point-policy",
calculated=True,
)
)
if point_delta < 0:
warnings.append(
CalculationWarning(
code="POINTS_BELOW_TARGET_NO_DEDUCTION",
field="actual_points",
message=(
"Actual points are below target; the active "
"policy does not reduce pay."
),
context=(("point_delta", decimal_text(point_delta)),),
)
)
for input_line in sorted(
request.lines,
key=lambda item: item.line_id,
):
lines.append(self._to_result_line(input_line, quantize))
totals = self._calculate_totals(lines)
if totals.payable < 0 and not rules.allow_negative_payable:
raise PayrollValidationError(
[
PayrollIssue(
code="NEGATIVE_PAYABLE_NOT_ALLOWED",
field="lines",
message=(
"Deductions and recorded payments exceed available pay."
),
context={"payable": decimal_text(totals.payable)},
)
]
)
warnings.extend(
self._reconcile_expected(
request.expected_totals,
lines,
totals,
quantize,
)
)
input_hash = _canonical_hash(request.calculation_dict())
rules_payload = rules.to_dict()
ruleset_hash = _canonical_hash(rules_payload)
result_payload = {
"employee_ref": request.employee_ref,
"period": request.period.to_dict(),
"currency": request.currency,
"lines": [line.to_dict() for line in lines],
"point_delta": (
decimal_text(point_delta) if point_delta is not None else None
),
"totals": totals.to_dict(),
"ruleset_id": rules.ruleset_id,
"ruleset_version": rules.version,
"ruleset_hash": ruleset_hash,
"calculator_version": self.CALCULATOR_VERSION,
"rounding_policy": rules.rounding.value,
"input_hash": input_hash,
}
result_hash = _canonical_hash(result_payload)
reconciliation_hash = _canonical_hash(
{
"result_hash": result_hash,
"warnings": [warning.to_dict() for warning in warnings],
}
)
return PayrollResult(
employee_ref=request.employee_ref,
period=request.period,
currency=request.currency,
lines=tuple(lines),
point_delta=point_delta,
totals=totals,
ruleset_id=rules.ruleset_id,
ruleset_version=rules.version,
ruleset_hash=ruleset_hash,
calculator_version=self.CALCULATOR_VERSION,
rounding_policy=rules.rounding.value,
input_hash=input_hash,
result_hash=result_hash,
reconciliation_hash=reconciliation_hash,
warnings=tuple(warnings),
)
@staticmethod
def _quantizer(rules: PayrollRules):
def quantize(value: Decimal) -> Decimal:
try:
units = (value / rules.money_quantum).quantize(
Decimal("1"),
rounding=rules.rounding.value,
)
return units * rules.money_quantum
except DecimalException as exc:
raise PayrollValidationError(
[
PayrollIssue(
code="ARITHMETIC_ERROR",
message="A monetary value cannot be rounded safely.",
)
]
) from exc
return quantize
@staticmethod
def _to_result_line(input_line: InputLine, quantize) -> PayrollLine:
return PayrollLine(
line_id=input_line.line_id,
code=input_line.code,
label=input_line.label,
kind=input_line.kind,
amount=quantize(input_line.amount),
source_ref=input_line.source_ref,
reason=input_line.reason,
source_period=input_line.source_period,
is_retro=input_line.is_retro,
calculated=False,
)
@staticmethod
def _calculate_totals(lines: list[PayrollLine]) -> PayrollTotals:
earnings = sum(
(line.amount for line in lines if line.kind is LineKind.EARNING),
Decimal("0"),
)
adjustment_credits = sum(
(line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_CREDIT),
Decimal("0"),
)
adjustment_debits = sum(
(line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_DEBIT),
Decimal("0"),
)
deductions = sum(
(line.amount for line in lines if line.kind is LineKind.DEDUCTION),
Decimal("0"),
)
payments = sum(
(line.amount for line in lines if line.kind is LineKind.PAYMENT),
Decimal("0"),
)
adjustment_total = adjustment_credits - adjustment_debits
gross = earnings + adjustment_total
net_pay = gross - deductions
return PayrollTotals(
gross_before_adjustments=earnings,
adjustment_total=adjustment_total,
gross_after_adjustments=gross,
deduction_total=deductions,
net_pay=net_pay,
payment_total=payments,
payable=net_pay - payments,
)
@staticmethod
def _reconcile_expected(
expected: ExpectedTotals | None,
lines: list[PayrollLine],
totals: PayrollTotals,
quantize,
) -> list[CalculationWarning]:
if expected is None:
return []
point_amount = next(
(line.amount for line in lines if line.line_id == "system:point-excess"),
Decimal("0"),
)
checks = (
("point_amount", expected.point_amount, point_amount),
("gross", expected.gross, totals.gross_after_adjustments),
(
"recalculated_gross",
expected.recalculated_gross,
totals.recalculated_gross,
),
("payable", expected.payable, totals.payable),
)
warnings = []
for field_name, expected_value, actual_value in checks:
if expected_value is None:
continue
normalized_expected = quantize(expected_value)
if normalized_expected != actual_value:
warnings.append(
CalculationWarning(
code="LEGACY_EXPECTED_TOTAL_MISMATCH",
field=f"expected_totals.{field_name}",
message=(
"The calculated amount differs from the legacy "
"control total."
),
context=(
("expected", decimal_text(normalized_expected)),
("actual", decimal_text(actual_value)),
),
)
)
return warnings
def _validate(
self,
request: PayrollRequest,
rules: PayrollRules,
) -> list[PayrollIssue]:
issues: list[PayrollIssue] = []
if not isinstance(request, PayrollRequest):
return [
PayrollIssue(
code="INVALID_REQUEST",
field="request",
message="Request must be a PayrollRequest instance.",
)
]
if not isinstance(rules, PayrollRules):
return [
PayrollIssue(
code="INVALID_RULESET",
field="rules",
message="Rules must be a PayrollRules instance.",
)
]
if not self._is_nonempty_string(request.employee_ref):
issues.append(
PayrollIssue(
code="MISSING_EMPLOYEE_REF",
field="employee_ref",
message="Employee reference is required.",
)
)
period_is_valid = self._validate_period(
request.period,
"period",
issues,
)
if not isinstance(request.currency, str) or not _CURRENCY_RE.fullmatch(
request.currency
):
issues.append(
PayrollIssue(
code="INVALID_CURRENCY",
field="currency",
message="Currency must be a three-letter uppercase code.",
)
)
self._validate_decimal(
request.base_accrual,
"base_accrual",
issues,
minimum=Decimal("0"),
)
if not self._is_nonempty_string(request.base_source_ref):
issues.append(
PayrollIssue(
code="MISSING_SOURCE_REF",
field="base_source_ref",
message="Base accrual must identify its source.",
)
)
self._validate_decimal(
request.point_rate,
"point_rate",
issues,
minimum=Decimal("0"),
)
if not self._is_nonempty_string(rules.ruleset_id):
issues.append(
PayrollIssue(
code="INVALID_RULESET",
field="rules.ruleset_id",
message="Ruleset identifier is required.",
)
)
if not self._is_nonempty_string(rules.version):
issues.append(
PayrollIssue(
code="INVALID_RULESET",
field="rules.version",
message="Ruleset version is required.",
)
)
rules_from_valid = isinstance(rules.effective_from, date) and not isinstance(
rules.effective_from,
datetime,
)
rules_to_valid = rules.effective_to is None or (
isinstance(rules.effective_to, date)
and not isinstance(rules.effective_to, datetime)
)
if not rules_from_valid:
issues.append(
PayrollIssue(
code="INVALID_RULESET_PERIOD",
field="rules.effective_from",
message="Ruleset effective_from must be a date.",
)
)
if not rules_to_valid:
issues.append(
PayrollIssue(
code="INVALID_RULESET_PERIOD",
field="rules.effective_to",
message="Ruleset effective_to must be a date or None.",
)
)
if (
rules_from_valid
and rules_to_valid
and rules.effective_to is not None
and rules.effective_from > rules.effective_to
):
issues.append(
PayrollIssue(
code="INVALID_RULESET_PERIOD",
field="rules.effective_to",
message="Ruleset effective dates are invalid.",
)
)
rules_outside_period = False
if period_is_valid and rules_from_valid and rules_to_valid:
rules_outside_period = (
request.period.start < rules.effective_from
or rules.effective_to is not None
and request.period.end > rules.effective_to
)
if rules_outside_period:
issues.append(
PayrollIssue(
code="RULESET_NOT_EFFECTIVE",
field="period",
message="Ruleset is not effective for the full period.",
)
)
if not isinstance(rules.point_policy, PointPolicy):
issues.append(
PayrollIssue(
code="UNSUPPORTED_POINT_POLICY",
field="rules.point_policy",
message="Point policy is not supported.",
)
)
if not isinstance(rules.rounding, RoundingPolicy):
issues.append(
PayrollIssue(
code="UNSUPPORTED_ROUNDING_POLICY",
field="rules.rounding",
message="Rounding policy is not supported.",
)
)
if not isinstance(rules.allow_negative_payable, bool):
issues.append(
PayrollIssue(
code="INVALID_RULE_OPTION",
field="rules.allow_negative_payable",
message="allow_negative_payable must be a boolean.",
)
)
self._validate_decimal(
rules.money_quantum,
"rules.money_quantum",
issues,
minimum=Decimal("0"),
exclusive_minimum=True,
)
if rules.point_policy is PointPolicy.EXCESS_ONLY:
self._validate_decimal(
request.target_points,
"target_points",
issues,
minimum=Decimal("0"),
exclusive_minimum=True,
)
self._validate_decimal(
request.actual_points,
"actual_points",
issues,
minimum=Decimal("0"),
)
elif rules.point_policy is PointPolicy.DISABLED:
has_target = request.target_points is not None
has_actual = request.actual_points is not None
if has_target != has_actual:
issues.append(
PayrollIssue(
code="INCOMPLETE_POINT_METRICS",
field="target_points",
message="Target and actual points must be supplied together.",
)
)
if has_target:
self._validate_decimal(
request.target_points,
"target_points",
issues,
minimum=Decimal("0"),
exclusive_minimum=True,
)
if has_actual:
self._validate_decimal(
request.actual_points,
"actual_points",
issues,
minimum=Decimal("0"),
)
seen_line_ids: set[str] = set()
if not isinstance(request.lines, tuple):
issues.append(
PayrollIssue(
code="INVALID_LINES_CONTAINER",
field="lines",
message="Input lines must be an immutable tuple.",
)
)
lines = ()
else:
lines = request.lines
if len(lines) > self.MAX_INPUT_LINES:
issues.append(
PayrollIssue(
code="TOO_MANY_INPUT_LINES",
field="lines",
message=f"At most {self.MAX_INPUT_LINES} input lines are allowed.",
)
)
for index, line in enumerate(lines):
prefix = f"lines[{index}]"
if not isinstance(line, InputLine):
issues.append(
PayrollIssue(
code="INVALID_COMPONENT",
field=prefix,
message="Every input line must be an InputLine instance.",
)
)
continue
line_id_is_valid = self._is_nonempty_string(line.line_id)
if not line_id_is_valid:
issues.append(
PayrollIssue(
code="MISSING_COMPONENT_ID",
field=f"{prefix}.line_id",
message="Input line identifier is required.",
)
)
else:
if line.line_id.startswith(_SYSTEM_LINE_PREFIX):
issues.append(
PayrollIssue(
code="RESERVED_COMPONENT_ID",
field=f"{prefix}.line_id",
message="Input line uses a reserved system prefix.",
)
)
if line.line_id in seen_line_ids:
issues.append(
PayrollIssue(
code="DUPLICATE_COMPONENT_ID",
field=f"{prefix}.line_id",
message="Input line identifiers must be unique.",
)
)
seen_line_ids.add(line.line_id)
if not isinstance(line.code, str) or not _CODE_RE.fullmatch(line.code):
issues.append(
PayrollIssue(
code="INVALID_COMPONENT_CODE",
field=f"{prefix}.code",
message="Component code must use canonical uppercase form.",
)
)
if not self._is_nonempty_string(line.label):
issues.append(
PayrollIssue(
code="MISSING_COMPONENT_LABEL",
field=f"{prefix}.label",
message="Input line label is required.",
)
)
kind_is_valid = isinstance(line.kind, LineKind)
if not kind_is_valid:
issues.append(
PayrollIssue(
code="INVALID_COMPONENT_KIND",
field=f"{prefix}.kind",
message="Input line kind is not supported.",
)
)
self._validate_decimal(
line.amount,
f"{prefix}.amount",
issues,
minimum=Decimal("0"),
)
if not self._is_nonempty_string(line.source_ref):
issues.append(
PayrollIssue(
code="MISSING_SOURCE_REF",
field=f"{prefix}.source_ref",
message="Every input line must identify its source.",
)
)
if (
kind_is_valid
and line.kind
in {
LineKind.ADJUSTMENT_CREDIT,
LineKind.ADJUSTMENT_DEBIT,
}
and not self._is_nonempty_string(line.reason)
):
issues.append(
PayrollIssue(
code="MISSING_ADJUSTMENT_REASON",
field=f"{prefix}.reason",
message="Adjustment reason is required.",
)
)
is_retro_valid = isinstance(line.is_retro, bool)
if not is_retro_valid:
issues.append(
PayrollIssue(
code="INVALID_RETRO_FLAG",
field=f"{prefix}.is_retro",
message="is_retro must be a boolean.",
)
)
if line.is_retro is True and line.source_period is None:
issues.append(
PayrollIssue(
code="MISSING_RETRO_SOURCE_PERIOD",
field=f"{prefix}.source_period",
message="Retro input must reference the affected period.",
)
)
if line.source_period is not None:
source_period_valid = self._validate_period(
line.source_period,
f"{prefix}.source_period",
issues,
)
if is_retro_valid and line.is_retro is not True:
issues.append(
PayrollIssue(
code="UNEXPECTED_SOURCE_PERIOD",
field=f"{prefix}.source_period",
message="A source period requires is_retro=True.",
)
)
elif (
is_retro_valid
and line.is_retro is True
and source_period_valid
and period_is_valid
and line.source_period.end >= request.period.start
):
issues.append(
PayrollIssue(
code="INVALID_RETRO_SOURCE_PERIOD",
field=f"{prefix}.source_period",
message="Retro source period must end before this period.",
)
)
if request.expected_totals is not None and not isinstance(
request.expected_totals,
ExpectedTotals,
):
issues.append(
PayrollIssue(
code="INVALID_EXPECTED_TOTALS",
field="expected_totals",
message="Expected totals must be an ExpectedTotals instance.",
)
)
elif request.expected_totals is not None:
for field_name in (
"point_amount",
"gross",
"recalculated_gross",
"payable",
):
self._validate_decimal(
getattr(request.expected_totals, field_name),
f"expected_totals.{field_name}",
issues,
minimum=(
None
if field_name == "payable" and rules.allow_negative_payable
else Decimal("0")
),
allow_none=True,
)
return issues
@staticmethod
def _is_nonempty_string(value: object) -> bool:
return isinstance(value, str) and bool(value.strip())
@staticmethod
def _validate_period(
value: object,
field_name: str,
issues: list[PayrollIssue],
) -> bool:
if not isinstance(value, PayrollPeriod):
issues.append(
PayrollIssue(
code="INVALID_PERIOD",
field=field_name,
message="Period must be a PayrollPeriod instance.",
)
)
return False
if (
not isinstance(value.start, date)
or isinstance(value.start, datetime)
or not isinstance(value.end, date)
or isinstance(value.end, datetime)
):
issues.append(
PayrollIssue(
code="INVALID_PERIOD",
field=field_name,
message="Period boundaries must be dates.",
)
)
return False
if value.start > value.end:
issues.append(
PayrollIssue(
code="INVALID_PERIOD",
field=field_name,
message="Period start must not be after period end.",
)
)
return False
return True
@staticmethod
def _validate_decimal(
value: object,
field_name: str,
issues: list[PayrollIssue],
*,
minimum: Decimal | None,
exclusive_minimum: bool = False,
allow_none: bool = False,
) -> None:
if value is None and allow_none:
return
if not isinstance(value, Decimal):
issues.append(
PayrollIssue(
code="INVALID_DECIMAL_TYPE",
field=field_name,
message="Monetary and quantity values must use Decimal.",
)
)
return
if not _is_finite_decimal(value):
issues.append(
PayrollIssue(
code="NON_FINITE_DECIMAL",
field=field_name,
message="NaN and infinite values are not supported.",
)
)
return
decimal_tuple = value.as_tuple()
fractional_places = max(-decimal_tuple.exponent, 0)
integer_digits = max(value.adjusted() + 1, 0) if value else 0
if (
len(decimal_tuple.digits)
> DeterministicPayrollCalculator.MAX_DECIMAL_DIGITS
or integer_digits > DeterministicPayrollCalculator.MAX_DECIMAL_DIGITS
or fractional_places > DeterministicPayrollCalculator.MAX_FRACTIONAL_PLACES
):
issues.append(
PayrollIssue(
code="DECIMAL_OUT_OF_RANGE",
field=field_name,
message="Decimal precision or scale exceeds safe limits.",
)
)
return
below_minimum = minimum is not None and value < minimum
equals_exclusive_minimum = (
minimum is not None and exclusive_minimum and value == minimum
)
if below_minimum or equals_exclusive_minimum:
comparator = "greater than" if exclusive_minimum else "at least"
issues.append(
PayrollIssue(
code="NEGATIVE_OR_INVALID_AMOUNT",
field=field_name,
message=(f"Value must be {comparator} {decimal_text(minimum)}."),
)
)

View File

@ -0,0 +1,339 @@
"""Immutable DTOs shared by payroll calculators and host adapters."""
from __future__ import annotations
from dataclasses import dataclass, field as dataclass_field
from datetime import date
from decimal import Decimal
from enum import Enum
class LineKind(str, Enum):
"""How a positive input amount affects payroll totals."""
EARNING = "earning"
ADJUSTMENT_CREDIT = "adjustment_credit"
ADJUSTMENT_DEBIT = "adjustment_debit"
DEDUCTION = "deduction"
PAYMENT = "payment"
class PointPolicy(str, Enum):
"""Supported policies for the points-based component."""
DISABLED = "disabled"
EXCESS_ONLY = "excess_only"
class RoundingPolicy(str, Enum):
"""Explicit decimal rounding policies supported by the core."""
HALF_UP = "ROUND_HALF_UP"
HALF_EVEN = "ROUND_HALF_EVEN"
DOWN = "ROUND_DOWN"
def decimal_text(value: Decimal) -> str:
"""Return a canonical non-scientific Decimal representation."""
if not isinstance(value, Decimal) or not value.is_finite():
raise ValueError("Only finite Decimal values can be serialized")
if value.is_zero():
return "0"
sign, digits_tuple, exponent = value.as_tuple()
digits = "".join(str(digit) for digit in digits_tuple) or "0"
if exponent >= 0:
rendered = digits + ("0" * exponent)
else:
decimal_position = len(digits) + exponent
if decimal_position <= 0:
rendered = f"0.{('0' * -decimal_position)}{digits}"
else:
rendered = f"{digits[:decimal_position]}.{digits[decimal_position:]}"
rendered = rendered.rstrip("0").rstrip(".")
rendered = rendered.lstrip("0") or "0"
if rendered.startswith("."):
rendered = f"0{rendered}"
return f"-{rendered}" if sign else rendered
@dataclass(frozen=True, slots=True)
class PayrollPeriod:
start: date
end: date
def to_dict(self) -> dict[str, str]:
return {
"start": self.start.isoformat(),
"end": self.end.isoformat(),
}
@dataclass(frozen=True, slots=True)
class InputLine:
"""An explicit, already-resolved payroll input line.
Amounts are always non-negative. ``kind`` carries the direction so that a
negative correction cannot accidentally become a double negative.
"""
line_id: str
code: str
label: str
kind: LineKind
amount: Decimal
source_ref: str
reason: str = ""
source_period: PayrollPeriod | None = None
is_retro: bool = False
def to_dict(self) -> dict[str, object]:
return {
"line_id": self.line_id,
"code": self.code,
"label": self.label,
"kind": self.kind.value,
"amount": decimal_text(self.amount),
"source_ref": self.source_ref,
"reason": self.reason,
"source_period": (
self.source_period.to_dict() if self.source_period else None
),
"is_retro": self.is_retro,
}
@dataclass(frozen=True, slots=True)
class ExpectedTotals:
"""Legacy spreadsheet totals used only for reconciliation."""
point_amount: Decimal | None = None
gross: Decimal | None = None
recalculated_gross: Decimal | None = None
payable: Decimal | None = None
def to_dict(self) -> dict[str, str | None]:
return {
"point_amount": (
decimal_text(self.point_amount)
if self.point_amount is not None
else None
),
"gross": (decimal_text(self.gross) if self.gross is not None else None),
"recalculated_gross": (
decimal_text(self.recalculated_gross)
if self.recalculated_gross is not None
else None
),
"payable": (
decimal_text(self.payable) if self.payable is not None else None
),
}
@dataclass(frozen=True, slots=True)
class PayrollRequest:
"""Complete calculation input for one opaque employee reference."""
employee_ref: str
period: PayrollPeriod
currency: str
base_accrual: Decimal
base_source_ref: str
target_points: Decimal | None = None
actual_points: Decimal | None = None
point_rate: Decimal = Decimal("0")
lines: tuple[InputLine, ...] = dataclass_field(default_factory=tuple)
expected_totals: ExpectedTotals | None = None
def to_dict(self) -> dict[str, object]:
ordered_lines = sorted(self.lines, key=lambda line: line.line_id)
return {
"employee_ref": self.employee_ref,
"period": self.period.to_dict(),
"currency": self.currency,
"base_accrual": decimal_text(self.base_accrual),
"base_source_ref": self.base_source_ref,
"target_points": (
decimal_text(self.target_points)
if self.target_points is not None
else None
),
"actual_points": (
decimal_text(self.actual_points)
if self.actual_points is not None
else None
),
"point_rate": decimal_text(self.point_rate),
"lines": [line.to_dict() for line in ordered_lines],
"expected_totals": (
self.expected_totals.to_dict()
if self.expected_totals is not None
else None
),
}
def calculation_dict(self) -> dict[str, object]:
"""Return inputs that affect money, excluding control checkpoints."""
payload = self.to_dict()
payload.pop("expected_totals")
return payload
@dataclass(frozen=True, slots=True)
class PayrollRules:
"""Immutable, versioned rules selected by the host application."""
ruleset_id: str
version: str
effective_from: date
effective_to: date | None = None
point_policy: PointPolicy = PointPolicy.DISABLED
money_quantum: Decimal = Decimal("0.01")
rounding: RoundingPolicy = RoundingPolicy.HALF_UP
allow_negative_payable: bool = False
def to_dict(self) -> dict[str, object]:
return {
"ruleset_id": self.ruleset_id,
"version": self.version,
"effective_from": self.effective_from.isoformat(),
"effective_to": (
self.effective_to.isoformat() if self.effective_to else None
),
"point_policy": self.point_policy.value,
"money_quantum": decimal_text(self.money_quantum),
"rounding": self.rounding.value,
"allow_negative_payable": self.allow_negative_payable,
}
@dataclass(frozen=True, slots=True)
class PayrollLine:
line_id: str
code: str
label: str
kind: LineKind
amount: Decimal
source_ref: str
reason: str = ""
source_period: PayrollPeriod | None = None
is_retro: bool = False
calculated: bool = False
def to_dict(self) -> dict[str, object]:
return {
"line_id": self.line_id,
"code": self.code,
"label": self.label,
"kind": self.kind.value,
"amount": decimal_text(self.amount),
"source_ref": self.source_ref,
"reason": self.reason,
"source_period": (
self.source_period.to_dict() if self.source_period else None
),
"is_retro": self.is_retro,
"calculated": self.calculated,
}
@dataclass(frozen=True, slots=True)
class CalculationWarning:
code: str
message: str
field: str | None = None
context: tuple[tuple[str, str], ...] = dataclass_field(default_factory=tuple)
def to_dict(self) -> dict[str, object]:
return {
"code": self.code,
"message": self.message,
"field": self.field,
"context": dict(self.context),
}
@dataclass(frozen=True, slots=True)
class PayrollTotals:
gross_before_adjustments: Decimal
adjustment_total: Decimal
gross_after_adjustments: Decimal
deduction_total: Decimal
net_pay: Decimal
payment_total: Decimal
payable: Decimal
@property
def recalculated_gross(self) -> Decimal:
"""Legacy UI alias; it is not another amount to add."""
return self.gross_after_adjustments
def to_dict(self) -> dict[str, str]:
return {
"gross_before_adjustments": decimal_text(self.gross_before_adjustments),
"adjustment_total": decimal_text(self.adjustment_total),
"gross_after_adjustments": decimal_text(self.gross_after_adjustments),
"recalculated_gross": decimal_text(self.recalculated_gross),
"deduction_total": decimal_text(self.deduction_total),
"net_pay": decimal_text(self.net_pay),
"payment_total": decimal_text(self.payment_total),
"payable": decimal_text(self.payable),
}
@dataclass(frozen=True, slots=True)
class PayrollResult:
employee_ref: str
period: PayrollPeriod
currency: str
lines: tuple[PayrollLine, ...]
point_delta: Decimal | None
totals: PayrollTotals
ruleset_id: str
ruleset_version: str
ruleset_hash: str
calculator_version: str
rounding_policy: str
input_hash: str
result_hash: str
reconciliation_hash: str
warnings: tuple[CalculationWarning, ...] = dataclass_field(default_factory=tuple)
def hash_payload(self) -> dict[str, object]:
"""Return the canonical payload covered by ``result_hash``."""
return {
"employee_ref": self.employee_ref,
"period": self.period.to_dict(),
"currency": self.currency,
"lines": [line.to_dict() for line in self.lines],
"point_delta": (
decimal_text(self.point_delta) if self.point_delta is not None else None
),
"totals": self.totals.to_dict(),
"ruleset_id": self.ruleset_id,
"ruleset_version": self.ruleset_version,
"ruleset_hash": self.ruleset_hash,
"calculator_version": self.calculator_version,
"rounding_policy": self.rounding_policy,
"input_hash": self.input_hash,
}
def to_dict(self) -> dict[str, object]:
return {
**self.hash_payload(),
"result_hash": self.result_hash,
"reconciliation_hash": self.reconciliation_hash,
"warnings": [warning.to_dict() for warning in self.warnings],
}
def reconciliation_payload(self) -> dict[str, object]:
"""Return checkpoint warnings covered by ``reconciliation_hash``."""
return {
"result_hash": self.result_hash,
"warnings": [warning.to_dict() for warning in self.warnings],
}

View File

@ -0,0 +1,33 @@
"""Public error types returned by the payroll core."""
from __future__ import annotations
from dataclasses import dataclass, field as dataclass_field
from typing import Mapping
@dataclass(frozen=True, slots=True)
class PayrollIssue:
"""A machine-readable validation or calculation issue."""
code: str
message: str
field: str | None = None
context: Mapping[str, str] = dataclass_field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"code": self.code,
"message": self.message,
"field": self.field,
"context": dict(self.context),
}
class PayrollValidationError(ValueError):
"""Raised when calculation input cannot be processed safely."""
def __init__(self, issues: tuple[PayrollIssue, ...] | list[PayrollIssue]):
self.issues = tuple(issues)
message = "; ".join(f"{issue.code}: {issue.message}" for issue in self.issues)
super().__init__(message or "Payroll validation failed")

View File

@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "portable-payroll-core"
version = "0.1.0"
description = "Deterministic project-independent payroll calculation core"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
test = ["pytest>=8"]
[tool.setuptools]
packages = ["payroll_core"]
package-dir = {payroll_core = "."}

View File

@ -0,0 +1,567 @@
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime
from decimal import Decimal, ROUND_DOWN, getcontext, localcontext
import hashlib
import json
import pytest
from payroll_core import (
DeterministicPayrollCalculator,
ExpectedTotals,
InputLine,
LineKind,
PayrollPeriod,
PayrollRequest,
PayrollRules,
PayrollValidationError,
PointPolicy,
)
@pytest.fixture
def period():
return PayrollPeriod(date(2026, 6, 1), date(2026, 6, 30))
@pytest.fixture
def rules():
return PayrollRules(
ruleset_id="company-standard",
version="2026.07.1",
effective_from=date(2026, 1, 1),
point_policy=PointPolicy.EXCESS_ONLY,
)
def make_request(period, **overrides):
values = {
"employee_ref": "employee:42",
"period": period,
"currency": "RUB",
"base_accrual": Decimal("80000"),
"base_source_ref": "rate:42:revision:1",
"target_points": Decimal("110"),
"actual_points": Decimal("110"),
"point_rate": Decimal("100"),
}
values.update(overrides)
return PayrollRequest(**values)
def input_line(
line_id,
code,
kind,
amount,
*,
reason="",
source_period=None,
is_retro=False,
):
return InputLine(
line_id=line_id,
code=code,
label=code.replace("_", " ").title(),
kind=kind,
amount=Decimal(amount),
source_ref=f"fixture:{line_id}",
reason=reason,
source_period=source_period,
is_retro=is_retro,
)
def issue_codes(error):
return {issue.code for issue in error.value.issues}
def test_golden_excel_row_calculates_115000(period, rules):
request = make_request(
period,
lines=(
input_line(
"bonus:1",
"BONUS",
LineKind.EARNING,
"15000",
),
input_line(
"correction:1",
"MANUAL_CORRECTION",
LineKind.ADJUSTMENT_CREDIT,
"20000",
reason="Approved accounting correction",
),
),
expected_totals=ExpectedTotals(
point_amount=Decimal("0"),
gross=Decimal("115000"),
recalculated_gross=Decimal("115000"),
payable=Decimal("115000"),
),
)
result = DeterministicPayrollCalculator().calculate(request, rules)
assert result.point_delta == Decimal("0")
assert result.totals.gross_before_adjustments == Decimal("95000.00")
assert result.totals.adjustment_total == Decimal("20000.00")
assert result.totals.gross_after_adjustments == Decimal("115000.00")
assert result.totals.recalculated_gross == Decimal("115000.00")
assert result.totals.payable == Decimal("115000.00")
assert result.warnings == ()
def test_points_above_target_create_an_earning(period, rules):
request = make_request(period, actual_points=Decimal("120"))
result = DeterministicPayrollCalculator().calculate(request, rules)
point_line = next(line for line in result.lines if line.code == "POINT_EXCESS")
assert result.point_delta == Decimal("10")
assert point_line.amount == Decimal("1000.00")
assert result.totals.gross_after_adjustments == Decimal("81000.00")
def test_points_below_target_do_not_silently_reduce_pay(period, rules):
request = make_request(period, actual_points=Decimal("100"))
result = DeterministicPayrollCalculator().calculate(request, rules)
point_line = next(line for line in result.lines if line.code == "POINT_EXCESS")
assert point_line.amount == Decimal("0.00")
assert result.totals.payable == Decimal("80000.00")
assert [warning.code for warning in result.warnings] == [
"POINTS_BELOW_TARGET_NO_DEDUCTION"
]
def test_debit_deduction_and_advance_have_distinct_effects(period, rules):
request = make_request(
period,
lines=(
input_line(
"correction:debit",
"CORRECTION_DEBIT",
LineKind.ADJUSTMENT_DEBIT,
"20000",
reason="Reversal",
),
input_line(
"tax:1",
"TAX",
LineKind.DEDUCTION,
"5000",
),
input_line(
"advance:1",
"ADVANCE",
LineKind.PAYMENT,
"10000",
),
),
)
result = DeterministicPayrollCalculator().calculate(request, rules)
assert result.totals.gross_after_adjustments == Decimal("60000.00")
assert result.totals.net_pay == Decimal("55000.00")
assert result.totals.payment_total == Decimal("10000.00")
assert result.totals.payable == Decimal("45000.00")
def test_rounds_each_line_half_up_before_summing(period, rules):
request = make_request(
period,
base_accrual=Decimal("0"),
target_points=Decimal("1"),
actual_points=Decimal("2"),
point_rate=Decimal("0.005"),
lines=(input_line("earning:1", "EARNING_ONE", LineKind.EARNING, "0.005"),),
)
result = DeterministicPayrollCalculator().calculate(request, rules)
assert [line.amount for line in result.lines] == [
Decimal("0.00"),
Decimal("0.01"),
Decimal("0.01"),
]
assert result.totals.gross_after_adjustments == Decimal("0.02")
def test_rejects_float_and_non_finite_decimal(period, rules):
request = make_request(
period,
base_accrual=80000.0,
point_rate=Decimal("NaN"),
)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, rules)
assert issue_codes(error) == {
"INVALID_DECIMAL_TYPE",
"NON_FINITE_DECIMAL",
}
def test_rejects_duplicate_ids_and_unsafe_adjustment(period, rules):
duplicate_lines = (
input_line("same", "BONUS", LineKind.EARNING, "1"),
input_line(
"same",
"CORRECTION",
LineKind.ADJUSTMENT_CREDIT,
"1",
),
)
request = make_request(period, lines=duplicate_lines)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, rules)
assert issue_codes(error) == {
"DUPLICATE_COMPONENT_ID",
"MISSING_ADJUSTMENT_REASON",
}
def test_retro_line_requires_source_period(period, rules):
request = make_request(
period,
lines=(
input_line(
"retro:1",
"RETRO_BONUS",
LineKind.ADJUSTMENT_CREDIT,
"1000",
reason="Late bonus",
is_retro=True,
),
),
)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, rules)
assert issue_codes(error) == {"MISSING_RETRO_SOURCE_PERIOD"}
def test_negative_payable_is_an_explicit_error(period, rules):
request = make_request(
period,
base_accrual=Decimal("100"),
lines=(input_line("deduction:1", "DEDUCTION", LineKind.DEDUCTION, "101"),),
)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, rules)
assert issue_codes(error) == {"NEGATIVE_PAYABLE_NOT_ALLOWED"}
def test_legacy_totals_only_create_reconciliation_warnings(period, rules):
request = make_request(
period,
expected_totals=ExpectedTotals(
gross=Decimal("123"),
recalculated_gross=Decimal("115000"),
),
)
result = DeterministicPayrollCalculator().calculate(request, rules)
assert result.totals.gross_after_adjustments == Decimal("80000.00")
assert [warning.field for warning in result.warnings] == [
"expected_totals.gross",
"expected_totals.recalculated_gross",
]
def test_result_is_invariant_to_input_order_and_global_context(period, rules):
first = input_line("a", "BONUS_A", LineKind.EARNING, "1.235")
second = input_line("b", "BONUS_B", LineKind.EARNING, "2.345")
request_one = make_request(period, lines=(first, second))
request_two = make_request(period, lines=(second, first))
calculator = DeterministicPayrollCalculator()
original_rounding = getcontext().rounding
getcontext().rounding = ROUND_DOWN
try:
result_one = calculator.calculate(request_one, rules)
result_two = calculator.calculate(request_two, rules)
finally:
getcontext().rounding = original_rounding
assert result_one.input_hash == result_two.input_hash
assert result_one.result_hash == result_two.result_hash
assert result_one.lines == result_two.lines
def test_rule_version_changes_result_identity(period, rules):
request = make_request(period)
calculator = DeterministicPayrollCalculator()
first = calculator.calculate(request, rules)
second = calculator.calculate(request, replace(rules, version="2026.07.2"))
assert first.input_hash == second.input_hash
assert first.ruleset_hash != second.ruleset_hash
assert first.result_hash != second.result_hash
def test_rules_must_cover_the_full_payroll_period(period, rules):
request = make_request(period)
late_rules = replace(rules, effective_from=date(2026, 6, 15))
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, late_rules)
assert issue_codes(error) == {"RULESET_NOT_EFFECTIVE"}
def test_money_quantum_is_a_real_step_not_only_a_decimal_scale(period, rules):
step_rules = replace(rules, money_quantum=Decimal("0.05"))
request = make_request(period, base_accrual=Decimal("1.03"))
result = DeterministicPayrollCalculator().calculate(request, step_rules)
assert result.lines[0].amount == Decimal("1.05")
assert result.totals.payable == Decimal("1.05")
def test_equivalent_quantum_representations_have_same_identity(period, rules):
request = make_request(period, base_accrual=Decimal("1.005"))
calculator = DeterministicPayrollCalculator()
first = calculator.calculate(
request,
replace(rules, money_quantum=Decimal("0.01")),
)
second = calculator.calculate(
request,
replace(rules, money_quantum=Decimal("0.010")),
)
assert first.lines == second.lines
assert first.ruleset_hash == second.ruleset_hash
assert first.result_hash == second.result_hash
def test_serialization_does_not_use_the_global_decimal_context(period, rules):
request = make_request(period, base_accrual=Decimal("12345.67"))
result = DeterministicPayrollCalculator().calculate(request, rules)
with localcontext() as context:
context.prec = 3
payload = result.to_dict()
assert payload["lines"][0]["amount"] == "12345.67"
assert payload["totals"]["payable"] == "12345.67"
def test_disabled_point_policy_still_validates_supplied_metrics(period, rules):
disabled_rules = replace(rules, point_policy=PointPolicy.DISABLED)
request = make_request(
period,
target_points=float("nan"),
actual_points=Decimal("1"),
)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, disabled_rules)
assert issue_codes(error) == {"INVALID_DECIMAL_TYPE"}
def test_rule_booleans_are_strict(period, rules):
request = make_request(
period,
base_accrual=Decimal("0"),
lines=(input_line("deduction:1", "DEDUCTION", LineKind.DEDUCTION, "1"),),
)
unsafe_rules = replace(rules, allow_negative_payable="false")
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, unsafe_rules)
assert "INVALID_RULE_OPTION" in issue_codes(error)
def test_large_valid_lines_sum_without_losing_minor_units(period, rules):
request = make_request(
period,
base_accrual=Decimal("99999999999999999999999999.98"),
lines=(input_line("earning:1", "EARNING", LineKind.EARNING, "0.03"),),
)
result = DeterministicPayrollCalculator().calculate(request, rules)
assert result.totals.gross_after_adjustments == Decimal(
"100000000000000000000000000.01"
)
def test_retro_marker_survives_and_source_period_must_be_past(period, rules):
old_period = PayrollPeriod(date(2026, 5, 1), date(2026, 5, 31))
retro = input_line(
"retro:1",
"RETRO_BONUS",
LineKind.ADJUSTMENT_CREDIT,
"100",
reason="Late approval",
source_period=old_period,
is_retro=True,
)
result = DeterministicPayrollCalculator().calculate(
make_request(period, lines=(retro,)),
rules,
)
saved_line = next(line for line in result.lines if line.line_id == "retro:1")
assert saved_line.is_retro is True
assert saved_line.source_period == old_period
future_period = PayrollPeriod(date(2026, 7, 1), date(2026, 7, 31))
invalid = replace(retro, source_period=future_period)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(
make_request(period, lines=(invalid,)),
rules,
)
assert "INVALID_RETRO_SOURCE_PERIOD" in issue_codes(error)
def test_control_totals_do_not_change_calculation_identity(period, rules):
calculator = DeterministicPayrollCalculator()
request = make_request(period)
matching = replace(
request,
expected_totals=ExpectedTotals(
gross=Decimal("80000"),
payable=Decimal("80000"),
),
)
mismatching = replace(
request,
expected_totals=ExpectedTotals(gross=Decimal("1")),
)
plain_result = calculator.calculate(request, rules)
matching_result = calculator.calculate(matching, rules)
mismatching_result = calculator.calculate(mismatching, rules)
assert plain_result.input_hash == matching_result.input_hash
assert plain_result.input_hash == mismatching_result.input_hash
assert plain_result.result_hash == matching_result.result_hash
assert plain_result.result_hash == mismatching_result.result_hash
assert plain_result.reconciliation_hash == matching_result.reconciliation_hash
assert plain_result.reconciliation_hash != mismatching_result.reconciliation_hash
assert mismatching_result.warnings[0].code == "LEGACY_EXPECTED_TOTAL_MISMATCH"
def test_result_hash_is_reproducible_from_returned_result(period, rules):
result = DeterministicPayrollCalculator().calculate(make_request(period), rules)
encoded = json.dumps(
result.hash_payload(),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
assert hashlib.sha256(encoded).hexdigest() == result.result_hash
reconciliation_encoded = json.dumps(
result.reconciliation_payload(),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
assert (
hashlib.sha256(reconciliation_encoded).hexdigest() == result.reconciliation_hash
)
def test_retro_flag_must_be_a_real_boolean(period, rules):
old_period = PayrollPeriod(date(2026, 5, 1), date(2026, 5, 31))
invalid = input_line(
"retro:wrong-flag",
"RETRO_BONUS",
LineKind.ADJUSTMENT_CREDIT,
"1",
reason="Late approval",
source_period=old_period,
is_retro="false",
)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(
make_request(period, lines=(invalid,)),
rules,
)
assert issue_codes(error) == {"INVALID_RETRO_FLAG"}
def test_negative_expected_payable_is_allowed_when_rules_allow_it(period, rules):
negative_rules = replace(rules, allow_negative_payable=True)
request = make_request(
period,
base_accrual=Decimal("0"),
lines=(input_line("deduction:1", "DEDUCTION", LineKind.DEDUCTION, "1"),),
expected_totals=ExpectedTotals(payable=Decimal("-1")),
)
result = DeterministicPayrollCalculator().calculate(request, negative_rules)
assert result.totals.payable == Decimal("-1.00")
assert result.warnings == ()
@pytest.mark.parametrize(
"invalid_period",
[
PayrollPeriod(datetime(2026, 6, 1), date(2026, 6, 30)),
PayrollPeriod(date(2026, 6, 1), datetime(2026, 6, 30)),
],
)
def test_datetime_is_not_accepted_as_a_payroll_date(invalid_period, rules):
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(
make_request(invalid_period),
rules,
)
assert "INVALID_PERIOD" in issue_codes(error)
def test_invalid_expected_totals_are_reported_without_attribute_error(period, rules):
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(
make_request(period, expected_totals={"gross": "80000"}),
rules,
)
assert issue_codes(error) == {"INVALID_EXPECTED_TOTALS"}
def test_unhashable_line_fields_are_reported_without_type_error(period, rules):
invalid = replace(
input_line("line:1", "BONUS", LineKind.EARNING, "1"),
line_id=["not", "hashable"],
kind=["not", "hashable"],
)
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(
make_request(period, lines=(invalid,)),
rules,
)
assert issue_codes(error) == {
"MISSING_COMPONENT_ID",
"INVALID_COMPONENT_KIND",
}

View File

@ -0,0 +1,42 @@
from __future__ import annotations
import ast
from pathlib import Path
ALLOWED_STDLIB_ROOTS = {
"__future__",
"dataclasses",
"datetime",
"decimal",
"enum",
"hashlib",
"json",
"re",
"typing",
}
def test_payroll_core_has_no_host_or_framework_imports():
package_root = Path(__file__).resolve().parents[1]
violations = []
for source_file in package_root.rglob("*.py"):
if "tests" in source_file.parts:
continue
tree = ast.parse(source_file.read_text(encoding="utf-8"))
for node in ast.walk(tree):
imported_roots = []
if isinstance(node, ast.Import):
imported_roots = [alias.name.split(".", 1)[0] for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
imported_roots = [node.module.split(".", 1)[0]]
for imported_root in imported_roots:
if imported_root not in ALLOWED_STDLIB_ROOTS:
violations.append(
f"{source_file.name}:{node.lineno} imports non-stdlib "
f"dependency {imported_root}"
)
assert violations == []

View File

@ -18,7 +18,7 @@ markers =
addopts = -m "not manual" --strict-markers
# Пути для сбора тестов
testpaths = tests employees documents
testpaths = tests employees documents payroll_core/tests
# Игнорировать директории
norecursedirs = .git .venv __pycache__ node_modules staticfiles media scripts tests/manual

View File

View File

@ -0,0 +1,692 @@
import uuid
from decimal import Decimal
import pytest
from django.contrib.auth.models import Permission
from django.db import IntegrityError, transaction
from django.urls import reverse
from django.utils import timezone
from finance.models import (
EmployeePayRate,
PayrollAuditEvent,
PayrollComponent,
PayrollInputLine,
PayrollPeriod,
PayrollRun,
PayrollWorkRecord,
)
pytestmark = pytest.mark.django_db
def grant(user, *codenames):
permissions = Permission.objects.filter(
content_type__app_label="finance",
codename__in=codenames,
)
assert permissions.count() == len(codenames)
user.user_permissions.add(*permissions)
for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, cache_name):
delattr(user, cache_name)
def api_url(name, **kwargs):
return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None)
def assert_no_store(response):
assert "no-store" in response["Cache-Control"]
def make_period(*, creator, code="2026-06"):
return PayrollPeriod.objects.create(
code=code,
name="Июнь 2026",
date_from="2026-06-01",
date_to="2026-06-30",
pay_date="2026-07-05",
currency="RUB",
created_by=creator,
)
def test_admin_workspace_rejects_anonymous_and_generic_model_permission(
user_factory,
auth_client_factory,
):
url = api_url("admin-workspace")
anonymous = auth_client_factory()
anonymous_response = anonymous.get(url)
assert anonymous_response.status_code in {401, 403}
assert_no_store(anonymous_response)
generic_viewer = user_factory(email="generic.payroll.viewer@example.test")
generic_permission = Permission.objects.get(
content_type__app_label="finance",
codename="view_payrollperiod",
)
generic_viewer.user_permissions.add(generic_permission)
response = auth_client_factory(generic_viewer).get(url)
assert response.status_code == 403
assert response.json()["code"] == "PERMISSION_DENIED"
assert_no_store(response)
override_only = user_factory(email="override.only@example.test")
grant(override_only, "override_payroll_approval")
override_response = auth_client_factory(override_only).get(url)
assert override_response.status_code == 403
assert override_response.json()["code"] == "PERMISSION_DENIED"
def test_workspace_contract_is_minimal_and_redacts_money_without_view_all(
user_factory,
auth_client_factory,
):
manager = user_factory(
email="workspace.manager@example.test",
first_name="Мария",
last_name="Расчётова",
)
grant(manager, "manage_payroll_inputs")
response = auth_client_factory(manager).get(api_url("admin-workspace"))
assert response.status_code == 200
assert_no_store(response)
body = response.json()
assert set(body["permissions"]) == {
"manage_inputs",
"approve_inputs",
"calculate",
"approve_run",
"override_approval",
"publish",
"view_all",
"audit",
}
assert body["permissions"]["manage_inputs"] is True
assert body["permissions"]["override_approval"] is False
assert body["summary"] is None
assert set(body["readiness"]) == {
"rates",
"work_records",
"input_lines",
"calculation",
}
employee = next(item for item in body["employees"] if item["id"] == manager.pk)
assert employee == {
"id": manager.pk,
"display_name": "Мария Расчётова",
"position": None,
"department": None,
}
def test_workspace_blocks_period_outside_ruleset_effective_dates(
settings,
user_factory,
auth_client_factory,
):
settings.FINANCE_PAYROLL = {"EFFECTIVE_FROM": "2026-07-01"}
operator = user_factory(email="ruleset.operator@example.test")
checker = user_factory(email="ruleset.checker@example.test")
employee = user_factory(email="ruleset.employee@example.test")
grant(operator, "calculate_payroll")
period = make_period(creator=operator)
approved_at = timezone.now()
EmployeePayRate.objects.create(
employee=employee,
amount="80000",
effective_from="2026-01-01",
status="approved",
created_by=operator,
approved_by=checker,
approved_at=approved_at,
)
PayrollWorkRecord.objects.create(
period=period,
employee=employee,
target_points="100",
actual_points="100",
status="approved",
created_by=operator,
approved_by=checker,
approved_at=approved_at,
)
client = auth_client_factory(operator)
workspace = client.get(
api_url("admin-workspace"),
{"period_id": period.pk},
)
assert workspace.status_code == 200
readiness = workspace.json()["readiness"]
assert readiness["rates"]["ready"] is True
assert readiness["work_records"]["ready"] is True
assert readiness["calculation"]["ready"] is False
ruleset_blocker = next(
blocker
for blocker in readiness["calculation"]["blockers"]
if blocker["code"] == "RULESET_NOT_EFFECTIVE"
)
assert ruleset_blocker == {
"code": "RULESET_NOT_EFFECTIVE",
"message": (
"Для выбранного периода нет действующих правил расчёта. "
"Набор eusrr-standard, версия 2026.07.1, применяется с 01.07.2026. "
"Измените период или подключите историческую версию правил."
),
"details": {
"period": {
"date_from": "2026-06-01",
"date_to": "2026-06-30",
},
"ruleset": {
"id": "eusrr-standard",
"version": "2026.07.1",
"effective_from": "2026-07-01",
},
},
}
calculation = client.post(
api_url("admin-period-calculate", pk=period.pk),
{
"expected_lock_version": period.lock_version,
"idempotency_key": str(uuid.uuid4()),
"recalculation_reason": "",
},
format="json",
)
assert calculation.status_code == 409
assert calculation.json()["code"] == "RULESET_NOT_EFFECTIVE"
assert calculation.json()["message"] == ruleset_blocker["message"]
assert (
calculation.json()["details"]["period"] == ruleset_blocker["details"]["period"]
)
assert (
calculation.json()["details"]["ruleset"]
== ruleset_blocker["details"]["ruleset"]
)
assert PayrollRun.objects.count() == 0
def test_period_and_rate_drafts_use_exact_optimistic_versions_and_maker_checker(
user_factory,
auth_client_factory,
):
maker = user_factory(email="rate.maker@example.test")
other_maker = user_factory(email="other.rate.maker@example.test")
approver = user_factory(email="rate.approver@example.test")
employee = user_factory(
email="paid.employee@example.test",
first_name="Игорь",
last_name="Надеин",
)
inactive_employee = user_factory(
email="inactive.paid.employee@example.test",
active=False,
)
grant(maker, "manage_payroll_inputs", "approve_payroll_inputs")
grant(other_maker, "manage_payroll_inputs")
grant(approver, "approve_payroll_inputs")
maker_client = auth_client_factory(maker)
period_response = maker_client.post(
api_url("admin-period-list"),
{
"code": "2026-06",
"name": "Июнь 2026",
"date_from": "2026-06-01",
"date_to": "2026-06-30",
"pay_date": "2026-07-05",
"currency": "RUB",
},
format="json",
)
assert period_response.status_code == 201
assert period_response.json()["lock_version"] == 0
assert_no_store(period_response)
period_id = period_response.json()["id"]
period_patch_url = api_url("admin-period-detail", pk=period_id)
patched_period = maker_client.patch(
period_patch_url,
{"name": "Зарплата за июнь", "expected_lock_version": 0},
format="json",
)
assert patched_period.status_code == 200
assert patched_period.json()["lock_version"] == 1
stale_period = maker_client.patch(
period_patch_url,
{"name": "Устаревшее название", "expected_lock_version": 0},
format="json",
)
assert stale_period.status_code == 409
assert stale_period.json()["code"] == "STALE_PERIOD"
assert PayrollPeriod.objects.get(pk=period_id).name == "Зарплата за июнь"
rate_list_url = api_url("admin-pay-rate-list")
inactive_response = maker_client.post(
rate_list_url,
{
"employee_id": inactive_employee.pk,
"rate_code": "BASE",
"amount": "80000.0000",
"point_rate": "0.0000",
"currency": "RUB",
"effective_from": "2026-06-01",
"reason": "",
},
format="json",
)
assert inactive_response.status_code == 400
created = maker_client.post(
rate_list_url,
{
"employee_id": employee.pk,
"rate_code": "BASE",
"amount": "80000.0000",
"point_rate": "0.0000",
"currency": "RUB",
"effective_from": "2026-06-01",
"reason": "",
},
format="json",
)
assert created.status_code == 201
rate_id = created.json()["id"]
assert created.json()["created_by"] == {
"id": maker.pk,
"display_name": maker.get_full_name(),
}
assert created.json()["employee"]["position"] is None
assert "source_ref" not in created.json()
assert "idempotency_key" not in created.json()
detail_url = api_url("admin-pay-rate-detail", pk=rate_id)
first_patch = maker_client.patch(
detail_url,
{"amount": "81000.0000", "expected_lock_version": 0},
format="json",
)
assert first_patch.status_code == 200
assert first_patch.json()["lock_version"] == 1
stale_patch = maker_client.patch(
detail_url,
{"amount": "999999.0000", "expected_lock_version": 0},
format="json",
)
assert stale_patch.status_code == 409
assert stale_patch.json()["code"] == "STALE_DRAFT"
assert_no_store(stale_patch)
assert EmployeePayRate.objects.get(pk=rate_id).amount == Decimal("81000")
hidden_from_other_maker = auth_client_factory(other_maker).patch(
detail_url,
{"amount": "82000.0000", "expected_lock_version": 1},
format="json",
)
assert hidden_from_other_maker.status_code == 404
stale_approval = auth_client_factory(approver).post(
api_url("admin-pay-rate-approve", pk=rate_id),
{"expected_lock_version": 0},
format="json",
)
assert stale_approval.status_code == 409
assert stale_approval.json()["code"] == "APPROVAL_VERSION_CONFLICT"
assert EmployeePayRate.objects.get(pk=rate_id).status == "draft"
self_approval = maker_client.post(
api_url("admin-pay-rate-approve", pk=rate_id),
{"expected_lock_version": 1},
format="json",
)
assert self_approval.status_code == 409
assert self_approval.json()["code"] == "SELF_APPROVAL_FORBIDDEN"
approved = auth_client_factory(approver).post(
api_url("admin-pay-rate-approve", pk=rate_id),
{"expected_lock_version": 1},
format="json",
)
assert approved.status_code == 200
assert approved.json()["status"] == "approved"
assert approved.json()["approved_by"]["id"] == approver.pk
immutable_patch = maker_client.patch(
detail_url,
{"amount": "83000.0000", "expected_lock_version": 1},
format="json",
)
assert immutable_patch.status_code == 404
assert EmployeePayRate.objects.get(pk=rate_id).amount == Decimal("81000")
revision = maker_client.post(
api_url("admin-pay-rate-revise", pk=rate_id),
{"reason": "Новая согласованная ставка"},
format="json",
)
assert revision.status_code == 201
assert revision.json()["status"] == "draft"
assert revision.json()["revision"] == 2
assert revision.json()["replaces_id"] == rate_id
def test_native_api_runs_complete_service_workflow_and_redacts_aggregate_money(
user_factory,
auth_client_factory,
):
maker = user_factory(email="workflow.maker@example.test")
input_approver = user_factory(email="workflow.input.approver@example.test")
operator = user_factory(email="workflow.operator@example.test")
run_approver = user_factory(email="workflow.run.approver@example.test")
publisher = user_factory(email="workflow.publisher@example.test")
viewer = user_factory(email="workflow.viewer@example.test")
employee = user_factory(email="workflow.employee@example.test")
grant(maker, "manage_payroll_inputs")
grant(input_approver, "approve_payroll_inputs")
grant(operator, "calculate_payroll")
grant(run_approver, "approve_payroll")
grant(publisher, "publish_payroll")
grant(viewer, "view_all_payroll")
period = make_period(creator=maker)
maker_client = auth_client_factory(maker)
rate_response = maker_client.post(
api_url("admin-pay-rate-list"),
{
"employee_id": employee.pk,
"rate_code": "BASE",
"amount": "80000.0000",
"point_rate": "0.0000",
"currency": "RUB",
"effective_from": "2026-06-01",
"reason": "",
},
format="json",
)
assert rate_response.status_code == 201
work_response = maker_client.post(
api_url("admin-work-record-list"),
{
"period_id": period.pk,
"employee_id": employee.pk,
"target_points": "110.0000",
"actual_points": "110.0000",
"reason": "",
},
format="json",
)
assert work_response.status_code == 201
bonus = PayrollComponent.objects.get(code="BONUS")
input_response = maker_client.post(
api_url("admin-input-line-list"),
{
"period_id": period.pk,
"employee_id": employee.pk,
"component_id": bonus.pk,
"amount": "100.00",
"reason": "Премия за месяц",
},
format="json",
)
assert input_response.status_code == 201
assert input_response.json()["component"]["code"] == "BONUS"
checker_client = auth_client_factory(input_approver)
for route_name, object_response in (
("admin-pay-rate-approve", rate_response),
("admin-work-record-approve", work_response),
("admin-input-line-approve", input_response),
):
approval = checker_client.post(
api_url(route_name, pk=object_response.json()["id"]),
{"expected_lock_version": object_response.json()["lock_version"]},
format="json",
)
assert approval.status_code == 200
assert approval.json()["status"] == "approved"
assert approval.json()["self_approval_overridden"] is False
operator_client = auth_client_factory(operator)
workspace_before = operator_client.get(
api_url("admin-workspace"),
{"period_id": period.pk},
)
assert workspace_before.status_code == 200
assert workspace_before.json()["readiness"]["calculation"]["ready"] is True
assert workspace_before.json()["summary"] is None
calculation = operator_client.post(
api_url("admin-period-calculate", pk=period.pk),
{
"expected_lock_version": period.lock_version,
"idempotency_key": str(uuid.uuid4()),
"recalculation_reason": "",
},
format="json",
)
assert calculation.status_code == 201
assert calculation.json()["status"] == "calculated"
assert calculation.json()["gross_total"] is None
assert calculation.json()["requested_by"]["id"] == operator.pk
assert "input_hash" not in calculation.json()
assert_no_store(calculation)
run_id = calculation.json()["id"]
blocked_draft = maker_client.post(
api_url("admin-input-line-list"),
{
"period_id": period.pk,
"employee_id": employee.pk,
"component_id": bonus.pk,
"amount": "50.00",
"reason": "Поздняя премия",
},
format="json",
)
assert blocked_draft.status_code == 409
assert blocked_draft.json()["code"] == "PERIOD_INPUTS_LOCKED"
viewer_workspace = auth_client_factory(viewer).get(
api_url("admin-workspace"),
{"period_id": period.pk},
)
assert viewer_workspace.status_code == 200
assert viewer_workspace.json()["summary"] == {
"employee_count": 1,
"gross_total": "80100.00",
"deduction_total": "0.00",
"payable_total": "80100.00",
}
submitted = operator_client.post(
api_url("admin-run-submit-review", pk=run_id),
{},
format="json",
)
assert submitted.status_code == 200
assert submitted.json()["status"] == "review"
approved = auth_client_factory(run_approver).post(
api_url("admin-run-approve", pk=run_id),
{},
format="json",
)
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
published = auth_client_factory(publisher).post(
api_url("admin-run-publish", pk=run_id),
{},
format="json",
)
assert published.status_code == 200
assert published.json()["status"] == "published"
assert published.json()["published_by"]["id"] == publisher.pk
closed = auth_client_factory(publisher).post(
api_url("admin-period-close", pk=period.pk),
{},
format="json",
)
assert closed.status_code == 200
assert closed.json()["status"] == "closed"
blocked_closed_draft = maker_client.post(
api_url("admin-input-line-list"),
{
"period_id": period.pk,
"employee_id": employee.pk,
"component_id": bonus.pk,
"amount": "50.00",
"reason": "После закрытия",
},
format="json",
)
assert blocked_closed_draft.status_code == 409
assert blocked_closed_draft.json()["code"] == "PERIOD_INPUTS_LOCKED"
assert PayrollRun.objects.get(pk=run_id).status == "published"
assert (
PayrollWorkRecord.objects.filter(period=period, status="approved").count() == 1
)
assert (
PayrollInputLine.objects.filter(period=period, status="approved").count() == 1
)
def test_override_holder_can_self_approve_every_serialized_payroll_object(
user_factory,
auth_client_factory,
):
operator = user_factory(email="self.approver@example.test")
employee = user_factory(email="self.approval.employee@example.test")
grant(
operator,
"manage_payroll_inputs",
"approve_payroll_inputs",
"calculate_payroll",
"approve_payroll",
"override_payroll_approval",
)
client = auth_client_factory(operator)
period = make_period(creator=operator, code="2026-07")
workspace = client.get(
api_url("admin-workspace"),
{"period_id": period.pk},
)
assert workspace.status_code == 200
assert workspace.json()["permissions"]["override_approval"] is True
rate = client.post(
api_url("admin-pay-rate-list"),
{
"employee_id": employee.pk,
"rate_code": "BASE",
"amount": "90000.0000",
"point_rate": "0.0000",
"currency": "RUB",
"effective_from": "2026-06-01",
"reason": "",
},
format="json",
)
work_record = client.post(
api_url("admin-work-record-list"),
{
"period_id": period.pk,
"employee_id": employee.pk,
"target_points": "100.0000",
"actual_points": "100.0000",
"reason": "",
},
format="json",
)
bonus = PayrollComponent.objects.get(code="BONUS")
input_line = client.post(
api_url("admin-input-line-list"),
{
"period_id": period.pk,
"employee_id": employee.pk,
"component_id": bonus.pk,
"amount": "1000.00",
"reason": "Премия",
},
format="json",
)
assert {rate.status_code, work_record.status_code, input_line.status_code} == {201}
approved_objects = []
for route_name, response in (
("admin-pay-rate-approve", rate),
("admin-work-record-approve", work_record),
("admin-input-line-approve", input_line),
):
approval = client.post(
api_url(route_name, pk=response.json()["id"]),
{"expected_lock_version": response.json()["lock_version"]},
format="json",
)
assert approval.status_code == 200
assert approval.json()["approved_by_id"] == operator.pk
assert approval.json()["self_approval_overridden"] is True
approved_objects.append(approval.json())
calculation = client.post(
api_url("admin-period-calculate", pk=period.pk),
{
"expected_lock_version": period.lock_version,
"idempotency_key": str(uuid.uuid4()),
"recalculation_reason": "",
},
format="json",
)
assert calculation.status_code == 201
run_id = calculation.json()["id"]
submitted = client.post(api_url("admin-run-submit-review", pk=run_id), {})
assert submitted.status_code == 200
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
records = (
(EmployeePayRate, approved_objects[0]["id"]),
(PayrollWorkRecord, approved_objects[1]["id"]),
(PayrollInputLine, approved_objects[2]["id"]),
(PayrollRun, run_id),
)
for model, object_id in records:
instance = model.objects.get(pk=object_id)
assert instance.self_approval_overridden is True
with pytest.raises(IntegrityError):
with transaction.atomic():
model.objects.filter(pk=object_id).update(
self_approval_overridden=False
)
events = PayrollAuditEvent.objects.filter(
actor=operator,
action__in={
"payroll.rate_approved",
"payroll.work_record_approved",
"payroll.input_line_approved",
"payroll.approved",
},
)
assert events.count() == 4
for event in events:
assert event.metadata["self_approval_overridden"] is True
assert event.metadata["approval_mode"] == "self_override"
assert (
event.metadata["override_permission"] == "finance.override_payroll_approval"
)

View File

@ -0,0 +1,257 @@
import pytest
from django.contrib import admin
from django.contrib.auth.models import Permission
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.exceptions import PermissionDenied
from django.test import RequestFactory
from django.urls import reverse
from finance.admin import (
EmployeePayRateAdmin,
PayrollAuditEventAdmin,
PayrollStatementAdmin,
)
from finance.models import EmployeePayRate, PayrollAuditEvent, PayrollStatement
pytestmark = pytest.mark.django_db
def grant(user, *codenames):
permissions = Permission.objects.filter(
content_type__app_label="finance",
codename__in=codenames,
)
assert permissions.count() == len(codenames)
user.user_permissions.add(*permissions)
for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, cache_name):
delattr(user, cache_name)
def admin_request(user):
request = RequestFactory().get("/admin/finance/")
request.user = user
return request
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")
model_admin = PayrollStatementAdmin(PayrollStatement, admin.site)
request = admin_request(staff)
assert model_admin.has_module_permission(request) is False
assert model_admin.has_view_permission(request) is False
grant(staff, "view_all_payroll")
assert model_admin.has_module_permission(request) is True
assert model_admin.has_view_permission(request) is True
def test_audit_requires_dedicated_audit_permission(user_factory):
staff = user_factory(email="audit.admin@example.test", staff=True)
grant(staff, "view_payrollauditevent", "view_all_payroll")
model_admin = PayrollAuditEventAdmin(PayrollAuditEvent, admin.site)
request = admin_request(staff)
assert model_admin.has_view_permission(request) is False
grant(staff, "audit_payroll")
assert model_admin.has_view_permission(request) is True
def test_approver_can_review_but_not_edit_another_users_draft(
user_factory,
):
creator = user_factory(email="draft.creator@example.test", staff=True)
approver = user_factory(email="draft.approver@example.test", staff=True)
employee = user_factory(email="draft.employee@example.test")
grant(creator, "manage_payroll_inputs")
grant(approver, "approve_payroll_inputs")
rate = EmployeePayRate.objects.create(
employee=employee,
amount="80000",
effective_from="2026-01-01",
created_by=creator,
)
model_admin = EmployeePayRateAdmin(EmployeePayRate, admin.site)
creator_readonly = model_admin.get_readonly_fields(
admin_request(creator),
rate,
)
approver_readonly = model_admin.get_readonly_fields(
admin_request(approver),
rate,
)
concrete_fields = {field.name for field in rate._meta.concrete_fields}
assert "amount" not in creator_readonly
assert concrete_fields.issubset(set(approver_readonly))
assert model_admin.has_delete_permission(admin_request(creator), rate) is False
def test_view_or_approval_role_cannot_edit_own_draft_without_manage_permission(
user_factory,
):
approver = user_factory(email="own.draft.approver@example.test", staff=True)
employee = user_factory(email="own.draft.employee@example.test")
grant(approver, "approve_payroll_inputs")
rate = EmployeePayRate.objects.create(
employee=employee,
amount="80000",
effective_from="2026-01-01",
created_by=approver,
)
model_admin = EmployeePayRateAdmin(EmployeePayRate, admin.site)
request = admin_request(approver)
concrete_fields = {field.name for field in rate._meta.concrete_fields}
assert concrete_fields.issubset(set(model_admin.get_readonly_fields(request, rate)))
with pytest.raises(PermissionDenied):
model_admin.save_model(request, rate, form=None, change=True)
def test_stale_maker_form_cannot_reopen_an_approved_rate(user_factory):
creator = user_factory(email="race.creator@example.test", staff=True)
approver = user_factory(email="race.approver@example.test", staff=True)
employee = user_factory(email="race.employee@example.test")
grant(creator, "manage_payroll_inputs")
grant(approver, "approve_payroll_inputs")
rate = EmployeePayRate.objects.create(
employee=employee,
amount="80000",
effective_from="2026-01-01",
created_by=creator,
)
stale_form_instance = EmployeePayRate.objects.get(pk=rate.pk)
from finance.payroll.services import approve_pay_rate
approve_pay_rate(
rate.pk,
actor=approver,
expected_lock_version=rate.lock_version,
)
stale_form_instance.amount = "800000"
model_admin = EmployeePayRateAdmin(EmployeePayRate, admin.site)
with pytest.raises(PermissionDenied):
model_admin.save_model(
admin_request(creator),
stale_form_instance,
form=None,
change=True,
)
rate.refresh_from_db()
assert rate.status == "approved"
assert str(rate.amount) == "80000.0000"
def test_approval_action_shows_and_posts_the_reviewed_draft_version(user_factory):
creator = user_factory(email="confirm.creator@example.test", staff=True)
approver = user_factory(email="confirm.approver@example.test", staff=True)
employee = user_factory(email="confirm.employee@example.test")
grant(approver, "approve_payroll_inputs")
rate = EmployeePayRate.objects.create(
employee=employee,
amount="80000",
effective_from="2026-01-01",
created_by=creator,
)
request = RequestFactory().post(
"/admin/finance/employeepayrate/",
{
"action": "approve_selected",
"_selected_action": [rate.pk],
},
)
request.user = approver
model_admin = EmployeePayRateAdmin(EmployeePayRate, admin.site)
response = model_admin.approve_selected(
request,
EmployeePayRate.objects.filter(pk=rate.pk),
)
rendered = response.rendered_content
assert "Утвердить показанные версии" in rendered
assert f'name="lock_version_{rate.pk}"' in rendered
assert f'value="{rate.lock_version}"' in rendered
confirm = RequestFactory().post(
"/admin/finance/employeepayrate/",
{
"action": "approve_selected",
"_selected_action": [rate.pk],
"confirm_payroll_approval": "1",
f"lock_version_{rate.pk}": str(rate.lock_version),
},
)
confirm.user = approver
confirm.session = {}
confirm._messages = FallbackStorage(confirm)
model_admin.approve_selected(
confirm,
EmployeePayRate.objects.filter(pk=rate.pk),
)
rate.refresh_from_db()
assert rate.status == "approved"
def test_two_admin_tabs_cannot_silently_overwrite_a_newer_draft(
client,
user_factory,
):
creator = user_factory(email="tabs.creator@example.test", staff=True)
employee = user_factory(email="tabs.employee@example.test")
grant(creator, "manage_payroll_inputs")
rate = EmployeePayRate.objects.create(
employee=employee,
amount="80000",
point_rate="100",
currency="RUB",
effective_from="2026-01-01",
created_by=creator,
)
client.force_login(creator)
url = reverse("admin:finance_employeepayrate_change", args=[rate.pk])
first_tab = client.get(url)
second_tab = client.get(url)
assert first_tab.status_code == 200
assert second_tab.status_code == 200
first_version = first_tab.context["adminform"].form.initial["expected_lock_version"]
second_version = second_tab.context["adminform"].form.initial[
"expected_lock_version"
]
assert first_version == second_version == 0
def form_data(amount, expected_version):
return {
"employee": str(employee.pk),
"rate_code": "BASE",
"amount": amount,
"point_rate": "100",
"currency": "RUB",
"effective_from": "2026-01-01",
"revision": "1",
"replaces": "",
"reason": "",
"source": "manual",
"source_ref": "",
"expected_lock_version": str(expected_version),
"_save": "Сохранить",
}
first_save = client.post(url, form_data("81000", first_version))
assert first_save.status_code == 302
rate.refresh_from_db()
assert rate.amount == 81000
assert rate.lock_version == 1
stale_save = client.post(url, form_data("82000", second_version))
assert stale_save.status_code == 403
rate.refresh_from_db()
assert rate.amount == 81000
assert rate.lock_version == 1

View File

@ -0,0 +1,374 @@
from datetime import date
from decimal import Decimal
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from django.utils import timezone
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from finance.enums import ApprovalStatus, InputSource
from finance.models import PayrollAuditEvent, PayrollPeriod, PayrollWorkRecord
pytestmark = pytest.mark.django_db
def grant(user, *codenames):
permissions = Permission.objects.filter(
content_type__app_label="finance",
codename__in=codenames,
)
assert permissions.count() == len(codenames)
user.user_permissions.add(*permissions)
for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, cache_name):
delattr(user, cache_name)
def endpoint(period):
return reverse(
"api:v1:finance-payroll:admin-period-attendance-work-records",
kwargs={"pk": period.pk},
)
def make_period(creator, *, suffix="base"):
return PayrollPeriod.objects.create(
code=f"2024-01-{suffix}",
name="Тестовый период",
date_from=date(2024, 1, 1),
date_to=date(2024, 1, 3),
pay_date=date(2024, 1, 5),
created_by=creator,
)
def make_attendance(employee, *, technical=False):
run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=date(2024, 1, 1),
period_end=date(2024, 1, 3),
status=AttendanceAnalysisRun.STATUS_SUCCESS,
schedule_payload={
"expected_hours": 8,
"workdays": ["Monday", "Tuesday"],
},
)
rows = [
{
"date": date(2024, 1, 1),
"work_hours": 8,
"expected_hours": 8,
"is_workday": True,
"effective_is_workday": True,
"arrival_time": "09:00",
"departure_time": "17:00",
"technical_issues": ["Нет данных СКУД"] if technical else [],
},
{
"date": date(2024, 1, 2),
"work_hours": 0,
"expected_hours": 8,
"is_workday": True,
"effective_is_workday": True,
"is_absent": True,
},
{
"date": date(2024, 1, 3),
"work_hours": 0,
"expected_hours": 8,
"is_workday": False,
"effective_is_workday": False,
},
]
return [
AttendanceRecord.objects.create(
analysis_run=run,
employee=employee,
**row,
)
for row in rows
]
def apply_payload(preview, period, *, mode="missing_only", reason=""):
return {
"mode": mode,
"preview_token": preview["preview_token"],
"expected_period_lock_version": period.lock_version,
"reason": reason,
}
def test_attendance_preview_and_missing_only_create_audited_draft(
user_factory,
auth_client_factory,
):
manager = user_factory(email="attendance.manager@example.test")
employee = user_factory(email="attendance.employee@example.test")
grant(manager, "manage_payroll_inputs")
period = make_period(manager)
make_attendance(employee)
client = auth_client_factory(manager)
preview_response = client.get(endpoint(period))
assert preview_response.status_code == 200
preview = preview_response.json()
assert preview["policy"]["code"] == "hours_as_work_units_v1"
assert preview["summary"] == {
"attendance_employees": 1,
"existing": 0,
"blocked": 0,
"modes": {
"missing_only": {
"create": 1,
"update": 0,
"revise": 0,
"unchanged": 0,
"skip": 0,
"blocked": 0,
"changes": 1,
},
"replace_existing": {
"create": 1,
"update": 0,
"revise": 0,
"unchanged": 0,
"skip": 0,
"blocked": 0,
"changes": 1,
},
},
}
item = preview["items"][0]
assert item["target_points"] == "16.0000"
assert item["actual_points"] == "8.0000"
assert item["actions"] == {
"missing_only": "create",
"replace_existing": "create",
}
assert item["warnings"][0]["code"] == "ATTENDANCE_ABSENCES_INCLUDED"
applied = client.post(
endpoint(period),
apply_payload(preview, period),
format="json",
)
assert applied.status_code == 200
assert applied.json()["summary"] == {
"created": 1,
"updated": 0,
"revised": 0,
"unchanged": 0,
"skipped": 0,
"blocked": 0,
}
record = PayrollWorkRecord.objects.get(period=period, employee=employee)
assert record.status == ApprovalStatus.DRAFT
assert record.source == InputSource.ATTENDANCE
assert record.target_points == Decimal("16.0000")
assert record.actual_points == Decimal("8.0000")
assert record.created_by == manager
assert record.approved_by is None
assert PayrollAuditEvent.objects.filter(
action="payroll.work_record_attendance_draft_created",
object_id=str(record.pk),
).exists()
def test_replace_mode_revises_approved_and_preserves_excel_controls(
user_factory,
auth_client_factory,
):
manager = user_factory(email="attendance.revise.manager@example.test")
maker = user_factory(email="attendance.revise.maker@example.test")
checker = user_factory(email="attendance.revise.checker@example.test")
employee = user_factory(email="attendance.revise.employee@example.test")
grant(manager, "manage_payroll_inputs")
period = make_period(manager, suffix="revise")
make_attendance(employee)
approved = PayrollWorkRecord.objects.create(
period=period,
employee=employee,
target_points="10",
actual_points="9",
expected_payable="1234.50",
status=ApprovalStatus.APPROVED,
created_by=maker,
approved_by=checker,
approved_at=timezone.now(),
)
client = auth_client_factory(manager)
preview = client.get(endpoint(period)).json()
assert preview["items"][0]["actions"] == {
"missing_only": "skip",
"replace_existing": "revise",
}
skipped = client.post(
endpoint(period),
apply_payload(preview, period),
format="json",
)
assert skipped.status_code == 200
assert skipped.json()["summary"]["skipped"] == 1
assert PayrollWorkRecord.objects.filter(period=period).count() == 1
replace_preview = client.get(endpoint(period)).json()
revised_response = client.post(
endpoint(period),
apply_payload(
replace_preview,
period,
mode="replace_existing",
reason="Сверка с посещаемостью",
),
format="json",
)
assert revised_response.status_code == 200
assert revised_response.json()["summary"]["revised"] == 1
revision = PayrollWorkRecord.objects.get(replaces=approved)
assert revision.status == ApprovalStatus.DRAFT
assert revision.revision == 2
assert revision.source == InputSource.ATTENDANCE
assert revision.expected_payable == Decimal("1234.50")
approved.refresh_from_db()
assert approved.status == ApprovalStatus.APPROVED
def test_replace_updates_only_own_draft_and_protects_foreign_draft(
user_factory,
auth_client_factory,
):
manager = user_factory(email="attendance.draft.manager@example.test")
other = user_factory(email="attendance.draft.other@example.test")
own_employee = user_factory(email="attendance.draft.own@example.test")
foreign_employee = user_factory(email="attendance.draft.foreign@example.test")
grant(manager, "manage_payroll_inputs")
period = make_period(manager, suffix="drafts")
make_attendance(own_employee)
make_attendance(foreign_employee)
own = PayrollWorkRecord.objects.create(
period=period,
employee=own_employee,
target_points="1",
actual_points="1",
created_by=manager,
)
foreign = PayrollWorkRecord.objects.create(
period=period,
employee=foreign_employee,
target_points="1",
actual_points="1",
created_by=other,
)
client = auth_client_factory(manager)
preview = client.get(endpoint(period)).json()
actions = {
item["employee"]["id"]: item["actions"]["replace_existing"]
for item in preview["items"]
}
assert actions == {own_employee.pk: "update", foreign_employee.pk: "blocked"}
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
assert response.json()["summary"]["blocked"] == 1
own.refresh_from_db()
foreign.refresh_from_db()
assert own.target_points == Decimal("16.0000")
assert own.lock_version == 1
assert own.source == InputSource.ATTENDANCE
assert foreign.target_points == Decimal("1")
assert foreign.lock_version == 0
def test_preview_blocks_technical_days_and_rejects_stale_source(
user_factory,
auth_client_factory,
):
manager = user_factory(email="attendance.stale.manager@example.test")
blocked_employee = user_factory(email="attendance.blocked@example.test")
ready_employee = user_factory(email="attendance.ready@example.test")
grant(manager, "manage_payroll_inputs")
period = make_period(manager, suffix="stale")
make_attendance(blocked_employee, technical=True)
ready_records = make_attendance(ready_employee)
client = auth_client_factory(manager)
preview = client.get(endpoint(period)).json()
blocked = next(
item
for item in preview["items"]
if item["employee"]["id"] == blocked_employee.pk
)
assert blocked["actions"]["missing_only"] == "blocked"
assert "TECHNICAL_ATTENDANCE_ISSUES" in {
issue["code"] for issue in blocked["blockers"]
}
ready_records[0].work_hours = 7
ready_records[0].save(update_fields=["work_hours", "updated_at"])
stale = client.post(
endpoint(period),
apply_payload(preview, period),
format="json",
)
assert stale.status_code == 409
assert stale.json()["code"] == "ATTENDANCE_PREVIEW_STALE"
assert PayrollWorkRecord.objects.count() == 0
def test_attendance_import_permission_and_point_policy_guards(
settings,
user_factory,
auth_client_factory,
):
manager = user_factory(email="attendance.policy.manager@example.test")
outsider = user_factory(email="attendance.policy.outsider@example.test")
employee = user_factory(email="attendance.policy.employee@example.test")
grant(manager, "manage_payroll_inputs")
period = make_period(manager, suffix="policy")
make_attendance(employee)
forbidden = auth_client_factory(outsider).get(endpoint(period))
assert forbidden.status_code == 403
settings.FINANCE_PAYROLL = {"POINT_POLICY": "excess_only"}
incompatible = auth_client_factory(manager).get(endpoint(period))
assert incompatible.status_code == 409
assert incompatible.json()["code"] == "ATTENDANCE_UNIT_POLICY_CONFLICT"
def test_period_lock_version_is_checked_before_import(
user_factory,
auth_client_factory,
):
manager = user_factory(email="attendance.lock.manager@example.test")
employee = user_factory(email="attendance.lock.employee@example.test")
grant(manager, "manage_payroll_inputs")
period = make_period(manager, suffix="lock")
make_attendance(employee)
client = auth_client_factory(manager)
preview = client.get(endpoint(period)).json()
payload = apply_payload(preview, period)
payload["expected_period_lock_version"] = period.lock_version + 1
response = client.post(endpoint(period), payload, format="json")
assert response.status_code == 409
assert response.json()["code"] == "STALE_PERIOD"
assert PayrollWorkRecord.objects.count() == 0

View File

@ -0,0 +1,67 @@
from decimal import Decimal
import pytest
from django.core.exceptions import ImproperlyConfigured
from finance.payroll.config import build_rules
from payroll_core import PointPolicy, RoundingPolicy
@pytest.mark.parametrize(
("configured_value", "expected"),
[
("half_up", RoundingPolicy.HALF_UP),
("ROUND_HALF_UP", RoundingPolicy.HALF_UP),
("half_even", RoundingPolicy.HALF_EVEN),
("down", RoundingPolicy.DOWN),
],
)
def test_rounding_accepts_environment_and_canonical_names(
settings,
configured_value,
expected,
):
settings.FINANCE_PAYROLL = {
"ROUNDING": configured_value,
"POINT_POLICY": PointPolicy.DISABLED.value,
}
assert build_rules().rounding is expected
def test_payroll_adapter_is_safe_by_default(settings):
settings.FINANCE_PAYROLL = {}
rules = build_rules()
assert rules.point_policy is PointPolicy.DISABLED
assert rules.money_quantum == Decimal("0.01")
assert rules.allow_negative_payable is False
def test_config_accepts_enum_values(settings):
settings.FINANCE_PAYROLL = {
"POINT_POLICY": PointPolicy.DISABLED,
"ROUNDING": RoundingPolicy.HALF_UP,
}
rules = build_rules()
assert rules.point_policy is PointPolicy.DISABLED
assert rules.rounding is RoundingPolicy.HALF_UP
@pytest.mark.parametrize(
"override",
[
{"MONEY_QUANTUM": "0.001"},
{"ALLOW_NEGATIVE_PAYABLE": "true"},
{"ROUNDING": "bankers-ish"},
{"POINT_POLICY": "invented"},
],
)
def test_adapter_rejects_rules_that_cannot_be_persisted(settings, override):
settings.FINANCE_PAYROLL = override
with pytest.raises(ImproperlyConfigured):
build_rules()

View File

@ -0,0 +1,881 @@
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
import pytest
from django.contrib import admin
from django.contrib.auth.models import Permission
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import RequestFactory
from django.urls import reverse
from finance.enums import (
ApprovalStatus,
PayrollPeriodStatus,
PayrollRunStatus,
)
from finance.admin import PayrollPeriodAdmin
from finance.models import (
EmployeePayRate,
PayrollAuditEvent,
PayrollComponent,
PayrollInputLine,
PayrollPeriod,
PayrollRun,
PayrollStatement,
PayrollStatementAcknowledgement,
PayrollWorkRecord,
)
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
from finance.payroll.services import (
acknowledge_statement,
approve_input_line,
approve_pay_rate,
approve_run,
approve_work_record,
calculate_period,
publish_run,
return_run_for_correction,
submit_run_for_review,
)
pytestmark = pytest.mark.django_db
def grant(user, *codenames):
permissions = Permission.objects.filter(
content_type__app_label="finance",
codename__in=codenames,
)
assert permissions.count() == len(codenames)
user.user_permissions.add(*permissions)
for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, cache_name):
delattr(user, cache_name)
@pytest.fixture
def payroll_users(user_factory):
operator = user_factory(email="payroll.operator@example.test")
input_approver = user_factory(email="payroll.inputs@example.test")
run_approver = user_factory(email="payroll.approver@example.test")
publisher = user_factory(email="payroll.publisher@example.test")
employee = user_factory(
email="igor.nadein@example.test",
first_name="Игорь",
last_name="Надеин",
)
grant(operator, "calculate_payroll")
grant(input_approver, "approve_payroll_inputs")
grant(run_approver, "approve_payroll")
grant(publisher, "publish_payroll")
return {
"operator": operator,
"input_approver": input_approver,
"run_approver": run_approver,
"publisher": publisher,
"employee": employee,
}
@pytest.fixture
def june_period(payroll_users):
return PayrollPeriod.objects.create(
code="2026-06",
name="Июнь 2026",
date_from=date(2026, 6, 1),
date_to=date(2026, 6, 30),
pay_date=date(2026, 7, 5),
currency="RUB",
created_by=payroll_users["operator"],
)
def create_approved_excel_inputs(period, users):
operator = users["operator"]
approver = users["input_approver"]
employee = users["employee"]
rate = EmployeePayRate.objects.create(
employee=employee,
amount=Decimal("80000"),
point_rate=Decimal("100"),
currency="RUB",
effective_from=date(2026, 1, 1),
created_by=operator,
reason="Штатная ставка",
)
approve_pay_rate(
rate.pk,
actor=approver,
expected_lock_version=rate.lock_version,
)
work_record = PayrollWorkRecord.objects.create(
period=period,
employee=employee,
target_points=Decimal("110"),
actual_points=Decimal("110"),
expected_point_amount=Decimal("0"),
expected_gross=Decimal("115000"),
expected_recalculated_gross=Decimal("115000"),
expected_payable=Decimal("115000"),
source="excel",
source_ref=f"excel:{period.code}:employee:{employee.pk}:work",
created_by=operator,
)
approve_work_record(
work_record.pk,
actor=approver,
expected_lock_version=work_record.lock_version,
)
bonus = PayrollInputLine.objects.create(
period=period,
employee=employee,
component=PayrollComponent.objects.get(code="BONUS"),
amount=Decimal("15000"),
source="excel",
source_ref=f"excel:{period.code}:employee:{employee.pk}:bonus",
created_by=operator,
)
correction = PayrollInputLine.objects.create(
period=period,
employee=employee,
component=PayrollComponent.objects.get(code="CORRECTION_CREDIT"),
amount=Decimal("20000"),
reason="Коррекция из исходной таблицы",
source="excel",
source_ref=f"excel:{period.code}:employee:{employee.pk}:correction",
created_by=operator,
)
approve_input_line(
bonus.pk,
actor=approver,
expected_lock_version=bonus.lock_version,
)
approve_input_line(
correction.pk,
actor=approver,
expected_lock_version=correction.lock_version,
)
return rate, work_record, bonus, correction
def calculate_excel_period(period, users, *, idempotency_key=None):
create_approved_excel_inputs(period, users)
return calculate_period(
period.pk,
actor=users["operator"],
idempotency_key=idempotency_key,
)
def publish_calculation(run, users):
submit_run_for_review(run.pk, actor=users["operator"])
approve_run(run.pk, actor=users["run_approver"])
return publish_run(run.pk, actor=users["publisher"])
def test_excel_row_is_persisted_as_a_deterministic_statement(
june_period,
payroll_users,
):
run = calculate_excel_period(june_period, payroll_users)
run.refresh_from_db()
june_period.refresh_from_db()
statement = run.statements.get(employee=payroll_users["employee"])
assert run.revision == 1
assert run.status == PayrollRunStatus.CALCULATED
assert run.employee_count == 1
assert run.gross_total == Decimal("115000.00")
assert run.deduction_total == Decimal("0.00")
assert run.payable_total == Decimal("115000.00")
assert june_period.current_run_id == run.pk
assert june_period.status == PayrollPeriodStatus.CALCULATED
assert statement.gross_before_adjustments == Decimal("95000.00")
assert statement.adjustment_total == Decimal("20000.00")
assert statement.point_delta is None
assert statement.gross_total == Decimal("115000.00")
assert statement.net_pay == Decimal("115000.00")
assert statement.payable == Decimal("115000.00")
assert statement.employee_snapshot["display_name"] == "Игорь Надеин"
assert statement.input_snapshot["base_source_ref"].startswith("pay-rate:")
assert statement.result_snapshot["warnings"] == []
assert list(statement.lines.values_list("code", flat=True)) == [
"BASE",
"BONUS",
"CORRECTION_CREDIT",
]
assert PayrollAuditEvent.objects.filter(
action="payroll.calculated",
object_id=str(run.pk),
).exists()
def test_full_workflow_requires_a_second_person_and_employee_can_acknowledge(
june_period,
payroll_users,
user_factory,
):
run = calculate_excel_period(june_period, payroll_users)
submit_run_for_review(run.pk, actor=payroll_users["operator"])
grant(payroll_users["operator"], "approve_payroll")
with pytest.raises(PayrollOperationError) as error:
approve_run(run.pk, actor=payroll_users["operator"])
assert error.value.code == "SELF_APPROVAL_FORBIDDEN"
approve_run(run.pk, actor=payroll_users["run_approver"])
publish_run(run.pk, actor=payroll_users["publisher"])
run.refresh_from_db()
june_period.refresh_from_db()
assert run.status == PayrollRunStatus.PUBLISHED
assert june_period.status == PayrollPeriodStatus.PUBLISHED
statement = run.statements.get()
stranger = user_factory(email="not.the.employee@example.test")
with pytest.raises(PayrollOperationError) as error:
acknowledge_statement(statement.pk, actor=stranger)
assert error.value.code == "STATEMENT_NOT_FOUND"
key = uuid.uuid4()
acknowledgement = acknowledge_statement(
statement.pk,
actor=payroll_users["employee"],
idempotency_key=key,
)
duplicate = acknowledge_statement(
statement.pk,
actor=payroll_users["employee"],
idempotency_key=key,
)
assert duplicate.pk == acknowledgement.pk
assert acknowledgement.content_hash == statement.result_hash
assert acknowledgement.acknowledged_at is not None
assert PayrollStatementAcknowledgement.objects.count() == 1
def test_override_permission_never_replaces_operational_approval_permissions(
user_factory,
):
override_only = user_factory(email="override.without.approval@example.test")
employee = user_factory(email="override.target@example.test")
grant(override_only, "override_payroll_approval")
rate = EmployeePayRate.objects.create(
employee=employee,
amount=Decimal("80000"),
effective_from=date(2026, 1, 1),
created_by=override_only,
)
with pytest.raises(PayrollPermissionDenied) as input_error:
approve_pay_rate(
rate.pk,
actor=override_only,
expected_lock_version=rate.lock_version,
)
assert input_error.value.details["permission"] == ("finance.approve_payroll_inputs")
with pytest.raises(PayrollPermissionDenied) as run_error:
approve_run(999_999, actor=override_only)
assert run_error.value.details["permission"] == "finance.approve_payroll"
def test_database_rejects_override_flag_without_an_actual_self_approval(
payroll_users,
):
rate = EmployeePayRate.objects.create(
employee=payroll_users["employee"],
amount=Decimal("80000"),
effective_from=date(2026, 1, 1),
created_by=payroll_users["operator"],
)
with pytest.raises(IntegrityError):
with transaction.atomic():
EmployeePayRate.objects.filter(pk=rate.pk).update(
self_approval_overridden=True
)
def test_recalculation_creates_a_revision_without_mutating_published_result(
june_period,
payroll_users,
):
first_run = calculate_excel_period(june_period, payroll_users)
publish_calculation(first_run, payroll_users)
first_statement = first_run.statements.get()
first_hash = first_statement.result_hash
debit = PayrollInputLine.objects.create(
period=june_period,
employee=payroll_users["employee"],
component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"),
amount=Decimal("5000"),
reason="Исправление после публикации",
created_by=payroll_users["operator"],
)
approve_input_line(
debit.pk,
actor=payroll_users["input_approver"],
expected_lock_version=debit.lock_version,
)
old_work_record = PayrollWorkRecord.objects.get(
period=june_period,
status=ApprovalStatus.APPROVED,
)
replacement_work_record = PayrollWorkRecord.objects.create(
period=june_period,
employee=payroll_users["employee"],
target_points=old_work_record.target_points,
actual_points=old_work_record.actual_points,
expected_point_amount=Decimal("0"),
expected_gross=Decimal("110000"),
expected_recalculated_gross=Decimal("110000"),
expected_payable=Decimal("110000"),
revision=2,
replaces=old_work_record,
reason="Контрольные итоги после утверждённой корректировки",
created_by=payroll_users["operator"],
)
approve_work_record(
replacement_work_record.pk,
actor=payroll_users["input_approver"],
expected_lock_version=replacement_work_record.lock_version,
)
second_run = calculate_period(
june_period.pk,
actor=payroll_users["operator"],
recalculation_reason="Исправление утверждённой корректировки",
)
second_statement = second_run.statements.get()
first_run.refresh_from_db()
first_statement.refresh_from_db()
assert second_run.revision == 2
assert second_run.supersedes_id == first_run.pk
assert second_statement.supersedes_id == first_statement.pk
assert second_statement.payable == Decimal("110000.00")
assert first_run.status == PayrollRunStatus.PUBLISHED
assert first_statement.result_hash == first_hash
assert first_statement.payable == Decimal("115000.00")
publish_calculation(second_run, payroll_users)
first_run.refresh_from_db()
second_run.refresh_from_db()
assert first_run.status == PayrollRunStatus.SUPERSEDED
assert second_run.status == PayrollRunStatus.PUBLISHED
def test_calculation_idempotency_key_returns_the_same_run(
june_period,
payroll_users,
):
key = uuid.uuid4()
first = calculate_excel_period(
june_period,
payroll_users,
idempotency_key=key,
)
second = calculate_period(
june_period.pk,
actor=payroll_users["operator"],
idempotency_key=key,
)
assert first.pk == second.pk
assert PayrollRun.objects.count() == 1
assert PayrollStatement.objects.count() == 1
def test_unapproved_work_record_is_not_used_and_run_is_not_partially_created(
june_period,
payroll_users,
):
rate = EmployeePayRate.objects.create(
employee=payroll_users["employee"],
amount=Decimal("80000"),
effective_from=date(2026, 1, 1),
created_by=payroll_users["operator"],
)
approve_pay_rate(
rate.pk,
actor=payroll_users["input_approver"],
expected_lock_version=rate.lock_version,
)
PayrollWorkRecord.objects.create(
period=june_period,
employee=payroll_users["employee"],
target_points=Decimal("110"),
actual_points=Decimal("110"),
created_by=payroll_users["operator"],
)
with pytest.raises(PayrollOperationError) as error:
calculate_period(june_period.pk, actor=payroll_users["operator"])
assert error.value.code == "NO_APPROVED_WORK_RECORDS"
assert PayrollRun.objects.count() == 0
june_period.refresh_from_db()
assert june_period.status == PayrollPeriodStatus.OPEN
def test_ruleset_not_effective_keeps_domain_code_and_localized_message(
settings,
june_period,
payroll_users,
):
create_approved_excel_inputs(june_period, payroll_users)
settings.FINANCE_PAYROLL = {"EFFECTIVE_FROM": "2026-07-01"}
with pytest.raises(PayrollOperationError) as error:
calculate_period(june_period.pk, actor=payroll_users["operator"])
assert error.value.code == "RULESET_NOT_EFFECTIVE"
assert error.value.message == (
"Для выбранного периода нет действующих правил расчёта. "
"Набор eusrr-standard, версия 2026.07.1, применяется с 01.07.2026. "
"Измените период или подключите историческую версию правил."
)
assert error.value.details["employee_id"] == payroll_users["employee"].pk
assert error.value.details["period"] == {
"date_from": "2026-06-01",
"date_to": "2026-06-30",
}
assert error.value.details["ruleset"] == {
"id": "eusrr-standard",
"version": "2026.07.1",
"effective_from": "2026-07-01",
}
assert {issue["code"] for issue in error.value.details["issues"]} == {
"RULESET_NOT_EFFECTIVE"
}
assert PayrollRun.objects.count() == 0
def test_mid_period_rate_change_fails_closed_until_policy_is_defined(
june_period,
payroll_users,
):
create_approved_excel_inputs(june_period, payroll_users)
second_rate = EmployeePayRate.objects.create(
employee=payroll_users["employee"],
amount=Decimal("90000"),
effective_from=date(2026, 6, 15),
created_by=payroll_users["operator"],
)
approve_pay_rate(
second_rate.pk,
actor=payroll_users["input_approver"],
expected_lock_version=second_rate.lock_version,
)
with pytest.raises(PayrollOperationError) as error:
calculate_period(june_period.pk, actor=payroll_users["operator"])
assert error.value.code == "MID_PERIOD_RATE_CHANGE_UNSUPPORTED"
assert PayrollRun.objects.count() == 0
def test_staff_flag_alone_does_not_grant_payroll_calculation(
june_period,
payroll_users,
user_factory,
):
create_approved_excel_inputs(june_period, payroll_users)
unrelated_staff = user_factory(
email="staff.without.finance@example.test",
staff=True,
)
with pytest.raises(PayrollOperationError) as error:
calculate_period(june_period.pk, actor=unrelated_staff)
assert error.value.code == "PERMISSION_DENIED"
assert PayrollRun.objects.count() == 0
def test_every_new_revision_requires_a_reason(june_period, payroll_users):
first_run = calculate_excel_period(june_period, payroll_users)
with pytest.raises(PayrollOperationError) as error:
calculate_period(june_period.pk, actor=payroll_users["operator"])
assert error.value.code == "RECALCULATION_REASON_REQUIRED"
assert PayrollRun.objects.get() == first_run
def test_reviewer_can_return_run_and_operator_creates_new_revision(
june_period,
payroll_users,
):
first_run = calculate_excel_period(june_period, payroll_users)
submit_run_for_review(first_run.pk, actor=payroll_users["operator"])
returned = return_run_for_correction(
first_run.pk,
actor=payroll_users["run_approver"],
reason="Неверно указана корректировка",
)
returned.refresh_from_db()
june_period.refresh_from_db()
assert returned.status == PayrollRunStatus.RETURNED
assert june_period.status == PayrollPeriodStatus.CALCULATED
second_run = calculate_period(
june_period.pk,
actor=payroll_users["operator"],
recalculation_reason="Исправлена корректировка",
)
returned.refresh_from_db()
assert second_run.revision == 2
assert returned.status == PayrollRunStatus.SUPERSEDED
def test_excel_mismatch_blocks_review(june_period, payroll_users):
create_approved_excel_inputs(june_period, payroll_users)
PayrollWorkRecord.objects.filter(period=june_period).update(
expected_gross=Decimal("1")
)
run = calculate_period(june_period.pk, actor=payroll_users["operator"])
with pytest.raises(PayrollOperationError) as error:
submit_run_for_review(run.pk, actor=payroll_users["operator"])
assert error.value.code == "RECONCILIATION_MISMATCH"
run.refresh_from_db()
assert run.status == PayrollRunStatus.CALCULATED
def test_approved_input_outside_roster_blocks_calculation(
june_period,
payroll_users,
user_factory,
):
create_approved_excel_inputs(june_period, payroll_users)
outsider = user_factory(email="outside.roster@example.test")
outside_bonus = PayrollInputLine.objects.create(
period=june_period,
employee=outsider,
component=PayrollComponent.objects.get(code="BONUS"),
amount=Decimal("100"),
created_by=payroll_users["operator"],
)
approve_input_line(
outside_bonus.pk,
actor=payroll_users["input_approver"],
expected_lock_version=outside_bonus.lock_version,
)
with pytest.raises(PayrollOperationError) as error:
calculate_period(june_period.pk, actor=payroll_users["operator"])
assert error.value.code == "INPUT_EMPLOYEE_NOT_IN_ROSTER"
assert PayrollRun.objects.count() == 0
def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
june_period,
payroll_users,
user_factory,
auth_client_factory,
extract_results,
):
run = calculate_excel_period(june_period, payroll_users)
publish_calculation(run, payroll_users)
statement = run.statements.get()
employee_client = auth_client_factory(payroll_users["employee"])
stranger = user_factory(email="api.stranger@example.test")
stranger_client = auth_client_factory(stranger)
list_url = reverse("api:v1:finance-payroll:own-statement-list")
response = employee_client.get(list_url)
assert response.status_code == 200
items = extract_results(response.json())
assert len(items) == 1
assert items[0]["public_id"] == str(statement.public_id)
assert items[0]["payable"] == "115000.00"
assert "lines" not in items[0]
assert "gross_total" not in items[0]
assert "employee" not in items[0]
assert "input_snapshot" not in items[0]
assert "no-store" in response["Cache-Control"]
assert PayrollAuditEvent.objects.filter(
action="payroll.statement_summary_viewed",
actor=payroll_users["employee"],
).exists()
detail_url = reverse(
"api:v1:finance-payroll:own-statement-detail",
kwargs={"public_id": statement.public_id},
)
assert stranger_client.get(detail_url).status_code == 404
detail_response = employee_client.get(detail_url)
assert detail_response.status_code == 200
assert len(detail_response.json()["lines"]) == 3
assert PayrollAuditEvent.objects.filter(
action="payroll.statement_viewed",
actor=payroll_users["employee"],
).exists()
acknowledgement_url = reverse(
"api:v1:finance-payroll:own-statement-acknowledge",
kwargs={"public_id": statement.public_id},
)
ack_response = employee_client.post(acknowledgement_url, {}, format="json")
assert ack_response.status_code == 200
assert ack_response.json()["acknowledged_at"] is not None
assert (
stranger_client.post(
acknowledgement_url,
{},
format="json",
).status_code
== 404
)
def test_new_approval_requires_return_and_recalculation(
june_period,
payroll_users,
):
run = calculate_excel_period(june_period, payroll_users)
extra_bonus = PayrollInputLine.objects.create(
period=june_period,
employee=payroll_users["employee"],
component=PayrollComponent.objects.get(code="BONUS"),
amount=Decimal("100"),
source_ref="manual:late-bonus",
created_by=payroll_users["operator"],
)
with pytest.raises(PayrollOperationError) as error:
approve_input_line(
extra_bonus.pk,
actor=payroll_users["input_approver"],
expected_lock_version=extra_bonus.lock_version,
)
assert error.value.code == "PERIOD_INPUTS_LOCKED"
return_run_for_correction(
run.pk,
actor=payroll_users["operator"],
reason="Поздно утверждённая премия",
)
approve_input_line(
extra_bonus.pk,
actor=payroll_users["input_approver"],
expected_lock_version=extra_bonus.lock_version,
)
revised = calculate_period(
june_period.pk,
actor=payroll_users["operator"],
recalculation_reason="Добавлена поздно утверждённая премия",
)
assert revised.revision == 2
assert revised.payable_total == Decimal("115100.00")
def test_freshness_gate_detects_source_changes_before_review(
june_period,
payroll_users,
):
run = calculate_excel_period(june_period, payroll_users)
PayrollWorkRecord.objects.filter(
period=june_period,
employee=payroll_users["employee"],
status=ApprovalStatus.APPROVED,
).update(actual_points=Decimal("111"))
with pytest.raises(PayrollOperationError) as error:
submit_run_for_review(run.pk, actor=payroll_users["operator"])
assert error.value.code == "RUN_STALE_RECALCULATION_REQUIRED"
def test_rate_approval_is_blocked_while_employee_run_is_active(
june_period,
payroll_users,
):
run = calculate_excel_period(june_period, payroll_users)
original_rate = EmployeePayRate.objects.get(status=ApprovalStatus.APPROVED)
replacement = EmployeePayRate.objects.create(
employee=payroll_users["employee"],
rate_code=original_rate.rate_code,
amount=Decimal("81000"),
point_rate=original_rate.point_rate,
currency=original_rate.currency,
effective_from=original_rate.effective_from,
revision=2,
replaces=original_rate,
reason="Изменение ставки",
created_by=payroll_users["operator"],
)
with pytest.raises(PayrollOperationError) as error:
approve_pay_rate(
replacement.pk,
actor=payroll_users["input_approver"],
expected_lock_version=replacement.lock_version,
)
assert run.status == PayrollRunStatus.CALCULATED
assert error.value.code == "RATE_USED_BY_ACTIVE_RUN"
def test_employee_visible_period_fields_freeze_after_calculation(
june_period,
payroll_users,
):
calculate_excel_period(june_period, payroll_users)
june_period.pay_date = date(2026, 7, 6)
with pytest.raises(ValidationError):
june_period.save()
def test_stale_period_form_cannot_overwrite_calculation_state(
june_period,
payroll_users,
):
stale_period = PayrollPeriod.objects.get(pk=june_period.pk)
run = calculate_excel_period(june_period, payroll_users)
stale_period.save()
stale_period.refresh_from_db()
assert stale_period.status == PayrollPeriodStatus.CALCULATED
assert stale_period.current_run_id == run.pk
assert stale_period.lock_version == 1
def test_checker_approves_only_the_version_they_reviewed(
payroll_users,
):
rate = EmployeePayRate.objects.create(
employee=payroll_users["employee"],
amount=Decimal("80000"),
point_rate=Decimal("100"),
currency="RUB",
effective_from=date(2026, 1, 1),
created_by=payroll_users["operator"],
)
reviewed_version = rate.lock_version
rate.amount = Decimal("800000")
rate.save()
assert rate.lock_version == reviewed_version + 1
with pytest.raises(PayrollOperationError) as error:
approve_pay_rate(
rate.pk,
actor=payroll_users["input_approver"],
expected_lock_version=reviewed_version,
)
assert error.value.code == "APPROVAL_VERSION_CONFLICT"
rate.refresh_from_db()
assert rate.status == ApprovalStatus.DRAFT
def test_stale_draft_save_is_rejected(payroll_users):
rate = EmployeePayRate.objects.create(
employee=payroll_users["employee"],
amount=Decimal("80000"),
effective_from=date(2026, 1, 1),
created_by=payroll_users["operator"],
)
stale = EmployeePayRate.objects.get(pk=rate.pk)
rate.amount = Decimal("81000")
rate.save()
stale.amount = Decimal("82000")
with pytest.raises(ValidationError):
stale.save()
rate.refresh_from_db()
assert rate.amount == Decimal("81000.0000")
assert rate.lock_version == 1
def test_component_semantics_freeze_after_creation(payroll_users):
component = PayrollComponent.objects.get(code="BONUS")
component.kind = "deduction"
with pytest.raises(ValidationError):
component.save()
component.refresh_from_db()
component.is_active = False
component.save()
assert component.is_active is False
def test_inactive_component_cannot_be_approved(
june_period,
payroll_users,
):
component = PayrollComponent.objects.get(code="BONUS")
component.is_active = False
component.save()
line = PayrollInputLine.objects.create(
period=june_period,
employee=payroll_users["employee"],
component=component,
amount=Decimal("100"),
created_by=payroll_users["operator"],
)
with pytest.raises(PayrollOperationError) as error:
approve_input_line(
line.pk,
actor=payroll_users["input_approver"],
expected_lock_version=line.lock_version,
)
assert error.value.code == "PAYROLL_COMPONENT_INACTIVE"
def test_admin_recalculation_collects_and_persists_reason(
june_period,
payroll_users,
):
calculate_excel_period(june_period, payroll_users)
model_admin = PayrollPeriodAdmin(PayrollPeriod, admin.site)
queryset = PayrollPeriod.objects.filter(pk=june_period.pk)
preview_request = RequestFactory().post(
"/admin/finance/payrollperiod/",
{
"action": "calculate_selected",
"_selected_action": [june_period.pk],
},
)
preview_request.user = payroll_users["operator"]
preview = model_admin.calculate_selected(preview_request, queryset)
assert preview.context_data["reason_required"] is True
reason = "Повторная сверка с исходной таблицей"
confirm_request = RequestFactory().post(
"/admin/finance/payrollperiod/",
{
"action": "calculate_selected",
"_selected_action": [june_period.pk],
"confirm_payroll_action": "1",
"reason": reason,
},
)
confirm_request.user = payroll_users["operator"]
confirm_request.session = {}
confirm_request._messages = FallbackStorage(confirm_request)
model_admin.calculate_selected(confirm_request, queryset)
revised = PayrollRun.objects.get(period=june_period, revision=2)
assert revised.recalculation_reason == reason

View File

@ -1,9 +1,291 @@
import { AppShell } from "../../components/AppShell";
"use client";
import {
ArrowRight,
BadgeCheck,
CalendarDays,
ChevronRight,
Clock3,
History,
Loader2,
RefreshCw,
Wallet,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { PayrollStatementDetailModal } from "@/components/finance/PayrollStatementDetailModal";
import { PayrollAdminWorkspace } from "@/components/finance/admin/PayrollAdminWorkspace";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import type { PayrollAcknowledgement, PayrollStatementSummary } from "@/lib/api/finance";
import { canOpenPayrollAdmin } from "@/lib/permissions";
import {
formatPayrollDate,
formatPayrollMoney,
getPayrollPeriodLabel,
getPayrollPeriodRange,
normalizePayrollStatements,
} from "@/lib/payroll";
function listErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("401")) return "Сессия истекла. Войдите в портал ещё раз.";
return "Не удалось загрузить расчётные листки. Проверьте соединение и повторите попытку.";
}
function StatementStatus({ statement }: { statement: PayrollStatementSummary }) {
if (statement.acknowledgement?.disputed_at) {
return <span className="app-status-pill app-action-warning">Есть обращение</span>;
}
if (statement.acknowledgement?.acknowledged_at) {
return (
<span className="app-status-pill app-action-success gap-1.5">
<BadgeCheck size={13} />
Подтверждён
</span>
);
}
return (
<span className="app-status-pill app-badge-accent gap-1.5">
<Clock3 size={13} />
Ждёт подтверждения
</span>
);
}
function EmptyPayroll() {
return (
<section className="app-surface rounded-2xl p-5 sm:p-8">
<div className="app-surface-muted mx-auto flex max-w-xl flex-col items-center rounded-2xl px-5 py-10 text-center sm:px-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[color:var(--accent-soft)] text-[var(--accent-primary-strong)]">
<Wallet size={27} />
</div>
<h2 className="mt-5 text-lg font-semibold text-[var(--foreground)]">Расчётных листков пока нет</h2>
<p className="app-text-muted mt-2 max-w-md text-sm leading-relaxed">
Здесь появятся ваши зарплатные расчёты после проверки и публикации финансовым отделом.
</p>
</div>
</section>
);
}
export default function FinancesPage() {
const { user } = useUser();
const [activeTab, setActiveTab] = useState<"statement" | "management">("statement");
const [statements, setStatements] = useState<PayrollStatementSummary[]>([]);
const [selectedStatement, setSelectedStatement] = useState<PayrollStatementSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const loadStatements = useCallback(async () => {
setLoading(true);
setError(null);
try {
const payload = await apiClient.getMyPayrollStatements();
setStatements(normalizePayrollStatements(payload));
} catch (loadError: unknown) {
setError(listErrorMessage(loadError));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadStatements();
}, [loadStatements, reloadKey]);
const latestStatement = statements[0] || null;
const pendingCount = useMemo(
() => statements.filter((statement) => !statement.acknowledgement?.acknowledged_at).length,
[statements],
);
const previousStatements = statements.slice(1);
const showPayrollAdmin = canOpenPayrollAdmin(user);
const handleAcknowledged = (publicId: string, acknowledgement: PayrollAcknowledgement) => {
setStatements((current) => current.map((statement) => (
statement.public_id === publicId ? { ...statement, acknowledgement } : statement
)));
setSelectedStatement((current) => (
current?.public_id === publicId ? { ...current, acknowledgement } : current
));
};
return (
<AppShell>
<p className="text-sm text-gray-500">Раздел в разработке.</p>
<AppShell desktopWideMode={activeTab === "management"}>
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<p className="app-card-caption">Финансы</p>
{activeTab === "management" && showPayrollAdmin ? (
<div id="finance-management-actions" className="ml-auto max-w-full" />
) : null}
</div>
{activeTab === "management" && showPayrollAdmin ? (
<div id="finance-management-header" className="mt-4" />
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setActiveTab("statement")}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "statement" ? "app-pill-active" : "app-pill"}`}
>
Расчётный лист
</button>
{showPayrollAdmin ? (
<button
type="button"
onClick={() => {
setSelectedStatement(null);
setActiveTab("management");
}}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "management" ? "app-pill-active" : "app-pill"}`}
>
Управление
</button>
) : null}
</div>
</section>
{activeTab === "management" && showPayrollAdmin ? (
<PayrollAdminWorkspace
embedded
actionsTargetId="finance-management-actions"
headerTargetId="finance-management-header"
/>
) : (
<>
{loading ? (
<section className="app-surface flex min-h-64 flex-col items-center justify-center rounded-2xl p-8 text-center">
<Loader2 className="app-accent-text animate-spin" size={30} />
<p className="app-text-muted mt-4 text-sm">Загружаем ваши расчётные листки…</p>
</section>
) : error ? (
<section className="app-surface rounded-2xl p-5 sm:p-8">
<div className="app-feedback-danger mx-auto max-w-xl rounded-xl px-4 py-3 text-center text-sm">{error}</div>
<div className="mt-4 text-center">
<button
type="button"
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
onClick={() => setReloadKey((value) => value + 1)}
>
<RefreshCw size={16} />
Повторить
</button>
</div>
</section>
) : statements.length === 0 ? (
<EmptyPayroll />
) : (
<>
{latestStatement ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="app-card-caption text-xs">Последний расчёт</p>
<StatementStatus statement={latestStatement} />
</div>
<h2 className="mt-2 text-lg font-semibold text-[var(--foreground)]">
Зарплата за {getPayrollPeriodLabel(latestStatement.period).toLocaleLowerCase("ru-RU")}
</h2>
<div className="mt-5">
<p className="app-text-muted text-xs">К выплате</p>
<p className="app-accent-text mt-1 truncate text-4xl font-bold tracking-tight" title={formatPayrollMoney(latestStatement.payable, latestStatement.currency)}>
{formatPayrollMoney(latestStatement.payable, latestStatement.currency)}
</p>
</div>
<div className="app-surface-muted mt-5 divide-y divide-[var(--border-subtle)] overflow-hidden rounded-xl">
<div className="flex items-start gap-3 px-3 py-3.5 sm:px-4">
<CalendarDays className="app-text-muted mt-0.5 shrink-0" size={17} />
<div className="min-w-0">
<p className="app-text-muted text-xs">Расчётный период</p>
<p className="mt-0.5 text-sm font-medium text-[var(--foreground)]">{getPayrollPeriodRange(latestStatement.period)}</p>
</div>
</div>
<div className="flex items-start gap-3 px-3 py-3.5 sm:px-4">
<Clock3 className="app-text-muted mt-0.5 shrink-0" size={17} />
<div className="min-w-0">
<p className="app-text-muted text-xs">Дата выплаты</p>
<p className="mt-0.5 text-sm font-medium text-[var(--foreground)]">{formatPayrollDate(latestStatement.period.pay_date)}</p>
</div>
</div>
</div>
<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={() => setSelectedStatement(latestStatement)}
>
{latestStatement.acknowledgement?.acknowledged_at
? "Посмотреть расчёт"
: "Проверить и подтвердить листок"}
<ArrowRight size={16} />
</button>
{!latestStatement.acknowledgement?.acknowledged_at ? (
<p className="app-text-muted mt-2 text-center text-xs leading-relaxed">
Сначала проверьте начисления. Подтверждается получение листка, а не перевод денег.
</p>
) : null}
</section>
) : null}
{previousStatements.length > 0 ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<History className="app-text-muted shrink-0" size={18} />
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-[var(--foreground)]">Предыдущие расчёты</h2>
<p className="app-text-muted mt-0.5 text-xs">Откройте любой листок, чтобы посмотреть начисления</p>
</div>
</div>
{pendingCount > 1 ? (
<span className="app-counter h-6 min-w-6 shrink-0 px-1.5 text-xs" title="Листки, ожидающие подтверждения">
{pendingCount}
</span>
) : null}
</div>
<div className="space-y-2">
{previousStatements.map((statement) => (
<button
type="button"
key={statement.public_id}
className="app-surface-muted group grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl p-3 text-left transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-elevated)] sm:p-4"
onClick={() => setSelectedStatement(statement)}
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{getPayrollPeriodLabel(statement.period)}</p>
<p className="app-text-muted mt-1 truncate text-xs">Выплата {formatPayrollDate(statement.period.pay_date)}</p>
<div className="mt-2"><StatementStatus statement={statement} /></div>
</div>
<div className="flex items-center justify-end gap-2">
<p className="whitespace-nowrap text-right text-sm font-bold text-[var(--foreground)]">
{formatPayrollMoney(statement.payable, statement.currency)}
</p>
<ChevronRight className="app-text-muted shrink-0 transition-transform group-hover:translate-x-0.5" size={18} />
</div>
</button>
))}
</div>
</section>
) : null}
</>
)}
<PayrollStatementDetailModal
isOpen={Boolean(selectedStatement)}
statement={selectedStatement}
onClose={() => setSelectedStatement(null)}
onAcknowledged={handleAcknowledged}
/>
</>
)}
</AppShell>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { AppShell } from "@/components/AppShell";
import { PayrollAdminWorkspace } from "@/components/finance/admin/PayrollAdminWorkspace";
import { useUser } from "@/contexts/UserContext";
import { canOpenPayrollAdmin } from "@/lib/permissions";
export default function PayrollManagePage() {
const { user, loading } = useUser();
const router = useRouter();
const canManagePayroll = canOpenPayrollAdmin(user);
useEffect(() => {
if (!loading && user && !canManagePayroll) router.replace("/finances");
}, [canManagePayroll, loading, router, user]);
return (
<AppShell desktopWideMode>
{!loading && canManagePayroll ? (
<PayrollAdminWorkspace />
) : (
<section className="app-surface flex min-h-64 items-center justify-center rounded-2xl">
<Loader2 className="app-accent-text animate-spin" size={26} />
</section>
)}
</AppShell>
);
}

View File

@ -18,6 +18,11 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { useNotifications } from "@/contexts/NotificationsContext";
import { apiClient } from "@/lib/api";
import { getDocumentFileExtension } from "@/lib/document-preview";
import {
getRegulationAcknowledgementDepartments,
isCompanyAcknowledgementRegulation,
regulationMatchesAcknowledgementSource,
} from "@/lib/feed-regulation-filters";
import { loadAllPages, userProfileLink } from "@/lib/shared";
import { resolveMediaUrl } from "@/lib/url";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -62,13 +67,6 @@ function postMatchesSource(post: Post, source: PostSourceFilter) {
return post.department_id === Number(source.split(":")[1]);
}
function regulationMatchesSource(document: Document, source: PostSourceFilter) {
if (source === "all") return true;
if (source === "company") return Boolean(document.sent_to_all);
const departmentId = Number(source.split(":")[1]);
return (document.departments || []).some((department) => department.id === departmentId);
}
export default function Home() {
return (
<Suspense fallback={<HomePageFallback />}>
@ -381,8 +379,8 @@ function HomePageContent() {
}
for (const document of regulations) {
if (document.sent_to_all) companyCount += 1;
for (const department of document.departments || []) {
if (isCompanyAcknowledgementRegulation(document)) companyCount += 1;
for (const department of getRegulationAcknowledgementDepartments(document)) {
const current = departmentCounts.get(department.id);
if (current) {
current.total += 1;
@ -433,8 +431,8 @@ function HomePageContent() {
const regulation = documentId !== null ? regulationsById.get(documentId) : undefined;
if (regulation) {
regulationsUnread += 1;
if (regulation.sent_to_all) companyRegulationsUnread += 1;
for (const department of regulation.departments || []) {
if (isCompanyAcknowledgementRegulation(regulation)) companyRegulationsUnread += 1;
for (const department of getRegulationAcknowledgementDepartments(regulation)) {
departmentRegulationsUnread.set(
department.id,
(departmentRegulationsUnread.get(department.id) || 0) + 1,
@ -488,7 +486,9 @@ function HomePageContent() {
const departmentRegulationsUnread = new Map(
postSourceUnreadCounts.departments.map((entry) => [entry.id, entry.regulationsUnread]),
);
const scopedRegulations = regulations.filter((document) => regulationMatchesSource(document, sourceFilter));
const scopedRegulations = regulations.filter((document) => (
regulationMatchesAcknowledgementSource(document, sourceFilter)
));
const scopedRegulationsUnread = sourceFilter === "all"
? postSourceUnreadCounts.regulations
: sourceFilter === "company"
@ -529,7 +529,7 @@ function HomePageContent() {
if (regulationsOnly && entry.kind !== "regulation") return false;
return entry.kind === "post"
? postMatchesSource(entry.post, sourceFilter)
: regulationMatchesSource(entry.document, sourceFilter);
: regulationMatchesAcknowledgementSource(entry.document, sourceFilter);
}).sort((left, right) => {
const leftPinned = left.kind === "post" && left.post.pinned ? 1 : 0;
const rightPinned = right.kind === "post" && right.post.pinned ? 1 : 0;

View File

@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users } from "lucide-react";
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -62,7 +62,7 @@ const navItems = [
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks },
{ href: "/documents", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents },
// { href: "/finances", label: "Финансы", icon: Wallet },
{ href: "/finances", label: "Финансы", icon: Wallet },
];
function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNav, onOpenCalendar }: HeaderProps) {
@ -351,11 +351,13 @@ function LeftNavContent({ onNavigate, compact = false }: LeftNavContentProps) {
}
};
const isNavItemActive = (href: string) => pathname === href || (href !== "/" && pathname.startsWith(`${href}/`));
const navLinkClass = (href: string) =>
`relative flex items-center rounded-lg transition ${compact ? "h-10 w-10 justify-center p-0" : "gap-3 px-3 py-2"} ${pathname === href ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
`relative flex items-center rounded-lg transition ${compact ? "h-10 w-10 justify-center p-0" : "gap-3 px-3 py-2"} ${isNavItemActive(href) ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
}`;
const navIconClass = (href: string) => (pathname === href ? "app-accent-text" : "app-text-muted");
const navIconClass = (href: string) => (isNavItemActive(href) ? "app-accent-text" : "app-text-muted");
return (
<div className={`app-surface rounded-2xl ${compact ? "p-2" : "p-5"}`}>

View File

@ -0,0 +1,328 @@
"use client";
import {
BadgeCheck,
BriefcaseBusiness,
Calculator,
Check,
CircleMinus,
Clock3,
CreditCard,
FileCheck2,
Loader2,
RefreshCcw,
RotateCcw,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type {
PayrollAcknowledgement,
PayrollStatement,
PayrollStatementSummary,
} from "@/lib/api/finance";
import {
formatPayrollDate,
formatPayrollDateTime,
formatPayrollMoney,
getPayrollLineDirection,
getPayrollPeriodLabel,
getPayrollPeriodRange,
groupPayrollLines,
type PayrollLineGroupKey,
} from "@/lib/payroll";
type PayrollStatementDetailModalProps = {
isOpen: boolean;
statement: PayrollStatementSummary | null;
onClose: () => void;
onAcknowledged: (publicId: string, acknowledgement: PayrollAcknowledgement) => void;
};
const groupIcons: Record<PayrollLineGroupKey, typeof BriefcaseBusiness> = {
accruals: BriefcaseBusiness,
adjustments: RefreshCcw,
deductions: CircleMinus,
payments: CreditCard,
};
function errorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("404")) {
return "Листок больше недоступен. Возможно, опубликована новая ревизия.";
}
if (message.includes("409")) {
return "Не удалось подтвердить текущую ревизию. Обновите список и попробуйте ещё раз.";
}
return "Не удалось загрузить расчётный листок. Проверьте соединение и повторите попытку.";
}
function StatCard({ label, value, subtle }: { label: string; value: string; subtle?: string }) {
return (
<div className="app-surface-muted min-w-0 rounded-xl p-3">
<p className="app-card-caption text-[0.68rem]">{label}</p>
<p className="mt-1 truncate text-base font-semibold text-[var(--foreground)]" title={value}>
{value}
</p>
{subtle ? <p className="app-text-muted mt-1 text-xs">{subtle}</p> : null}
</div>
);
}
export function PayrollStatementDetailModal({
isOpen,
statement,
onClose,
onAcknowledged,
}: PayrollStatementDetailModalProps) {
const [detail, setDetail] = useState<PayrollStatement | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [acknowledging, setAcknowledging] = useState(false);
const [acknowledgeError, setAcknowledgeError] = useState<string | null>(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
if (!isOpen || !statement) return;
let cancelled = false;
setDetail(null);
setLoadError(null);
setAcknowledgeError(null);
setConfirmationOpen(false);
setLoading(true);
apiClient
.getMyPayrollStatement(statement.public_id)
.then((payload) => {
if (!cancelled) setDetail(payload);
})
.catch((error: unknown) => {
if (!cancelled) setLoadError(errorMessage(error));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [isOpen, statement, reloadKey]);
const lineGroups = useMemo(() => groupPayrollLines(detail?.lines || []), [detail?.lines]);
const acknowledgement = detail?.acknowledgement || statement?.acknowledgement || null;
const isAcknowledged = Boolean(acknowledgement?.acknowledged_at);
const handleAcknowledge = async () => {
if (!detail || acknowledging || isAcknowledged) return;
setAcknowledging(true);
setAcknowledgeError(null);
try {
const nextAcknowledgement = await apiClient.acknowledgeMyPayrollStatement(detail.public_id);
setDetail((current) => current ? { ...current, acknowledgement: nextAcknowledgement } : current);
onAcknowledged(detail.public_id, nextAcknowledgement);
setConfirmationOpen(false);
} catch (error: unknown) {
setAcknowledgeError(errorMessage(error));
} finally {
setAcknowledging(false);
}
};
const footer = detail ? (
<div className="app-divider flex flex-col gap-3 border-t pb-4 pt-4 sm:flex-row sm:items-center sm:justify-between sm:pb-6">
<div className="min-w-0">
{isAcknowledged ? (
<div className="flex items-start gap-2 text-sm text-emerald-600">
<BadgeCheck className="mt-0.5 shrink-0" size={18} />
<div>
<p className="font-semibold">Получение листка подтверждено</p>
<p className="app-text-muted mt-0.5 text-xs">
{formatPayrollDateTime(acknowledgement?.acknowledged_at)}
</p>
</div>
</div>
) : (
<div className="flex items-start gap-2">
<ShieldCheck className="app-accent-text mt-0.5 shrink-0" size={18} />
<p className="app-text-muted max-w-md text-xs leading-relaxed">
Подтверждение фиксирует получение расчётного листка, но не является подтверждением банковской выплаты.
</p>
</div>
)}
</div>
{!isAcknowledged ? (
confirmationOpen ? (
<div className="flex shrink-0 flex-col-reverse gap-2 sm:flex-row">
<button
type="button"
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium"
onClick={() => setConfirmationOpen(false)}
disabled={acknowledging}
>
Отмена
</button>
<button
type="button"
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
onClick={handleAcknowledge}
disabled={acknowledging}
>
{acknowledging ? <Loader2 className="animate-spin" size={17} /> : <Check size={17} />}
Да, подтверждаю
</button>
</div>
) : (
<button
type="button"
className="app-action-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold"
onClick={() => setConfirmationOpen(true)}
>
<FileCheck2 size={17} />
Подтвердить получение листка
</button>
)
) : null}
</div>
) : null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={statement ? `Расчётный листок · ${getPayrollPeriodLabel(statement.period)}` : "Расчётный листок"}
size="lg"
closeOnEsc={!acknowledging}
footer={footer}
>
{loading ? (
<div className="flex min-h-80 flex-col items-center justify-center py-12 text-center">
<Loader2 className="app-accent-text animate-spin" size={30} />
<p className="app-text-muted mt-4 text-sm">Загружаем защищённые данные листка…</p>
</div>
) : loadError ? (
<div className="flex min-h-72 flex-col items-center justify-center py-10 text-center">
<div className="app-feedback-danger max-w-lg rounded-xl px-4 py-3 text-sm">{loadError}</div>
<button
type="button"
className="app-action-secondary mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
onClick={() => setReloadKey((value) => value + 1)}
>
<RotateCcw size={16} />
Повторить
</button>
</div>
) : detail ? (
<div className="space-y-5">
<section className="overflow-hidden rounded-2xl border border-[color:var(--accent-primary)]/20 bg-[color:var(--accent-soft)] p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="app-text-muted text-xs">Расчётный период</p>
<h4 className="mt-1 text-xl font-bold text-[var(--foreground)]">
Зарплата за {getPayrollPeriodLabel(detail.period).toLocaleLowerCase("ru-RU")}
</h4>
<p className="app-text-muted mt-1 text-sm">{getPayrollPeriodRange(detail.period)}</p>
</div>
<div className="sm:text-right">
<p className="app-card-caption text-[0.7rem]">К выплате</p>
<p className="app-accent-text mt-1 text-3xl font-bold tracking-tight">
{formatPayrollMoney(detail.payable, detail.currency)}
</p>
<p className="app-text-muted mt-1 flex items-center gap-1.5 text-xs sm:justify-end">
<Clock3 size={13} />
Дата выплаты: {formatPayrollDate(detail.period.pay_date)}
</p>
</div>
</div>
</section>
<section>
<div className="mb-3 flex items-center gap-2">
<Calculator className="app-text-muted" size={17} />
<h4 className="text-sm font-semibold text-[var(--foreground)]">Итоги расчёта</h4>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<StatCard label="До корректировок" value={formatPayrollMoney(detail.gross_before_adjustments, detail.currency)} />
<StatCard label="Корректировки" value={formatPayrollMoney(detail.adjustment_total, detail.currency)} />
<StatCard label="Удержания" value={formatPayrollMoney(detail.deduction_total, detail.currency)} />
<StatCard label="Выплачено ранее" value={formatPayrollMoney(detail.payment_total, detail.currency)} />
</div>
</section>
<section>
<div className="mb-3 flex items-center gap-2">
<Sparkles className="app-text-muted" size={17} />
<h4 className="text-sm font-semibold text-[var(--foreground)]">Состав расчёта</h4>
</div>
{lineGroups.length > 0 ? (
<div className="space-y-3">
{lineGroups.map((group) => {
const Icon = groupIcons[group.key];
return (
<div key={group.key} className="app-surface-muted overflow-hidden rounded-xl">
<div className="app-divider flex items-center gap-2 border-b px-3 py-2.5 sm:px-4">
<Icon className="app-text-muted" size={16} />
<h5 className="text-xs font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">
{group.label}
</h5>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{group.lines.map((line, index) => {
const direction = getPayrollLineDirection(line.kind);
const sourcePeriod = line.source_period_from && line.source_period_to
? `${formatPayrollDate(line.source_period_from)} — ${formatPayrollDate(line.source_period_to)}`
: null;
return (
<div key={`${line.code}-${index}`} className="flex items-start justify-between gap-4 px-3 py-3 sm:px-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-sm font-medium text-[var(--foreground)]">{line.label}</p>
{line.is_retro ? (
<span className="app-badge app-badge-accent px-2 py-0.5 text-[0.65rem]">Перерасчёт</span>
) : null}
</div>
{sourcePeriod ? (
<p className="app-text-muted mt-1 text-xs">За период: {sourcePeriod}</p>
) : null}
</div>
<p className={`shrink-0 text-sm font-semibold ${direction === "negative" ? "text-amber-600" : "text-[var(--foreground)]"}`}>
{direction === "negative" ? "−" : "+"}
{formatPayrollMoney(line.amount, detail.currency)}
</p>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-6 text-center">
<p className="app-text-muted text-sm">Детализация начислений отсутствует.</p>
</div>
)}
</section>
<section className="app-surface-muted rounded-xl px-4 py-3 text-xs leading-relaxed text-[var(--muted-foreground)]">
Листок опубликован {formatPayrollDateTime(detail.published_at)}.
</section>
{confirmationOpen && !isAcknowledged ? (
<div className="app-feedback-warning rounded-xl px-4 py-3 text-sm">
Проверьте суммы и состав расчёта. После подтверждения портал зафиксирует дату и время получения этого листка.
</div>
) : null}
{acknowledgeError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{acknowledgeError}</div>
) : null}
</div>
) : null}
</Modal>
);
}

View File

@ -0,0 +1,508 @@
"use client";
import { AlertTriangle, Check, Loader2 } from "lucide-react";
import { useMemo, useState, type FormEvent } from "react";
import { Modal } from "@/components/ui";
import type {
PayrollAdminEmployee,
PayrollAdminInputLine,
PayrollAdminPayRate,
PayrollAdminPeriod,
PayrollAdminWorkRecord,
PayrollComponent,
PayrollInputLineWrite,
PayrollPayRateWrite,
PayrollPeriodWrite,
PayrollWorkRecordWrite,
} from "@/lib/api/finance";
import { getPayrollAdminError, isPayrollAdminStaleConflict } from "@/lib/payroll-admin";
const inputClass = "app-input w-full rounded-lg px-3 py-2.5 text-sm";
const selectClass = "app-select w-full rounded-lg px-3 py-2.5 text-sm";
function ModalActions({
busy,
onClose,
submitLabel,
}: {
busy: boolean;
onClose: () => void;
submitLabel: string;
}) {
return (
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium"
disabled={busy}
onClick={onClose}
>
Отмена
</button>
<button
type="submit"
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold disabled:opacity-60"
disabled={busy}
>
{busy ? <Loader2 className="animate-spin" size={16} /> : <Check size={16} />}
{submitLabel}
</button>
</div>
);
}
function FormError({ message }: { message: string | null }) {
if (!message) return null;
return <div className="app-feedback-danger mt-4 rounded-lg px-3 py-2.5 text-sm">{message}</div>;
}
async function submitModalForm(
operation: () => Promise<unknown>,
options: {
fallback: string;
setBusy: (busy: boolean) => void;
setError: (message: string | null) => void;
onSaved: () => void | Promise<void>;
onStale?: () => void | Promise<void>;
},
) {
options.setBusy(true);
options.setError(null);
try {
await operation();
await options.onSaved();
} catch (error) {
if (isPayrollAdminStaleConflict(error) && options.onStale) {
await options.onStale();
return;
}
options.setError(getPayrollAdminError(error, options.fallback));
} finally {
options.setBusy(false);
}
}
export function PayrollPeriodFormModal({
isOpen,
period,
onClose,
onSubmit,
onSaved,
onStale,
}: {
isOpen: boolean;
period?: PayrollAdminPeriod | null;
onClose: () => void;
onSubmit: (payload: PayrollPeriodWrite) => Promise<unknown>;
onSaved: () => void | Promise<void>;
onStale?: () => void | Promise<void>;
}) {
const [form, setForm] = useState<PayrollPeriodWrite>(() => period ? {
code: period.code,
name: period.name,
date_from: period.date_from,
date_to: period.date_to,
pay_date: period.pay_date,
currency: period.currency,
} : {
code: "",
name: "",
date_from: "",
date_to: "",
pay_date: null,
currency: "RUB",
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = (event: FormEvent) => {
event.preventDefault();
void submitModalForm(() => onSubmit(form), {
fallback: "Не удалось сохранить расчётный период.",
setBusy,
setError,
onSaved,
onStale,
});
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={period ? "Параметры периода" : "Новый расчётный период"} size="md">
<form onSubmit={submit}>
<p className="app-text-muted mb-4 text-sm leading-relaxed">
Период задаёт границы расчёта и дату, которую сотрудники увидят в листке.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label>
<span className="app-field-label">Название</span>
<input className={inputClass} value={form.name} placeholder="Июль 2026" required onChange={(event) => setForm({ ...form, name: event.target.value })} />
</label>
<label>
<span className="app-field-label">Код периода</span>
<input className={inputClass} value={form.code} placeholder="2026-07" required disabled={Boolean(period)} onChange={(event) => setForm({ ...form, code: event.target.value })} />
</label>
<label>
<span className="app-field-label">Начало</span>
<input type="date" className={inputClass} value={form.date_from} required onChange={(event) => setForm({ ...form, date_from: event.target.value })} />
</label>
<label>
<span className="app-field-label">Окончание</span>
<input type="date" className={inputClass} value={form.date_to} required onChange={(event) => setForm({ ...form, date_to: event.target.value })} />
</label>
<label>
<span className="app-field-label">Дата выплаты</span>
<input type="date" className={inputClass} value={form.pay_date || ""} onChange={(event) => setForm({ ...form, pay_date: event.target.value || null })} />
</label>
<label>
<span className="app-field-label">Валюта</span>
<select className={selectClass} value={form.currency} onChange={(event) => setForm({ ...form, currency: event.target.value })}>
<option value="RUB">RUB — российский рубль</option>
</select>
</label>
</div>
<FormError message={error} />
<ModalActions busy={busy} onClose={onClose} submitLabel={period ? "Сохранить" : "Создать период"} />
</form>
</Modal>
);
}
export function PayrollRateFormModal({
isOpen,
period,
employees,
rate,
onClose,
onSubmit,
onSaved,
onStale,
}: {
isOpen: boolean;
period: PayrollAdminPeriod;
employees: PayrollAdminEmployee[];
rate?: PayrollAdminPayRate | null;
onClose: () => void;
onSubmit: (payload: PayrollPayRateWrite) => Promise<unknown>;
onSaved: () => void | Promise<void>;
onStale?: () => void | Promise<void>;
}) {
const [employeeId, setEmployeeId] = useState(() => rate ? String(rate.employee.id) : "");
const [amount, setAmount] = useState(() => rate?.amount || "");
const [pointRate, setPointRate] = useState(() => rate?.point_rate || "0");
const [effectiveFrom, setEffectiveFrom] = useState(() => rate?.effective_from || period.date_from);
const [reason, setReason] = useState(() => rate?.reason || "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = (event: FormEvent) => {
event.preventDefault();
void submitModalForm(() => onSubmit({
employee_id: Number(employeeId),
rate_code: "BASE",
amount,
point_rate: pointRate || "0",
currency: period.currency,
effective_from: effectiveFrom,
reason,
}), {
fallback: "Не удалось сохранить ставку.",
setBusy,
setError,
onSaved,
onStale,
});
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={rate ? "Изменить черновик ставки" : "Новая ставка"} size="md">
<form onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2">
<span className="app-field-label">Сотрудник</span>
<select className={selectClass} required value={employeeId} disabled={Boolean(rate)} onChange={(event) => setEmployeeId(event.target.value)}>
<option value="">Выберите сотрудника</option>
{employees.map((employee) => <option value={employee.id} key={employee.id}>{employee.display_name}{employee.position ? ` — ${employee.position}` : ""}</option>)}
</select>
</label>
<label>
<span className="app-field-label">Оклад за период</span>
<input type="number" className={inputClass} inputMode="decimal" min="0.01" step="0.01" value={amount} required onChange={(event) => setAmount(event.target.value)} />
</label>
<label>
<span className="app-field-label">Цена балла сверх нормы</span>
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.01" value={pointRate} required onChange={(event) => setPointRate(event.target.value)} />
</label>
<label>
<span className="app-field-label">Действует с</span>
<input type="date" className={inputClass} value={effectiveFrom} required onChange={(event) => setEffectiveFrom(event.target.value)} />
</label>
<label>
<span className="app-field-label">Валюта</span>
<input className={inputClass} value={period.currency} disabled />
</label>
<label className="sm:col-span-2">
<span className="app-field-label">Основание</span>
<textarea className={`${inputClass} min-h-20 resize-y`} value={reason} placeholder="Приказ, договор или пояснение к изменению" onChange={(event) => setReason(event.target.value)} />
</label>
</div>
<FormError message={error} />
<ModalActions busy={busy} onClose={onClose} submitLabel="Сохранить черновик" />
</form>
</Modal>
);
}
export function PayrollWorkRecordFormModal({
isOpen,
period,
employees,
record,
onClose,
onSubmit,
onSaved,
onStale,
}: {
isOpen: boolean;
period: PayrollAdminPeriod;
employees: PayrollAdminEmployee[];
record?: PayrollAdminWorkRecord | null;
onClose: () => void;
onSubmit: (payload: PayrollWorkRecordWrite) => Promise<unknown>;
onSaved: () => void | Promise<void>;
onStale?: () => void | Promise<void>;
}) {
const fromAttendance = record?.source === "attendance";
const [form, setForm] = useState(() => ({
employee_id: record ? String(record.employee.id) : "",
target_points: record?.target_points || "",
actual_points: record?.actual_points || "",
expected_point_amount: record?.expected_point_amount || "",
expected_gross: record?.expected_gross || "",
expected_recalculated_gross: record?.expected_recalculated_gross || "",
expected_payable: record?.expected_payable || "",
reason: record?.reason || "",
}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const optionalMoney = (value: string) => value.trim() || null;
const submit = (event: FormEvent) => {
event.preventDefault();
void submitModalForm(() => onSubmit({
period_id: period.id,
employee_id: Number(form.employee_id),
target_points: form.target_points,
actual_points: form.actual_points,
expected_point_amount: optionalMoney(form.expected_point_amount),
expected_gross: optionalMoney(form.expected_gross),
expected_recalculated_gross: optionalMoney(form.expected_recalculated_gross),
expected_payable: optionalMoney(form.expected_payable),
reason: form.reason,
}), {
fallback: "Не удалось сохранить выработку.",
setBusy,
setError,
onSaved,
onStale,
});
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={record ? "Изменить черновик выработки" : "Добавить выработку"} size="lg">
<form onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2">
<span className="app-field-label">Сотрудник</span>
<select className={selectClass} required value={form.employee_id} disabled={Boolean(record)} onChange={(event) => setForm({ ...form, employee_id: event.target.value })}>
<option value="">Выберите сотрудника</option>
{employees.map((employee) => <option value={employee.id} key={employee.id}>{employee.display_name}{employee.position ? ` — ${employee.position}` : ""}</option>)}
</select>
</label>
<label>
<span className="app-field-label">{fromAttendance ? "Норма, часы" : "Норма баллов"}</span>
<input type="number" className={inputClass} inputMode="decimal" min="0.0001" step="0.0001" required value={form.target_points} onChange={(event) => setForm({ ...form, target_points: event.target.value })} />
</label>
<label>
<span className="app-field-label">{fromAttendance ? "Отработано, часы" : "Фактические баллы"}</span>
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.0001" required value={form.actual_points} onChange={(event) => setForm({ ...form, actual_points: event.target.value })} />
</label>
</div>
<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>
<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>
<label><span className="app-field-label">Перерасчёт</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_recalculated_gross} onChange={(event) => setForm({ ...form, expected_recalculated_gross: event.target.value })} /></label>
<label><span className="app-field-label">К выплате</span><input type="number" className={inputClass} inputMode="decimal" step="0.01" value={form.expected_payable} onChange={(event) => setForm({ ...form, expected_payable: event.target.value })} /></label>
</div>
</details>
<label className="mt-4 block">
<span className="app-field-label">Основание изменения</span>
<textarea className={`${inputClass} min-h-20 resize-y`} value={form.reason} placeholder="Обязательно для новой ревизии" onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</label>
<FormError message={error} />
<ModalActions busy={busy} onClose={onClose} submitLabel="Сохранить черновик" />
</form>
</Modal>
);
}
export function PayrollInputLineFormModal({
isOpen,
period,
periods,
employees,
components,
line,
onClose,
onSubmit,
onSaved,
onStale,
}: {
isOpen: boolean;
period: PayrollAdminPeriod;
periods: PayrollAdminPeriod[];
employees: PayrollAdminEmployee[];
components: PayrollComponent[];
line?: PayrollAdminInputLine | null;
onClose: () => void;
onSubmit: (payload: PayrollInputLineWrite) => Promise<unknown>;
onSaved: () => void | Promise<void>;
onStale?: () => void | Promise<void>;
}) {
const [form, setForm] = useState(() => ({
employee_id: line ? String(line.employee.id) : "",
component_id: line ? String(line.component.id) : "",
amount: line?.amount || "",
relates_to_period_id: line?.relates_to_period_id ? String(line.relates_to_period_id) : "",
reason: line?.reason || "",
}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const component = useMemo(() => components.find((item) => item.id === Number(form.component_id)), [components, form.component_id]);
const submit = (event: FormEvent) => {
event.preventDefault();
void submitModalForm(() => onSubmit({
period_id: period.id,
employee_id: Number(form.employee_id),
component_id: Number(form.component_id),
amount: form.amount,
relates_to_period_id: form.relates_to_period_id ? Number(form.relates_to_period_id) : null,
reason: form.reason,
}), {
fallback: "Не удалось сохранить начисление.",
setBusy,
setError,
onSaved,
onStale,
});
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={line ? "Изменить строку" : "Новое начисление или выплата"} size="md">
<form onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2">
<span className="app-field-label">Сотрудник</span>
<select className={selectClass} required disabled={Boolean(line)} value={form.employee_id} onChange={(event) => setForm({ ...form, employee_id: event.target.value })}>
<option value="">Выберите сотрудника</option>
{employees.map((employee) => <option value={employee.id} key={employee.id}>{employee.display_name}{employee.position ? ` — ${employee.position}` : ""}</option>)}
</select>
</label>
<label className="sm:col-span-2">
<span className="app-field-label">Вид операции</span>
<select className={selectClass} required disabled={Boolean(line)} value={form.component_id} onChange={(event) => setForm({ ...form, component_id: event.target.value })}>
<option value="">Выберите вид</option>
{components.filter((item) => item.is_active).map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</select>
</label>
<label>
<span className="app-field-label">Сумма</span>
<input type="number" className={inputClass} inputMode="decimal" min="0.01" step="0.01" required value={form.amount} onChange={(event) => setForm({ ...form, amount: event.target.value })} />
</label>
<label>
<span className="app-field-label">Относится к периоду</span>
<select className={selectClass} value={form.relates_to_period_id} onChange={(event) => setForm({ ...form, relates_to_period_id: event.target.value })}>
<option value="">Текущий период</option>
{periods.filter((item) => item.id !== period.id).map((item) => <option key={item.id} value={item.id}>{item.name || item.code}</option>)}
</select>
</label>
<label className="sm:col-span-2">
<span className="app-field-label">Основание{component?.requires_reason || form.relates_to_period_id ? " *" : ""}</span>
<textarea className={`${inputClass} min-h-20 resize-y`} required={Boolean(component?.requires_reason || form.relates_to_period_id)} value={form.reason} placeholder="Премия, приказ, причина корректировки" onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</label>
</div>
<FormError message={error} />
<ModalActions busy={busy} onClose={onClose} submitLabel="Сохранить черновик" />
</form>
</Modal>
);
}
export function PayrollConfirmModal({
isOpen,
title,
description,
confirmLabel,
reasonLabel,
reasonRequired = false,
warning,
onClose,
onConfirm,
onDone,
onStale,
}: {
isOpen: boolean;
title: string;
description: string;
confirmLabel: string;
reasonLabel?: string;
reasonRequired?: boolean;
warning?: string;
onClose: () => void;
onConfirm: (reason: string) => Promise<unknown>;
onDone: () => void | Promise<void>;
onStale?: () => void | Promise<void>;
}) {
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = (event: FormEvent) => {
event.preventDefault();
void submitModalForm(() => onConfirm(reason), {
fallback: "Не удалось выполнить действие.",
setBusy,
setError,
onSaved: onDone,
onStale,
});
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<form onSubmit={submit}>
<p className="app-text-muted text-sm leading-relaxed">{description}</p>
{warning ? (
<div className="app-feedback-warning mt-4 flex items-start gap-2 rounded-lg px-3 py-2.5 text-sm">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>{warning}</span>
</div>
) : null}
{reasonLabel ? (
<label className="mt-4 block">
<span className="app-field-label">{reasonLabel}</span>
<textarea className={`${inputClass} min-h-24 resize-y`} required={reasonRequired} value={reason} onChange={(event) => setReason(event.target.value)} />
</label>
) : null}
<FormError message={error} />
<ModalActions busy={busy} onClose={onClose} submitLabel={confirmLabel} />
</form>
</Modal>
);
}

View File

@ -0,0 +1,355 @@
"use client";
import { CalendarCheck2, Check, Loader2, Pencil, RotateCcw } from "lucide-react";
import type {
PayrollAdminInputLine,
PayrollAdminPayRate,
PayrollAdminWorkRecord,
PayrollApprovalStatus,
} from "@/lib/api/finance";
import { formatPayrollDate, formatPayrollMoney } from "@/lib/payroll";
import { formatPayrollWorkMetric, isAttendancePayrollWorkRecord } from "@/lib/payroll-attendance";
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;
}) {
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>
);
}
function ListState({ loading, error, empty }: { loading: boolean; error: string | null; empty: boolean }) {
if (loading) {
return (
<div className="flex min-h-40 items-center justify-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 className="animate-spin" size={18} /> Загрузка данных…
</div>
);
}
if (error) return <div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</div>;
if (empty) return <div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">По выбранным условиям записей нет.</div>;
return null;
}
function ActionButton({
label,
icon,
onClick,
tone = "secondary",
disabled = false,
tooltip,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
tone?: "secondary" | "success";
disabled?: boolean;
tooltip?: string;
}) {
return (
<button
type="button"
className={`${tone === "success" ? "app-action-success" : "app-action-secondary"} inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50`}
onClick={onClick}
disabled={disabled}
title={tooltip || label}
>
{icon}
{label}
</button>
);
}
function RecordActions({
status,
isAuthor,
canManage,
canApprove,
canOverrideApproval,
onEdit,
onApprove,
onRevise,
}: {
status: PayrollApprovalStatus;
isAuthor: boolean;
canManage: boolean;
canApprove: boolean;
canOverrideApproval: 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}
</div>
);
}
if (status === "approved" && canManage && onRevise) {
return <ActionButton label="Новая версия" icon={<RotateCcw size={13} />} onClick={onRevise} />;
}
return null;
}
export function PayrollRatesTable({
records,
loading,
error,
currentUserId,
canManage,
canApprove,
canOverrideApproval,
onEdit,
onApprove,
onRevise,
}: {
records: PayrollAdminPayRate[];
loading: boolean;
error: string | null;
currentUserId?: number;
canManage: boolean;
canApprove: boolean;
canOverrideApproval: boolean;
onEdit: (record: PayrollAdminPayRate) => void;
onApprove: (record: PayrollAdminPayRate) => void;
onRevise: (record: PayrollAdminPayRate) => void;
}) {
const state = <ListState loading={loading} error={error} empty={records.length === 0} />;
if (loading || error || records.length === 0) return state;
return (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
<div className="md:min-w-[900px]">
<div className="app-surface-muted hidden grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Сотрудник</span><span>Оклад</span><span>Цена балла</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{records.map((record) => (
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] md:items-center">
<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><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)} />
</div>
))}
</div>
</div>
</div>
);
}
export function PayrollWorkRecordsTable({
records,
loading,
error,
currentUserId,
canManage,
canApprove,
canOverrideApproval,
onEdit,
onApprove,
onRevise,
}: {
records: PayrollAdminWorkRecord[];
loading: boolean;
error: string | null;
currentUserId?: number;
canManage: boolean;
canApprove: boolean;
canOverrideApproval: boolean;
onEdit: (record: PayrollAdminWorkRecord) => void;
onApprove: (record: PayrollAdminWorkRecord) => void;
onRevise: (record: PayrollAdminWorkRecord) => void;
}) {
const state = <ListState loading={loading} error={error} empty={records.length === 0} />;
if (loading || error || records.length === 0) return state;
return (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
<div className="md:min-w-[880px]">
<div className="app-surface-muted hidden grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Сотрудник</span><span>Норма</span><span>Факт</span><span>Контроль</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{records.map((record) => {
const fromAttendance = isAttendancePayrollWorkRecord(record.source);
return (
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] md:items-center">
<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>
{fromAttendance ? (
<span className="app-selected mt-1.5 inline-flex items-center gap-1 rounded-full px-2 py-1 text-[10px] font-semibold leading-none">
<CalendarCheck2 size={11} /> Из посещаемости
</span>
) : null}
</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.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)} />
</div>
);
})}
</div>
</div>
</div>
);
}
export function PayrollInputLinesTable({
records,
loading,
error,
currentUserId,
canManage,
canApprove,
canOverrideApproval,
onEdit,
onApprove,
}: {
records: PayrollAdminInputLine[];
loading: boolean;
error: string | null;
currentUserId?: number;
canManage: boolean;
canApprove: boolean;
canOverrideApproval: boolean;
onEdit: (record: PayrollAdminInputLine) => void;
onApprove: (record: PayrollAdminInputLine) => void;
}) {
const state = <ListState loading={loading} error={error} empty={records.length === 0} />;
if (loading || error || records.length === 0) return state;
return (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
<div className="md:min-w-[800px]">
<div className="app-surface-muted hidden grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Сотрудник</span><span>Операция</span><span>Сумма</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{records.map((record) => (
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] md:items-center">
<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)} />
</div>
))}
</div>
</div>
</div>
);
}
type ApprovalQueueItem = {
key: string;
group: "rate" | "work" | "input";
title: string;
subtitle: string;
value: string;
createdById: number;
raw: PayrollAdminPayRate | PayrollAdminWorkRecord | PayrollAdminInputLine;
};
export function PayrollApprovalQueue({
rates,
workRecords,
inputLines,
currentUserId,
canApprove,
canOverrideApproval,
onApproveRate,
onApproveWork,
onApproveInput,
}: {
rates: PayrollAdminPayRate[];
workRecords: PayrollAdminWorkRecord[];
inputLines: PayrollAdminInputLine[];
currentUserId?: number;
canApprove: boolean;
canOverrideApproval: boolean;
onApproveRate: (record: PayrollAdminPayRate) => void;
onApproveWork: (record: PayrollAdminWorkRecord) => void;
onApproveInput: (record: PayrollAdminInputLine) => void;
}) {
const items: ApprovalQueueItem[] = [
...rates.filter((item) => item.status === "draft").map((item) => ({ key: `rate-${item.id}`, group: "rate" as const, title: item.employee.display_name, subtitle: "Базовая ставка", value: formatPayrollMoney(item.amount, item.currency), createdById: item.created_by.id, raw: item })),
...workRecords.filter((item) => item.status === "draft").map((item) => ({ key: `work-${item.id}`, group: "work" as const, title: item.employee.display_name, subtitle: `Выработка: ${item.actual_points} из ${item.target_points} баллов`, value: "", createdById: item.created_by.id, raw: item })),
...inputLines.filter((item) => item.status === "draft").map((item) => ({ key: `input-${item.id}`, group: "input" as const, title: item.employee.display_name, subtitle: item.component.name, value: formatPayrollMoney(item.amount), createdById: item.created_by.id, raw: item })),
];
if (items.length === 0) return <div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">Нет черновиков, ожидающих решения.</div>;
return (
<div className="divide-y divide-[var(--border-subtle)] overflow-hidden rounded-xl border border-[var(--border-subtle)]">
{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 approve = () => {
if (item.group === "rate") onApproveRate(item.raw as PayrollAdminPayRate);
else if (item.group === "work") onApproveWork(item.raw as PayrollAdminWorkRecord);
else onApproveInput(item.raw as PayrollAdminInputLine);
};
return (
<div key={item.key} className="flex flex-col gap-3 bg-[var(--surface-primary)] px-4 py-3 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{item.title}</p><p className="app-text-muted mt-0.5 truncate text-xs">{item.subtitle}</p></div>
{item.value ? <p className="shrink-0 text-sm font-semibold text-[var(--foreground)]">{item.value}</p> : null}
<ActionButton label={approvalLabel} icon={<Check size={13} />} onClick={approve} tone="success" disabled={!approvalAllowed} tooltip={approvalTooltip} />
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,564 @@
"use client";
import {
AlertCircle,
ArrowLeft,
Banknote,
CalendarCheck2,
Calculator,
Check,
CheckCircle2,
ChevronRight,
Clock3,
FileCheck2,
Loader2,
Pencil,
Plus,
Search,
Send,
ShieldCheck,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
PayrollConfirmModal,
PayrollInputLineFormModal,
PayrollPeriodFormModal,
PayrollRateFormModal,
PayrollWorkRecordFormModal,
} from "@/components/finance/admin/PayrollAdminModals";
import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal";
import {
PayrollApprovalQueue,
PayrollInputLinesTable,
PayrollRatesTable,
PayrollWorkRecordsTable,
} from "@/components/finance/admin/PayrollAdminTables";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import type {
PayrollAdminInputLine,
PayrollAdminPayRate,
PayrollAdminPeriod,
PayrollAdminRun,
PayrollAdminWorkspace as WorkspacePayload,
PayrollAdminWorkRecord,
PayrollApprovalStatus,
PayrollInputLineWrite,
PayrollPayRateWrite,
PayrollPeriodWrite,
PayrollWorkRecordWrite,
} from "@/lib/api/finance";
import {
getPayrollAdminError,
getPrimaryPayrollRunAction,
normalizePayrollAdminList,
PAYROLL_ADMIN_TABS,
PAYROLL_SELF_APPROVAL_RECORDED,
PAYROLL_SELF_APPROVAL_WARNING,
payrollRunActionLabels,
periodStatusMeta,
runStatusMeta,
type PayrollAdminTab,
type PayrollRunAction,
} from "@/lib/payroll-admin";
import { buildPayrollAttendanceApplyNotice } from "@/lib/payroll-attendance";
import { formatPayrollDate, formatPayrollDateTime, formatPayrollMoney, getPayrollPeriodRange } from "@/lib/payroll";
type PendingAction = {
title: string;
description: string;
confirmLabel: string;
reasonLabel?: string;
reasonRequired?: boolean;
warning?: string;
successMessage: string;
execute: (reason: string) => Promise<unknown>;
};
function StatusPill({ period }: { period: PayrollAdminPeriod }) {
const meta = periodStatusMeta[period.status];
return <span className={`app-status-pill ${meta.className}`}>{meta.label}</span>;
}
function SummaryCards({ workspace }: { workspace: WorkspacePayload }) {
const summary = workspace.summary;
const hiddenValue = workspace.current_run ? "Скрыто" : "—";
const values = [
{ label: "Сотрудников", value: summary ? String(summary.employee_count) : String(workspace.current_run?.employee_count ?? workspace.readiness.work_records.total), icon: UsersRound },
{ label: "Начислено", value: summary?.gross_total != null ? formatPayrollMoney(summary.gross_total, workspace.selected_period?.currency) : hiddenValue, icon: Banknote },
{ label: "Удержано", value: summary?.deduction_total != null ? formatPayrollMoney(summary.deduction_total, workspace.selected_period?.currency) : hiddenValue, icon: FileCheck2 },
{ label: "К выплате", value: summary?.payable_total != null ? formatPayrollMoney(summary.payable_total, workspace.selected_period?.currency) : hiddenValue, icon: Calculator },
];
return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{values.map(({ label, value, icon: Icon }) => (
<div key={label} className="app-surface-muted rounded-xl p-3.5">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]"><Icon size={15} /><span className="text-xs">{label}</span></div>
<p className="mt-2 truncate text-lg font-semibold text-[var(--foreground)]" title={value}>{value}</p>
</div>
))}
</div>
);
}
function TableToolbar({
search,
status,
onSearch,
onStatus,
onAdd,
onFillFromAttendance,
addLabel,
}: {
search: string;
status: "" | PayrollApprovalStatus;
onSearch: (value: string) => void;
onStatus: (value: "" | PayrollApprovalStatus) => void;
onAdd?: () => void;
onFillFromAttendance?: () => void;
addLabel: string;
}) {
return (
<div className="mb-4 flex flex-col gap-2 sm:flex-row">
<label className="relative min-w-0 flex-1">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотруднику" onChange={(event) => onSearch(event.target.value)} />
</label>
<select className="app-select rounded-lg px-3 py-2.5 text-sm sm:w-44" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
<option value="">Все статусы</option><option value="draft">Черновики</option><option value="approved">Утверждённые</option><option value="voided">Аннулированные</option>
</select>
{onFillFromAttendance ? (
<button type="button" className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-medium" onClick={onFillFromAttendance}>
<CalendarCheck2 size={16} /> Заполнить из посещаемости
</button>
) : null}
{onAdd ? (
<button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
<Plus size={16} /> {addLabel}
</button>
) : null}
</div>
);
}
function SectionIntro({ title, description }: { title: string; description: string }) {
return <div className="mb-4"><h2 className="text-base font-semibold text-[var(--foreground)]">{title}</h2><p className="app-text-muted mt-1 text-sm">{description}</p></div>;
}
function RunCard({ run, current = false }: { run: PayrollAdminRun; current?: boolean }) {
const meta = runStatusMeta[run.status];
return (
<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>
</div>
{run.payable_total != null ? (
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-[var(--muted-foreground)]">
<span>Сотрудников: <b className="text-[var(--foreground)]">{run.employee_count}</b></span>
<span>К выплате: <b className="text-[var(--foreground)]">{formatPayrollMoney(run.payable_total)}</b></span>
</div>
) : null}
{run.recalculation_reason ? <p className="app-text-muted mt-3 text-xs">Основание: {run.recalculation_reason}</p> : null}
</div>
);
}
type PayrollAdminWorkspaceProps = {
actionsTargetId?: string;
embedded?: boolean;
headerTargetId?: string;
};
export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, headerTargetId }: PayrollAdminWorkspaceProps) {
const { user } = useUser();
const [workspace, setWorkspace] = useState<WorkspacePayload | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [tab, setTab] = useState<PayrollAdminTab>("readiness");
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"" | PayrollApprovalStatus>("");
const [rates, setRates] = useState<PayrollAdminPayRate[]>([]);
const [workRecords, setWorkRecords] = useState<PayrollAdminWorkRecord[]>([]);
const [inputLines, setInputLines] = useState<PayrollAdminInputLine[]>([]);
const [tableLoading, setTableLoading] = useState(false);
const [tableError, setTableError] = useState<string | null>(null);
const [tableVersion, setTableVersion] = useState(0);
const [notice, setNotice] = useState<string | null>(null);
const [periodModalOpen, setPeriodModalOpen] = useState(false);
const [editingPeriod, setEditingPeriod] = useState<PayrollAdminPeriod | null>(null);
const [rateModalOpen, setRateModalOpen] = useState(false);
const [editingRate, setEditingRate] = useState<PayrollAdminPayRate | null>(null);
const [workModalOpen, setWorkModalOpen] = useState(false);
const [editingWork, setEditingWork] = useState<PayrollAdminWorkRecord | null>(null);
const [attendanceWorkModalOpen, setAttendanceWorkModalOpen] = useState(false);
const [inputModalOpen, setInputModalOpen] = useState(false);
const [editingInput, setEditingInput] = useState<PayrollAdminInputLine | null>(null);
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const createdPeriodIdRef = useRef<number | null>(null);
const selectedPeriod = workspace?.selected_period || null;
const loadWorkspace = useCallback(async (periodId?: number, silent = false) => {
if (!silent) setLoading(true);
setLoadError(null);
try {
const payload = await apiClient.getPayrollAdminWorkspace(periodId);
setWorkspace(payload);
} catch (error) {
setLoadError(getPayrollAdminError(error, "Не удалось загрузить управление зарплатой."));
} finally {
if (!silent) setLoading(false);
}
}, []);
useEffect(() => { void loadWorkspace(); }, [loadWorkspace]);
useEffect(() => {
if (!selectedPeriod) {
setRates([]); setWorkRecords([]); setInputLines([]);
return;
}
if (tab === "readiness") return;
const canLoadInputs = Boolean(workspace?.permissions.manage_inputs || workspace?.permissions.approve_inputs || workspace?.permissions.view_all);
if (["rates", "work", "inputs"].includes(tab) && !canLoadInputs) {
setRates([]); setWorkRecords([]); setInputLines([]); setTableError(null); setTableLoading(false);
return;
}
if (tab === "approval" && !canLoadInputs) {
setRates([]); setWorkRecords([]); setInputLines([]); setTableError(null); setTableLoading(false);
return;
}
let cancelled = false;
setTableLoading(true);
setTableError(null);
const params = { period_id: selectedPeriod.id };
const load = async () => {
try {
if (tab === "rates") {
const payload = await apiClient.getPayrollAdminPayRates(params);
if (!cancelled) setRates(normalizePayrollAdminList(payload));
} else if (tab === "work") {
const payload = await apiClient.getPayrollAdminWorkRecords(params);
if (!cancelled) setWorkRecords(normalizePayrollAdminList(payload));
} else if (tab === "inputs") {
const payload = await apiClient.getPayrollAdminInputLines(params);
if (!cancelled) setInputLines(normalizePayrollAdminList(payload));
} else {
const [ratesPayload, workPayload, inputsPayload] = await Promise.all([
apiClient.getPayrollAdminPayRates({ ...params, status: "draft" }),
apiClient.getPayrollAdminWorkRecords({ ...params, status: "draft" }),
apiClient.getPayrollAdminInputLines({ ...params, status: "draft" }),
]);
if (!cancelled) {
setRates(normalizePayrollAdminList(ratesPayload));
setWorkRecords(normalizePayrollAdminList(workPayload));
setInputLines(normalizePayrollAdminList(inputsPayload));
}
}
} catch (error) {
if (!cancelled) setTableError(getPayrollAdminError(error, "Не удалось загрузить данные раздела."));
} finally {
if (!cancelled) setTableLoading(false);
}
};
void load();
return () => { cancelled = true; };
}, [
selectedPeriod,
tab,
tableVersion,
workspace?.permissions.approve_inputs,
workspace?.permissions.manage_inputs,
workspace?.permissions.view_all,
]);
const refreshView = useCallback(async (message?: string) => {
if (message) setNotice(message);
await loadWorkspace(selectedPeriod?.id, true);
setTableVersion((value) => value + 1);
}, [loadWorkspace, selectedPeriod?.id]);
const handleStale = useCallback(async () => {
setPeriodModalOpen(false); setRateModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null);
await refreshView("Данные изменились в другой сессии. Экран обновлён — повторите действие.");
}, [refreshView]);
const filteredRates = useMemo(() => rates.filter((record) => {
const needle = search.trim().toLocaleLowerCase("ru-RU");
return (!status || record.status === status) && (!needle || `${record.employee.display_name} ${record.reason}`.toLocaleLowerCase("ru-RU").includes(needle));
}), [rates, search, status]);
const filteredWork = useMemo(() => workRecords.filter((record) => {
const needle = search.trim().toLocaleLowerCase("ru-RU");
return (!status || record.status === status) && (!needle || `${record.employee.display_name} ${record.reason}`.toLocaleLowerCase("ru-RU").includes(needle));
}), [workRecords, search, status]);
const filteredInputs = useMemo(() => inputLines.filter((record) => {
const needle = search.trim().toLocaleLowerCase("ru-RU");
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),
});
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),
});
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),
});
const openReviseRate = (record: PayrollAdminPayRate) => setPendingAction({
title: "Создать новую версию ставки", description: `Будет создан отдельный черновик ставки для ${record.employee.display_name}.`, confirmLabel: "Создать черновик", reasonLabel: "Основание изменения", reasonRequired: true, successMessage: "Новая версия ставки создана в черновиках.", execute: (reason) => apiClient.revisePayrollAdminPayRate(record.id, reason),
});
const openReviseWork = (record: PayrollAdminWorkRecord) => setPendingAction({
title: "Создать новую версию выработки", description: `Будет создан отдельный черновик выработки для ${record.employee.display_name}.`, confirmLabel: "Создать черновик", reasonLabel: "Причина исправления", reasonRequired: true, successMessage: "Новая версия выработки создана в черновиках.", execute: (reason) => apiClient.revisePayrollAdminWorkRecord(record.id, reason),
});
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 ? "Запустить перерасчёт" : "Запустить расчёт",
description: `Будет рассчитана зарплата за ${selectedPeriod.name || selectedPeriod.code} для всех сотрудников с утверждёнными данными.`,
confirmLabel: run ? "Пересчитать" : "Рассчитать",
reasonLabel: run ? "Основание перерасчёта" : undefined,
reasonRequired: Boolean(run),
warning: "Результат сохранится как отдельная неизменяемая ревизия.",
successMessage: run ? "Новая ревизия рассчитана." : "Расчёт выполнен.",
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) },
publish: { title: "Опубликовать расчётные листки", description: "После публикации сотрудники увидят суммы в разделе «Финансы».", confirmLabel: "Опубликовать", warning: "Проверьте дату выплаты и итог к выплате. Публикация сразу открывает листки сотрудникам.", successMessage: "Расчётные листки опубликованы.", execute: () => apiClient.publishPayrollAdminRun(run!.id) },
close: { title: "Закрыть период", description: "Закрытый период больше нельзя пересчитывать.", confirmLabel: "Закрыть период", warning: "Это финальное действие для расчётного периода.", successMessage: "Период закрыт.", execute: () => apiClient.closePayrollAdminPeriod(selectedPeriod.id) },
};
setPendingAction(configs[action]);
};
const openReturn = () => {
const run = workspace?.current_run;
if (!run) return;
setPendingAction({ title: "Вернуть расчёт на исправление", description: "Текущая ревизия останется в истории, а данные можно будет исправить и пересчитать.", confirmLabel: "Вернуть", reasonLabel: "Причина возврата", reasonRequired: true, warning: "Для продолжения потребуется новая ревизия расчёта.", successMessage: "Расчёт возвращён на исправление.", execute: (reason) => apiClient.returnPayrollAdminRun(run.id, reason) });
};
if (loading) return <section className="app-surface flex min-h-72 items-center justify-center rounded-2xl"><Loader2 className="app-accent-text animate-spin" size={28} /></section>;
if (loadError || !workspace) {
return (
<section className="app-surface rounded-2xl p-5">
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{loadError || "Раздел управления недоступен."}</div>
<div className="mt-4 flex gap-2">{!embedded ? <Link href="/finances" className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium">Вернуться</Link> : null}<button type="button" className="app-action-primary rounded-lg px-4 py-2.5 text-sm font-medium" onClick={() => void loadWorkspace()}>Повторить</button></div>
</section>
);
}
const permissions = workspace.permissions;
const canOpenInputTabs = permissions.manage_inputs || permissions.approve_inputs || permissions.view_all;
const visibleTabs = PAYROLL_ADMIN_TABS.filter((item) => (
!["rates", "work", "inputs"].includes(item.value) || canOpenInputTabs
));
const primaryAction = selectedPeriod ? getPrimaryPayrollRunAction(workspace.current_run, selectedPeriod.status, permissions, workspace.readiness.calculation.ready, user?.id) : null;
const canReturn = Boolean(workspace.current_run && ["calculated", "review", "approved"].includes(workspace.current_run.status) && (workspace.current_run.status === "calculated" ? permissions.calculate : permissions.approve_run));
const sourceDataLocked = selectedPeriod?.status === "closed" || Boolean(
workspace.current_run && ["calculated", "review", "approved"].includes(workspace.current_run.status),
);
const canManageSourceData = permissions.manage_inputs && !sourceDataLocked;
const canApproveSourceData = permissions.approve_inputs && !sourceDataLocked;
const savePeriod = async (payload: PayrollPeriodWrite) => {
if (editingPeriod) await apiClient.updatePayrollAdminPeriod(editingPeriod.id, { ...payload, expected_lock_version: editingPeriod.lock_version });
else {
const createdPeriod = await apiClient.createPayrollAdminPeriod(payload);
createdPeriodIdRef.current = createdPeriod.id;
}
};
const saveRate = async (payload: PayrollPayRateWrite) => {
if (editingRate) await apiClient.updatePayrollAdminPayRate(editingRate.id, { amount: payload.amount, point_rate: payload.point_rate, effective_from: payload.effective_from, reason: payload.reason, expected_lock_version: editingRate.lock_version });
else await apiClient.createPayrollAdminPayRate(payload);
};
const saveWork = async (payload: PayrollWorkRecordWrite) => {
if (editingWork) await apiClient.updatePayrollAdminWorkRecord(editingWork.id, { target_points: payload.target_points, actual_points: payload.actual_points, expected_point_amount: payload.expected_point_amount, expected_gross: payload.expected_gross, expected_recalculated_gross: payload.expected_recalculated_gross, expected_payable: payload.expected_payable, reason: payload.reason, expected_lock_version: editingWork.lock_version });
else await apiClient.createPayrollAdminWorkRecord(payload);
};
const saveInput = async (payload: PayrollInputLineWrite) => {
if (editingInput) await apiClient.updatePayrollAdminInputLine(editingInput.id, { amount: payload.amount, relates_to_period_id: payload.relates_to_period_id, reason: payload.reason, expected_lock_version: editingInput.lock_version });
else await apiClient.createPayrollAdminInputLine(payload);
};
const managementInfo = (
<div className="min-w-0">
{!embedded ? <Link href="/finances" className="app-link-accent inline-flex items-center gap-1.5 text-xs font-medium"><ArrowLeft size={14} /> Финансы</Link> : null}
<div className={`${embedded ? "" : "mt-2"} flex flex-wrap items-center gap-2`}><h1 className="text-xl font-semibold text-[var(--foreground)] sm:text-2xl">Управление зарплатой</h1>{selectedPeriod ? <StatusPill period={selectedPeriod} /> : null}</div>
{selectedPeriod ? <p className="app-text-muted mt-1 text-sm">{getPayrollPeriodRange(selectedPeriod)} · выплата {formatPayrollDate(selectedPeriod.pay_date)}</p> : <p className="app-text-muted mt-1 text-sm">Создайте первый расчётный период, чтобы начать подготовку.</p>}
</div>
);
const managementActions = (
<div className="flex flex-col gap-2 sm:flex-row">
{workspace.periods.length ? (
<select className="app-select min-w-52 rounded-lg px-3 py-2.5 text-sm" value={selectedPeriod?.id || ""} onChange={(event) => void loadWorkspace(Number(event.target.value), true)} aria-label="Расчётный период">
{workspace.periods.map((period) => <option value={period.id} key={period.id}>{period.name || period.code}</option>)}
</select>
) : null}
{permissions.manage_inputs ? <button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold" onClick={() => { setEditingPeriod(null); setPeriodModalOpen(true); }}><Plus size={16} /> Новый период</button> : null}
</div>
);
const headerTarget = headerTargetId && typeof document !== "undefined"
? document.getElementById(headerTargetId)
: null;
const actionsTarget = actionsTargetId && typeof document !== "undefined"
? document.getElementById(actionsTargetId)
: null;
return (
<div className="space-y-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1">
{headerTargetId ? (
<>
{headerTarget ? createPortal(managementInfo, headerTarget) : null}
{actionsTarget ? createPortal(managementActions, actionsTarget) : null}
</>
) : (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">{managementInfo}{managementActions}</div>
</section>
)}
{notice ? <div className="app-feedback-success flex items-start justify-between gap-3 rounded-xl px-4 py-3 text-sm"><span>{notice}</span><button type="button" onClick={() => setNotice(null)} aria-label="Скрыть сообщение">×</button></div> : null}
{!selectedPeriod ? (
<section className="app-surface rounded-2xl p-8 text-center"><CalendarEmpty /><p className="app-text-muted mx-auto mt-2 max-w-md text-sm">Без периода нельзя ввести ставки, выработку и начисления.</p>{permissions.manage_inputs ? <button type="button" className="app-action-primary mt-5 inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold" onClick={() => setPeriodModalOpen(true)}><Plus size={16} /> Создать период</button> : null}</section>
) : (
<>
<section className="app-surface rounded-2xl p-4 sm:p-5"><SummaryCards workspace={workspace} /></section>
<section className="app-surface rounded-2xl p-2 sm:p-3">
<div className="flex gap-1 overflow-x-auto" role="tablist" aria-label="Разделы управления зарплатой">
{visibleTabs.map((item) => {
const count = item.value === "approval" ? workspace.pending_approvals.rates + workspace.pending_approvals.work_records + workspace.pending_approvals.input_lines : 0;
return <button type="button" role="tab" aria-selected={tab === item.value} key={item.value} className={`${tab === item.value ? "app-chip-active" : "app-chip"} inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium sm:text-sm`} onClick={() => { setTab(item.value); setSearch(""); setStatus(""); }}>{item.label}{count > 0 ? <span className={`${tab === item.value ? "bg-white/20 text-white" : "app-counter"} inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px]`}>{count}</span> : null}</button>;
})}
</div>
</section>
{tab === "readiness" ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<div>
<SectionIntro title="Готовность к расчёту" description="Система запускает расчёт только по утверждённым данным и показывает, что ещё нужно подготовить." />
<div className="space-y-2">
{[
{ label: "Ставки сотрудников", value: `${workspace.readiness.rates.approved} из ${workspace.readiness.rates.total}`, ready: workspace.readiness.rates.ready, tab: "rates" as const },
{ label: "Выработка и состав", value: `${workspace.readiness.work_records.approved} из ${workspace.readiness.work_records.total}`, ready: workspace.readiness.work_records.ready, tab: "work" as const },
{ label: "Начисления и выплаты", value: workspace.readiness.input_lines.draft ? `${workspace.readiness.input_lines.draft} черн.` : `${workspace.readiness.input_lines.approved} утвержд.`, ready: workspace.readiness.input_lines.draft === 0, tab: "inputs" as const },
].map((item) => <button type="button" key={item.label} disabled={!canOpenInputTabs} className="app-surface-muted flex w-full items-center gap-3 rounded-xl p-3 text-left disabled:cursor-default" onClick={() => { if (canOpenInputTabs) setTab(item.tab); }}><span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${item.ready ? "app-feedback-success" : "app-feedback-warning"}`}>{item.ready ? <Check size={15} /> : <Clock3 size={15} />}</span><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium text-[var(--foreground)]">{item.label}</span><span className="app-text-muted mt-0.5 block text-xs">{item.value}</span></span>{canOpenInputTabs ? <ChevronRight className="app-text-muted" size={17} /> : null}</button>)}
</div>
{workspace.readiness.calculation.blockers.length ? <div className="app-feedback-warning mt-4 rounded-xl p-3"><p className="text-sm font-semibold">Что мешает расчёту</p><ul className="mt-2 space-y-1.5 text-xs">{workspace.readiness.calculation.blockers.map((blocker) => { const employee = blocker.employee_id ? workspace.employees.find((item) => item.id === blocker.employee_id) : null; return <li key={`${blocker.code}-${blocker.employee_id || "all"}`} className="flex items-start gap-2"><AlertCircle className="mt-0.5 shrink-0" size={14} /><span>{blocker.message}{employee ? ` — ${employee.display_name}` : ""}</span></li>; })}</ul></div> : null}
</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>}
{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 === "work" ? (
<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}
onFillFromAttendance={canManageSourceData && selectedPeriod.status !== "published" ? () => setAttendanceWorkModalOpen(true) : undefined}
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} />
</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 === "approval" ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<SectionIntro title="Согласование" description="Проверяющий утверждает исходные данные, затем расчёт проходит проверку, утверждение и публикацию." />
{!canOpenInputTabs ? (
<div className="app-surface-muted rounded-xl p-4 text-sm">
<p className="font-semibold text-[var(--foreground)]">Очередь исходных данных недоступна для вашей роли</p>
<p className="app-text-muted mt-1 leading-relaxed">Доступные действия с расчётом показаны ниже и на вкладке «Готовность».</p>
</div>
) : (
<>
{!permissions.view_all ? (
<div className="app-feedback-warning mb-4 rounded-xl p-4 text-sm">
<p className="font-semibold">Общие итоги расчёта скрыты</p>
<p className="mt-1 leading-relaxed">Черновики, необходимые для проверки, доступны в очереди согласно вашей роли.</p>
</div>
) : null}
{tableLoading ? (
<div className="flex min-h-32 items-center justify-center"><Loader2 className="app-accent-text animate-spin" size={22} /></div>
) : tableError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{tableError}</div>
) : (
<PayrollApprovalQueue rates={rates} workRecords={workRecords} inputLines={inputLines} currentUserId={user?.id} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} onApproveRate={openApproveRate} onApproveWork={openApproveWork} onApproveInput={openApproveInput} />
)}
</>
)}
{workspace.current_run ? <div className="mt-5"><div className="mb-3 flex flex-wrap items-center justify-between gap-2"><h3 className="text-sm font-semibold text-[var(--foreground)]">Текущий расчёт</h3><div className="flex gap-2">{canReturn ? <button type="button" className="app-action-warning rounded-lg px-3 py-2 text-xs font-medium" onClick={openReturn}>Вернуть</button> : null}{primaryAction && primaryAction !== "calculate" ? <button type="button" className="app-action-primary rounded-lg px-3 py-2 text-xs font-semibold" onClick={() => openRunAction(primaryAction)}>{payrollRunActionLabels[primaryAction]}</button> : null}</div></div><RunCard run={workspace.current_run} current /></div> : null}
{workspace.runs.length > 1 ? <details className="mt-4"><summary className="cursor-pointer text-sm font-medium text-[var(--foreground)]">История ревизий ({workspace.runs.length})</summary><div className="mt-3 grid gap-2">{workspace.runs.map((run) => <RunCard run={run} key={run.id} current={run.id === workspace.current_run?.id} />)}</div></details> : null}
</section>
) : null}
</>
)}
{periodModalOpen ? <PayrollPeriodFormModal isOpen period={editingPeriod} onClose={() => setPeriodModalOpen(false)} onSubmit={savePeriod} onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; createdPeriodIdRef.current = null; if (createdPeriodId) { setNotice("Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null}
{selectedPeriod && rateModalOpen ? <PayrollRateFormModal isOpen period={selectedPeriod} employees={workspace.employees} rate={editingRate} onClose={() => setRateModalOpen(false)} onSubmit={saveRate} onSaved={async () => { setRateModalOpen(false); await refreshView("Черновик ставки сохранён."); }} onStale={handleStale} /> : null}
{selectedPeriod && workModalOpen ? <PayrollWorkRecordFormModal isOpen period={selectedPeriod} employees={workspace.employees} record={editingWork} onClose={() => setWorkModalOpen(false)} onSubmit={saveWork} onSaved={async () => { setWorkModalOpen(false); await refreshView("Черновик выработки сохранён."); }} onStale={handleStale} /> : null}
{selectedPeriod && attendanceWorkModalOpen ? (
<PayrollAttendanceWorkModal
isOpen
period={selectedPeriod}
onClose={() => setAttendanceWorkModalOpen(false)}
onApplied={async (result) => {
setAttendanceWorkModalOpen(false);
setSearch("");
setStatus("draft");
await refreshView(buildPayrollAttendanceApplyNotice(result));
}}
onStale={handleStale}
/>
) : null}
{selectedPeriod && inputModalOpen ? <PayrollInputLineFormModal isOpen period={selectedPeriod} periods={workspace.periods} employees={workspace.employees} components={workspace.components} line={editingInput} onClose={() => setInputModalOpen(false)} onSubmit={saveInput} onSaved={async () => { setInputModalOpen(false); await refreshView("Черновик операции сохранён."); }} onStale={handleStale} /> : null}
{pendingAction ? <PayrollConfirmModal isOpen title={pendingAction.title} description={pendingAction.description} confirmLabel={pendingAction.confirmLabel} reasonLabel={pendingAction.reasonLabel} reasonRequired={pendingAction.reasonRequired} warning={pendingAction.warning} onClose={() => setPendingAction(null)} onConfirm={pendingAction.execute} onDone={async () => { const message = pendingAction.successMessage; setPendingAction(null); await refreshView(message); }} onStale={handleStale} /> : null}
</div>
);
}
function CalendarEmpty() {
return <div className="app-badge-accent mx-auto flex h-12 w-12 items-center justify-center rounded-xl"><Calculator size={22} /></div>;
}

View File

@ -0,0 +1,421 @@
"use client";
import {
AlertTriangle,
CalendarCheck2,
Check,
Clock3,
Loader2,
RefreshCw,
} from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState, type FormEvent } from "react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type {
PayrollAdminPeriod,
PayrollAttendanceWorkAction,
PayrollAttendanceWorkApplyResult,
PayrollAttendanceWorkIssue,
PayrollAttendanceWorkMode,
PayrollAttendanceWorkPreview,
PayrollAttendanceWorkPreviewItem,
} from "@/lib/api/finance";
import {
getPayrollAdminError,
isPayrollAdminStaleConflict,
approvalStatusMeta,
} from "@/lib/payroll-admin";
import {
formatPayrollAttendanceEmployeeCount,
getPayrollAttendanceIssueMessage,
getPayrollAttendanceModeSummary,
payrollAttendanceActionMeta,
} from "@/lib/payroll-attendance";
import { formatPayrollDateTime, getPayrollPeriodRange } from "@/lib/payroll";
type PayrollAttendanceWorkModalProps = {
isOpen: boolean;
period: PayrollAdminPeriod;
onClose: () => void;
onApplied: (result: PayrollAttendanceWorkApplyResult) => void | Promise<void>;
onStale?: () => void | Promise<void>;
};
function issueMessage(issue: PayrollAttendanceWorkIssue): string {
return getPayrollAttendanceIssueMessage(issue);
}
function ActionBadge({ action }: { action: PayrollAttendanceWorkAction }) {
const meta = payrollAttendanceActionMeta[action];
return (
<span className={`${meta.className} inline-flex w-fit rounded-full px-2 py-1 text-[10px] font-semibold leading-none`}>
{meta.label}
</span>
);
}
function ExistingRecord({ item }: { item: PayrollAttendanceWorkPreviewItem }) {
const record = item.existing_record;
if (!record) return <span className="app-text-muted text-xs">Нет записи</span>;
const status = approvalStatusMeta[record.status];
return (
<div className="flex min-w-0 flex-col items-start gap-1">
<span className={`${status.className} inline-flex rounded-full px-2 py-1 text-[10px] font-semibold leading-none`}>
{status.label}
</span>
<span className="app-text-muted text-[11px]">Ревизия {record.revision}</span>
</div>
);
}
function ItemIssues({ item }: { item: PayrollAttendanceWorkPreviewItem }) {
const issues = [...item.blockers, ...item.warnings];
if (!issues.length) return null;
return (
<div className="mt-2 space-y-1">
{issues.map((issue, index) => (
<p
key={`${issue.code}-${index}`}
className={`${index < item.blockers.length ? "text-[var(--danger-foreground)]" : "app-text-muted"} flex items-start gap-1.5 text-[11px] leading-relaxed`}
>
<AlertTriangle className="mt-0.5 shrink-0" size={12} />
<span>{issueMessage(issue)}</span>
</p>
))}
</div>
);
}
function PreviewTable({
items,
mode,
}: {
items: PayrollAttendanceWorkPreviewItem[];
mode: PayrollAttendanceWorkMode;
}) {
if (!items.length) {
return (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
За этот период данных посещаемости нет.
</div>
);
}
return (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
<div className="md:min-w-[980px]">
<div className="app-surface-muted hidden grid-cols-[minmax(190px,1.4fr)_150px_150px_115px_150px] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Сотрудник</span>
<span>Посещаемость</span>
<span>Выработка</span>
<span>Текущая запись</span>
<span>Результат</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{items.map((item) => {
const action = item.actions[mode];
return (
<div
key={item.employee.id}
className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(190px,1.4fr)_150px_150px_115px_150px] md:items-start"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{item.employee.display_name}
</p>
<p className="app-text-muted mt-0.5 truncate text-xs">
{item.employee.position || item.employee.department || "Сотрудник"}
</p>
<ItemIssues item={item} />
</div>
<div className="text-xs text-[var(--foreground)]">
<span className="app-text-muted mr-1 md:hidden">Посещаемость:</span>
<p>Норма: <b>{item.expected_hours} ч</b></p>
<p className="mt-1">Отработано: <b>{item.worked_hours} ч</b></p>
<p className="app-text-muted mt-1 text-[11px]">
Записей: {item.attendance_days} · рабочих дней: {item.effective_workdays}
</p>
{item.technical_issue_days ? (
<p className="mt-1 text-[11px] text-[var(--danger-foreground)]">
Тех. проблем: {item.technical_issue_days}
</p>
) : null}
</div>
<div className="text-xs text-[var(--foreground)]">
<span className="app-text-muted mr-1 md:hidden">Выработка:</span>
<p>Норма: <b>{item.target_points} ч</b></p>
<p className="mt-1">Факт: <b>{item.actual_points} ч</b></p>
<span className="app-selected mt-2 inline-flex rounded-full px-2 py-1 text-[10px] font-semibold leading-none">
Из посещаемости
</span>
</div>
<div>
<span className="app-text-muted mr-1 text-xs md:hidden">Текущая запись:</span>
<ExistingRecord item={item} />
</div>
<div>
<span className="app-text-muted mr-1 text-xs md:hidden">Результат:</span>
<ActionBadge action={action} />
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
export function PayrollAttendanceWorkModal({
isOpen,
period,
onClose,
onApplied,
onStale,
}: PayrollAttendanceWorkModalProps) {
const formId = useId();
const [preview, setPreview] = useState<PayrollAttendanceWorkPreview | null>(null);
const [mode, setMode] = useState<PayrollAttendanceWorkMode>("missing_only");
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(true);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadPreview = useCallback(async () => {
setLoading(true);
setError(null);
try {
const payload = await apiClient.getPayrollAdminAttendanceWorkPreview(period.id);
setPreview(payload);
setMode("missing_only");
setReason("");
} catch (loadError) {
setError(getPayrollAdminError(loadError, "Не удалось подготовить выработку по посещаемости."));
} finally {
setLoading(false);
}
}, [period.id]);
useEffect(() => {
if (isOpen) void loadPreview();
}, [isOpen, loadPreview]);
const modeSummary = useMemo(
() => preview ? getPayrollAttendanceModeSummary(preview, mode) : null,
[mode, preview],
);
const reasonRequired = mode === "replace_existing";
const canApply = Boolean(
preview
&& modeSummary
&& modeSummary.changes > 0
&& (!reasonRequired || reason.trim()),
);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!preview || !canApply) return;
setApplying(true);
setError(null);
try {
const result = await apiClient.applyPayrollAdminAttendanceWork(period.id, {
mode,
preview_token: preview.preview_token,
expected_period_lock_version: period.lock_version,
reason: mode === "replace_existing" ? reason.trim() : "",
});
await onApplied(result);
} catch (applyError) {
if (isPayrollAdminStaleConflict(applyError) && onStale) {
await onStale();
return;
}
setError(getPayrollAdminError(applyError, "Не удалось сохранить выработку по посещаемости."));
} finally {
setApplying(false);
}
};
const footer = (
<div className="flex flex-col-reverse gap-2 border-t border-[var(--border-subtle)] pb-1 pt-4 sm:flex-row sm:justify-end">
<button
type="button"
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium"
disabled={applying}
onClick={onClose}
>
Отмена
</button>
<button
type="submit"
form={formId}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
disabled={applying || loading || !canApply}
>
{applying ? <Loader2 className="animate-spin" size={16} /> : <Check size={16} />}
{modeSummary?.changes ? `Сохранить изменения (${modeSummary.changes})` : "Нет изменений"}
</button>
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={applying ? () => undefined : onClose}
title="Расчёт выработки по посещаемости"
size="xl"
showCloseButton={!applying}
closeOnEsc={!applying}
footer={footer}
>
<form id={formId} onSubmit={(event) => void submit(event)}>
{loading ? (
<div className="flex min-h-72 items-center justify-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 className="app-accent-text animate-spin" size={20} />
Подготавливаем предпросмотр…
</div>
) : !preview ? (
<div className="py-5">
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{error || "Предпросмотр недоступен."}
</div>
<button
type="button"
className="app-action-secondary mt-3 inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
onClick={() => void loadPreview()}
>
<RefreshCw size={15} /> Повторить
</button>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
<p className="app-text-muted mt-1 text-xs">{getPayrollPeriodRange(period)}</p>
</div>
<p className="app-text-muted text-xs">Предпросмотр: {formatPayrollDateTime(preview.generated_at)}</p>
</div>
<div className="app-selected flex items-start gap-3 rounded-xl p-4">
<CalendarCheck2 className="app-accent-text mt-0.5 shrink-0" size={19} />
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
1 час = 1 единица выработки
</p>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
{preview.policy.description || preview.policy.label}
</p>
<p className="mt-2 text-xs font-medium text-[var(--foreground)]">
Часы попадут в норму и факт выработки, но при текущей политике не изменяют начисление по баллам.
</p>
</div>
</div>
{preview.summary.existing > 0 ? (
<fieldset>
<legend className="app-field-label">Что делать с существующими записями</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label className={`${mode === "missing_only" ? "app-selected" : "app-surface-muted"} flex cursor-pointer items-start gap-3 rounded-xl p-3.5`}>
<input
type="radio"
className="app-radio mt-0.5"
name="attendance-work-mode"
value="missing_only"
checked={mode === "missing_only"}
onChange={() => setMode("missing_only")}
/>
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Только отсутствующие</span>
<span className="app-text-muted mt-1 block text-xs leading-relaxed">
Рекомендуется. Существующие записи останутся без изменений.
</span>
</span>
</label>
<label className={`${mode === "replace_existing" ? "app-selected" : "app-surface-muted"} flex cursor-pointer items-start gap-3 rounded-xl p-3.5`}>
<input
type="radio"
className="app-radio mt-0.5"
name="attendance-work-mode"
value="replace_existing"
checked={mode === "replace_existing"}
onChange={() => setMode("replace_existing")}
/>
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Пересчитать существующие</span>
<span className="app-text-muted mt-1 block text-xs leading-relaxed">
Обновит доступные черновики, для утверждённых создаст новые ревизии.
</span>
</span>
</label>
</div>
</fieldset>
) : null}
{mode === "replace_existing" ? (
<div className="app-feedback-warning rounded-xl p-3.5 text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<p className="leading-relaxed">
Утверждённые показатели не перезаписываются. Для них будут созданы новые черновики-ревизии, которые потребуется отдельно проверить и утвердить.
</p>
</div>
<label className="mt-3 block">
<span className="app-field-label">Причина перерасчёта *</span>
<textarea
className="app-input min-h-20 w-full resize-y rounded-lg px-3 py-2.5 text-sm"
value={reason}
required
placeholder={`Перерасчёт по посещаемости за ${period.name || period.code}`}
onChange={(event) => setReason(event.target.value)}
/>
</label>
</div>
) : null}
{modeSummary ? (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{[
{ label: "В посещаемости", value: preview.summary.attendance_employees },
{ label: "Новых черновиков", value: modeSummary.create },
{ label: "Обновлений и ревизий", value: modeSummary.update + modeSummary.revise },
{ label: "Требует внимания", value: modeSummary.skip + modeSummary.blocked },
].map((item) => (
<div key={item.label} className="app-surface-muted rounded-xl p-3">
<p className="app-text-muted text-xs">{item.label}</p>
<p className="mt-1 text-lg font-semibold text-[var(--foreground)]">{item.value}</p>
</div>
))}
</div>
) : null}
{preview.summary.blocked ? (
<div className="app-feedback-warning flex items-start gap-2 rounded-xl px-3 py-2.5 text-sm">
<Clock3 className="mt-0.5 shrink-0" size={16} />
<span>
Для {formatPayrollAttendanceEmployeeCount(preview.summary.blocked)} есть незавершённые или технически проблемные дни. Эти строки не будут сохранены, пока посещаемость не исправлена.
</span>
</div>
) : null}
<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 ? (
<div className="app-feedback-danger flex items-start justify-between gap-3 rounded-xl px-4 py-3 text-sm">
<span>{error}</span>
<button type="button" className="shrink-0 underline" onClick={() => void loadPreview()}>
Обновить предпросмотр
</button>
</div>
) : null}
</div>
)}
</form>
</Modal>
);
}

View File

@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createFinanceApi } from "./finance.ts";
test("finance API uses own published payroll endpoints", async () => {
const calls = [];
const request = async (endpoint, options) => {
calls.push({ endpoint, options });
return { ok: true };
};
const finance = createFinanceApi(request);
await finance.getMyPayrollStatements();
await finance.getMyPayrollStatement("3f845e30-4ae1-4dd7-a474-66c90f9ef013");
await finance.acknowledgeMyPayrollStatement("3f845e30-4ae1-4dd7-a474-66c90f9ef013");
assert.deepEqual(calls, [
{
endpoint: "/api/v1/finance/payroll/me/statements/?page_size=100",
options: undefined,
},
{
endpoint: "/api/v1/finance/payroll/me/statements/3f845e30-4ae1-4dd7-a474-66c90f9ef013/",
options: undefined,
},
{
endpoint: "/api/v1/finance/payroll/me/statements/3f845e30-4ae1-4dd7-a474-66c90f9ef013/acknowledge/",
options: { method: "POST" },
},
]);
});
test("finance API encodes statement identifiers before adding them to a path", async () => {
const endpoints = [];
const finance = createFinanceApi(async (endpoint) => {
endpoints.push(endpoint);
return {};
});
await finance.getMyPayrollStatement("id/with spaces");
assert.equal(
endpoints[0],
"/api/v1/finance/payroll/me/statements/id%2Fwith%20spaces/",
);
});
test("finance API keeps payroll management inside the native admin REST namespace", async () => {
const calls = [];
const finance = createFinanceApi(async (endpoint, options) => {
calls.push({ endpoint, options });
return {};
});
await finance.getPayrollAdminWorkspace(17);
await finance.getPayrollAdminPayRates({ period_id: 17, status: "draft" });
await finance.updatePayrollAdminPayRate(21, {
amount: "115000.00",
expected_lock_version: 3,
});
await finance.approvePayrollAdminPayRate(21, 4);
await finance.revisePayrollAdminPayRate(21, "Приказ № 42");
await finance.calculatePayrollAdminPeriod(17, {
idempotency_key: "2bdb6c92-b263-42de-963f-121491b95f54",
recalculation_reason: "Исправлена премия",
expected_lock_version: 7,
});
await finance.publishPayrollAdminRun(33);
assert.deepEqual(calls, [
{
endpoint: "/api/v1/finance/payroll/admin/workspace/?period_id=17",
options: undefined,
},
{
endpoint: "/api/v1/finance/payroll/admin/pay-rates/?period_id=17&status=draft&page_size=200",
options: undefined,
},
{
endpoint: "/api/v1/finance/payroll/admin/pay-rates/21/",
options: {
method: "PATCH",
body: JSON.stringify({ amount: "115000.00", expected_lock_version: 3 }),
},
},
{
endpoint: "/api/v1/finance/payroll/admin/pay-rates/21/approve/",
options: {
method: "POST",
body: JSON.stringify({ expected_lock_version: 4 }),
},
},
{
endpoint: "/api/v1/finance/payroll/admin/pay-rates/21/revise/",
options: {
method: "POST",
body: JSON.stringify({ reason: "Приказ № 42" }),
},
},
{
endpoint: "/api/v1/finance/payroll/admin/periods/17/calculate/",
options: {
method: "POST",
body: JSON.stringify({
idempotency_key: "2bdb6c92-b263-42de-963f-121491b95f54",
recalculation_reason: "Исправлена премия",
expected_lock_version: 7,
}),
},
},
{
endpoint: "/api/v1/finance/payroll/admin/runs/33/publish/",
options: { method: "POST", body: "{}" },
},
]);
});
test("finance API previews and applies attendance work records through one period command", async () => {
const calls = [];
const finance = createFinanceApi(async (endpoint, options) => {
calls.push({ endpoint, options });
return {};
});
await finance.getPayrollAdminAttendanceWorkPreview(17);
await finance.applyPayrollAdminAttendanceWork(17, {
mode: "replace_existing",
preview_token: "attendance-preview-token",
expected_period_lock_version: 8,
reason: "Перерасчёт по посещаемости за июль 2026",
});
assert.deepEqual(calls, [
{
endpoint: "/api/v1/finance/payroll/admin/periods/17/attendance-work-records/",
options: undefined,
},
{
endpoint: "/api/v1/finance/payroll/admin/periods/17/attendance-work-records/",
options: {
method: "POST",
body: JSON.stringify({
mode: "replace_existing",
preview_token: "attendance-preview-token",
expected_period_lock_version: 8,
reason: "Перерасчёт по посещаемости за июль 2026",
}),
},
},
]);
});

View File

@ -0,0 +1,475 @@
import type { RequestFn } from "./utils";
export type PayrollLineKind =
| "earning"
| "adjustment_credit"
| "adjustment_debit"
| "deduction"
| "payment";
export type PayrollPeriodSummary = {
code: string;
name: string;
date_from: string;
date_to: string;
pay_date: string | null;
};
export type PayrollAcknowledgement = {
viewed_at: string | null;
acknowledged_at: string | null;
disputed_at: string | null;
};
export type PayrollStatementSummary = {
public_id: string;
period: PayrollPeriodSummary;
revision: number;
published_at: string;
currency: string;
payable: string;
acknowledgement: PayrollAcknowledgement | null;
};
export type PayrollStatementLine = {
code: string;
label: string;
kind: PayrollLineKind;
amount: string;
source_period_from: string | null;
source_period_to: string | null;
is_retro: boolean;
calculated: boolean;
};
export type PayrollStatement = PayrollStatementSummary & {
point_delta: string | null;
gross_before_adjustments: string;
adjustment_total: string;
gross_total: string;
deduction_total: string;
net_pay: string;
payment_total: string;
lines: PayrollStatementLine[];
};
export type PayrollStatementPage = {
count: number;
next: string | null;
previous: string | null;
results: PayrollStatementSummary[];
};
export type PayrollApprovalStatus = "draft" | "approved" | "voided";
export type PayrollPeriodStatus = "open" | "calculated" | "review" | "approved" | "published" | "closed";
export type PayrollRunStatus = "calculated" | "review" | "approved" | "published" | "returned" | "superseded";
export type PayrollAdminUserRef = {
id: number;
display_name: string;
};
export type PayrollAdminEmployee = PayrollAdminUserRef & {
position: string | null;
department?: string | null;
};
export type PayrollAdminPeriod = PayrollPeriodSummary & {
id: number;
currency: string;
status: PayrollPeriodStatus;
lock_version: number;
current_run_id: number | null;
};
export type PayrollAdminRun = {
id: number;
period_id: number;
revision: number;
status: PayrollRunStatus;
employee_count: number;
gross_total?: string | null;
deduction_total?: string | null;
payable_total?: string | null;
requested_by: PayrollAdminUserRef;
requested_at: string;
approved_by: PayrollAdminUserRef | null;
approved_at: string | null;
published_by: PayrollAdminUserRef | null;
published_at: string | null;
recalculation_reason: string;
self_approval_overridden: boolean;
};
export type PayrollAdminPermissions = {
manage_inputs: boolean;
approve_inputs: boolean;
override_approval: boolean;
calculate: boolean;
approve_run: boolean;
publish: boolean;
view_all: boolean;
audit: boolean;
};
export type PayrollReadinessGroup = {
ready: boolean;
total: number;
approved: number;
draft: number;
missing_employee_ids?: number[];
};
export type PayrollCalculationBlocker = {
code: string;
message: string;
employee_id?: number;
};
export type PayrollAdminWorkspace = {
permissions: PayrollAdminPermissions;
employees: PayrollAdminEmployee[];
components: PayrollComponent[];
periods: PayrollAdminPeriod[];
selected_period: PayrollAdminPeriod | null;
readiness: {
rates: PayrollReadinessGroup;
work_records: PayrollReadinessGroup;
input_lines: Pick<PayrollReadinessGroup, "approved" | "draft">;
calculation: {
ready: boolean;
blockers: PayrollCalculationBlocker[];
};
};
current_run: PayrollAdminRun | null;
runs: PayrollAdminRun[];
summary: {
employee_count: number;
gross_total: string | null;
deduction_total: string | null;
payable_total: string | null;
} | null;
pending_approvals: {
rates: number;
work_records: number;
input_lines: number;
};
};
export type PayrollComponent = {
id: number;
code: string;
name: string;
kind: PayrollLineKind;
requires_reason: boolean;
is_active: boolean;
display_order: number;
};
export type PayrollAdminPayRate = {
id: number;
employee_id?: number;
employee: PayrollAdminEmployee;
rate_code: string;
amount: string;
point_rate: string;
currency: string;
effective_from: string;
revision: number;
status: PayrollApprovalStatus;
reason: string;
lock_version: number;
created_by: PayrollAdminUserRef;
created_at: string;
approved_by: PayrollAdminUserRef | null;
approved_at: string | null;
self_approval_overridden: boolean;
};
export type PayrollAdminWorkRecord = {
id: number;
period_id: number;
employee_id?: number;
employee: PayrollAdminEmployee;
target_points: string;
actual_points: string;
expected_point_amount: string | null;
expected_gross: string | null;
expected_recalculated_gross: string | null;
expected_payable: string | null;
revision: number;
status: PayrollApprovalStatus;
source?: string;
replaces_id?: number | null;
reason: string;
lock_version: number;
created_by: PayrollAdminUserRef;
created_at: string;
approved_by: PayrollAdminUserRef | null;
approved_at: string | null;
self_approval_overridden: boolean;
};
export type PayrollAttendanceWorkMode = "missing_only" | "replace_existing";
export type PayrollAttendanceWorkAction =
| "create"
| "update"
| "revise"
| "unchanged"
| "skip"
| "blocked";
export type PayrollAttendanceWorkIssue = {
code: string;
message: string;
};
export type PayrollAttendanceWorkModeSummary = {
create: number;
update: number;
revise: number;
unchanged: number;
skip: number;
blocked: number;
changes: number;
};
export type PayrollAttendanceExistingWorkRecord = {
id: number;
revision: number;
status: PayrollApprovalStatus;
source?: string;
target_points: string;
actual_points: string;
created_by?: PayrollAdminUserRef;
};
export type PayrollAttendanceWorkPreviewItem = {
employee: PayrollAdminEmployee;
target_points: string;
actual_points: string;
expected_hours: string;
worked_hours: string;
attendance_days: number;
effective_workdays: number;
technical_issue_days: number;
warnings: PayrollAttendanceWorkIssue[];
blockers: PayrollAttendanceWorkIssue[];
existing_record: PayrollAttendanceExistingWorkRecord | null;
actions: Record<PayrollAttendanceWorkMode, PayrollAttendanceWorkAction>;
};
export type PayrollAttendanceWorkPreview = {
period_id: number;
generated_at: string;
preview_token: string;
policy: {
code: string;
label: string;
description: string;
};
summary: {
attendance_employees: number;
existing: number;
blocked: number;
modes: Record<PayrollAttendanceWorkMode, PayrollAttendanceWorkModeSummary>;
};
items: PayrollAttendanceWorkPreviewItem[];
};
export type PayrollAttendanceWorkApplyPayload = {
mode: PayrollAttendanceWorkMode;
preview_token: string;
expected_period_lock_version: number;
reason: string;
};
export type PayrollAttendanceWorkApplyResult = {
mode: PayrollAttendanceWorkMode;
summary: {
created: number;
updated: number;
revised: number;
unchanged: number;
skipped: number;
blocked: number;
};
records: PayrollAdminWorkRecord[];
};
export type PayrollAdminInputLine = {
id: number;
period_id: number;
employee: PayrollAdminEmployee;
employee_id?: number;
component: PayrollComponent;
component_id?: number;
amount: string;
relates_to_period_id: number | null;
reason: string;
status: PayrollApprovalStatus;
lock_version: number;
created_by: PayrollAdminUserRef;
created_at: string;
approved_by: PayrollAdminUserRef | null;
approved_at: string | null;
self_approval_overridden: boolean;
};
export type PayrollAdminPage<T> = {
count: number;
next: string | null;
previous: string | null;
results: T[];
};
export type PayrollAdminListPayload<T> = PayrollAdminPage<T> | T[];
export type PayrollPeriodWrite = {
code: string;
name: string;
date_from: string;
date_to: string;
pay_date: string | null;
currency: string;
};
export type PayrollPayRateWrite = {
employee_id: number;
rate_code: string;
amount: string;
point_rate: string;
currency: string;
effective_from: string;
reason: string;
};
export type PayrollWorkRecordWrite = {
period_id: number;
employee_id: number;
target_points: string;
actual_points: string;
expected_point_amount?: string | null;
expected_gross?: string | null;
expected_recalculated_gross?: string | null;
expected_payable?: string | null;
reason: string;
};
export type PayrollInputLineWrite = {
period_id: number;
employee_id: number;
component_id: number;
amount: string;
relates_to_period_id?: number | null;
reason: string;
};
type PayrollAdminListParams = {
period_id?: number;
search?: string;
status?: PayrollApprovalStatus | "";
};
const adminPrefix = "/api/v1/finance/payroll/admin";
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
});
const query = search.toString();
return query ? `?${query}` : "";
}
function jsonOptions(method: "POST" | "PATCH", payload: object = {}): RequestInit {
return { method, body: JSON.stringify(payload) };
}
export function createFinanceApi(request: RequestFn) {
return {
getMyPayrollStatements: (): Promise<PayrollStatementPage | PayrollStatementSummary[]> =>
request("/api/v1/finance/payroll/me/statements/?page_size=100"),
getMyPayrollStatement: (publicId: string): Promise<PayrollStatement> =>
request(`/api/v1/finance/payroll/me/statements/${encodeURIComponent(publicId)}/`),
acknowledgeMyPayrollStatement: (publicId: string): Promise<PayrollAcknowledgement> =>
request(
`/api/v1/finance/payroll/me/statements/${encodeURIComponent(publicId)}/acknowledge/`,
{ method: "POST" },
),
getPayrollAdminWorkspace: (periodId?: number): Promise<PayrollAdminWorkspace> =>
request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`),
getPayrollAdminPeriods: (): Promise<PayrollAdminListPayload<PayrollAdminPeriod>> =>
request(`${adminPrefix}/periods/`),
createPayrollAdminPeriod: (payload: PayrollPeriodWrite): Promise<PayrollAdminPeriod> =>
request(`${adminPrefix}/periods/`, jsonOptions("POST", payload)),
updatePayrollAdminPeriod: (
periodId: number,
payload: Partial<PayrollPeriodWrite> & { expected_lock_version: number },
): Promise<PayrollAdminPeriod> =>
request(`${adminPrefix}/periods/${periodId}/`, jsonOptions("PATCH", payload)),
getPayrollAdminPayRates: (params: PayrollAdminListParams = {}): Promise<PayrollAdminListPayload<PayrollAdminPayRate>> =>
request(`${adminPrefix}/pay-rates/${buildQuery({ ...params, page_size: 200 })}`),
createPayrollAdminPayRate: (payload: PayrollPayRateWrite): Promise<PayrollAdminPayRate> =>
request(`${adminPrefix}/pay-rates/`, jsonOptions("POST", payload)),
updatePayrollAdminPayRate: (
rateId: number,
payload: Partial<Omit<PayrollPayRateWrite, "employee_id" | "rate_code" | "currency">> & { expected_lock_version: number },
): Promise<PayrollAdminPayRate> =>
request(`${adminPrefix}/pay-rates/${rateId}/`, jsonOptions("PATCH", payload)),
approvePayrollAdminPayRate: (rateId: number, expectedLockVersion: number): Promise<PayrollAdminPayRate> =>
request(`${adminPrefix}/pay-rates/${rateId}/approve/`, jsonOptions("POST", { expected_lock_version: expectedLockVersion })),
revisePayrollAdminPayRate: (rateId: number, reason: string): Promise<PayrollAdminPayRate> =>
request(`${adminPrefix}/pay-rates/${rateId}/revise/`, jsonOptions("POST", { reason })),
getPayrollAdminWorkRecords: (params: PayrollAdminListParams = {}): Promise<PayrollAdminListPayload<PayrollAdminWorkRecord>> =>
request(`${adminPrefix}/work-records/${buildQuery({ ...params, page_size: 200 })}`),
createPayrollAdminWorkRecord: (payload: PayrollWorkRecordWrite): Promise<PayrollAdminWorkRecord> =>
request(`${adminPrefix}/work-records/`, jsonOptions("POST", payload)),
updatePayrollAdminWorkRecord: (
recordId: number,
payload: Partial<Omit<PayrollWorkRecordWrite, "period_id" | "employee_id">> & { expected_lock_version: number },
): Promise<PayrollAdminWorkRecord> =>
request(`${adminPrefix}/work-records/${recordId}/`, jsonOptions("PATCH", payload)),
approvePayrollAdminWorkRecord: (recordId: number, expectedLockVersion: number): Promise<PayrollAdminWorkRecord> =>
request(`${adminPrefix}/work-records/${recordId}/approve/`, jsonOptions("POST", { expected_lock_version: expectedLockVersion })),
revisePayrollAdminWorkRecord: (recordId: number, reason: string): Promise<PayrollAdminWorkRecord> =>
request(`${adminPrefix}/work-records/${recordId}/revise/`, jsonOptions("POST", { reason })),
getPayrollAdminAttendanceWorkPreview: (periodId: number): Promise<PayrollAttendanceWorkPreview> =>
request(`${adminPrefix}/periods/${periodId}/attendance-work-records/`),
applyPayrollAdminAttendanceWork: (
periodId: number,
payload: PayrollAttendanceWorkApplyPayload,
): Promise<PayrollAttendanceWorkApplyResult> =>
request(
`${adminPrefix}/periods/${periodId}/attendance-work-records/`,
jsonOptions("POST", payload),
),
getPayrollAdminInputLines: (params: PayrollAdminListParams = {}): Promise<PayrollAdminListPayload<PayrollAdminInputLine>> =>
request(`${adminPrefix}/input-lines/${buildQuery({ ...params, page_size: 200 })}`),
createPayrollAdminInputLine: (payload: PayrollInputLineWrite): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/input-lines/`, jsonOptions("POST", payload)),
updatePayrollAdminInputLine: (
lineId: number,
payload: Partial<Omit<PayrollInputLineWrite, "period_id" | "employee_id" | "component_id">> & { expected_lock_version: number },
): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/input-lines/${lineId}/`, jsonOptions("PATCH", payload)),
approvePayrollAdminInputLine: (lineId: number, expectedLockVersion: number): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/input-lines/${lineId}/approve/`, jsonOptions("POST", { expected_lock_version: expectedLockVersion })),
calculatePayrollAdminPeriod: (
periodId: number,
payload: { idempotency_key: string; recalculation_reason: string; expected_lock_version: number },
): Promise<PayrollAdminRun> =>
request(`${adminPrefix}/periods/${periodId}/calculate/`, jsonOptions("POST", payload)),
submitPayrollAdminRunForReview: (runId: number): Promise<PayrollAdminRun> =>
request(`${adminPrefix}/runs/${runId}/submit-review/`, jsonOptions("POST")),
returnPayrollAdminRun: (runId: number, reason: string): Promise<PayrollAdminRun> =>
request(`${adminPrefix}/runs/${runId}/return/`, jsonOptions("POST", { reason })),
approvePayrollAdminRun: (runId: number): Promise<PayrollAdminRun> =>
request(`${adminPrefix}/runs/${runId}/approve/`, jsonOptions("POST")),
publishPayrollAdminRun: (runId: number): Promise<PayrollAdminRun> =>
request(`${adminPrefix}/runs/${runId}/publish/`, jsonOptions("POST")),
closePayrollAdminPeriod: (periodId: number): Promise<PayrollAdminPeriod> =>
request(`${adminPrefix}/periods/${periodId}/close/`, jsonOptions("POST")),
};
}

View File

@ -16,6 +16,7 @@ import { createProcurementApi } from './procurement';
import { createAttendanceApi } from './attendance';
import { createGuestsApi } from './guests';
import { createTasksApi } from './tasks';
import { createFinanceApi } from './finance';
const base = new ApiClientBase();
const req = base.request.bind(base);
@ -36,6 +37,7 @@ export const apiClient = Object.assign(base, {
...createAttendanceApi(req, tok),
...createGuestsApi(req),
...createTasksApi(req, raw),
...createFinanceApi(req),
});
export default apiClient;

View File

@ -0,0 +1,76 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getRegulationAcknowledgementDepartments,
isCompanyAcknowledgementRegulation,
regulationMatchesAcknowledgementSource,
} from "./feed-regulation-filters.ts";
const accounting = { id: 10, name: "Бухгалтерия" };
const procurement = { id: 20, name: "Закупки" };
test("selective regulations are filtered by acknowledgement departments, not access departments", () => {
const regulation = {
sent_to_all: false,
departments: [accounting],
acknowledgement_required: true,
acknowledgement_for_all: false,
acknowledgement_departments: [procurement],
};
assert.equal(regulationMatchesAcknowledgementSource(regulation, "department:10"), false);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "department:20"), true);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "company"), false);
});
test("selective acknowledgement does not become company-wide because access is company-wide", () => {
const regulation = {
sent_to_all: true,
acknowledgement_required: true,
acknowledgement_for_all: false,
acknowledgement_departments: [accounting],
};
assert.equal(isCompanyAcknowledgementRegulation(regulation), false);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "department:10"), true);
});
test("all-access acknowledgement inherits departments from a selectively available document", () => {
const regulation = {
sent_to_all: false,
departments: [accounting],
acknowledgement_required: true,
acknowledgement_for_all: true,
acknowledgement_departments: [procurement],
};
assert.deepEqual(getRegulationAcknowledgementDepartments(regulation), [accounting]);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "department:10"), true);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "department:20"), false);
});
test("company acknowledgement is based on acknowledgement mode and company access together", () => {
const regulation = {
sent_to_all: true,
acknowledgement_required: true,
acknowledgement_for_all: true,
};
assert.equal(isCompanyAcknowledgementRegulation(regulation), true);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "company"), true);
});
test("regulations without acknowledgement stay in all regulations but not in audience filters", () => {
const regulation = {
sent_to_all: true,
departments: [accounting],
acknowledgement_required: false,
acknowledgement_for_all: true,
acknowledgement_departments: [accounting],
};
assert.equal(regulationMatchesAcknowledgementSource(regulation, "all"), true);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "company"), false);
assert.equal(regulationMatchesAcknowledgementSource(regulation, "department:10"), false);
});

View File

@ -0,0 +1,47 @@
export type RegulationSourceFilter = "all" | "company" | `department:${number}`;
type RegulationDepartment = {
id: number;
name: string;
};
type RegulationAudience = {
sent_to_all?: boolean;
departments?: RegulationDepartment[];
acknowledgement_required?: boolean;
acknowledgement_for_all?: boolean;
acknowledgement_departments?: RegulationDepartment[];
};
export function isCompanyAcknowledgementRegulation(document: RegulationAudience) {
return Boolean(
document.acknowledgement_required
&& document.acknowledgement_for_all
&& document.sent_to_all,
);
}
export function getRegulationAcknowledgementDepartments(
document: RegulationAudience,
): RegulationDepartment[] {
if (!document.acknowledgement_required) return [];
if (document.acknowledgement_for_all) {
return document.sent_to_all ? [] : (document.departments || []);
}
return document.acknowledgement_departments || [];
}
export function regulationMatchesAcknowledgementSource(
document: RegulationAudience,
source: RegulationSourceFilter,
) {
if (source === "all") return true;
if (source === "company") return isCompanyAcknowledgementRegulation(document);
const departmentId = Number(source.split(":")[1]);
return getRegulationAcknowledgementDepartments(document).some(
(department) => department.id === departmentId,
);
}

View File

@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
canApprovePayrollDraft,
getPayrollAdminError,
getPrimaryPayrollRunAction,
isPayrollAdminStaleConflict,
normalizePayrollAdminList,
} from "./payroll-admin.ts";
const permissions = {
manage_inputs: true,
approve_inputs: true,
override_approval: false,
calculate: true,
approve_run: true,
publish: true,
view_all: true,
audit: false,
};
test("payroll admin normalizes DRF and plain list payloads", () => {
assert.deepEqual(normalizePayrollAdminList([1, 2]), [1, 2]);
assert.deepEqual(
normalizePayrollAdminList({ count: 1, next: null, previous: null, results: [3] }),
[3],
);
});
test("payroll workflow exposes only the next permitted primary action", () => {
assert.equal(getPrimaryPayrollRunAction(null, "open", permissions, true), "calculate");
assert.equal(
getPrimaryPayrollRunAction({ status: "calculated" }, "calculated", permissions, true),
"submit_review",
);
assert.equal(
getPrimaryPayrollRunAction({ status: "review" }, "review", { ...permissions, approve_run: false }, true),
null,
);
assert.equal(
getPrimaryPayrollRunAction({ status: "approved" }, "approved", permissions, true),
"publish",
);
assert.equal(getPrimaryPayrollRunAction(null, "closed", permissions, true), null);
});
test("self-approval requires both the normal approval permission and the override", () => {
assert.equal(canApprovePayrollDraft(false, true, false), true);
assert.equal(canApprovePayrollDraft(true, true, false), false);
assert.equal(canApprovePayrollDraft(true, true, true), true);
assert.equal(canApprovePayrollDraft(true, false, true), false);
const ownRun = { status: "review", requested_by: { id: 17 } };
assert.equal(getPrimaryPayrollRunAction(ownRun, "review", permissions, true, 17), null);
assert.equal(
getPrimaryPayrollRunAction(
ownRun,
"review",
{ ...permissions, override_approval: true },
true,
17,
),
"approve",
);
assert.equal(getPrimaryPayrollRunAction(ownRun, "review", permissions, true, 18), "approve");
assert.equal(
getPrimaryPayrollRunAction(
ownRun,
"review",
{ ...permissions, approve_run: false, override_approval: true },
true,
17,
),
null,
);
});
test("payroll admin presents backend and conflict errors in plain Russian", () => {
assert.equal(
getPayrollAdminError(
new Error('API Error: 400 {"code":"INVALID","message":"Проверьте период"}'),
"Ошибка",
),
"Проверьте период",
);
assert.equal(
getPayrollAdminError(new Error("API Error: 409 Conflict"), "Ошибка"),
"Возник конфликт данных. Проверьте условия и повторите действие.",
);
assert.equal(
getPayrollAdminError(
new Error('API Error: 409 {"code":"CALCULATION_VALIDATION_FAILED","message":"Расчёт не прошёл проверку","details":{"issues":[]}} trailing context'),
"Ошибка",
),
"Расчёт не прошёл проверку",
);
});
test("only explicit stale payroll conflicts trigger an automatic refresh", () => {
assert.equal(
isPayrollAdminStaleConflict(
new Error('API Error: 409 {"code":"STALE_PERIOD","message":"Период уже изменён"}'),
),
true,
);
assert.equal(
isPayrollAdminStaleConflict(
new Error('API Error: 409 {"detail":{"code":"STALE_DRAFT","message":"Черновик уже изменён"}}'),
),
true,
);
assert.equal(
isPayrollAdminStaleConflict(
new Error('API Error: 409 {"code":"CONCURRENT_CALCULATION_CONFLICT","message":"Повторите расчёт"}'),
),
true,
);
assert.equal(
isPayrollAdminStaleConflict(
new Error('API Error: 409 {"code":"CALCULATION_VALIDATION_FAILED","message":"Проверьте данные"}'),
),
false,
);
assert.equal(
isPayrollAdminStaleConflict(
new Error('API Error: 409 {"code":"RULESET_NOT_EFFECTIVE","message":"Нет действующих правил"}'),
),
false,
);
assert.equal(isPayrollAdminStaleConflict(new Error("API Error: 409 Conflict")), false);
});

View File

@ -0,0 +1,201 @@
import type {
PayrollAdminListPayload,
PayrollAdminPermissions,
PayrollAdminRun,
PayrollApprovalStatus,
PayrollPeriodStatus,
PayrollRunStatus,
} from "@/lib/api/finance";
export type PayrollAdminTab = "readiness" | "rates" | "work" | "inputs" | "approval";
export const PAYROLL_ADMIN_TABS: Array<{ value: PayrollAdminTab; label: string }> = [
{ value: "readiness", label: "Готовность" },
{ value: "rates", label: "Ставки" },
{ value: "work", label: "Выработка" },
{ value: "inputs", label: "Начисления и выплаты" },
{ value: "approval", label: "Согласование" },
];
export const approvalStatusMeta: Record<PayrollApprovalStatus, { label: string; className: string }> = {
draft: { label: "Черновик", className: "app-badge" },
approved: { label: "Утверждено", className: "app-feedback-success" },
voided: { label: "Аннулировано", className: "app-feedback-danger" },
};
export const periodStatusMeta: Record<PayrollPeriodStatus, { label: string; className: string }> = {
open: { label: "Подготовка", className: "app-badge" },
calculated: { label: "Рассчитан", className: "app-selected" },
review: { label: "На проверке", className: "app-feedback-warning" },
approved: { label: "Утверждён", className: "app-feedback-success" },
published: { label: "Опубликован", className: "app-badge-accent" },
closed: { label: "Закрыт", className: "app-badge" },
};
export const runStatusMeta: Record<PayrollRunStatus, { label: string; className: string }> = {
calculated: { label: "Рассчитан", className: "app-selected" },
review: { label: "На проверке", className: "app-feedback-warning" },
approved: { label: "Утверждён", className: "app-feedback-success" },
published: { label: "Опубликован", className: "app-badge-accent" },
returned: { label: "На исправлении", className: "app-feedback-danger" },
superseded: { label: "Заменён", className: "app-badge" },
};
export function normalizePayrollAdminList<T>(payload: PayrollAdminListPayload<T>): T[] {
return Array.isArray(payload) ? payload : payload.results || [];
}
type ParsedPayrollAdminError = {
status: number | null;
code: string | null;
message: string | null;
};
function extractJsonValue(raw: string): unknown {
for (let start = 0; start < raw.length; start += 1) {
const first = raw[start];
if (first !== "{" && first !== "[") continue;
const stack = [first === "{" ? "}" : "]"];
let inString = false;
let escaped = false;
for (let index = start + 1; index < raw.length; index += 1) {
const character = raw[index];
if (inString) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') inString = false;
continue;
}
if (character === '"') {
inString = true;
continue;
}
if (character === "{" || character === "[") {
stack.push(character === "{" ? "}" : "]");
continue;
}
if (character !== stack.at(-1)) continue;
stack.pop();
if (stack.length > 0) continue;
try {
return JSON.parse(raw.slice(start, index + 1));
} catch {
break;
}
}
}
return null;
}
function readErrorPayload(value: unknown): { code: string | null; message: string | null } {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { code: null, message: null };
}
const record = value as Record<string, unknown>;
const code = typeof record.code === "string" && record.code.trim()
? record.code.trim()
: null;
const message = typeof record.message === "string" && record.message.trim()
? record.message.trim()
: null;
let detailPayload = { code: null, message: null } as {
code: string | null;
message: string | null;
};
if (record.detail && typeof record.detail === "object") {
detailPayload = readErrorPayload(record.detail);
} else if (typeof record.detail === "string" && record.detail.trim()) {
const detail = record.detail.trim();
const nested = extractJsonValue(detail);
if (nested) {
const parsedNested = readErrorPayload(nested);
if (parsedNested.code || parsedNested.message) detailPayload = parsedNested;
else detailPayload.message = detail;
} else {
detailPayload.message = detail;
}
}
return {
code: code || detailPayload.code,
message: message || detailPayload.message,
};
}
function parsePayrollAdminError(error: unknown, fallback = ""): ParsedPayrollAdminError {
const raw = error instanceof Error ? error.message : String(error || fallback);
const statusMatch = raw.match(/API Error:\s*(\d{3})\b/i);
const payload = readErrorPayload(extractJsonValue(raw));
return {
status: statusMatch ? Number(statusMatch[1]) : null,
code: payload.code,
message: payload.message,
};
}
export function getPayrollAdminError(error: unknown, fallback: string): string {
const parsed = parsePayrollAdminError(error, fallback);
if (parsed.message) return parsed.message;
if (parsed.status === 409) return "Возник конфликт данных. Проверьте условия и повторите действие.";
if (parsed.status === 403) return "Для этого действия недостаточно прав.";
if (parsed.status === 401) return "Сессия истекла. Войдите в портал ещё раз.";
return fallback;
}
export function isPayrollAdminStaleConflict(error: unknown): boolean {
const { code } = parsePayrollAdminError(error);
return code === "STALE_PERIOD"
|| code === "STALE_DRAFT"
|| code === "CONCURRENT_CALCULATION_CONFLICT";
}
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. */
export function canApprovePayrollDraft(
isAuthor: boolean,
canApprove: boolean,
canOverrideApproval: boolean,
): boolean {
return canApprove && (!isAuthor || canOverrideApproval);
}
export function getPrimaryPayrollRunAction(
run: PayrollAdminRun | null,
periodStatus: PayrollPeriodStatus,
permissions: PayrollAdminPermissions,
calculationReady: boolean,
currentUserId?: number,
): PayrollRunAction {
if (periodStatus === "closed") return null;
if (!run || run.status === "returned") {
return permissions.calculate && calculationReady ? "calculate" : null;
}
if (run.status === "calculated") return permissions.calculate ? "submit_review" : null;
if (run.status === "review") {
const isRequester = currentUserId !== undefined && run.requested_by?.id === currentUserId;
return canApprovePayrollDraft(isRequester, permissions.approve_run, permissions.override_approval)
? "approve"
: null;
}
if (run.status === "approved") return permissions.publish ? "publish" : null;
if (run.status === "published") return permissions.publish ? "close" : null;
return null;
}
export const payrollRunActionLabels: Record<Exclude<PayrollRunAction, null>, string> = {
calculate: "Запустить расчёт",
submit_review: "Передать на проверку",
approve: "Утвердить расчёт",
publish: "Опубликовать листки",
close: "Закрыть период",
};

View File

@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildPayrollAttendanceApplyNotice,
formatPayrollAttendanceEmployeeCount,
formatPayrollWorkMetric,
getPayrollAttendanceModeSummary,
isAttendancePayrollWorkRecord,
} from "./payroll-attendance.ts";
test("attendance warnings use the correct employee form", () => {
assert.equal(formatPayrollAttendanceEmployeeCount(1), "1 сотрудника");
assert.equal(formatPayrollAttendanceEmployeeCount(2), "2 сотрудников");
assert.equal(formatPayrollAttendanceEmployeeCount(11), "11 сотрудников");
});
test("attendance work metrics are explicitly displayed as hours", () => {
assert.equal(isAttendancePayrollWorkRecord("attendance"), true);
assert.equal(isAttendancePayrollWorkRecord("manual"), false);
assert.equal(formatPayrollWorkMetric("152.5000", "attendance"), "152.5000 ч");
assert.equal(formatPayrollWorkMetric("110.0000", "manual"), "110.0000");
});
test("attendance preview selects the summary for the chosen import mode", () => {
const missingOnly = {
create: 2,
update: 0,
revise: 0,
unchanged: 0,
skip: 3,
blocked: 1,
changes: 2,
};
const preview = {
summary: {
modes: {
missing_only: missingOnly,
replace_existing: { ...missingOnly, update: 1, revise: 2, changes: 5 },
},
},
};
assert.equal(getPayrollAttendanceModeSummary(preview, "missing_only"), missingOnly);
assert.equal(getPayrollAttendanceModeSummary(preview, "replace_existing").changes, 5);
});
test("attendance apply notice explains drafts, revisions and skipped rows", () => {
const notice = buildPayrollAttendanceApplyNotice({
mode: "replace_existing",
summary: {
created: 2,
updated: 1,
revised: 3,
unchanged: 4,
skipped: 2,
blocked: 1,
},
records: [],
});
assert.equal(
notice,
"Данные посещаемости обработаны: создано 2 черновика, обновлено 1, создано 3 ревизии, без изменений 4, пропущено 2, требует исправления 1. Проверьте и утвердите созданные черновики.",
);
});

View File

@ -0,0 +1,76 @@
import type {
PayrollAttendanceWorkAction,
PayrollAttendanceWorkApplyResult,
PayrollAttendanceWorkIssue,
PayrollAttendanceWorkMode,
PayrollAttendanceWorkModeSummary,
PayrollAttendanceWorkPreview,
} from "@/lib/api/finance";
export const payrollAttendanceActionMeta: Record<
PayrollAttendanceWorkAction,
{ label: string; className: string }
> = {
create: { label: "Новый черновик", className: "app-feedback-success" },
update: { label: "Обновить черновик", className: "app-selected" },
revise: { label: "Новая ревизия", className: "app-feedback-warning" },
unchanged: { label: "Без изменений", className: "app-badge" },
skip: { label: "Будет пропущено", className: "app-badge" },
blocked: { label: "Требует исправления", className: "app-feedback-danger" },
};
export function getPayrollAttendanceModeSummary(
preview: PayrollAttendanceWorkPreview,
mode: PayrollAttendanceWorkMode,
): PayrollAttendanceWorkModeSummary {
return preview.summary.modes[mode];
}
export function getPayrollAttendanceIssueMessage(issue: PayrollAttendanceWorkIssue): string {
return issue.message;
}
export function isAttendancePayrollWorkRecord(source?: string | null): boolean {
return source === "attendance";
}
export function formatPayrollWorkMetric(value: string, source?: string | null): string {
return `${value}${isAttendancePayrollWorkRecord(source) ? " ч" : ""}`;
}
function pluralize(count: number, one: string, few: string, many: string): string {
const normalized = Math.abs(count) % 100;
const lastDigit = normalized % 10;
if (normalized > 10 && normalized < 20) return many;
if (lastDigit === 1) return one;
if (lastDigit >= 2 && lastDigit <= 4) return few;
return many;
}
export function formatPayrollAttendanceEmployeeCount(count: number): string {
return `${count} ${pluralize(count, "сотрудника", "сотрудников", "сотрудников")}`;
}
export function buildPayrollAttendanceApplyNotice(
result: PayrollAttendanceWorkApplyResult,
): string {
const { summary } = result;
const parts: string[] = [];
if (summary.created) {
parts.push(
`создано ${summary.created} ${pluralize(summary.created, "черновик", "черновика", "черновиков")}`,
);
}
if (summary.updated) parts.push(`обновлено ${summary.updated}`);
if (summary.revised) {
parts.push(
`создано ${summary.revised} ${pluralize(summary.revised, "ревизия", "ревизии", "ревизий")}`,
);
}
if (summary.unchanged) parts.push(`без изменений ${summary.unchanged}`);
if (summary.skipped) parts.push(`пропущено ${summary.skipped}`);
if (summary.blocked) parts.push(`требует исправления ${summary.blocked}`);
const outcome = parts.length ? parts.join(", ") : "изменений нет";
return `Данные посещаемости обработаны: ${outcome}. Проверьте и утвердите созданные черновики.`;
}

View File

@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatPayrollDate,
formatPayrollMoney,
getPayrollLineDirection,
getPayrollPeriodLabel,
groupPayrollLines,
normalizePayrollStatements,
} from "./payroll.ts";
const period = {
code: "2026-06",
name: "",
date_from: "2026-06-01",
date_to: "2026-06-30",
pay_date: "2026-07-05",
};
test("payroll list payload is normalized for paginated and plain responses", () => {
const statement = { public_id: "id", period };
assert.deepEqual(normalizePayrollStatements([statement]), [statement]);
assert.deepEqual(
normalizePayrollStatements({ count: 1, next: null, previous: null, results: [statement] }),
[statement],
);
});
test("payroll formatters keep date-only values in their calendar day", () => {
assert.match(getPayrollPeriodLabel(period), /Июнь 2026/);
assert.match(formatPayrollDate("2026-07-05"), /05 июля 2026/);
assert.match(formatPayrollMoney("115000.00", "RUB"), /115.{0,2}000/);
assert.equal(formatPayrollMoney("not-a-number", "RUB"), "—");
});
test("payroll lines are grouped and debit-like lines have a negative direction", () => {
const base = {
code: "BASE",
label: "Оклад",
amount: "80000.00",
source_period_from: null,
source_period_to: null,
is_retro: false,
calculated: false,
};
const groups = groupPayrollLines([
{ ...base, kind: "earning" },
{ ...base, code: "BONUS", label: "Премия", kind: "adjustment_credit" },
{ ...base, code: "ADVANCE", label: "Аванс", kind: "payment" },
]);
assert.deepEqual(groups.map((group) => group.key), ["accruals", "adjustments", "payments"]);
assert.equal(getPayrollLineDirection("adjustment_credit"), "positive");
assert.equal(getPayrollLineDirection("deduction"), "negative");
assert.equal(getPayrollLineDirection("payment"), "negative");
});

118
frontend/src/lib/payroll.ts Normal file
View File

@ -0,0 +1,118 @@
import type {
PayrollPeriodSummary,
PayrollStatementLine,
PayrollStatementPage,
PayrollStatementSummary,
} from "@/lib/api/finance";
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
export type PayrollLineGroupKey = "accruals" | "adjustments" | "deductions" | "payments";
export type PayrollLineGroup = {
key: PayrollLineGroupKey;
label: string;
lines: PayrollStatementLine[];
};
function parseDateOnly(value: string): Date | null {
const match = DATE_ONLY_PATTERN.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return Number.isNaN(date.getTime()) ? null : date;
}
export function normalizePayrollStatements(
payload: PayrollStatementPage | PayrollStatementSummary[],
): PayrollStatementSummary[] {
return Array.isArray(payload) ? payload : payload.results || [];
}
export function formatPayrollMoney(value: string | number, currency = "RUB"): string {
const amount = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(amount)) return "—";
try {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: currency || "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount);
} catch {
return `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(amount)} ${currency}`.trim();
}
}
export function formatPayrollDate(value: string | null | undefined): string {
if (!value) return "—";
const date = parseDateOnly(value) || new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "long",
year: "numeric",
}).format(date);
}
export function formatPayrollDateTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export function getPayrollPeriodLabel(period: PayrollPeriodSummary): string {
if (period.name.trim()) return period.name.trim();
const start = parseDateOnly(period.date_from);
if (!start) return period.code;
const label = new Intl.DateTimeFormat("ru-RU", {
month: "long",
year: "numeric",
}).format(start);
return label.charAt(0).toUpperCase() + label.slice(1);
}
export function getPayrollPeriodRange(period: PayrollPeriodSummary): string {
return `${formatPayrollDate(period.date_from)} — ${formatPayrollDate(period.date_to)}`;
}
export function groupPayrollLines(lines: PayrollStatementLine[]): PayrollLineGroup[] {
const groups: Record<PayrollLineGroupKey, PayrollStatementLine[]> = {
accruals: [],
adjustments: [],
deductions: [],
payments: [],
};
for (const line of lines) {
if (line.kind === "earning") groups.accruals.push(line);
else if (line.kind === "adjustment_credit" || line.kind === "adjustment_debit") {
groups.adjustments.push(line);
} else if (line.kind === "deduction") groups.deductions.push(line);
else if (line.kind === "payment") groups.payments.push(line);
}
const labels: Record<PayrollLineGroupKey, string> = {
accruals: "Начисления",
adjustments: "Корректировки",
deductions: "Удержания",
payments: "Выплаты",
};
return (Object.keys(groups) as PayrollLineGroupKey[])
.filter((key) => groups[key].length > 0)
.map((key) => ({ key, label: labels[key], lines: groups[key] }));
}
export function getPayrollLineDirection(kind: PayrollStatementLine["kind"]): "positive" | "negative" {
return kind === "adjustment_debit" || kind === "deduction" || kind === "payment"
? "negative"
: "positive";
}

View File

@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import { canOpenPayrollAdmin } from "./permissions.ts";
test("payroll management requires an operational payroll permission", () => {
assert.equal(canOpenPayrollAdmin(null), false);
assert.equal(canOpenPayrollAdmin({ auth: { is_staff: true, permissions: [] } }), false);
assert.equal(
canOpenPayrollAdmin({
auth: {
is_staff: true,
permissions: ["finance.manage_payroll_inputs"],
},
}),
true,
);
assert.equal(
canOpenPayrollAdmin({
auth: {
is_staff: true,
permissions_by_app: { finance: ["view_all_payroll"] },
},
}),
true,
);
});
test("custom payroll roles do not need Django staff access", () => {
assert.equal(
canOpenPayrollAdmin({
auth: {
is_staff: false,
permissions: ["finance.view_all_payroll"],
},
}),
true,
);
assert.equal(canOpenPayrollAdmin({ auth: { is_superuser: true } }), true);
assert.equal(
canOpenPayrollAdmin({ auth: { permissions: ["finance.audit_payroll"] } }),
false,
);
assert.equal(
canOpenPayrollAdmin({ auth: { permissions: ["finance.override_payroll_approval"] } }),
false,
);
assert.equal(
canOpenPayrollAdmin({ auth: { permissions_by_app: { finance: ["override_payroll_approval"] } } }),
false,
);
});

View File

@ -1,5 +1,27 @@
import type { User } from "@/types/api";
const PAYROLL_ADMIN_PERMISSIONS = [
"manage_payroll_inputs",
"approve_payroll_inputs",
"calculate_payroll",
"approve_payroll",
"publish_payroll",
"view_all_payroll",
] as const;
/** Whether the user can enter the native payroll management workspace. */
export function canOpenPayrollAdmin(user?: User | null): boolean {
const auth = user?.auth;
if (!auth) return false;
if (auth.is_superuser) return true;
const permissions = auth.permissions || [];
const financePermissions = auth.permissions_by_app?.finance || [];
return PAYROLL_ADMIN_PERMISSIONS.some(
(code) => permissions.includes(`finance.${code}`) || financePermissions.includes(code),
);
}
/**
* Проверяет, может ли пользователь обрабатывать (approve/reject) заявления.
*