feat(finance): add payroll core and management workspace
This commit is contained in:
parent
de48eefdcf
commit
bb1694c584
13
.env.example
13
.env.example
@ -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
|
||||
|
||||
0
backend/api/v1/finance/__init__.py
Normal file
0
backend/api/v1/finance/__init__.py
Normal file
561
backend/api/v1/finance/admin_serializers.py
Normal file
561
backend/api/v1/finance/admin_serializers.py
Normal 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
|
||||
1207
backend/api/v1/finance/admin_views.py
Normal file
1207
backend/api/v1/finance/admin_views.py
Normal file
File diff suppressed because it is too large
Load Diff
116
backend/api/v1/finance/serializers.py
Normal file
116
backend/api/v1/finance/serializers.py
Normal 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)
|
||||
173
backend/api/v1/finance/urls.py
Normal file
173
backend/api/v1/finance/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
132
backend/api/v1/finance/views.py
Normal file
132
backend/api/v1/finance/views.py
Normal 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,
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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
178
backend/finance/README.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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
42
backend/finance/enums.py
Normal 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", "Заменён новой ревизией"
|
||||
1542
backend/finance/migrations/0001_initial.py
Normal file
1542
backend/finance/migrations/0001_initial.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
49
backend/finance/migrations/0003_seed_payroll_components.py
Normal file
49
backend/finance/migrations/0003_seed_payroll_components.py
Normal 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),
|
||||
]
|
||||
@ -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="Публичный идентификатор",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
17
backend/finance/migrations/0006_payrollworkrecord_reason.py
Normal file
17
backend/finance/migrations/0006_payrollworkrecord_reason.py
Normal 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="Основание изменения"),
|
||||
),
|
||||
]
|
||||
@ -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="Версия черновика"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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
27
backend/finance/payroll/__init__.py
Normal file
27
backend/finance/payroll/__init__.py
Normal 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",
|
||||
]
|
||||
96
backend/finance/payroll/adapter.py
Normal file
96
backend/finance/payroll/adapter.py
Normal 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),
|
||||
}
|
||||
764
backend/finance/payroll/attendance.py
Normal file
764
backend/finance/payroll/attendance.py
Normal 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}
|
||||
184
backend/finance/payroll/config.py
Normal file
184
backend/finance/payroll/config.py
Normal 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"])
|
||||
28
backend/finance/payroll/exceptions.py
Normal file
28
backend/finance/payroll/exceptions.py
Normal 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},
|
||||
)
|
||||
1050
backend/finance/payroll/services.py
Normal file
1050
backend/finance/payroll/services.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 %}
|
||||
@ -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 %}
|
||||
96
backend/payroll_core/README.md
Normal file
96
backend/payroll_core/README.md
Normal 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.
|
||||
44
backend/payroll_core/__init__.py
Normal file
44
backend/payroll_core/__init__.py
Normal 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"
|
||||
820
backend/payroll_core/calculator.py
Normal file
820
backend/payroll_core/calculator.py
Normal 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)}."),
|
||||
)
|
||||
)
|
||||
339
backend/payroll_core/domain.py
Normal file
339
backend/payroll_core/domain.py
Normal 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],
|
||||
}
|
||||
33
backend/payroll_core/exceptions.py
Normal file
33
backend/payroll_core/exceptions.py
Normal 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")
|
||||
18
backend/payroll_core/pyproject.toml
Normal file
18
backend/payroll_core/pyproject.toml
Normal 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 = "."}
|
||||
567
backend/payroll_core/tests/test_calculator.py
Normal file
567
backend/payroll_core/tests/test_calculator.py
Normal 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",
|
||||
}
|
||||
42
backend/payroll_core/tests/test_import_boundaries.py
Normal file
42
backend/payroll_core/tests/test_import_boundaries.py
Normal 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 == []
|
||||
@ -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
|
||||
|
||||
0
backend/tests/finance/__init__.py
Normal file
0
backend/tests/finance/__init__.py
Normal file
692
backend/tests/finance/test_payroll_admin_api.py
Normal file
692
backend/tests/finance/test_payroll_admin_api.py
Normal 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"
|
||||
)
|
||||
257
backend/tests/finance/test_payroll_admin_permissions.py
Normal file
257
backend/tests/finance/test_payroll_admin_permissions.py
Normal 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
|
||||
374
backend/tests/finance/test_payroll_attendance_import.py
Normal file
374
backend/tests/finance/test_payroll_attendance_import.py
Normal 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
|
||||
67
backend/tests/finance/test_payroll_config.py
Normal file
67
backend/tests/finance/test_payroll_config.py
Normal 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()
|
||||
881
backend/tests/finance/test_payroll_services.py
Normal file
881
backend/tests/finance/test_payroll_services.py
Normal 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
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
32
frontend/src/app/finances/payroll/manage/page.tsx
Normal file
32
frontend/src/app/finances/payroll/manage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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"}`}>
|
||||
|
||||
328
frontend/src/components/finance/PayrollStatementDetailModal.tsx
Normal file
328
frontend/src/components/finance/PayrollStatementDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
508
frontend/src/components/finance/admin/PayrollAdminModals.tsx
Normal file
508
frontend/src/components/finance/admin/PayrollAdminModals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
frontend/src/components/finance/admin/PayrollAdminTables.tsx
Normal file
355
frontend/src/components/finance/admin/PayrollAdminTables.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
564
frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx
Normal file
564
frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx
Normal 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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
152
frontend/src/lib/api/finance.test.js
Normal file
152
frontend/src/lib/api/finance.test.js
Normal 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",
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
475
frontend/src/lib/api/finance.ts
Normal file
475
frontend/src/lib/api/finance.ts
Normal 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")),
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
76
frontend/src/lib/feed-regulation-filters.test.js
Normal file
76
frontend/src/lib/feed-regulation-filters.test.js
Normal 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);
|
||||
});
|
||||
47
frontend/src/lib/feed-regulation-filters.ts
Normal file
47
frontend/src/lib/feed-regulation-filters.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
132
frontend/src/lib/payroll-admin.test.js
Normal file
132
frontend/src/lib/payroll-admin.test.js
Normal 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);
|
||||
});
|
||||
201
frontend/src/lib/payroll-admin.ts
Normal file
201
frontend/src/lib/payroll-admin.ts
Normal 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: "Закрыть период",
|
||||
};
|
||||
66
frontend/src/lib/payroll-attendance.test.js
Normal file
66
frontend/src/lib/payroll-attendance.test.js
Normal 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. Проверьте и утвердите созданные черновики.",
|
||||
);
|
||||
});
|
||||
76
frontend/src/lib/payroll-attendance.ts
Normal file
76
frontend/src/lib/payroll-attendance.ts
Normal 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}. Проверьте и утвердите созданные черновики.`;
|
||||
}
|
||||
57
frontend/src/lib/payroll.test.js
Normal file
57
frontend/src/lib/payroll.test.js
Normal 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
118
frontend/src/lib/payroll.ts
Normal 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";
|
||||
}
|
||||
52
frontend/src/lib/permissions-payroll.test.js
Normal file
52
frontend/src/lib/permissions-payroll.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
@ -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) заявления.
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user