From bb1694c5846fd14f95e9625b5b337bf3c3e7f99d Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 17 Jul 2026 23:35:29 +0300 Subject: [PATCH] feat(finance): add payroll core and management workspace --- .env.example | 13 + backend/api/v1/finance/__init__.py | 0 backend/api/v1/finance/admin_serializers.py | 561 ++++++ backend/api/v1/finance/admin_views.py | 1207 +++++++++++++ backend/api/v1/finance/serializers.py | 116 ++ backend/api/v1/finance/urls.py | 173 ++ backend/api/v1/finance/views.py | 132 ++ backend/api/v1/urls.py | 2 + backend/eusrr_backend/settings.py | 22 + backend/finance/README.md | 178 ++ backend/finance/admin.py | 693 +++++++- backend/finance/apps.py | 5 +- backend/finance/enums.py | 42 + backend/finance/migrations/0001_initial.py | 1542 +++++++++++++++++ ...ate_pay_rate_approval_complete_and_more.py | 79 + .../0003_seed_payroll_components.py | 49 + ..._payroll_run_reason_statement_public_id.py | 45 + ..._payrollstatementline_is_retro_and_more.py | 46 + .../0006_payrollworkrecord_reason.py | 17 + ...7_employeepayrate_lock_version_and_more.py | 33 + ...08_alter_payrollperiod_options_and_more.py | 191 ++ .../0009_alter_payroll_input_sources.py | 57 + backend/finance/models.py | 1269 +++++++++++++- backend/finance/payroll/__init__.py | 27 + backend/finance/payroll/adapter.py | 96 + backend/finance/payroll/attendance.py | 764 ++++++++ backend/finance/payroll/config.py | 184 ++ backend/finance/payroll/exceptions.py | 28 + backend/finance/payroll/services.py | 1050 +++++++++++ .../finance/payroll_approval_action.html | 40 + .../admin/finance/payroll_reason_action.html | 38 + backend/payroll_core/README.md | 96 + backend/payroll_core/__init__.py | 44 + backend/payroll_core/calculator.py | 820 +++++++++ backend/payroll_core/domain.py | 339 ++++ backend/payroll_core/exceptions.py | 33 + backend/payroll_core/pyproject.toml | 18 + backend/payroll_core/tests/test_calculator.py | 567 ++++++ .../tests/test_import_boundaries.py | 42 + backend/pytest.ini | 2 +- backend/tests/finance/__init__.py | 0 .../tests/finance/test_payroll_admin_api.py | 692 ++++++++ .../finance/test_payroll_admin_permissions.py | 257 +++ .../finance/test_payroll_attendance_import.py | 374 ++++ backend/tests/finance/test_payroll_config.py | 67 + .../tests/finance/test_payroll_services.py | 881 ++++++++++ frontend/src/app/finances/page.tsx | 288 ++- .../src/app/finances/payroll/manage/page.tsx | 32 + frontend/src/app/page.tsx | 26 +- frontend/src/components/AppShell.tsx | 10 +- .../finance/PayrollStatementDetailModal.tsx | 328 ++++ .../finance/admin/PayrollAdminModals.tsx | 508 ++++++ .../finance/admin/PayrollAdminTables.tsx | 355 ++++ .../finance/admin/PayrollAdminWorkspace.tsx | 564 ++++++ .../admin/PayrollAttendanceWorkModal.tsx | 421 +++++ frontend/src/lib/api/finance.test.js | 152 ++ frontend/src/lib/api/finance.ts | 475 +++++ frontend/src/lib/api/index.ts | 2 + .../src/lib/feed-regulation-filters.test.js | 76 + frontend/src/lib/feed-regulation-filters.ts | 47 + frontend/src/lib/payroll-admin.test.js | 132 ++ frontend/src/lib/payroll-admin.ts | 201 +++ frontend/src/lib/payroll-attendance.test.js | 66 + frontend/src/lib/payroll-attendance.ts | 76 + frontend/src/lib/payroll.test.js | 57 + frontend/src/lib/payroll.ts | 118 ++ frontend/src/lib/permissions-payroll.test.js | 52 + frontend/src/lib/permissions.ts | 22 + 68 files changed, 16914 insertions(+), 25 deletions(-) create mode 100644 backend/api/v1/finance/__init__.py create mode 100644 backend/api/v1/finance/admin_serializers.py create mode 100644 backend/api/v1/finance/admin_views.py create mode 100644 backend/api/v1/finance/serializers.py create mode 100644 backend/api/v1/finance/urls.py create mode 100644 backend/api/v1/finance/views.py create mode 100644 backend/finance/README.md create mode 100644 backend/finance/enums.py create mode 100644 backend/finance/migrations/0001_initial.py create mode 100644 backend/finance/migrations/0002_employeepayrate_pay_rate_approval_complete_and_more.py create mode 100644 backend/finance/migrations/0003_seed_payroll_components.py create mode 100644 backend/finance/migrations/0004_payroll_run_reason_statement_public_id.py create mode 100644 backend/finance/migrations/0005_payrollstatementline_is_retro_and_more.py create mode 100644 backend/finance/migrations/0006_payrollworkrecord_reason.py create mode 100644 backend/finance/migrations/0007_employeepayrate_lock_version_and_more.py create mode 100644 backend/finance/migrations/0008_alter_payrollperiod_options_and_more.py create mode 100644 backend/finance/migrations/0009_alter_payroll_input_sources.py create mode 100644 backend/finance/payroll/__init__.py create mode 100644 backend/finance/payroll/adapter.py create mode 100644 backend/finance/payroll/attendance.py create mode 100644 backend/finance/payroll/config.py create mode 100644 backend/finance/payroll/exceptions.py create mode 100644 backend/finance/payroll/services.py create mode 100644 backend/finance/templates/admin/finance/payroll_approval_action.html create mode 100644 backend/finance/templates/admin/finance/payroll_reason_action.html create mode 100644 backend/payroll_core/README.md create mode 100644 backend/payroll_core/__init__.py create mode 100644 backend/payroll_core/calculator.py create mode 100644 backend/payroll_core/domain.py create mode 100644 backend/payroll_core/exceptions.py create mode 100644 backend/payroll_core/pyproject.toml create mode 100644 backend/payroll_core/tests/test_calculator.py create mode 100644 backend/payroll_core/tests/test_import_boundaries.py create mode 100644 backend/tests/finance/__init__.py create mode 100644 backend/tests/finance/test_payroll_admin_api.py create mode 100644 backend/tests/finance/test_payroll_admin_permissions.py create mode 100644 backend/tests/finance/test_payroll_attendance_import.py create mode 100644 backend/tests/finance/test_payroll_config.py create mode 100644 backend/tests/finance/test_payroll_services.py create mode 100644 frontend/src/app/finances/payroll/manage/page.tsx create mode 100644 frontend/src/components/finance/PayrollStatementDetailModal.tsx create mode 100644 frontend/src/components/finance/admin/PayrollAdminModals.tsx create mode 100644 frontend/src/components/finance/admin/PayrollAdminTables.tsx create mode 100644 frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx create mode 100644 frontend/src/components/finance/admin/PayrollAttendanceWorkModal.tsx create mode 100644 frontend/src/lib/api/finance.test.js create mode 100644 frontend/src/lib/api/finance.ts create mode 100644 frontend/src/lib/feed-regulation-filters.test.js create mode 100644 frontend/src/lib/feed-regulation-filters.ts create mode 100644 frontend/src/lib/payroll-admin.test.js create mode 100644 frontend/src/lib/payroll-admin.ts create mode 100644 frontend/src/lib/payroll-attendance.test.js create mode 100644 frontend/src/lib/payroll-attendance.ts create mode 100644 frontend/src/lib/payroll.test.js create mode 100644 frontend/src/lib/payroll.ts create mode 100644 frontend/src/lib/permissions-payroll.test.js diff --git a/.env.example b/.env.example index 9b30f0fb..0c3e8b82 100644 --- a/.env.example +++ b/.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 diff --git a/backend/api/v1/finance/__init__.py b/backend/api/v1/finance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py new file mode 100644 index 00000000..b686b9b3 --- /dev/null +++ b/backend/api/v1/finance/admin_serializers.py @@ -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 diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py new file mode 100644 index 00000000..503dd91c --- /dev/null +++ b/backend/api/v1/finance/admin_views.py @@ -0,0 +1,1207 @@ +"""Native, permission-gated payroll administration API. + +The endpoints in this module are an HTTP adapter around the existing finance +models and payroll services. Generic Django model permissions never grant +access to payroll data. +""" + +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.db.models import F, OuterRef, Q, Subquery +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, status +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus +from finance.models import ( + EmployeePayRate, + PayrollAuditEvent, + PayrollComponent, + PayrollInputLine, + PayrollPeriod, + PayrollRun, + PayrollWorkRecord, +) +from finance.payroll.config import ( + base_rate_code, + build_rules, + rules_cover_period, + ruleset_not_effective_message, + ruleset_period_details, +) +from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied +from finance.payroll.services import ( + 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, +) +from finance.payroll.attendance import ( + apply_attendance_work_preview, + build_attendance_work_preview, +) + +from .admin_serializers import ( + AttendanceWorkImportCommandSerializer, + ApprovalCommandSerializer, + CalculatePayrollCommandSerializer, + EmployeePayRateRevisionSerializer, + EmployeePayRateSerializer, + EmployeePayRateWriteSerializer, + PayrollComponentSerializer, + PayrollEmployeeMiniSerializer, + PayrollInputLineSerializer, + PayrollInputLineWriteSerializer, + PayrollPeriodSerializer, + PayrollPeriodWriteSerializer, + PayrollRunSerializer, + PayrollWorkRecordRevisionSerializer, + PayrollWorkRecordSerializer, + PayrollWorkRecordWriteSerializer, + ReturnPayrollCommandSerializer, + _validation_detail, +) +from .views import NoStorePayrollResponseMixin, PayrollConflict + +Employee = get_user_model() + +PAYROLL_PERMISSIONS = { + "manage_inputs": "finance.manage_payroll_inputs", + "approve_inputs": "finance.approve_payroll_inputs", + "calculate": "finance.calculate_payroll", + "approve_run": "finance.approve_payroll", + "override_approval": "finance.override_payroll_approval", + "publish": "finance.publish_payroll", + "view_all": "finance.view_all_payroll", + "audit": "finance.audit_payroll", +} +OPERATIONAL_PERMISSIONS = tuple( + permission + for key, permission in PAYROLL_PERMISSIONS.items() + if key not in {"audit", "override_approval"} +) +INPUT_VIEW_PERMISSIONS = ( + PAYROLL_PERMISSIONS["manage_inputs"], + PAYROLL_PERMISSIONS["approve_inputs"], + PAYROLL_PERMISSIONS["view_all"], +) +RUN_VIEW_PERMISSIONS = ( + PAYROLL_PERMISSIONS["calculate"], + PAYROLL_PERMISSIONS["approve_run"], + PAYROLL_PERMISSIONS["publish"], + PAYROLL_PERMISSIONS["view_all"], +) + + +def _permission_error(permission): + raise PermissionDenied( + detail={ + "code": "PERMISSION_DENIED", + "message": "Недостаточно прав для операции с зарплатой.", + "details": {"permission": permission}, + } + ) + + +def _conflict(code: str, message: str, **details): + raise PayrollConflict(detail={"code": code, "message": message, "details": details}) + + +def _optional_int(request, name): + raw_value = request.query_params.get(name) + if raw_value in (None, ""): + return None + try: + value = int(raw_value) + except (TypeError, ValueError) as exc: + raise ValidationError({name: "Ожидается целое число."}) from exc + if value < 1: + raise ValidationError({name: "Значение должно быть положительным."}) + return value + + +def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None): + PayrollAuditEvent.objects.create( + actor=actor, + action=action, + object_type=instance._meta.label_lower, + object_id=str(instance.pk), + period=period, + metadata={"channel": "admin_api", **(metadata or {})}, + ) + + +def _lock_period_accepting_draft_edits(period_id): + period = get_object_or_404( + PayrollPeriod.objects.select_for_update().select_related("current_run"), + pk=period_id, + ) + if period.status == PayrollPeriodStatus.CLOSED: + _conflict( + "PERIOD_INPUTS_LOCKED", + "Данные закрытого периода заблокированы.", + ) + if period.current_run is not None and period.current_run.status in { + PayrollRunStatus.CALCULATED, + PayrollRunStatus.REVIEW, + PayrollRunStatus.APPROVED, + }: + _conflict( + "PERIOD_INPUTS_LOCKED", + "Сначала верните текущий расчёт на исправление.", + run_id=period.current_run_id, + ) + return period + + +class PayrollAdminAPIView(NoStorePayrollResponseMixin, APIView): + permission_classes = [permissions.IsAuthenticated] + allowed_permissions = OPERATIONAL_PERMISSIONS + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if not any(request.user.has_perm(code) for code in self.allowed_permissions): + _permission_error(self.allowed_permissions[0]) + + def require_permission(self, request, code): + if not request.user.has_perm(code): + _permission_error(code) + + def include_amounts(self, request): + return request.user.has_perm(PAYROLL_PERMISSIONS["view_all"]) + + def run_payload(self, request, run): + return PayrollRunSerializer( + run, + context={"include_amounts": self.include_amounts(request)}, + ).data + + def handle_exception(self, exc): + if isinstance(exc, PayrollPermissionDenied): + exc = PermissionDenied( + detail={ + "code": exc.code, + "message": exc.message, + "details": exc.details, + } + ) + elif isinstance(exc, PayrollOperationError): + exc = PayrollConflict( + detail={ + "code": exc.code, + "message": exc.message, + "details": exc.details, + } + ) + return super().handle_exception(exc) + + +class PayrollAdminWorkspaceView(PayrollAdminAPIView): + """One bounded bootstrap response for the native payroll screen.""" + + def get(self, request): + permissions_payload = { + key: request.user.has_perm(permission) + for key, permission in PAYROLL_PERMISSIONS.items() + } + periods = list( + PayrollPeriod.objects.select_related("current_run").order_by( + "-date_from", "-id" + ) + ) + selected_period_id = _optional_int(request, "period_id") + if selected_period_id is None: + selected_period = periods[0] if periods else None + else: + selected_period = next( + (period for period in periods if period.pk == selected_period_id), + None, + ) + if selected_period is None: + raise Http404 + + employees = ( + Employee.objects.filter(is_active=True) + .select_related("position") + .prefetch_related("departments_links__department") + .order_by("last_name", "first_name", "id") + ) + components = PayrollComponent.objects.order_by("display_order", "code") + + current_run = None + runs = [] + readiness = { + "rates": { + "ready": False, + "total": 0, + "approved": 0, + "draft": 0, + "missing_employee_ids": [], + }, + "work_records": { + "ready": False, + "total": 0, + "approved": 0, + "draft": 0, + }, + "input_lines": {"approved": 0, "draft": 0}, + "calculation": { + "ready": False, + "blockers": [ + { + "code": "PERIOD_NOT_SELECTED", + "message": "Создайте или выберите расчётный период.", + } + ], + }, + } + pending_approvals = {"rates": 0, "work_records": 0, "input_lines": 0} + summary = None + if selected_period is not None: + runs = list( + PayrollRun.objects.filter(period=selected_period) + .select_related("period", "requested_by", "approved_by", "published_by") + .order_by("-revision") + ) + current_run = selected_period.current_run + approved_work = PayrollWorkRecord.objects.filter( + period=selected_period, + status=ApprovalStatus.APPROVED, + ) + draft_work = PayrollWorkRecord.objects.filter( + period=selected_period, + status=ApprovalStatus.DRAFT, + ) + approved_employee_ids = list( + approved_work.values_list("employee_id", flat=True) + ) + draft_work_employee_ids = list( + draft_work.values_list("employee_id", flat=True) + ) + roster_employee_ids = sorted( + set(approved_employee_ids) | set(draft_work_employee_ids) + ) + employees_with_rate = set( + EmployeePayRate.objects.filter( + employee_id__in=roster_employee_ids, + rate_code=base_rate_code(), + status=ApprovalStatus.APPROVED, + effective_from__lte=selected_period.date_from, + ).values_list("employee_id", flat=True) + ) + missing_rate_employee_ids = [ + employee_id + for employee_id in roster_employee_ids + if employee_id not in employees_with_rate + ] + approved_input_count = PayrollInputLine.objects.filter( + period=selected_period, + status=ApprovalStatus.APPROVED, + ).count() + draft_input_count = PayrollInputLine.objects.filter( + period=selected_period, + status=ApprovalStatus.DRAFT, + ).count() + pending_rate_count = EmployeePayRate.objects.filter( + employee_id__in=roster_employee_ids, + rate_code=base_rate_code(), + status=ApprovalStatus.DRAFT, + effective_from__lte=selected_period.date_to, + ).count() + blockers = [] + active_rules = build_rules() + if not rules_cover_period( + active_rules, + period_from=selected_period.date_from, + period_to=selected_period.date_to, + ): + blockers.append( + { + "code": "RULESET_NOT_EFFECTIVE", + "message": ruleset_not_effective_message(active_rules), + "details": ruleset_period_details( + active_rules, + period_from=selected_period.date_from, + period_to=selected_period.date_to, + ), + } + ) + if not approved_employee_ids: + blockers.append( + { + "code": "NO_APPROVED_WORK_RECORDS", + "message": "Нет утверждённого состава сотрудников за период.", + } + ) + blockers.extend( + { + "code": "MISSING_APPROVED_RATE", + "message": "Нет утверждённой ставки на начало периода.", + "employee_id": employee_id, + } + for employee_id in missing_rate_employee_ids[:20] + ) + if draft_work.exists(): + blockers.append( + { + "code": "DRAFT_WORK_RECORDS", + "message": "Остались неутверждённые показатели выработки.", + } + ) + if draft_input_count: + blockers.append( + { + "code": "DRAFT_INPUT_LINES", + "message": "Остались неутверждённые начисления или удержания.", + } + ) + if pending_rate_count: + blockers.append( + { + "code": "DRAFT_PAY_RATES", + "message": "Остались неутверждённые ставки сотрудников.", + } + ) + if selected_period.status == PayrollPeriodStatus.CLOSED: + blockers.append( + { + "code": "PERIOD_CLOSED", + "message": "Закрытый период нельзя рассчитать.", + } + ) + readiness = { + "rates": { + "ready": bool(roster_employee_ids) + and not missing_rate_employee_ids + and pending_rate_count == 0, + "total": len(roster_employee_ids), + "approved": len(roster_employee_ids) + - len(missing_rate_employee_ids), + "draft": pending_rate_count, + "missing_employee_ids": missing_rate_employee_ids, + }, + "work_records": { + "ready": bool(approved_employee_ids) + and not draft_work_employee_ids, + "total": len(roster_employee_ids), + "approved": len(approved_employee_ids), + "draft": len(draft_work_employee_ids), + }, + "input_lines": { + "approved": approved_input_count, + "draft": draft_input_count, + }, + "calculation": { + "ready": not blockers, + "blockers": blockers, + }, + } + pending_approvals = { + "rates": pending_rate_count, + "work_records": len(draft_work_employee_ids), + "input_lines": draft_input_count, + } + if current_run is not None and self.include_amounts(request): + summary = { + "employee_count": current_run.employee_count, + "gross_total": str(current_run.gross_total), + "deduction_total": str(current_run.deduction_total), + "payable_total": str(current_run.payable_total), + } + + run_context = {"include_amounts": self.include_amounts(request)} + return Response( + { + "permissions": permissions_payload, + "employees": PayrollEmployeeMiniSerializer(employees, many=True).data, + "components": PayrollComponentSerializer(components, many=True).data, + "periods": PayrollPeriodSerializer(periods, many=True).data, + "selected_period": ( + PayrollPeriodSerializer(selected_period).data + if selected_period is not None + else None + ), + "readiness": readiness, + "current_run": ( + PayrollRunSerializer(current_run, context=run_context).data + if current_run is not None + else None + ), + "runs": PayrollRunSerializer( + runs, + many=True, + context=run_context, + ).data, + "summary": summary, + "pending_approvals": pending_approvals, + } + ) + + +class PayrollAdminEmployeeListView(PayrollAdminAPIView): + def get(self, request): + employees = ( + Employee.objects.filter(is_active=True) + .select_related("position") + .prefetch_related("departments_links__department") + .order_by("last_name", "first_name", "id") + ) + return Response(PayrollEmployeeMiniSerializer(employees, many=True).data) + + +class PayrollAdminComponentListView(PayrollAdminAPIView): + allowed_permissions = INPUT_VIEW_PERMISSIONS + + def get(self, request): + components = PayrollComponent.objects.order_by("display_order", "code") + return Response(PayrollComponentSerializer(components, many=True).data) + + +class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): + def get(self, request): + periods = PayrollPeriod.objects.select_related("current_run").order_by( + "-date_from", "-id" + ) + return Response(PayrollPeriodSerializer(periods, many=True).data) + + @transaction.atomic + def post(self, request): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + serializer = PayrollPeriodWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + period_values = dict(serializer.validated_data) + period_values.pop("expected_lock_version", None) + period = PayrollPeriod( + **period_values, + created_by=request.user, + ) + try: + period.full_clean() + period.save(force_insert=True) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + except IntegrityError: + _conflict( + "PERIOD_CONFLICT", + "Параллельная операция уже создала пересекающийся период.", + ) + _audit_api_mutation( + actor=request.user, + action="payroll.period_created", + instance=period, + period=period, + ) + return Response( + PayrollPeriodSerializer(period).data, + status=status.HTTP_201_CREATED, + ) + + +class PayrollAdminPeriodDetailView(PayrollAdminAPIView): + def get(self, request, pk): + period = get_object_or_404(PayrollPeriod, pk=pk) + return Response(PayrollPeriodSerializer(period).data) + + @transaction.atomic + def patch(self, request, pk): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + period = get_object_or_404( + PayrollPeriod.objects.select_for_update(), + pk=pk, + ) + serializer = PayrollPeriodWriteSerializer( + period, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + expected_lock_version = serializer.validated_data.pop("expected_lock_version") + if period.lock_version != expected_lock_version: + _conflict( + "STALE_PERIOD", + "Период уже изменён; обновите данные.", + expected_lock_version=expected_lock_version, + actual_lock_version=period.lock_version, + ) + if period.runs.exists(): + _conflict( + "IMMUTABLE_PERIOD", + "Реквизиты периода нельзя менять после первого расчёта.", + ) + changed_fields = list(serializer.validated_data) + if not changed_fields: + raise ValidationError( + {"non_field_errors": "Не передано ни одного изменения."} + ) + for field_name, value in serializer.validated_data.items(): + setattr(period, field_name, value) + period.lock_version += 1 + try: + period.full_clean() + period.save(update_fields=[*changed_fields, "lock_version", "updated_at"]) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + _audit_api_mutation( + actor=request.user, + action="payroll.period_updated", + instance=period, + period=period, + metadata={"lock_version": period.lock_version}, + ) + return Response(PayrollPeriodSerializer(period).data) + + +class DraftCollectionView(PayrollAdminAPIView): + allowed_permissions = INPUT_VIEW_PERMISSIONS + model = None + read_serializer_class = None + write_serializer_class = None + audit_label = "input" + period_scoped = False + + def get_queryset(self): + return self.model.objects.all() + + def filter_queryset(self, request, queryset): + employee_id = _optional_int(request, "employee_id") + if employee_id is not None: + queryset = queryset.filter(employee_id=employee_id) + status_value = request.query_params.get("status") + if status_value: + queryset = queryset.filter(status=status_value) + search = request.query_params.get("search", "").strip() + if search: + queryset = queryset.filter( + Q(employee__last_name__icontains=search) + | Q(employee__first_name__icontains=search) + | Q(employee__patronymic__icontains=search) + ) + return queryset + + def get(self, request): + queryset = self.filter_queryset(request, self.get_queryset()) + return Response(self.read_serializer_class(queryset, many=True).data) + + def validate_create(self, validated_data): + if not self.period_scoped: + return + period = _lock_period_accepting_draft_edits(validated_data["period"].pk) + validated_data["period"] = period + + @transaction.atomic + def post(self, request): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + serializer = self.write_serializer_class( + data=request.data, + context={"actor": request.user}, + ) + serializer.is_valid(raise_exception=True) + self.validate_create(serializer.validated_data) + try: + instance = serializer.save() + except IntegrityError: + _conflict( + "DRAFT_CONFLICT", + "Параллельная операция уже создала такие входные данные.", + ) + period = getattr(instance, "period", None) + _audit_api_mutation( + actor=request.user, + action=f"payroll.{self.audit_label}_draft_created", + instance=instance, + period=period, + metadata={"lock_version": instance.lock_version}, + ) + instance = self.get_queryset().get(pk=instance.pk) + return Response( + self.read_serializer_class(instance).data, + status=status.HTTP_201_CREATED, + ) + + +class DraftDetailView(PayrollAdminAPIView): + allowed_permissions = INPUT_VIEW_PERMISSIONS + model = None + read_serializer_class = None + write_serializer_class = None + audit_label = "input" + period_scoped = False + immutable_patch_fields = () + + def get_queryset(self): + return self.model.objects.all() + + def get(self, request, pk): + instance = get_object_or_404(self.get_queryset(), pk=pk) + return Response(self.read_serializer_class(instance).data) + + @transaction.atomic + def patch(self, request, pk): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + forbidden_fields = set(request.data) & set(self.immutable_patch_fields) + if forbidden_fields: + raise ValidationError( + { + field_name: "Поле нельзя менять после создания черновика." + for field_name in sorted(forbidden_fields) + } + ) + # Do not reveal whether an out-of-scope or immutable payroll record exists. + scoped_queryset = self.get_queryset().filter( + pk=pk, + status=ApprovalStatus.DRAFT, + created_by=request.user, + ) + if self.period_scoped: + reference = get_object_or_404( + self.model.objects.only("period_id"), + pk=pk, + status=ApprovalStatus.DRAFT, + created_by=request.user, + ) + period = _lock_period_accepting_draft_edits(reference.period_id) + else: + period = None + instance = get_object_or_404(scoped_queryset.select_for_update()) + if period is not None: + instance._state.fields_cache["period"] = period + self.validate_period_update(instance, period) + serializer = self.write_serializer_class( + instance, + data=request.data, + partial=True, + context={"actor": request.user}, + ) + serializer.is_valid(raise_exception=True) + expected_lock_version = serializer.validated_data.get("expected_lock_version") + if instance.lock_version != expected_lock_version: + _conflict( + "STALE_DRAFT", + "Черновик уже изменён; обновите данные.", + expected_lock_version=expected_lock_version, + actual_lock_version=instance.lock_version, + ) + try: + instance = serializer.save() + except IntegrityError: + _conflict( + "DRAFT_CONFLICT", + "Изменение конфликтует с уже сохранёнными входными данными.", + ) + _audit_api_mutation( + actor=request.user, + action=f"payroll.{self.audit_label}_draft_updated", + instance=instance, + period=getattr(instance, "period", None), + metadata={"lock_version": instance.lock_version}, + ) + instance = self.get_queryset().get(pk=instance.pk) + return Response(self.read_serializer_class(instance).data) + + def validate_period_update(self, instance, period): + return None + + +class EmployeePayRateListCreateView(DraftCollectionView): + model = EmployeePayRate + read_serializer_class = EmployeePayRateSerializer + write_serializer_class = EmployeePayRateWriteSerializer + audit_label = "rate" + + def get_queryset(self): + return EmployeePayRate.objects.select_related( + "employee", + "employee__position", + "created_by", + "approved_by", + ).prefetch_related("employee__departments_links__department") + + def filter_queryset(self, request, queryset): + queryset = super().filter_queryset(request, queryset) + period_id = _optional_int(request, "period_id") + if period_id is None: + return queryset + period = get_object_or_404(PayrollPeriod, pk=period_id) + latest_effective_date = ( + EmployeePayRate.objects.filter( + employee_id=OuterRef("employee_id"), + rate_code=OuterRef("rate_code"), + effective_from__lte=period.date_from, + ) + .order_by("-effective_from") + .values("effective_from")[:1] + ) + return queryset.annotate( + _latest_effective_date=Subquery(latest_effective_date) + ).filter( + Q(effective_from=F("_latest_effective_date")) + | Q( + effective_from__gt=period.date_from, + effective_from__lte=period.date_to, + ) + ) + + +class EmployeePayRateDetailView(DraftDetailView): + model = EmployeePayRate + read_serializer_class = EmployeePayRateSerializer + write_serializer_class = EmployeePayRateWriteSerializer + audit_label = "rate" + immutable_patch_fields = ("employee_id", "rate_code", "currency") + + def get_queryset(self): + return EmployeePayRate.objects.select_related( + "employee", + "employee__position", + "created_by", + "approved_by", + ).prefetch_related("employee__departments_links__department") + + +class PayrollWorkRecordListCreateView(DraftCollectionView): + model = PayrollWorkRecord + read_serializer_class = PayrollWorkRecordSerializer + write_serializer_class = PayrollWorkRecordWriteSerializer + audit_label = "work_record" + period_scoped = True + + def validate_create(self, validated_data): + super().validate_create(validated_data) + if validated_data["period"].status == PayrollPeriodStatus.PUBLISHED: + _conflict( + "PUBLISHED_WORK_REPLACEMENT_REQUIRED", + "После публикации показатели меняются только новой ревизией.", + ) + + def get_queryset(self): + return PayrollWorkRecord.objects.select_related( + "period", + "employee", + "employee__position", + "created_by", + "approved_by", + ).prefetch_related("employee__departments_links__department") + + def filter_queryset(self, request, queryset): + queryset = super().filter_queryset(request, queryset) + period_id = _optional_int(request, "period_id") + return queryset.filter(period_id=period_id) if period_id else queryset + + +class PayrollWorkRecordDetailView(DraftDetailView): + model = PayrollWorkRecord + read_serializer_class = PayrollWorkRecordSerializer + write_serializer_class = PayrollWorkRecordWriteSerializer + audit_label = "work_record" + period_scoped = True + immutable_patch_fields = ("period_id", "employee_id") + + def validate_period_update(self, instance, period): + if period.status == PayrollPeriodStatus.PUBLISHED and not instance.replaces_id: + _conflict( + "PUBLISHED_WORK_REPLACEMENT_REQUIRED", + "После публикации показатели меняются только новой ревизией.", + ) + + def get_queryset(self): + return PayrollWorkRecord.objects.select_related( + "period", + "employee", + "employee__position", + "created_by", + "approved_by", + ).prefetch_related("employee__departments_links__department") + + +class PayrollInputLineListCreateView(DraftCollectionView): + model = PayrollInputLine + read_serializer_class = PayrollInputLineSerializer + write_serializer_class = PayrollInputLineWriteSerializer + audit_label = "input_line" + period_scoped = True + + def get_queryset(self): + return PayrollInputLine.objects.select_related( + "period", + "employee", + "employee__position", + "component", + "relates_to_period", + "created_by", + "approved_by", + ).prefetch_related("employee__departments_links__department") + + def filter_queryset(self, request, queryset): + queryset = super().filter_queryset(request, queryset) + period_id = _optional_int(request, "period_id") + component_id = _optional_int(request, "component_id") + if period_id: + queryset = queryset.filter(period_id=period_id) + if component_id: + queryset = queryset.filter(component_id=component_id) + return queryset + + +class PayrollInputLineDetailView(DraftDetailView): + model = PayrollInputLine + read_serializer_class = PayrollInputLineSerializer + write_serializer_class = PayrollInputLineWriteSerializer + audit_label = "input_line" + period_scoped = True + immutable_patch_fields = ("period_id", "employee_id", "component_id") + + def get_queryset(self): + return PayrollInputLine.objects.select_related( + "period", + "employee", + "employee__position", + "component", + "relates_to_period", + "created_by", + "approved_by", + ).prefetch_related("employee__departments_links__department") + + +class DraftApprovalView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],) + service = None + queryset = None + read_serializer_class = None + + def post(self, request, pk): + serializer = ApprovalCommandSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + if not self.queryset.filter(pk=pk).exists(): + raise Http404 + instance = self.service( + pk, + actor=request.user, + expected_lock_version=serializer.validated_data["expected_lock_version"], + ) + instance = self.queryset.get(pk=instance.pk) + return Response(self.read_serializer_class(instance).data) + + +class EmployeePayRateApproveView(DraftApprovalView): + service = staticmethod(approve_pay_rate) + queryset = EmployeePayRate.objects.select_related( + "employee", "employee__position", "created_by", "approved_by" + ).prefetch_related("employee__departments_links__department") + read_serializer_class = EmployeePayRateSerializer + + +class PayrollWorkRecordApproveView(DraftApprovalView): + service = staticmethod(approve_work_record) + queryset = PayrollWorkRecord.objects.select_related( + "period", "employee", "employee__position", "created_by", "approved_by" + ).prefetch_related("employee__departments_links__department") + read_serializer_class = PayrollWorkRecordSerializer + + +class PayrollInputLineApproveView(DraftApprovalView): + service = staticmethod(approve_input_line) + queryset = PayrollInputLine.objects.select_related( + "period", + "employee", + "employee__position", + "component", + "created_by", + "approved_by", + ).prefetch_related("employee__departments_links__department") + read_serializer_class = PayrollInputLineSerializer + + +class EmployeePayRateReviseView(PayrollAdminAPIView): + allowed_permissions = INPUT_VIEW_PERMISSIONS + + @transaction.atomic + def post(self, request, pk): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + source = get_object_or_404( + EmployeePayRate.objects.select_for_update().select_related("employee"), + pk=pk, + status=ApprovalStatus.APPROVED, + ) + if EmployeePayRate.objects.filter(replaces=source).exists(): + _conflict( + "REVISION_ALREADY_EXISTS", + "Для этой ставки уже создана следующая ревизия.", + ) + command = EmployeePayRateRevisionSerializer(data=request.data) + command.is_valid(raise_exception=True) + values = { + "amount": source.amount, + "point_rate": source.point_rate, + "currency": source.currency, + **command.validated_data, + } + revision = EmployeePayRate( + employee=source.employee, + rate_code=source.rate_code, + effective_from=source.effective_from, + revision=source.revision + 1, + replaces=source, + status=ApprovalStatus.DRAFT, + source="manual", + created_by=request.user, + **values, + ) + try: + revision.full_clean() + revision.save(force_insert=True) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + except IntegrityError: + _conflict( + "REVISION_CONFLICT", + "Параллельная операция уже создала ревизию ставки.", + ) + _audit_api_mutation( + actor=request.user, + action="payroll.rate_revision_draft_created", + instance=revision, + metadata={"replaces_id": source.pk, "revision": revision.revision}, + ) + revision = ( + EmployeePayRate.objects.select_related( + "employee", "employee__position", "created_by", "approved_by" + ) + .prefetch_related("employee__departments_links__department") + .get(pk=revision.pk) + ) + return Response( + EmployeePayRateSerializer(revision).data, + status=status.HTTP_201_CREATED, + ) + + +class PayrollWorkRecordReviseView(PayrollAdminAPIView): + allowed_permissions = INPUT_VIEW_PERMISSIONS + + @transaction.atomic + def post(self, request, pk): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + reference = get_object_or_404( + PayrollWorkRecord.objects.only("period_id"), + pk=pk, + status=ApprovalStatus.APPROVED, + ) + period = _lock_period_accepting_draft_edits(reference.period_id) + source = get_object_or_404( + PayrollWorkRecord.objects.select_for_update().select_related( + "period", "employee" + ), + pk=pk, + status=ApprovalStatus.APPROVED, + ) + source._state.fields_cache["period"] = period + if PayrollWorkRecord.objects.filter(replaces=source).exists(): + _conflict( + "REVISION_ALREADY_EXISTS", + "Для показателей уже создана следующая ревизия.", + ) + command = PayrollWorkRecordRevisionSerializer(data=request.data) + command.is_valid(raise_exception=True) + values = { + "target_points": source.target_points, + "actual_points": source.actual_points, + "expected_point_amount": source.expected_point_amount, + "expected_gross": source.expected_gross, + "expected_recalculated_gross": source.expected_recalculated_gross, + "expected_payable": source.expected_payable, + **command.validated_data, + } + revision = PayrollWorkRecord( + period=source.period, + employee=source.employee, + revision=source.revision + 1, + replaces=source, + status=ApprovalStatus.DRAFT, + source="manual", + created_by=request.user, + **values, + ) + try: + revision.full_clean() + revision.save(force_insert=True) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + except IntegrityError: + _conflict( + "REVISION_CONFLICT", + "Параллельная операция уже создала ревизию показателей.", + ) + _audit_api_mutation( + actor=request.user, + action="payroll.work_record_revision_draft_created", + instance=revision, + period=revision.period, + metadata={"replaces_id": source.pk, "revision": revision.revision}, + ) + revision = ( + PayrollWorkRecord.objects.select_related( + "period", + "employee", + "employee__position", + "created_by", + "approved_by", + ) + .prefetch_related("employee__departments_links__department") + .get(pk=revision.pk) + ) + return Response( + PayrollWorkRecordSerializer(revision).data, + status=status.HTTP_201_CREATED, + ) + + +class PayrollAttendanceWorkRecordsView(PayrollAdminAPIView): + """Preview and apply attendance-derived payroll work drafts.""" + + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + def get(self, request, pk): + period = get_object_or_404(PayrollPeriod, pk=pk) + preview = build_attendance_work_preview(period, actor=request.user) + preview.pop("_internal_items", None) + return Response(preview) + + def post(self, request, pk): + get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk) + command = AttendanceWorkImportCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + result = apply_attendance_work_preview( + pk, + actor=request.user, + **command.validated_data, + ) + record_ids = [record.pk for record in result["records"]] + records = list( + PayrollWorkRecord.objects.filter(pk__in=record_ids) + .select_related( + "period", + "employee", + "employee__position", + "created_by", + "approved_by", + ) + .prefetch_related("employee__departments_links__department") + .order_by("employee_id", "revision", "id") + ) + return Response( + { + "mode": result["mode"], + "summary": result["summary"], + "records": PayrollWorkRecordSerializer(records, many=True).data, + } + ) + + +class PayrollPeriodCalculateView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],) + + @transaction.atomic + def post(self, request, pk): + command = CalculatePayrollCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + period = get_object_or_404( + PayrollPeriod.objects.select_for_update(), + pk=pk, + ) + expected = command.validated_data["expected_lock_version"] + if period.lock_version != expected: + _conflict( + "STALE_PERIOD", + "Период уже изменён; обновите данные перед расчётом.", + expected_lock_version=expected, + actual_lock_version=period.lock_version, + ) + run = calculate_period( + period.pk, + actor=request.user, + idempotency_key=command.validated_data.get("idempotency_key"), + recalculation_reason=command.validated_data["recalculation_reason"], + ) + return Response(self.run_payload(request, run), status=status.HTTP_201_CREATED) + + +class PayrollRunDetailView(PayrollAdminAPIView): + allowed_permissions = RUN_VIEW_PERMISSIONS + + def get(self, request, pk): + run = get_object_or_404( + PayrollRun.objects.select_related( + "period", "requested_by", "approved_by", "published_by" + ), + pk=pk, + ) + return Response(self.run_payload(request, run)) + + +class PayrollRunSubmitReviewView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],) + + def post(self, request, pk): + if not PayrollRun.objects.filter(pk=pk).exists(): + raise Http404 + return Response( + self.run_payload(request, submit_run_for_review(pk, actor=request.user)) + ) + + +class PayrollRunReturnView(PayrollAdminAPIView): + allowed_permissions = ( + PAYROLL_PERMISSIONS["calculate"], + PAYROLL_PERMISSIONS["approve_run"], + ) + + def post(self, request, pk): + command = ReturnPayrollCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + if not PayrollRun.objects.filter(pk=pk).exists(): + raise Http404 + run = return_run_for_correction( + pk, + actor=request.user, + reason=command.validated_data["reason"], + ) + return Response(self.run_payload(request, run)) + + +class PayrollRunApproveView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["approve_run"],) + + def post(self, request, pk): + if not PayrollRun.objects.filter(pk=pk).exists(): + raise Http404 + return Response(self.run_payload(request, approve_run(pk, actor=request.user))) + + +class PayrollRunPublishView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["publish"],) + + def post(self, request, pk): + if not PayrollRun.objects.filter(pk=pk).exists(): + raise Http404 + return Response(self.run_payload(request, publish_run(pk, actor=request.user))) + + +class PayrollPeriodCloseView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["publish"],) + + def post(self, request, pk): + if not PayrollPeriod.objects.filter(pk=pk).exists(): + raise Http404 + period = close_period(pk, actor=request.user) + return Response(PayrollPeriodSerializer(period).data) diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py new file mode 100644 index 00000000..493d641c --- /dev/null +++ b/backend/api/v1/finance/serializers.py @@ -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) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py new file mode 100644 index 00000000..e6b4f00d --- /dev/null +++ b/backend/api/v1/finance/urls.py @@ -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//", + PayrollAdminPeriodDetailView.as_view(), + name="admin-period-detail", + ), + path( + "payroll/admin/periods//calculate/", + PayrollPeriodCalculateView.as_view(), + name="admin-period-calculate", + ), + path( + "payroll/admin/periods//attendance-work-records/", + PayrollAttendanceWorkRecordsView.as_view(), + name="admin-period-attendance-work-records", + ), + path( + "payroll/admin/periods//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//", + EmployeePayRateDetailView.as_view(), + name="admin-pay-rate-detail", + ), + path( + "payroll/admin/pay-rates//approve/", + EmployeePayRateApproveView.as_view(), + name="admin-pay-rate-approve", + ), + path( + "payroll/admin/pay-rates//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//", + PayrollWorkRecordDetailView.as_view(), + name="admin-work-record-detail", + ), + path( + "payroll/admin/work-records//approve/", + PayrollWorkRecordApproveView.as_view(), + name="admin-work-record-approve", + ), + path( + "payroll/admin/work-records//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//", + PayrollInputLineDetailView.as_view(), + name="admin-input-line-detail", + ), + path( + "payroll/admin/input-lines//approve/", + PayrollInputLineApproveView.as_view(), + name="admin-input-line-approve", + ), + path( + "payroll/admin/runs//", + PayrollRunDetailView.as_view(), + name="admin-run-detail", + ), + path( + "payroll/admin/runs//submit-review/", + PayrollRunSubmitReviewView.as_view(), + name="admin-run-submit-review", + ), + path( + "payroll/admin/runs//return/", + PayrollRunReturnView.as_view(), + name="admin-run-return", + ), + path( + "payroll/admin/runs//approve/", + PayrollRunApproveView.as_view(), + name="admin-run-approve", + ), + path( + "payroll/admin/runs//publish/", + PayrollRunPublishView.as_view(), + name="admin-run-publish", + ), + path( + "payroll/me/statements/", + OwnPayrollStatementListView.as_view(), + name="own-statement-list", + ), + path( + "payroll/me/statements//", + OwnPayrollStatementDetailView.as_view(), + name="own-statement-detail", + ), + path( + "payroll/me/statements//acknowledge/", + OwnPayrollStatementAcknowledgeView.as_view(), + name="own-statement-acknowledge", + ), +] diff --git a/backend/api/v1/finance/views.py b/backend/api/v1/finance/views.py new file mode 100644 index 00000000..f538d192 --- /dev/null +++ b/backend/api/v1/finance/views.py @@ -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, + ) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index e7c5e377..4b938b40 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -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 diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 398134ae..41399f3f 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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"), +} diff --git a/backend/finance/README.md b/backend/finance/README.md new file mode 100644 index 00000000..c2e936ee --- /dev/null +++ b/backend/finance/README.md @@ -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// +POST /api/v1/finance/payroll/me/statements//acknowledge/ +``` + +Payroll operators use the permission-gated native workspace under +`/api/v1/finance/payroll/admin/`. It exposes explicit commands rather than +generic model CRUD: draft edits require the version that the operator opened, +approvals require a different actor unless the explicit audited override is +used, and run transitions call the payroll services. The portal entry point is +`/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. diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 846f6b40..75794ee5 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -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 diff --git a/backend/finance/apps.py b/backend/finance/apps.py index 53023d8d..baa1abfa 100644 --- a/backend/finance/apps.py +++ b/backend/finance/apps.py @@ -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 = "Финансы" diff --git a/backend/finance/enums.py b/backend/finance/enums.py new file mode 100644 index 00000000..7397a4f4 --- /dev/null +++ b/backend/finance/enums.py @@ -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", "Заменён новой ревизией" diff --git a/backend/finance/migrations/0001_initial.py b/backend/finance/migrations/0001_initial.py new file mode 100644 index 00000000..7523da05 --- /dev/null +++ b/backend/finance/migrations/0001_initial.py @@ -0,0 +1,1542 @@ +# Generated by Django 5.2.4 on 2026-07-17 12:13 + +import django.core.validators +import django.db.models.deletion +import uuid +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PayrollComponent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.CharField( + max_length=64, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-", + regex="^[A-Z][A-Z0-9_.-]{0,63}$", + ) + ], + verbose_name="Код", + ), + ), + ("name", models.CharField(max_length=160, verbose_name="Название")), + ( + "kind", + models.CharField( + choices=[ + ("earning", "Начисление"), + ("adjustment_credit", "Коррекция +"), + ("adjustment_debit", "Коррекция −"), + ("deduction", "Удержание"), + ("payment", "Выплата"), + ], + max_length=24, + verbose_name="Тип", + ), + ), + ( + "requires_reason", + models.BooleanField( + default=False, verbose_name="Требует основание" + ), + ), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Активен"), + ), + ( + "display_order", + models.PositiveIntegerField(default=100, verbose_name="Порядок"), + ), + ], + options={ + "verbose_name": "Компонент зарплаты", + "verbose_name_plural": "Компоненты зарплаты", + "ordering": ["display_order", "code"], + }, + ), + migrations.CreateModel( + name="PayrollPeriod", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.CharField(max_length=32, unique=True, verbose_name="Код"), + ), + ( + "name", + models.CharField( + blank=True, max_length=120, verbose_name="Название" + ), + ), + ("date_from", models.DateField(verbose_name="Начало периода")), + ("date_to", models.DateField(verbose_name="Конец периода")), + ( + "pay_date", + models.DateField( + blank=True, null=True, verbose_name="Дата выплаты" + ), + ), + ( + "currency", + models.CharField( + default="RUB", + max_length=3, + validators=[ + django.core.validators.RegexValidator( + message="Валюта должна быть трёхбуквенным кодом в верхнем регистре.", + regex="^[A-Z]{3}$", + ) + ], + verbose_name="Валюта", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("open", "Открыт"), + ("calculated", "Рассчитан"), + ("review", "На проверке"), + ("approved", "Утверждён"), + ("published", "Опубликован"), + ("closed", "Закрыт"), + ], + default="open", + max_length=20, + verbose_name="Статус", + ), + ), + ( + "lock_version", + models.PositiveIntegerField( + default=0, verbose_name="Версия блокировки" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлён"), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_payroll_periods", + to=settings.AUTH_USER_MODEL, + verbose_name="Создал", + ), + ), + ], + options={ + "verbose_name": "Расчётный период", + "verbose_name_plural": "Расчётные периоды", + "ordering": ["-date_from", "-id"], + "permissions": [ + ("manage_payroll_inputs", "Может управлять данными для расчёта"), + ("approve_payroll_inputs", "Может утверждать данные для расчёта"), + ("calculate_payroll", "Может запускать расчёт зарплаты"), + ("approve_payroll", "Может утверждать расчёт зарплаты"), + ("publish_payroll", "Может публиковать расчётные листки"), + ("view_all_payroll", "Может просматривать все расчёты"), + ("audit_payroll", "Может просматривать аудит расчётов"), + ], + }, + ), + migrations.CreateModel( + name="PayrollRun", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("revision", models.PositiveIntegerField(verbose_name="Ревизия")), + ( + "status", + models.CharField( + choices=[ + ("calculated", "Рассчитан"), + ("review", "На проверке"), + ("approved", "Утверждён"), + ("published", "Опубликован"), + ("superseded", "Заменён новой ревизией"), + ], + default="calculated", + max_length=20, + verbose_name="Статус", + ), + ), + ( + "idempotency_key", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="Ключ идемпотентности", + ), + ), + ( + "ruleset_id", + models.CharField(max_length=120, verbose_name="Набор правил"), + ), + ( + "ruleset_version", + models.CharField(max_length=64, verbose_name="Версия правил"), + ), + ( + "ruleset_hash", + models.CharField(max_length=64, verbose_name="Хэш правил"), + ), + ( + "calculator_version", + models.CharField(max_length=64, verbose_name="Версия калькулятора"), + ), + ( + "input_hash", + models.CharField(max_length=64, verbose_name="Хэш входных данных"), + ), + ( + "result_hash", + models.CharField(max_length=64, verbose_name="Хэш результата"), + ), + ( + "employee_count", + models.PositiveIntegerField(default=0, verbose_name="Сотрудников"), + ), + ( + "gross_total", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=21, + verbose_name="Всего начислено", + ), + ), + ( + "deduction_total", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=21, + verbose_name="Всего удержано", + ), + ), + ( + "payable_total", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=21, + verbose_name="Всего к выплате", + ), + ), + ( + "requested_at", + models.DateTimeField(auto_now_add=True, verbose_name="Рассчитан"), + ), + ( + "approved_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Утверждён" + ), + ), + ( + "published_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Опубликован" + ), + ), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="approved_payroll_runs", + to=settings.AUTH_USER_MODEL, + verbose_name="Утвердил", + ), + ), + ( + "period", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="runs", + to="finance.payrollperiod", + verbose_name="Период", + ), + ), + ( + "published_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="published_payroll_runs", + to=settings.AUTH_USER_MODEL, + verbose_name="Опубликовал", + ), + ), + ( + "requested_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="requested_payroll_runs", + to=settings.AUTH_USER_MODEL, + verbose_name="Запустил", + ), + ), + ( + "supersedes", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="superseded_by_runs", + to="finance.payrollrun", + verbose_name="Пересчитывает ревизию", + ), + ), + ], + options={ + "verbose_name": "Запуск расчёта", + "verbose_name_plural": "Запуски расчёта", + "ordering": ["-period__date_from", "-revision"], + }, + ), + migrations.AddField( + model_name="payrollperiod", + name="current_run", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="active_for_periods", + to="finance.payrollrun", + verbose_name="Текущая ревизия расчёта", + ), + ), + migrations.CreateModel( + name="PayrollStatement", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "employee_snapshot", + models.JSONField(default=dict, verbose_name="Снимок сотрудника"), + ), + ( + "currency", + models.CharField( + max_length=3, + validators=[ + django.core.validators.RegexValidator( + message="Валюта должна быть трёхбуквенным кодом в верхнем регистре.", + regex="^[A-Z]{3}$", + ) + ], + verbose_name="Валюта", + ), + ), + ( + "point_delta", + models.DecimalField( + blank=True, + decimal_places=4, + max_digits=19, + null=True, + verbose_name="Отклонение баллов", + ), + ), + ( + "gross_before_adjustments", + models.DecimalField( + decimal_places=2, + max_digits=21, + verbose_name="Начислено до корректировок", + ), + ), + ( + "adjustment_total", + models.DecimalField( + decimal_places=2, max_digits=21, verbose_name="Корректировки" + ), + ), + ( + "gross_total", + models.DecimalField( + decimal_places=2, max_digits=21, verbose_name="Итого начислено" + ), + ), + ( + "deduction_total", + models.DecimalField( + decimal_places=2, max_digits=21, verbose_name="Удержания" + ), + ), + ( + "net_pay", + models.DecimalField( + decimal_places=2, max_digits=21, verbose_name="После удержаний" + ), + ), + ( + "payment_total", + models.DecimalField( + decimal_places=2, max_digits=21, verbose_name="Уже выплачено" + ), + ), + ( + "payable", + models.DecimalField( + decimal_places=2, max_digits=21, verbose_name="К выплате" + ), + ), + ( + "input_hash", + models.CharField(max_length=64, verbose_name="Хэш входных данных"), + ), + ( + "result_hash", + models.CharField(max_length=64, verbose_name="Хэш результата"), + ), + ( + "input_snapshot", + models.JSONField(default=dict, verbose_name="Входной снимок"), + ), + ( + "result_snapshot", + models.JSONField(default=dict, verbose_name="Результат расчёта"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="payroll_statements", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "run", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="statements", + to="finance.payrollrun", + verbose_name="Запуск", + ), + ), + ( + "supersedes", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="superseded_by_statement", + to="finance.payrollstatement", + verbose_name="Заменяет листок", + ), + ), + ], + options={ + "verbose_name": "Расчётный листок", + "verbose_name_plural": "Расчётные листки", + "ordering": ["-run__period__date_from", "employee_id"], + }, + ), + migrations.CreateModel( + name="PayrollStatementAcknowledgement", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content_hash", + models.CharField( + max_length=64, verbose_name="Хэш подтверждённого листка" + ), + ), + ( + "idempotency_key", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="Ключ идемпотентности", + ), + ), + ( + "viewed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Просмотрен" + ), + ), + ( + "acknowledged_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Получение подтверждено" + ), + ), + ( + "disputed_at", + models.DateTimeField(blank=True, null=True, verbose_name="Оспорен"), + ), + ( + "dispute_reason", + models.TextField(blank=True, verbose_name="Причина оспаривания"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="payroll_acknowledgements", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "statement", + models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name="acknowledgement", + to="finance.payrollstatement", + verbose_name="Расчётный листок", + ), + ), + ], + options={ + "verbose_name": "Подтверждение расчётного листка", + "verbose_name_plural": "Подтверждения расчётных листков", + }, + ), + migrations.CreateModel( + name="PayrollStatementLine", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("position", models.PositiveIntegerField(verbose_name="Позиция")), + ( + "line_id", + models.CharField( + max_length=160, verbose_name="Идентификатор строки" + ), + ), + ("code", models.CharField(max_length=64, verbose_name="Код")), + ("label", models.CharField(max_length=160, verbose_name="Название")), + ( + "kind", + models.CharField( + choices=[ + ("earning", "Начисление"), + ("adjustment_credit", "Коррекция +"), + ("adjustment_debit", "Коррекция −"), + ("deduction", "Удержание"), + ("payment", "Выплата"), + ], + max_length=24, + verbose_name="Тип", + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, max_digits=21, verbose_name="Сумма" + ), + ), + ( + "source_ref", + models.CharField(max_length=255, verbose_name="Источник"), + ), + ("reason", models.TextField(blank=True, verbose_name="Основание")), + ( + "source_period_from", + models.DateField( + blank=True, null=True, verbose_name="Период-источник: начало" + ), + ), + ( + "source_period_to", + models.DateField( + blank=True, null=True, verbose_name="Период-источник: конец" + ), + ), + ( + "calculated", + models.BooleanField(default=False, verbose_name="Рассчитана ядром"), + ), + ( + "statement", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="lines", + to="finance.payrollstatement", + verbose_name="Расчётный листок", + ), + ), + ], + options={ + "verbose_name": "Строка расчётного листка", + "verbose_name_plural": "Строки расчётных листков", + "ordering": ["statement_id", "position"], + }, + ), + migrations.CreateModel( + name="PayrollWorkRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "target_points", + models.DecimalField( + decimal_places=4, + max_digits=19, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.0001")) + ], + verbose_name="Норма баллов", + ), + ), + ( + "actual_points", + models.DecimalField( + decimal_places=4, + max_digits=19, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + verbose_name="Фактические баллы", + ), + ), + ( + "expected_point_amount", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=19, + null=True, + verbose_name="Контроль: выработка по баллам", + ), + ), + ( + "expected_gross", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=19, + null=True, + verbose_name="Контроль: итого начислено", + ), + ), + ( + "expected_recalculated_gross", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=19, + null=True, + verbose_name="Контроль: перерасчёт", + ), + ), + ( + "expected_payable", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=19, + null=True, + verbose_name="Контроль: к выплате", + ), + ), + ( + "revision", + models.PositiveIntegerField(default=1, verbose_name="Ревизия"), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Черновик"), + ("approved", "Утверждено"), + ("voided", "Аннулировано"), + ], + default="draft", + max_length=16, + verbose_name="Статус", + ), + ), + ( + "source", + models.CharField( + choices=[ + ("manual", "Вручную"), + ("excel", "Excel"), + ("api", "API"), + ], + default="manual", + max_length=16, + verbose_name="Источник", + ), + ), + ( + "source_ref", + models.CharField( + blank=True, + max_length=255, + verbose_name="Идентификатор в источнике", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создана"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлена"), + ), + ( + "approved_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Утверждена" + ), + ), + ( + "voided_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Аннулирована" + ), + ), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="approved_payroll_work_records", + to=settings.AUTH_USER_MODEL, + verbose_name="Утвердил", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_payroll_work_records", + to=settings.AUTH_USER_MODEL, + verbose_name="Создал", + ), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="payroll_work_records", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "period", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="work_records", + to="finance.payrollperiod", + verbose_name="Период", + ), + ), + ( + "replaces", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="replaced_by", + to="finance.payrollworkrecord", + verbose_name="Заменяет запись", + ), + ), + ( + "voided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="voided_payroll_work_records", + to=settings.AUTH_USER_MODEL, + verbose_name="Аннулировал", + ), + ), + ], + options={ + "verbose_name": "Выработка за период", + "verbose_name_plural": "Выработка за периоды", + "ordering": ["period_id", "employee_id", "-revision"], + }, + ), + migrations.CreateModel( + name="EmployeePayRate", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rate_code", + models.CharField( + default="BASE", + max_length=64, + validators=[ + django.core.validators.RegexValidator( + message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-", + regex="^[A-Z][A-Z0-9_.-]{0,63}$", + ) + ], + verbose_name="Код ставки", + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=4, + max_digits=19, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.0001")) + ], + verbose_name="Сумма ставки", + ), + ), + ( + "point_rate", + models.DecimalField( + decimal_places=4, + default=Decimal("0"), + max_digits=19, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + verbose_name="Цена балла сверх нормы", + ), + ), + ( + "currency", + models.CharField( + default="RUB", + max_length=3, + validators=[ + django.core.validators.RegexValidator( + message="Валюта должна быть трёхбуквенным кодом в верхнем регистре.", + regex="^[A-Z]{3}$", + ) + ], + verbose_name="Валюта", + ), + ), + ("effective_from", models.DateField(verbose_name="Действует с")), + ( + "revision", + models.PositiveIntegerField(default=1, verbose_name="Ревизия"), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Черновик"), + ("approved", "Утверждено"), + ("voided", "Аннулировано"), + ], + default="draft", + max_length=16, + verbose_name="Статус", + ), + ), + ("reason", models.TextField(blank=True, verbose_name="Основание")), + ( + "source", + models.CharField( + choices=[ + ("manual", "Вручную"), + ("excel", "Excel"), + ("api", "API"), + ], + default="manual", + max_length=16, + verbose_name="Источник", + ), + ), + ( + "source_ref", + models.CharField( + blank=True, + max_length=255, + verbose_name="Идентификатор в источнике", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создана"), + ), + ( + "approved_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Утверждена" + ), + ), + ( + "voided_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Аннулирована" + ), + ), + ( + "void_reason", + models.TextField(blank=True, verbose_name="Причина аннулирования"), + ), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="approved_pay_rates", + to=settings.AUTH_USER_MODEL, + verbose_name="Утвердил", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_pay_rates", + to=settings.AUTH_USER_MODEL, + verbose_name="Создал", + ), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pay_rates", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "replaces", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="replaced_by", + to="finance.employeepayrate", + verbose_name="Заменяет ставку", + ), + ), + ( + "voided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="voided_pay_rates", + to=settings.AUTH_USER_MODEL, + verbose_name="Аннулировал", + ), + ), + ], + options={ + "verbose_name": "Ставка сотрудника", + "verbose_name_plural": "Ставки сотрудников", + "ordering": ["employee_id", "-effective_from", "-revision"], + "indexes": [ + models.Index( + fields=["employee", "status", "effective_from"], + name="pay_rate_employee_date_idx", + ) + ], + "constraints": [ + models.CheckConstraint( + condition=models.Q(("amount__gt", 0)), + name="pay_rate_amount_positive", + ), + models.CheckConstraint( + condition=models.Q(("point_rate__gte", 0)), + name="pay_rate_point_nonnegative", + ), + models.CheckConstraint( + condition=models.Q( + ("approved_by__isnull", True), + models.Q( + ("created_by", models.F("approved_by")), _negated=True + ), + _connector="OR", + ), + name="pay_rate_maker_not_approver", + ), + models.UniqueConstraint( + fields=("employee", "rate_code", "effective_from", "revision"), + name="pay_rate_revision_unique", + ), + models.UniqueConstraint( + condition=models.Q(("status", "approved")), + fields=("employee", "rate_code", "effective_from"), + name="pay_rate_one_approved_event", + ), + models.UniqueConstraint( + condition=models.Q(("source_ref", ""), _negated=True), + fields=("source", "source_ref"), + name="pay_rate_source_ref_unique", + ), + ], + }, + ), + migrations.CreateModel( + name="PayrollInputLine", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + max_digits=19, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.01")) + ], + verbose_name="Сумма", + ), + ), + ("reason", models.TextField(blank=True, verbose_name="Основание")), + ( + "status", + models.CharField( + choices=[ + ("draft", "Черновик"), + ("approved", "Утверждено"), + ("voided", "Аннулировано"), + ], + default="draft", + max_length=16, + verbose_name="Статус", + ), + ), + ( + "source", + models.CharField( + choices=[ + ("manual", "Вручную"), + ("excel", "Excel"), + ("api", "API"), + ], + default="manual", + max_length=16, + verbose_name="Источник", + ), + ), + ( + "source_ref", + models.CharField( + blank=True, + max_length=255, + verbose_name="Идентификатор в источнике", + ), + ), + ( + "idempotency_key", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="Ключ идемпотентности", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создана"), + ), + ( + "approved_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Утверждена" + ), + ), + ( + "voided_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Аннулирована" + ), + ), + ( + "void_reason", + models.TextField(blank=True, verbose_name="Причина аннулирования"), + ), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="approved_payroll_input_lines", + to=settings.AUTH_USER_MODEL, + verbose_name="Утвердил", + ), + ), + ( + "component", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="input_lines", + to="finance.payrollcomponent", + verbose_name="Компонент", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_payroll_input_lines", + to=settings.AUTH_USER_MODEL, + verbose_name="Создал", + ), + ), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="payroll_input_lines", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "reversal_of", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="reversed_by", + to="finance.payrollinputline", + verbose_name="Сторнирует строку", + ), + ), + ( + "voided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="voided_payroll_input_lines", + to=settings.AUTH_USER_MODEL, + verbose_name="Аннулировал", + ), + ), + ( + "period", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="input_lines", + to="finance.payrollperiod", + verbose_name="Период выплаты", + ), + ), + ( + "relates_to_period", + models.ForeignKey( + blank=True, + help_text="Заполняется для перерасчёта прошлого периода.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="retro_input_lines", + to="finance.payrollperiod", + verbose_name="Относится к периоду", + ), + ), + ], + options={ + "verbose_name": "Строка начисления/удержания", + "verbose_name_plural": "Строки начислений/удержаний", + "ordering": [ + "period_id", + "employee_id", + "component__display_order", + "id", + ], + "indexes": [ + models.Index( + fields=["period", "employee", "status"], + name="pay_input_period_employee_idx", + ) + ], + "constraints": [ + models.CheckConstraint( + condition=models.Q(("amount__gt", 0)), + name="pay_input_amount_positive", + ), + models.CheckConstraint( + condition=models.Q( + ("approved_by__isnull", True), + models.Q( + ("created_by", models.F("approved_by")), _negated=True + ), + _connector="OR", + ), + name="pay_input_maker_not_approver", + ), + models.UniqueConstraint( + condition=models.Q(("source_ref", ""), _negated=True), + fields=("source", "source_ref"), + name="pay_input_source_ref_unique", + ), + ], + }, + ), + migrations.CreateModel( + name="PayrollAuditEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("action", models.CharField(max_length=80, verbose_name="Действие")), + ( + "object_type", + models.CharField(max_length=80, verbose_name="Тип объекта"), + ), + ( + "object_id", + models.CharField( + max_length=80, verbose_name="Идентификатор объекта" + ), + ), + ( + "before_hash", + models.CharField(blank=True, max_length=64, verbose_name="Хэш до"), + ), + ( + "after_hash", + models.CharField( + blank=True, max_length=64, verbose_name="Хэш после" + ), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, verbose_name="Безопасные метаданные" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "actor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="payroll_audit_events", + to=settings.AUTH_USER_MODEL, + verbose_name="Инициатор", + ), + ), + ( + "period", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="audit_events", + to="finance.payrollperiod", + verbose_name="Период", + ), + ), + ], + options={ + "verbose_name": "Событие аудита зарплаты", + "verbose_name_plural": "События аудита зарплаты", + "ordering": ["-created_at", "-id"], + "indexes": [ + models.Index( + fields=["object_type", "object_id", "created_at"], + name="pay_audit_object_time_idx", + ), + models.Index( + fields=["period", "created_at"], + name="pay_audit_period_time_idx", + ), + ], + }, + ), + migrations.AddIndex( + model_name="payrollrun", + index=models.Index( + fields=["period", "status", "revision"], + name="pay_run_period_status_idx", + ), + ), + migrations.AddConstraint( + model_name="payrollrun", + constraint=models.UniqueConstraint( + fields=("period", "revision"), name="pay_run_revision_unique" + ), + ), + 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), + _connector="OR", + ), + name="pay_run_maker_not_approver", + ), + ), + migrations.AddConstraint( + model_name="payrollrun", + constraint=models.CheckConstraint( + condition=models.Q(("gross_total__gte", 0)), + name="pay_run_gross_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollrun", + constraint=models.CheckConstraint( + condition=models.Q(("deduction_total__gte", 0)), + name="pay_run_deduction_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollrun", + constraint=models.CheckConstraint( + condition=models.Q(("payable_total__gte", 0)), + name="pay_run_payable_nonnegative", + ), + ), + migrations.AddIndex( + model_name="payrollperiod", + index=models.Index( + fields=["status", "date_from"], name="pay_period_status_date_idx" + ), + ), + migrations.AddConstraint( + model_name="payrollperiod", + constraint=models.CheckConstraint( + condition=models.Q(("date_from__lte", models.F("date_to"))), + name="pay_period_dates_valid", + ), + ), + migrations.AddConstraint( + model_name="payrollperiod", + constraint=models.UniqueConstraint( + fields=("date_from", "date_to"), name="pay_period_dates_unique" + ), + ), + migrations.AddIndex( + model_name="payrollstatement", + index=models.Index( + fields=["employee", "run"], name="pay_statement_employee_idx" + ), + ), + migrations.AddConstraint( + model_name="payrollstatement", + constraint=models.UniqueConstraint( + fields=("run", "employee"), name="pay_statement_run_employee_unique" + ), + ), + migrations.AddConstraint( + model_name="payrollstatement", + constraint=models.CheckConstraint( + condition=models.Q(("gross_before_adjustments__gte", 0)), + name="pay_statement_pre_gross_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollstatement", + constraint=models.CheckConstraint( + condition=models.Q(("gross_total__gte", 0)), + name="pay_statement_gross_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollstatement", + constraint=models.CheckConstraint( + condition=models.Q(("deduction_total__gte", 0)), + name="pay_statement_deduction_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollstatement", + constraint=models.CheckConstraint( + condition=models.Q(("payment_total__gte", 0)), + name="pay_statement_payment_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollstatement", + constraint=models.CheckConstraint( + condition=models.Q(("payable__gte", 0)), + name="pay_statement_payable_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollstatementline", + constraint=models.UniqueConstraint( + fields=("statement", "position"), + name="pay_statement_line_position_unique", + ), + ), + migrations.AddConstraint( + model_name="payrollstatementline", + constraint=models.UniqueConstraint( + fields=("statement", "line_id"), name="pay_statement_line_id_unique" + ), + ), + migrations.AddConstraint( + model_name="payrollstatementline", + constraint=models.CheckConstraint( + condition=models.Q(("amount__gte", 0)), + name="pay_statement_line_amount_nonnegative", + ), + ), + migrations.AddConstraint( + model_name="payrollworkrecord", + constraint=models.CheckConstraint( + condition=models.Q(("target_points__gt", 0)), + name="pay_work_target_positive", + ), + ), + migrations.AddConstraint( + model_name="payrollworkrecord", + constraint=models.CheckConstraint( + condition=models.Q(("actual_points__gte", 0)), + name="pay_work_actual_nonnegative", + ), + ), + 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), + _connector="OR", + ), + name="pay_work_maker_not_approver", + ), + ), + migrations.AddConstraint( + model_name="payrollworkrecord", + constraint=models.UniqueConstraint( + fields=("period", "employee", "revision"), + name="pay_work_revision_unique", + ), + ), + migrations.AddConstraint( + model_name="payrollworkrecord", + constraint=models.UniqueConstraint( + condition=models.Q(("status", "approved")), + fields=("period", "employee"), + name="pay_work_one_approved", + ), + ), + migrations.AddConstraint( + model_name="payrollworkrecord", + constraint=models.UniqueConstraint( + condition=models.Q(("source_ref", ""), _negated=True), + fields=("source", "source_ref"), + name="pay_work_source_ref_unique", + ), + ), + ] diff --git a/backend/finance/migrations/0002_employeepayrate_pay_rate_approval_complete_and_more.py b/backend/finance/migrations/0002_employeepayrate_pay_rate_approval_complete_and_more.py new file mode 100644 index 00000000..84ca9afb --- /dev/null +++ b/backend/finance/migrations/0002_employeepayrate_pay_rate_approval_complete_and_more.py @@ -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", + ), + ), + ] diff --git a/backend/finance/migrations/0003_seed_payroll_components.py b/backend/finance/migrations/0003_seed_payroll_components.py new file mode 100644 index 00000000..da00a76b --- /dev/null +++ b/backend/finance/migrations/0003_seed_payroll_components.py @@ -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), + ] diff --git a/backend/finance/migrations/0004_payroll_run_reason_statement_public_id.py b/backend/finance/migrations/0004_payroll_run_reason_statement_public_id.py new file mode 100644 index 00000000..c5b59d0a --- /dev/null +++ b/backend/finance/migrations/0004_payroll_run_reason_statement_public_id.py @@ -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="Публичный идентификатор", + ), + ), + ] diff --git a/backend/finance/migrations/0005_payrollstatementline_is_retro_and_more.py b/backend/finance/migrations/0005_payrollstatementline_is_retro_and_more.py new file mode 100644 index 00000000..9eb98f22 --- /dev/null +++ b/backend/finance/migrations/0005_payrollstatementline_is_retro_and_more.py @@ -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", + ), + ), + ] diff --git a/backend/finance/migrations/0006_payrollworkrecord_reason.py b/backend/finance/migrations/0006_payrollworkrecord_reason.py new file mode 100644 index 00000000..048e8c43 --- /dev/null +++ b/backend/finance/migrations/0006_payrollworkrecord_reason.py @@ -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="Основание изменения"), + ), + ] diff --git a/backend/finance/migrations/0007_employeepayrate_lock_version_and_more.py b/backend/finance/migrations/0007_employeepayrate_lock_version_and_more.py new file mode 100644 index 00000000..9ba1146a --- /dev/null +++ b/backend/finance/migrations/0007_employeepayrate_lock_version_and_more.py @@ -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="Версия черновика" + ), + ), + ] diff --git a/backend/finance/migrations/0008_alter_payrollperiod_options_and_more.py b/backend/finance/migrations/0008_alter_payrollperiod_options_and_more.py new file mode 100644 index 00000000..fe98ff64 --- /dev/null +++ b/backend/finance/migrations/0008_alter_payrollperiod_options_and_more.py @@ -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", + ), + ), + ] diff --git a/backend/finance/migrations/0009_alter_payroll_input_sources.py b/backend/finance/migrations/0009_alter_payroll_input_sources.py new file mode 100644 index 00000000..c1d7513e --- /dev/null +++ b/backend/finance/migrations/0009_alter_payroll_input_sources.py @@ -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="Источник", + ), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 6b202199..08168873 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -1 +1,1268 @@ -# Create your models here. +"""Persistence models for the EUSRR payroll integration. + +Calculation rules live in the project-independent :mod:`payroll_core` package. +These models resolve EUSRR identities, approval state and immutable snapshots. +""" + +from __future__ import annotations + +import uuid +from decimal import Decimal + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator, RegexValidator +from django.db import models, transaction +from django.db.models import F, Q + +from .enums import ( + ApprovalStatus, + InputSource, + PayrollComponentKind, + PayrollPeriodStatus, + PayrollRunStatus, +) + +currency_validator = RegexValidator( + regex=r"^[A-Z]{3}$", + message="Валюта должна быть трёхбуквенным кодом в верхнем регистре.", +) +component_code_validator = RegexValidator( + regex=r"^[A-Z][A-Z0-9_.-]{0,63}$", + message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-", +) + + +class PayrollPeriod(models.Model): + """A calculation and publication period controlled by one active run.""" + + code = models.CharField("Код", max_length=32, unique=True) + name = models.CharField("Название", max_length=120, blank=True) + date_from = models.DateField("Начало периода") + date_to = models.DateField("Конец периода") + pay_date = models.DateField("Дата выплаты", null=True, blank=True) + currency = models.CharField( + "Валюта", + max_length=3, + default="RUB", + validators=[currency_validator], + ) + status = models.CharField( + "Статус", + max_length=20, + choices=PayrollPeriodStatus.choices, + default=PayrollPeriodStatus.OPEN, + ) + current_run = models.ForeignKey( + "PayrollRun", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="active_for_periods", + verbose_name="Текущая ревизия расчёта", + ) + lock_version = models.PositiveIntegerField("Версия блокировки", default=0) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_payroll_periods", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создан", auto_now_add=True) + updated_at = models.DateTimeField("Обновлён", auto_now=True) + + class Meta: + verbose_name = "Расчётный период" + verbose_name_plural = "Расчётные периоды" + ordering = ["-date_from", "-id"] + indexes = [ + models.Index( + fields=["status", "date_from"], + name="pay_period_status_date_idx", + ) + ] + constraints = [ + models.CheckConstraint( + condition=Q(date_from__lte=F("date_to")), + name="pay_period_dates_valid", + ), + models.UniqueConstraint( + fields=["date_from", "date_to"], + name="pay_period_dates_unique", + ), + ] + permissions = [ + ("manage_payroll_inputs", "Может управлять данными для расчёта"), + ("approve_payroll_inputs", "Может утверждать данные для расчёта"), + ("calculate_payroll", "Может запускать расчёт зарплаты"), + ("approve_payroll", "Может утверждать расчёт зарплаты"), + ( + "override_payroll_approval", + "Может самоутверждать данные и расчёты зарплаты", + ), + ("publish_payroll", "Может публиковать расчётные листки"), + ("view_all_payroll", "Может просматривать все расчёты"), + ("audit_payroll", "Может просматривать аудит расчётов"), + ] + + def __str__(self): + return self.name or self.code + + def _validate_immutable_fields(self, original=None): + if self.pk: + original = original or PayrollPeriod.objects.filter(pk=self.pk).first() + if original and original.runs.exists(): + immutable_values = ( + self.code, + self.name, + self.date_from, + self.date_to, + self.pay_date, + self.currency, + ) + original_values = ( + original.code, + original.name, + original.date_from, + original.date_to, + original.pay_date, + original.currency, + ) + if immutable_values != original_values: + raise ValidationError( + "Показываемые сотруднику реквизиты периода нельзя " + "менять после расчёта." + ) + + def clean(self): + super().clean() + self._validate_immutable_fields() + if self.date_from and self.date_to and self.date_from > self.date_to: + raise ValidationError( + {"date_to": "Конец периода не может быть раньше начала."} + ) + if self.date_from and self.date_to: + overlaps = PayrollPeriod.objects.filter( + date_from__lte=self.date_to, + date_to__gte=self.date_from, + ) + if self.pk: + overlaps = overlaps.exclude(pk=self.pk) + if overlaps.exists(): + raise ValidationError("Расчётные периоды не должны пересекаться.") + + @transaction.atomic + def save(self, *args, **kwargs): + update_fields = kwargs.get("update_fields") + if self.pk and not self._state.adding: + original = PayrollPeriod.objects.select_for_update().get(pk=self.pk) + self._validate_immutable_fields(original) + if update_fields is None: + # Full-form saves may carry stale workflow fields. Those fields + # are changed only by services with explicit update_fields. + self.status = original.status + self.current_run_id = original.current_run_id + self.lock_version = original.lock_version + self.created_by_id = original.created_by_id + self.created_at = original.created_at + return super().save(*args, **kwargs) + + +class DraftVersionedModel(models.Model): + """Optimistic version for maker-checker records edited as drafts.""" + + lock_version = models.PositiveIntegerField("Версия черновика", default=0) + + class Meta: + abstract = True + + @transaction.atomic + def save(self, *args, **kwargs): + update_fields = kwargs.get("update_fields") + if self.pk and not self._state.adding and update_fields is None: + current = self.__class__.objects.select_for_update().get(pk=self.pk) + if ( + current.status != ApprovalStatus.DRAFT + or self.status != ApprovalStatus.DRAFT + ): + raise ValidationError( + "Изменять можно только актуальную версию черновика." + ) + expected_lock_version = getattr( + self, + "_expected_lock_version", + self.lock_version, + ) + if current.lock_version != expected_lock_version: + raise ValidationError( + "Черновик уже изменён другой операцией; обновите данные." + ) + self.lock_version = current.lock_version + 1 + result = super().save(*args, **kwargs) + if hasattr(self, "_expected_lock_version"): + del self._expected_lock_version + return result + + +class EmployeePayRate(DraftVersionedModel): + """Effective-dated, approved rate event for an employee.""" + + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="pay_rates", + verbose_name="Сотрудник", + ) + rate_code = models.CharField( + "Код ставки", + max_length=64, + default="BASE", + validators=[component_code_validator], + ) + amount = models.DecimalField( + "Сумма ставки", + max_digits=19, + decimal_places=4, + validators=[MinValueValidator(Decimal("0.0001"))], + ) + point_rate = models.DecimalField( + "Цена балла сверх нормы", + max_digits=19, + decimal_places=4, + default=Decimal("0"), + validators=[MinValueValidator(Decimal("0"))], + ) + currency = models.CharField( + "Валюта", + max_length=3, + default="RUB", + validators=[currency_validator], + ) + effective_from = models.DateField("Действует с") + revision = models.PositiveIntegerField("Ревизия", default=1) + status = models.CharField( + "Статус", + max_length=16, + choices=ApprovalStatus.choices, + default=ApprovalStatus.DRAFT, + ) + replaces = models.OneToOneField( + "self", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="replaced_by", + verbose_name="Заменяет ставку", + ) + reason = models.TextField("Основание", blank=True) + source = models.CharField( + "Источник", + max_length=16, + choices=InputSource.choices, + default=InputSource.MANUAL, + ) + source_ref = models.CharField( + "Идентификатор в источнике", + max_length=255, + blank=True, + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_pay_rates", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создана", auto_now_add=True) + approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="approved_pay_rates", + verbose_name="Утвердил", + ) + approved_at = models.DateTimeField("Утверждена", null=True, blank=True) + self_approval_overridden = models.BooleanField( + "Самоутверждение по особому праву", + default=False, + editable=False, + ) + voided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="voided_pay_rates", + verbose_name="Аннулировал", + ) + voided_at = models.DateTimeField("Аннулирована", null=True, blank=True) + void_reason = models.TextField("Причина аннулирования", blank=True) + + class Meta: + verbose_name = "Ставка сотрудника" + verbose_name_plural = "Ставки сотрудников" + ordering = ["employee_id", "-effective_from", "-revision"] + indexes = [ + models.Index( + fields=["employee", "status", "effective_from"], + name="pay_rate_employee_date_idx", + ) + ] + constraints = [ + models.CheckConstraint( + condition=Q(amount__gt=0), + name="pay_rate_amount_positive", + ), + models.CheckConstraint( + condition=Q(point_rate__gte=0), + name="pay_rate_point_nonnegative", + ), + models.CheckConstraint( + condition=Q(approved_by__isnull=True) + | ~Q(created_by=F("approved_by")) + | Q(self_approval_overridden=True), + name="pay_rate_maker_not_approver", + ), + models.CheckConstraint( + condition=Q(self_approval_overridden=False) + | Q( + approved_by__isnull=False, + created_by=F("approved_by"), + ), + name="pay_rate_self_override_valid", + ), + models.CheckConstraint( + condition=~Q(status=ApprovalStatus.APPROVED) + | Q(approved_by__isnull=False, approved_at__isnull=False), + name="pay_rate_approval_complete", + ), + models.UniqueConstraint( + fields=["employee", "rate_code", "effective_from", "revision"], + name="pay_rate_revision_unique", + ), + models.UniqueConstraint( + fields=["employee", "rate_code", "effective_from"], + condition=Q(status=ApprovalStatus.APPROVED), + name="pay_rate_one_approved_event", + ), + models.UniqueConstraint( + fields=["source", "source_ref"], + condition=~Q(source_ref=""), + name="pay_rate_source_ref_unique", + ), + ] + + def __str__(self): + return f"{self.employee} — {self.amount} {self.currency}" + + def clean(self): + super().clean() + if self.replaces_id: + if self.pk and self.replaces_id == self.pk: + raise ValidationError({"replaces": "Ставка не может заменять себя."}) + if self.replaces.employee_id != self.employee_id: + raise ValidationError( + {"replaces": "Заменяемая ставка относится к другому сотруднику."} + ) + if self.replaces.rate_code != self.rate_code: + raise ValidationError( + {"replaces": "Код заменяемой ставки должен совпадать."} + ) + if self.replaces.effective_from != self.effective_from: + raise ValidationError( + {"replaces": "Дата начала заменяемой ставки должна совпадать."} + ) + if self.revision != self.replaces.revision + 1: + raise ValidationError( + {"revision": "Ревизия должна следовать за заменяемой ставкой."} + ) + if self.status == ApprovalStatus.APPROVED: + if not self.approved_by_id or not self.approved_at: + raise ValidationError( + "Утверждённая ставка должна содержать автора и время решения." + ) + + +class PayrollWorkRecord(DraftVersionedModel): + """Approved monthly work metrics and optional Excel control totals.""" + + period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + related_name="work_records", + verbose_name="Период", + ) + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_work_records", + verbose_name="Сотрудник", + ) + target_points = models.DecimalField( + "Норма баллов", + max_digits=19, + decimal_places=4, + validators=[MinValueValidator(Decimal("0.0001"))], + ) + actual_points = models.DecimalField( + "Фактические баллы", + max_digits=19, + decimal_places=4, + validators=[MinValueValidator(Decimal("0"))], + ) + expected_point_amount = models.DecimalField( + "Контроль: выработка по баллам", + max_digits=19, + decimal_places=2, + null=True, + blank=True, + ) + expected_gross = models.DecimalField( + "Контроль: итого начислено", + max_digits=19, + decimal_places=2, + null=True, + blank=True, + ) + expected_recalculated_gross = models.DecimalField( + "Контроль: перерасчёт", + max_digits=19, + decimal_places=2, + null=True, + blank=True, + ) + expected_payable = models.DecimalField( + "Контроль: к выплате", + max_digits=19, + decimal_places=2, + null=True, + blank=True, + ) + revision = models.PositiveIntegerField("Ревизия", default=1) + status = models.CharField( + "Статус", + max_length=16, + choices=ApprovalStatus.choices, + default=ApprovalStatus.DRAFT, + ) + replaces = models.OneToOneField( + "self", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="replaced_by", + verbose_name="Заменяет запись", + ) + reason = models.TextField("Основание изменения", blank=True) + source = models.CharField( + "Источник", + max_length=16, + choices=InputSource.choices, + default=InputSource.MANUAL, + ) + source_ref = models.CharField( + "Идентификатор в источнике", + max_length=255, + blank=True, + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_payroll_work_records", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создана", auto_now_add=True) + updated_at = models.DateTimeField("Обновлена", auto_now=True) + approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="approved_payroll_work_records", + verbose_name="Утвердил", + ) + approved_at = models.DateTimeField("Утверждена", null=True, blank=True) + self_approval_overridden = models.BooleanField( + "Самоутверждение по особому праву", + default=False, + editable=False, + ) + voided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="voided_payroll_work_records", + verbose_name="Аннулировал", + ) + voided_at = models.DateTimeField("Аннулирована", null=True, blank=True) + + class Meta: + verbose_name = "Выработка за период" + verbose_name_plural = "Выработка за периоды" + ordering = ["period_id", "employee_id", "-revision"] + constraints = [ + models.CheckConstraint( + condition=Q(target_points__gt=0), + name="pay_work_target_positive", + ), + models.CheckConstraint( + condition=Q(actual_points__gte=0), + name="pay_work_actual_nonnegative", + ), + models.CheckConstraint( + condition=Q(approved_by__isnull=True) + | ~Q(created_by=F("approved_by")) + | Q(self_approval_overridden=True), + name="pay_work_maker_not_approver", + ), + models.CheckConstraint( + condition=Q(self_approval_overridden=False) + | Q( + approved_by__isnull=False, + created_by=F("approved_by"), + ), + name="pay_work_self_override_valid", + ), + models.CheckConstraint( + condition=~Q(status=ApprovalStatus.APPROVED) + | Q(approved_by__isnull=False, approved_at__isnull=False), + name="pay_work_approval_complete", + ), + models.UniqueConstraint( + fields=["period", "employee", "revision"], + name="pay_work_revision_unique", + ), + models.UniqueConstraint( + fields=["period", "employee"], + condition=Q(status=ApprovalStatus.APPROVED), + name="pay_work_one_approved", + ), + models.UniqueConstraint( + fields=["source", "source_ref"], + condition=~Q(source_ref=""), + name="pay_work_source_ref_unique", + ), + ] + + def __str__(self): + return f"{self.period}: {self.employee} ({self.actual_points})" + + def clean(self): + super().clean() + if not self.replaces_id: + return + if self.pk and self.replaces_id == self.pk: + raise ValidationError({"replaces": "Запись не может заменять себя."}) + if self.replaces.employee_id != self.employee_id: + raise ValidationError( + {"replaces": "Заменяемая запись относится к другому сотруднику."} + ) + if self.replaces.period_id != self.period_id: + raise ValidationError( + {"replaces": "Заменяемая запись относится к другому периоду."} + ) + if self.revision != self.replaces.revision + 1: + raise ValidationError( + {"revision": "Ревизия должна следовать за заменяемой записью."} + ) + if not self.reason.strip(): + raise ValidationError( + {"reason": "Для новой ревизии укажите основание изменения."} + ) + + +class PayrollComponent(models.Model): + """Reusable catalog entry for an explicit payroll input line.""" + + code = models.CharField( + "Код", + max_length=64, + unique=True, + validators=[component_code_validator], + ) + name = models.CharField("Название", max_length=160) + kind = models.CharField( + "Тип", + max_length=24, + choices=PayrollComponentKind.choices, + ) + requires_reason = models.BooleanField("Требует основание", default=False) + is_active = models.BooleanField("Активен", default=True) + display_order = models.PositiveIntegerField("Порядок", default=100) + + class Meta: + verbose_name = "Компонент зарплаты" + verbose_name_plural = "Компоненты зарплаты" + ordering = ["display_order", "code"] + + def __str__(self): + return f"{self.code} — {self.name}" + + semantic_fields = ( + "code", + "name", + "kind", + "requires_reason", + "display_order", + ) + + def _validate_semantics_immutable(self, original=None): + if not self.pk: + return + original = original or PayrollComponent.objects.get(pk=self.pk) + if any( + getattr(self, field_name) != getattr(original, field_name) + for field_name in self.semantic_fields + ): + raise ValidationError( + "Семантику компонента нельзя менять; создайте новый код." + ) + + def clean(self): + super().clean() + self._validate_semantics_immutable() + + @transaction.atomic + def save(self, *args, **kwargs): + if self.pk and not self._state.adding: + original = PayrollComponent.objects.select_for_update().get(pk=self.pk) + self._validate_semantics_immutable(original) + return super().save(*args, **kwargs) + + +class PayrollInputLine(DraftVersionedModel): + """Append-only bonus, correction, deduction or recorded payment input.""" + + period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + related_name="input_lines", + verbose_name="Период выплаты", + ) + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_input_lines", + verbose_name="Сотрудник", + ) + component = models.ForeignKey( + PayrollComponent, + on_delete=models.PROTECT, + related_name="input_lines", + verbose_name="Компонент", + ) + amount = models.DecimalField( + "Сумма", + max_digits=19, + decimal_places=2, + validators=[MinValueValidator(Decimal("0.01"))], + ) + relates_to_period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="retro_input_lines", + verbose_name="Относится к периоду", + help_text="Заполняется для перерасчёта прошлого периода.", + ) + reason = models.TextField("Основание", blank=True) + status = models.CharField( + "Статус", + max_length=16, + choices=ApprovalStatus.choices, + default=ApprovalStatus.DRAFT, + ) + reversal_of = models.OneToOneField( + "self", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="reversed_by", + verbose_name="Сторнирует строку", + ) + source = models.CharField( + "Источник", + max_length=16, + choices=InputSource.choices, + default=InputSource.MANUAL, + ) + source_ref = models.CharField( + "Идентификатор в источнике", + max_length=255, + blank=True, + ) + idempotency_key = models.UUIDField( + "Ключ идемпотентности", + default=uuid.uuid4, + unique=True, + editable=False, + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_payroll_input_lines", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создана", auto_now_add=True) + approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="approved_payroll_input_lines", + verbose_name="Утвердил", + ) + approved_at = models.DateTimeField("Утверждена", null=True, blank=True) + self_approval_overridden = models.BooleanField( + "Самоутверждение по особому праву", + default=False, + editable=False, + ) + voided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="voided_payroll_input_lines", + verbose_name="Аннулировал", + ) + voided_at = models.DateTimeField("Аннулирована", null=True, blank=True) + void_reason = models.TextField("Причина аннулирования", blank=True) + + class Meta: + verbose_name = "Строка начисления/удержания" + verbose_name_plural = "Строки начислений/удержаний" + ordering = ["period_id", "employee_id", "component__display_order", "id"] + indexes = [ + models.Index( + fields=["period", "employee", "status"], + name="pay_input_period_employee_idx", + ) + ] + constraints = [ + models.CheckConstraint( + condition=Q(amount__gt=0), + name="pay_input_amount_positive", + ), + models.CheckConstraint( + condition=Q(approved_by__isnull=True) + | ~Q(created_by=F("approved_by")) + | Q(self_approval_overridden=True), + name="pay_input_maker_not_approver", + ), + models.CheckConstraint( + condition=Q(self_approval_overridden=False) + | Q( + approved_by__isnull=False, + created_by=F("approved_by"), + ), + name="pay_input_self_override_valid", + ), + models.CheckConstraint( + condition=~Q(status=ApprovalStatus.APPROVED) + | Q(approved_by__isnull=False, approved_at__isnull=False), + name="pay_input_approval_complete", + ), + models.UniqueConstraint( + fields=["source", "source_ref"], + condition=~Q(source_ref=""), + name="pay_input_source_ref_unique", + ), + ] + + def __str__(self): + return f"{self.employee}: {self.component.code} {self.amount}" + + def clean(self): + super().clean() + if self.reversal_of_id: + raise ValidationError( + { + "reversal_of": ( + "Связанное сторно ещё не поддерживается. Используйте " + "утверждённую корректировку с обязательным основанием." + ) + } + ) + if self.component_id and self.component.requires_reason and not self.reason: + raise ValidationError({"reason": "Для компонента требуется основание."}) + if self.relates_to_period_id == self.period_id: + raise ValidationError( + {"relates_to_period": "Для текущего периода это поле не требуется."} + ) + if self.relates_to_period_id and not self.reason: + raise ValidationError( + {"reason": "Для перерасчёта прошлого периода укажите основание."} + ) + + +class PayrollRun(models.Model): + """Immutable calculation revision plus its controlled workflow state.""" + + period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + related_name="runs", + verbose_name="Период", + ) + revision = models.PositiveIntegerField("Ревизия") + status = models.CharField( + "Статус", + max_length=20, + choices=PayrollRunStatus.choices, + default=PayrollRunStatus.CALCULATED, + ) + supersedes = models.ForeignKey( + "self", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="superseded_by_runs", + verbose_name="Пересчитывает ревизию", + ) + recalculation_reason = models.TextField( + "Основание перерасчёта", + blank=True, + ) + idempotency_key = models.UUIDField( + "Ключ идемпотентности", + default=uuid.uuid4, + unique=True, + editable=False, + ) + ruleset_id = models.CharField("Набор правил", max_length=120) + ruleset_version = models.CharField("Версия правил", max_length=64) + ruleset_hash = models.CharField("Хэш правил", max_length=64) + calculator_version = models.CharField("Версия калькулятора", max_length=64) + input_hash = models.CharField("Хэш входных данных", max_length=64) + result_hash = models.CharField("Хэш результата", max_length=64) + employee_count = models.PositiveIntegerField("Сотрудников", default=0) + gross_total = models.DecimalField( + "Всего начислено", + max_digits=21, + decimal_places=2, + default=Decimal("0"), + ) + deduction_total = models.DecimalField( + "Всего удержано", + max_digits=21, + decimal_places=2, + default=Decimal("0"), + ) + payable_total = models.DecimalField( + "Всего к выплате", + max_digits=21, + decimal_places=2, + default=Decimal("0"), + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="requested_payroll_runs", + verbose_name="Запустил", + ) + requested_at = models.DateTimeField("Рассчитан", auto_now_add=True) + approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="approved_payroll_runs", + verbose_name="Утвердил", + ) + approved_at = models.DateTimeField("Утверждён", null=True, blank=True) + self_approval_overridden = models.BooleanField( + "Самоутверждение по особому праву", + default=False, + editable=False, + ) + published_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="published_payroll_runs", + verbose_name="Опубликовал", + ) + published_at = models.DateTimeField("Опубликован", null=True, blank=True) + + class Meta: + verbose_name = "Запуск расчёта" + verbose_name_plural = "Запуски расчёта" + ordering = ["-period__date_from", "-revision"] + indexes = [ + models.Index( + fields=["period", "status", "revision"], + name="pay_run_period_status_idx", + ) + ] + constraints = [ + models.UniqueConstraint( + fields=["period", "revision"], + name="pay_run_revision_unique", + ), + models.UniqueConstraint( + fields=["period"], + condition=Q(status=PayrollRunStatus.PUBLISHED), + name="pay_run_one_published_per_period", + ), + models.CheckConstraint( + condition=Q(approved_by__isnull=True) + | ~Q(requested_by=F("approved_by")) + | Q(self_approval_overridden=True), + name="pay_run_maker_not_approver", + ), + models.CheckConstraint( + condition=Q(self_approval_overridden=False) + | Q( + approved_by__isnull=False, + requested_by=F("approved_by"), + ), + name="pay_run_self_override_valid", + ), + models.CheckConstraint( + condition=~Q( + status__in=[ + PayrollRunStatus.APPROVED, + PayrollRunStatus.PUBLISHED, + ] + ) + | Q(approved_by__isnull=False, approved_at__isnull=False), + name="pay_run_approval_complete", + ), + models.CheckConstraint( + condition=~Q(status=PayrollRunStatus.PUBLISHED) + | Q(published_by__isnull=False, published_at__isnull=False), + name="pay_run_publication_complete", + ), + models.CheckConstraint( + condition=Q(gross_total__gte=0), + name="pay_run_gross_nonnegative", + ), + models.CheckConstraint( + condition=Q(deduction_total__gte=0), + name="pay_run_deduction_nonnegative", + ), + models.CheckConstraint( + condition=Q(payable_total__gte=0), + name="pay_run_payable_nonnegative", + ), + ] + + def __str__(self): + return f"{self.period} / ревизия {self.revision}" + + def clean(self): + super().clean() + if not self.supersedes_id: + return + if self.pk and self.supersedes_id == self.pk: + raise ValidationError({"supersedes": "Расчёт не может заменять себя."}) + if self.supersedes.period_id != self.period_id: + raise ValidationError( + {"supersedes": "Заменяемый расчёт относится к другому периоду."} + ) + if self.revision != self.supersedes.revision + 1: + raise ValidationError( + {"revision": "Ревизия должна следовать за заменяемым расчётом."} + ) + + +class PayrollStatement(models.Model): + """Employee result snapshot. Amount fields never change after creation.""" + + run = models.ForeignKey( + PayrollRun, + on_delete=models.PROTECT, + related_name="statements", + verbose_name="Запуск", + ) + public_id = models.UUIDField( + "Публичный идентификатор", + default=uuid.uuid4, + unique=True, + editable=False, + ) + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_statements", + verbose_name="Сотрудник", + ) + supersedes = models.OneToOneField( + "self", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="superseded_by_statement", + verbose_name="Заменяет листок", + ) + employee_snapshot = models.JSONField("Снимок сотрудника", default=dict) + currency = models.CharField( + "Валюта", + max_length=3, + validators=[currency_validator], + ) + point_delta = models.DecimalField( + "Отклонение баллов", + max_digits=19, + decimal_places=4, + null=True, + blank=True, + ) + gross_before_adjustments = models.DecimalField( + "Начислено до корректировок", + max_digits=21, + decimal_places=2, + ) + adjustment_total = models.DecimalField( + "Корректировки", + max_digits=21, + decimal_places=2, + ) + gross_total = models.DecimalField( + "Итого начислено", + max_digits=21, + decimal_places=2, + ) + deduction_total = models.DecimalField( + "Удержания", + max_digits=21, + decimal_places=2, + ) + net_pay = models.DecimalField( + "После удержаний", + max_digits=21, + decimal_places=2, + ) + payment_total = models.DecimalField( + "Уже выплачено", + max_digits=21, + decimal_places=2, + ) + payable = models.DecimalField( + "К выплате", + max_digits=21, + decimal_places=2, + ) + input_hash = models.CharField("Хэш входных данных", max_length=64) + result_hash = models.CharField("Хэш результата", max_length=64) + input_snapshot = models.JSONField("Входной снимок", default=dict) + result_snapshot = models.JSONField("Результат расчёта", default=dict) + created_at = models.DateTimeField("Создан", auto_now_add=True) + + class Meta: + verbose_name = "Расчётный листок" + verbose_name_plural = "Расчётные листки" + ordering = ["-run__period__date_from", "employee_id"] + indexes = [ + models.Index( + fields=["employee", "run"], + name="pay_statement_employee_idx", + ) + ] + constraints = [ + models.UniqueConstraint( + fields=["run", "employee"], + name="pay_statement_run_employee_unique", + ), + models.CheckConstraint( + condition=Q(gross_before_adjustments__gte=0), + name="pay_statement_pre_gross_nonnegative", + ), + models.CheckConstraint( + condition=Q(gross_total__gte=0), + name="pay_statement_gross_nonnegative", + ), + models.CheckConstraint( + condition=Q(deduction_total__gte=0), + name="pay_statement_deduction_nonnegative", + ), + models.CheckConstraint( + condition=Q(payment_total__gte=0), + name="pay_statement_payment_nonnegative", + ), + models.CheckConstraint( + condition=Q(payable__gte=0), + name="pay_statement_payable_nonnegative", + ), + ] + + def __str__(self): + return f"{self.run.period}: {self.employee}" + + def clean(self): + super().clean() + if not self.supersedes_id: + return + if self.pk and self.supersedes_id == self.pk: + raise ValidationError({"supersedes": "Листок не может заменять себя."}) + if self.supersedes.employee_id != self.employee_id: + raise ValidationError( + {"supersedes": "Заменяемый листок принадлежит другому сотруднику."} + ) + if self.supersedes.run.period_id != self.run.period_id: + raise ValidationError( + {"supersedes": "Заменяемый листок относится к другому периоду."} + ) + + +class PayrollStatementLine(models.Model): + """A rounded line copied from the core result for a statement.""" + + statement = models.ForeignKey( + PayrollStatement, + on_delete=models.PROTECT, + related_name="lines", + verbose_name="Расчётный листок", + ) + position = models.PositiveIntegerField("Позиция") + line_id = models.CharField("Идентификатор строки", max_length=160) + code = models.CharField("Код", max_length=64) + label = models.CharField("Название", max_length=160) + kind = models.CharField( + "Тип", + max_length=24, + choices=PayrollComponentKind.choices, + ) + amount = models.DecimalField( + "Сумма", + max_digits=21, + decimal_places=2, + ) + source_ref = models.CharField("Источник", max_length=255) + reason = models.TextField("Основание", blank=True) + source_period_from = models.DateField( + "Период-источник: начало", + null=True, + blank=True, + ) + source_period_to = models.DateField( + "Период-источник: конец", + null=True, + blank=True, + ) + is_retro = models.BooleanField("Перерасчёт прошлого периода", default=False) + calculated = models.BooleanField("Рассчитана ядром", default=False) + + class Meta: + verbose_name = "Строка расчётного листка" + verbose_name_plural = "Строки расчётных листков" + ordering = ["statement_id", "position"] + constraints = [ + models.UniqueConstraint( + fields=["statement", "position"], + name="pay_statement_line_position_unique", + ), + models.UniqueConstraint( + fields=["statement", "line_id"], + name="pay_statement_line_id_unique", + ), + models.CheckConstraint( + condition=Q(amount__gte=0), + name="pay_statement_line_amount_nonnegative", + ), + ] + + def __str__(self): + return f"{self.statement_id}: {self.code} {self.amount}" + + +class PayrollStatementAcknowledgement(models.Model): + """Acknowledgement of a payslip, explicitly not proof of bank payment.""" + + statement = models.OneToOneField( + PayrollStatement, + on_delete=models.PROTECT, + related_name="acknowledgement", + verbose_name="Расчётный листок", + ) + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_acknowledgements", + verbose_name="Сотрудник", + ) + content_hash = models.CharField("Хэш подтверждённого листка", max_length=64) + idempotency_key = models.UUIDField( + "Ключ идемпотентности", + default=uuid.uuid4, + unique=True, + editable=False, + ) + viewed_at = models.DateTimeField("Просмотрен", null=True, blank=True) + acknowledged_at = models.DateTimeField( + "Получение подтверждено", + null=True, + blank=True, + ) + disputed_at = models.DateTimeField("Оспорен", null=True, blank=True) + dispute_reason = models.TextField("Причина оспаривания", blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Подтверждение расчётного листка" + verbose_name_plural = "Подтверждения расчётных листков" + + def __str__(self): + return f"{self.employee}: листок {self.statement_id}" + + def clean(self): + super().clean() + if self.statement_id and self.employee_id != self.statement.employee_id: + raise ValidationError( + {"employee": "Подтвердить листок может только его сотрудник."} + ) + if self.statement_id and self.content_hash != self.statement.result_hash: + raise ValidationError( + {"content_hash": "Хэш не совпадает с расчётным листком."} + ) + + +class PayrollAuditEvent(models.Model): + """Append-only redacted audit trail for payroll commands and reads.""" + + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="payroll_audit_events", + verbose_name="Инициатор", + ) + action = models.CharField("Действие", max_length=80) + object_type = models.CharField("Тип объекта", max_length=80) + object_id = models.CharField("Идентификатор объекта", max_length=80) + period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="audit_events", + verbose_name="Период", + ) + before_hash = models.CharField("Хэш до", max_length=64, blank=True) + after_hash = models.CharField("Хэш после", max_length=64, blank=True) + metadata = models.JSONField("Безопасные метаданные", default=dict, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "Событие аудита зарплаты" + verbose_name_plural = "События аудита зарплаты" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index( + fields=["object_type", "object_id", "created_at"], + name="pay_audit_object_time_idx", + ), + models.Index( + fields=["period", "created_at"], + name="pay_audit_period_time_idx", + ), + ] + + def __str__(self): + return f"{self.action}: {self.object_type}#{self.object_id}" diff --git a/backend/finance/payroll/__init__.py b/backend/finance/payroll/__init__.py new file mode 100644 index 00000000..c2c4d9bd --- /dev/null +++ b/backend/finance/payroll/__init__.py @@ -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", +] diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py new file mode 100644 index 00000000..e876a61e --- /dev/null +++ b/backend/finance/payroll/adapter.py @@ -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), + } diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py new file mode 100644 index 00000000..61e59749 --- /dev/null +++ b/backend/finance/payroll/attendance.py @@ -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} diff --git a/backend/finance/payroll/config.py b/backend/finance/payroll/config.py new file mode 100644 index 00000000..36d565fc --- /dev/null +++ b/backend/finance/payroll/config.py @@ -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"]) diff --git a/backend/finance/payroll/exceptions.py b/backend/finance/payroll/exceptions.py new file mode 100644 index 00000000..87604d29 --- /dev/null +++ b/backend/finance/payroll/exceptions.py @@ -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}, + ) diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py new file mode 100644 index 00000000..31ca6d94 --- /dev/null +++ b/backend/finance/payroll/services.py @@ -0,0 +1,1050 @@ +"""Transactional orchestration around the pure payroll calculation core.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from collections import defaultdict +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.db.models import Max +from django.utils import timezone + +from payroll_core import ( + DeterministicPayrollCalculator, + PayrollValidationError, +) + +from finance.enums import ( + ApprovalStatus, + PayrollPeriodStatus, + PayrollRunStatus, +) +from finance.models import ( + EmployeePayRate, + PayrollAuditEvent, + PayrollComponent, + PayrollInputLine, + PayrollPeriod, + PayrollRun, + PayrollStatement, + PayrollStatementAcknowledgement, + PayrollStatementLine, + PayrollWorkRecord, +) + +from .adapter import build_core_request, employee_snapshot +from .config import ( + base_rate_code, + build_rules, + ruleset_not_effective_message, + ruleset_period_details, +) +from .exceptions import PayrollOperationError, PayrollPermissionDenied + +SELF_APPROVAL_OVERRIDE_PERMISSION = "finance.override_payroll_approval" + + +def _require_permission(actor, permission: str) -> None: + if ( + actor is None + or not getattr(actor, "is_authenticated", False) + or not actor.has_perm(permission) + ): + raise PayrollPermissionDenied(permission) + + +def _hash_items(items: list[str]) -> str: + payload = json.dumps(sorted(items), separators=(",", ":")).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _audit( + *, + actor, + action: str, + instance, + period: PayrollPeriod | None, + before_hash: str = "", + after_hash: str = "", + metadata: dict[str, object] | None = None, +) -> None: + PayrollAuditEvent.objects.create( + actor=actor, + action=action, + object_type=instance._meta.label_lower, + object_id=str(instance.pk), + period=period, + before_hash=before_hash, + after_hash=after_hash, + metadata=metadata or {}, + ) + + +def _operation_error(code: str, message: str, **details): + raise PayrollOperationError(code, message, details=details) + + +def _resolve_rates( + period: PayrollPeriod, + employee_ids: list[int], +) -> list[EmployeePayRate]: + rates = list( + EmployeePayRate.objects.filter( + employee_id__in=employee_ids, + rate_code=base_rate_code(), + status=ApprovalStatus.APPROVED, + effective_from__lte=period.date_to, + ) + .select_related("employee", "employee__position") + .order_by("employee_id", "effective_from", "revision") + ) + grouped = defaultdict(list) + for rate in rates: + grouped[rate.employee_id].append(rate) + + selected = [] + for employee_id in employee_ids: + employee_rates = grouped.get(employee_id, []) + period_start_rates = [ + rate for rate in employee_rates if rate.effective_from <= period.date_from + ] + if not period_start_rates: + _operation_error( + "MISSING_RATE_AT_PERIOD_START", + "Нет утверждённой ставки на начало периода.", + employee_id=employee_id, + ) + mid_period_rates = [ + rate + for rate in employee_rates + if period.date_from < rate.effective_from <= period.date_to + ] + if mid_period_rates: + _operation_error( + "MID_PERIOD_RATE_CHANGE_UNSUPPORTED", + "Изменение ставки внутри периода требует правила пропорционирования.", + employee_id=employee_id, + rate_ids=[rate.pk for rate in mid_period_rates], + ) + selected.append( + max( + period_start_rates, + key=lambda rate: ( + rate.effective_from, + rate.revision, + rate.pk, + ), + ) + ) + + if not selected: + _operation_error( + "NO_APPROVED_RATES", + "Для периода нет ни одной утверждённой базовой ставки.", + ) + return sorted(selected, key=lambda rate: rate.employee_id) + + +def _current_work_records(period): + return { + record.employee_id: record + for record in PayrollWorkRecord.objects.filter( + period=period, + status=ApprovalStatus.APPROVED, + ).select_related("employee") + } + + +def _input_lines_by_employee(period, employee_ids): + grouped = defaultdict(list) + records = ( + PayrollInputLine.objects.filter( + period=period, + employee_id__in=employee_ids, + status=ApprovalStatus.APPROVED, + ) + .select_related("component", "relates_to_period") + .order_by("employee_id", "component__display_order", "id") + ) + for record in records: + grouped[record.employee_id].append(record) + return grouped + + +def _calculate_current_period_inputs( + period: PayrollPeriod, + *, + calculator=None, + rules=None, +): + """Resolve and calculate the currently approved source-of-truth inputs.""" + + active_rules = rules or build_rules() + active_calculator = calculator or DeterministicPayrollCalculator() + work_records = _current_work_records(period) + if not work_records: + _operation_error( + "NO_APPROVED_WORK_RECORDS", + "Не задан утверждённый состав сотрудников за период.", + ) + employee_ids = sorted(work_records) + rates = _resolve_rates(period, employee_ids) + outside_roster = list( + PayrollInputLine.objects.filter( + period=period, + status=ApprovalStatus.APPROVED, + ) + .exclude(employee_id__in=employee_ids) + .values_list("employee_id", flat=True) + .distinct()[:20] + ) + if outside_roster: + _operation_error( + "INPUT_EMPLOYEE_NOT_IN_ROSTER", + "Утверждённые строки найдены для сотрудников вне состава периода.", + employee_ids=outside_roster, + ) + input_lines = _input_lines_by_employee(period, employee_ids) + + requests = [] + results = [] + for rate in rates: + if rate.currency != period.currency: + _operation_error( + "RATE_CURRENCY_MISMATCH", + "Валюта ставки не совпадает с валютой периода.", + employee_id=rate.employee_id, + rate_id=rate.pk, + ) + work_record = work_records.get(rate.employee_id) + if work_record is None: + _operation_error( + "MISSING_APPROVED_WORK_RECORD", + "Нет утверждённых показателей за период.", + employee_id=rate.employee_id, + ) + request = build_core_request( + period=period, + rate=rate, + work_record=work_record, + input_lines=input_lines[rate.employee_id], + ) + try: + result = active_calculator.calculate(request, active_rules) + except PayrollValidationError as exc: + issues = [issue.to_dict() for issue in exc.issues] + if any(issue.code == "RULESET_NOT_EFFECTIVE" for issue in exc.issues): + raise PayrollOperationError( + "RULESET_NOT_EFFECTIVE", + ruleset_not_effective_message(active_rules), + details={ + "employee_id": rate.employee_id, + "issues": issues, + **ruleset_period_details( + active_rules, + period_from=period.date_from, + period_to=period.date_to, + ), + }, + ) from exc + raise PayrollOperationError( + "CALCULATION_VALIDATION_FAILED", + "Ядро отклонило входные данные сотрудника.", + details={ + "employee_id": rate.employee_id, + "issues": issues, + }, + ) from exc + requests.append(request) + results.append((rate, result)) + + return active_rules, requests, results + + +def _ensure_run_is_fresh(run: PayrollRun, period: PayrollPeriod) -> None: + """Fail closed if approved inputs, rules or persisted snapshots changed.""" + + _, _, results = _calculate_current_period_inputs(period) + current_input_hash = _hash_items([result.input_hash for _, result in results]) + current_result_hash = _hash_items([result.result_hash for _, result in results]) + first_result = results[0][1] + statements = { + statement.employee_id: statement for statement in run.statements.all() + } + current_employee_ids = {rate.employee_id for rate, _ in results} + snapshots_match = set(statements) == current_employee_ids + if snapshots_match: + for rate, result in results: + statement = statements[rate.employee_id] + stored_warnings = statement.result_snapshot.get("warnings") + current_warnings = [warning.to_dict() for warning in result.warnings] + if ( + statement.input_hash != result.input_hash + or statement.result_hash != result.result_hash + or statement.result_snapshot.get("result_hash") != result.result_hash + or statement.result_snapshot.get("reconciliation_hash") + != result.reconciliation_hash + or stored_warnings != current_warnings + ): + snapshots_match = False + break + if ( + run.employee_count != len(results) + or run.input_hash != current_input_hash + or run.result_hash != current_result_hash + or run.ruleset_id != first_result.ruleset_id + or run.ruleset_version != first_result.ruleset_version + or run.ruleset_hash != first_result.ruleset_hash + or run.calculator_version != first_result.calculator_version + or not snapshots_match + ): + _operation_error( + "RUN_STALE_RECALCULATION_REQUIRED", + "Утверждённые данные или правила изменились; выполните новый расчёт.", + run_id=run.pk, + ) + + +@transaction.atomic +def calculate_period( + period_id: int, + *, + actor, + idempotency_key: uuid.UUID | None = None, + recalculation_reason: str = "", + calculator=None, + rules=None, +) -> PayrollRun: + """Calculate a complete period and persist a new immutable revision.""" + + _require_permission(actor, "finance.calculate_payroll") + period = PayrollPeriod.objects.select_for_update().get(pk=period_id) + previous_current = None + if period.current_run_id is not None: + previous_current = PayrollRun.objects.select_for_update().get( + pk=period.current_run_id + ) + if idempotency_key is not None: + existing = PayrollRun.objects.filter(idempotency_key=idempotency_key).first() + if existing is not None: + if existing.period_id != period_id: + _operation_error( + "IDEMPOTENCY_KEY_CONFLICT", + "Ключ уже использован для другого периода.", + ) + return existing + if period.status == PayrollPeriodStatus.CLOSED: + _operation_error( + "PERIOD_CLOSED", + "Закрытый период нельзя пересчитать.", + period_id=period.pk, + ) + if period.status in { + PayrollPeriodStatus.REVIEW, + PayrollPeriodStatus.APPROVED, + }: + _operation_error( + "PERIOD_UNDER_APPROVAL", + "Сначала верните текущую ревизию из согласования.", + period_id=period.pk, + ) + if previous_current is not None and not recalculation_reason.strip(): + _operation_error( + "RECALCULATION_REASON_REQUIRED", + "Для новой ревизии расчёта требуется основание.", + period_id=period.pk, + ) + + active_rules, requests, results = _calculate_current_period_inputs( + period, + calculator=calculator, + rules=rules, + ) + employee_ids = [rate.employee_id for rate, _ in results] + + next_revision = ( + PayrollRun.objects.filter(period=period).aggregate(Max("revision"))[ + "revision__max" + ] + or 0 + ) + 1 + aggregate_input_hash = _hash_items([result.input_hash for _, result in results]) + aggregate_result_hash = _hash_items([result.result_hash for _, result in results]) + gross_total = sum( + (result.totals.gross_after_adjustments for _, result in results), + Decimal("0"), + ) + deduction_total = sum( + (result.totals.deduction_total for _, result in results), + Decimal("0"), + ) + payable_total = sum( + (result.totals.payable for _, result in results), + Decimal("0"), + ) + first_result = results[0][1] + run = PayrollRun( + period=period, + revision=next_revision, + status=PayrollRunStatus.CALCULATED, + supersedes=previous_current, + recalculation_reason=recalculation_reason.strip(), + idempotency_key=idempotency_key or uuid.uuid4(), + ruleset_id=first_result.ruleset_id, + ruleset_version=first_result.ruleset_version, + ruleset_hash=first_result.ruleset_hash, + calculator_version=first_result.calculator_version, + input_hash=aggregate_input_hash, + result_hash=aggregate_result_hash, + employee_count=len(results), + gross_total=gross_total, + deduction_total=deduction_total, + payable_total=payable_total, + requested_by=actor, + ) + run.clean() + try: + with transaction.atomic(): + run.save(force_insert=True) + except IntegrityError as exc: + existing = None + if idempotency_key is not None: + existing = PayrollRun.objects.filter( + idempotency_key=idempotency_key + ).first() + if existing is not None and existing.period_id == period_id: + return existing + if existing is not None: + raise PayrollOperationError( + "IDEMPOTENCY_KEY_CONFLICT", + "Ключ уже использован для другого периода.", + ) from exc + raise PayrollOperationError( + "CONCURRENT_CALCULATION_CONFLICT", + "Параллельный расчёт создал конфликт ревизий; повторите операцию.", + ) from exc + + prior_statements = {} + if previous_current is not None: + prior_statements = { + statement.employee_id: statement + for statement in PayrollStatement.objects.filter( + run=previous_current, + employee_id__in=employee_ids, + ) + } + + for request, (rate, result) in zip(requests, results, strict=True): + totals = result.totals + statement = PayrollStatement( + run=run, + employee=rate.employee, + supersedes=prior_statements.get(rate.employee_id), + employee_snapshot=employee_snapshot(rate.employee), + currency=result.currency, + point_delta=result.point_delta, + gross_before_adjustments=totals.gross_before_adjustments, + adjustment_total=totals.adjustment_total, + gross_total=totals.gross_after_adjustments, + deduction_total=totals.deduction_total, + net_pay=totals.net_pay, + payment_total=totals.payment_total, + payable=totals.payable, + input_hash=result.input_hash, + result_hash=result.result_hash, + input_snapshot=request.to_dict(), + result_snapshot=result.to_dict(), + ) + statement.clean() + statement.save(force_insert=True) + PayrollStatementLine.objects.bulk_create( + [ + PayrollStatementLine( + statement=statement, + position=position, + line_id=line.line_id, + code=line.code, + label=line.label, + kind=line.kind.value, + amount=line.amount, + source_ref=line.source_ref, + reason=line.reason, + source_period_from=( + line.source_period.start if line.source_period else None + ), + source_period_to=( + line.source_period.end if line.source_period else None + ), + is_retro=line.is_retro, + calculated=line.calculated, + ) + for position, line in enumerate(result.lines, start=1) + ] + ) + + if previous_current and previous_current.status != PayrollRunStatus.PUBLISHED: + previous_status = previous_current.status + previous_current.status = PayrollRunStatus.SUPERSEDED + previous_current.save(update_fields=["status"]) + _audit( + actor=actor, + action="payroll.run_superseded", + instance=previous_current, + period=period, + before_hash=previous_current.result_hash, + after_hash=run.result_hash, + metadata={"previous_status": previous_status}, + ) + + period.current_run = run + period.status = PayrollPeriodStatus.CALCULATED + period.lock_version += 1 + period.save(update_fields=["current_run", "status", "lock_version", "updated_at"]) + _audit( + actor=actor, + action="payroll.calculated", + instance=run, + period=period, + after_hash=run.result_hash, + metadata={ + "revision": run.revision, + "employee_count": run.employee_count, + "is_recalculation": previous_current is not None, + }, + ) + return run + + +def _lock_current_run(run_id: int) -> tuple[PayrollRun, PayrollPeriod]: + run_reference = PayrollRun.objects.only("period_id").get(pk=run_id) + period = PayrollPeriod.objects.select_for_update().get(pk=run_reference.period_id) + run = PayrollRun.objects.select_for_update().get(pk=run_id) + if period.current_run_id != run.pk: + _operation_error( + "RUN_NOT_CURRENT", + "Операция разрешена только для текущей ревизии.", + run_id=run.pk, + ) + return run, period + + +@transaction.atomic +def submit_run_for_review(run_id: int, *, actor) -> PayrollRun: + _require_permission(actor, "finance.calculate_payroll") + run, period = _lock_current_run(run_id) + if run.status != PayrollRunStatus.CALCULATED: + _operation_error("INVALID_RUN_STATE", "Расчёт уже передан дальше.") + _ensure_run_is_fresh(run, period) + reconciliation_errors = [] + for statement in run.statements.only( + "employee_id", + "result_snapshot", + ): + warning_codes = { + warning.get("code") + for warning in statement.result_snapshot.get("warnings", []) + } + if "LEGACY_EXPECTED_TOTAL_MISMATCH" in warning_codes: + reconciliation_errors.append(statement.employee_id) + if reconciliation_errors: + _operation_error( + "RECONCILIATION_MISMATCH", + "Контрольные итоги Excel не совпадают с расчётом.", + employee_ids=reconciliation_errors[:20], + ) + run.status = PayrollRunStatus.REVIEW + run.save(update_fields=["status"]) + period.status = PayrollPeriodStatus.REVIEW + period.save(update_fields=["status", "updated_at"]) + _audit(actor=actor, action="payroll.review_requested", instance=run, period=period) + return run + + +@transaction.atomic +def return_run_for_correction( + run_id: int, + *, + actor, + reason: str, +) -> PayrollRun: + """Return a reviewed run so the operator can create a new revision.""" + + if not reason.strip(): + _operation_error( + "RETURN_REASON_REQUIRED", + "Укажите причину возврата расчёта.", + ) + run, period = _lock_current_run(run_id) + if run.status == PayrollRunStatus.CALCULATED: + _require_permission(actor, "finance.calculate_payroll") + else: + _require_permission(actor, "finance.approve_payroll") + if run.status not in { + PayrollRunStatus.CALCULATED, + PayrollRunStatus.REVIEW, + PayrollRunStatus.APPROVED, + }: + _operation_error( + "INVALID_RUN_STATE", + "Вернуть можно расчёт на проверке или после утверждения.", + ) + previous_status = run.status + run.status = PayrollRunStatus.RETURNED + run.save(update_fields=["status"]) + period.status = PayrollPeriodStatus.CALCULATED + period.save(update_fields=["status", "updated_at"]) + _audit( + actor=actor, + action="payroll.returned_for_correction", + instance=run, + period=period, + metadata={ + "previous_status": previous_status, + "reason": reason.strip(), + }, + ) + return run + + +@transaction.atomic +def approve_run(run_id: int, *, actor) -> PayrollRun: + _require_permission(actor, "finance.approve_payroll") + run, period = _lock_current_run(run_id) + if run.status != PayrollRunStatus.REVIEW: + _operation_error("INVALID_RUN_STATE", "Расчёт не находится на проверке.") + _ensure_run_is_fresh(run, period) + self_approval_overridden = run.requested_by_id == actor.pk + if self_approval_overridden and not actor.has_perm( + SELF_APPROVAL_OVERRIDE_PERMISSION + ): + _operation_error( + "SELF_APPROVAL_FORBIDDEN", + "Автор расчёта не может сам его утвердить.", + ) + run.status = PayrollRunStatus.APPROVED + run.approved_by = actor + run.approved_at = timezone.now() + run.self_approval_overridden = self_approval_overridden + run.save( + update_fields=[ + "status", + "approved_by", + "approved_at", + "self_approval_overridden", + ] + ) + period.status = PayrollPeriodStatus.APPROVED + period.save(update_fields=["status", "updated_at"]) + _audit( + actor=actor, + action="payroll.approved", + instance=run, + period=period, + metadata={ + "self_approval_overridden": self_approval_overridden, + "approval_mode": ( + "self_override" if self_approval_overridden else "maker_checker" + ), + **( + {"override_permission": SELF_APPROVAL_OVERRIDE_PERMISSION} + if self_approval_overridden + else {} + ), + }, + ) + return run + + +@transaction.atomic +def publish_run(run_id: int, *, actor) -> PayrollRun: + _require_permission(actor, "finance.publish_payroll") + run, period = _lock_current_run(run_id) + if run.status != PayrollRunStatus.APPROVED: + _operation_error("INVALID_RUN_STATE", "Расчёт ещё не утверждён.") + _ensure_run_is_fresh(run, period) + + prior_published_runs = list( + PayrollRun.objects.select_for_update() + .filter( + period=period, + status=PayrollRunStatus.PUBLISHED, + ) + .exclude(pk=run.pk) + ) + for prior_run in prior_published_runs: + prior_run.status = PayrollRunStatus.SUPERSEDED + prior_run.save(update_fields=["status"]) + _audit( + actor=actor, + action="payroll.run_superseded_on_publish", + instance=prior_run, + period=period, + before_hash=prior_run.result_hash, + after_hash=run.result_hash, + metadata={"replacement_run_id": run.pk}, + ) + now = timezone.now() + run.status = PayrollRunStatus.PUBLISHED + run.published_by = actor + run.published_at = now + run.save(update_fields=["status", "published_by", "published_at"]) + period.status = PayrollPeriodStatus.PUBLISHED + period.save(update_fields=["status", "updated_at"]) + _audit( + actor=actor, + action="payroll.published", + instance=run, + period=period, + after_hash=run.result_hash, + metadata={"revision": run.revision, "employee_count": run.employee_count}, + ) + return run + + +@transaction.atomic +def close_period(period_id: int, *, actor) -> PayrollPeriod: + _require_permission(actor, "finance.publish_payroll") + period = PayrollPeriod.objects.select_for_update().get(pk=period_id) + if period.status != PayrollPeriodStatus.PUBLISHED: + _operation_error( + "INVALID_PERIOD_STATE", + "Закрыть можно только опубликованный период.", + ) + period.status = PayrollPeriodStatus.CLOSED + period.save(update_fields=["status", "updated_at"]) + _audit(actor=actor, action="payroll.period_closed", instance=period, period=period) + return period + + +def _approve_record( + record, + *, + actor, + object_label: str, + expected_lock_version: int, +): + if ( + not isinstance(expected_lock_version, int) + or isinstance(expected_lock_version, bool) + or expected_lock_version < 0 + ): + _operation_error( + "APPROVAL_VERSION_REQUIRED", + "Укажите версию черновика, которую проверил утверждающий.", + ) + if record.lock_version != expected_lock_version: + _operation_error( + "APPROVAL_VERSION_CONFLICT", + "Черновик изменился после проверки; откройте и проверьте его заново.", + expected_lock_version=expected_lock_version, + actual_lock_version=record.lock_version, + ) + if record.status != ApprovalStatus.DRAFT: + _operation_error("INVALID_INPUT_STATE", "Утвердить можно только черновик.") + self_approval_overridden = record.created_by_id == actor.pk + if self_approval_overridden and not actor.has_perm( + SELF_APPROVAL_OVERRIDE_PERMISSION + ): + _operation_error( + "SELF_APPROVAL_FORBIDDEN", + "Автор входных данных не может сам их утвердить.", + ) + now = timezone.now() + replaced = None + replaces_id = getattr(record, "replaces_id", None) + if replaces_id: + replaced = record.__class__.objects.select_for_update().get(pk=replaces_id) + record._state.fields_cache["replaces"] = replaced + previous_status = replaced.status if replaced is not None else "" + if replaced is not None and replaced.status != ApprovalStatus.APPROVED: + _operation_error( + "REPLACEMENT_SOURCE_NOT_APPROVED", + "Заменить можно только действующую утверждённую запись.", + replaced_id=replaced.pk, + ) + if replaced: + replaced.status = ApprovalStatus.VOIDED + replaced.voided_by = actor + replaced.voided_at = now + replaced.save(update_fields=["status", "voided_by", "voided_at"]) + record.status = ApprovalStatus.APPROVED + record.approved_by = actor + record.approved_at = now + record.self_approval_overridden = self_approval_overridden + try: + record.full_clean() + except ValidationError as exc: + raise PayrollOperationError( + "INPUT_VALIDATION_FAILED", + "Входные данные не прошли проверку.", + details={ + "errors": exc.message_dict + if hasattr(exc, "message_dict") + else exc.messages + }, + ) from exc + record.save( + update_fields=[ + "status", + "approved_by", + "approved_at", + "self_approval_overridden", + ] + ) + period = getattr(record, "period", None) + if replaced is not None: + _audit( + actor=actor, + action=f"payroll.{object_label}_voided_on_replacement", + instance=replaced, + period=period, + metadata={ + "previous_status": previous_status, + "replacement_id": record.pk, + }, + ) + _audit( + actor=actor, + action=f"payroll.{object_label}_approved", + instance=record, + period=period, + metadata={ + "approved_lock_version": record.lock_version, + "self_approval_overridden": self_approval_overridden, + "approval_mode": ( + "self_override" if self_approval_overridden else "maker_checker" + ), + **( + {"override_permission": SELF_APPROVAL_OVERRIDE_PERMISSION} + if self_approval_overridden + else {} + ), + }, + ) + return record + + +def _ensure_period_accepts_approvals(period: PayrollPeriod) -> None: + if period.status == PayrollPeriodStatus.CLOSED: + _operation_error( + "PERIOD_INPUTS_LOCKED", + "Данные закрытого периода заблокированы.", + ) + if period.current_run_id is None: + return + current_run = PayrollRun.objects.select_for_update().get(pk=period.current_run_id) + if current_run.status in { + PayrollRunStatus.CALCULATED, + PayrollRunStatus.REVIEW, + PayrollRunStatus.APPROVED, + }: + _operation_error( + "PERIOD_INPUTS_LOCKED", + "Сначала верните текущий расчёт на исправление.", + run_id=current_run.pk, + ) + + +@transaction.atomic +def approve_pay_rate( + rate_id: int, + *, + actor, + expected_lock_version: int, +) -> EmployeePayRate: + _require_permission(actor, "finance.approve_payroll_inputs") + # A rate can affect any open period. Lock periods before runs and the rate, + # matching the global payroll lock order and serializing against calculate. + periods = list( + PayrollPeriod.objects.select_for_update() + .exclude(status=PayrollPeriodStatus.CLOSED) + .order_by("pk") + ) + current_run_ids = [ + period.current_run_id for period in periods if period.current_run_id is not None + ] + current_runs = list( + PayrollRun.objects.select_for_update() + .filter(pk__in=current_run_ids) + .order_by("pk") + ) + record = EmployeePayRate.objects.select_for_update().get(pk=rate_id) + for run in current_runs: + if ( + run.status + in { + PayrollRunStatus.CALCULATED, + PayrollRunStatus.REVIEW, + PayrollRunStatus.APPROVED, + } + and run.statements.filter(employee_id=record.employee_id).exists() + ): + _operation_error( + "RATE_USED_BY_ACTIVE_RUN", + "Ставку нельзя утвердить, пока расчёт сотрудника согласуется.", + run_id=run.pk, + ) + return _approve_record( + record, + actor=actor, + object_label="rate", + expected_lock_version=expected_lock_version, + ) + + +@transaction.atomic +def approve_work_record( + record_id: int, + *, + actor, + expected_lock_version: int, +) -> PayrollWorkRecord: + _require_permission(actor, "finance.approve_payroll_inputs") + reference = PayrollWorkRecord.objects.only("period_id").get(pk=record_id) + period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id) + _ensure_period_accepts_approvals(period) + record = PayrollWorkRecord.objects.select_for_update().get(pk=record_id) + if period.status == PayrollPeriodStatus.PUBLISHED and not record.replaces_id: + _operation_error( + "PUBLISHED_WORK_REPLACEMENT_REQUIRED", + "После публикации показатели меняются только новой ревизией.", + ) + return _approve_record( + record, + actor=actor, + object_label="work_record", + expected_lock_version=expected_lock_version, + ) + + +@transaction.atomic +def approve_input_line( + line_id: int, + *, + actor, + expected_lock_version: int, +) -> PayrollInputLine: + _require_permission(actor, "finance.approve_payroll_inputs") + reference = PayrollInputLine.objects.only("period_id", "component_id").get( + pk=line_id + ) + period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id) + _ensure_period_accepts_approvals(period) + component = PayrollComponent.objects.select_for_update().get( + pk=reference.component_id + ) + if not component.is_active: + _operation_error( + "PAYROLL_COMPONENT_INACTIVE", + "Нельзя утвердить строку с неактивным компонентом.", + component_id=component.pk, + ) + record = PayrollInputLine.objects.select_for_update().get(pk=line_id) + record._state.fields_cache["component"] = component + return _approve_record( + record, + actor=actor, + object_label="input_line", + expected_lock_version=expected_lock_version, + ) + + +@transaction.atomic +def acknowledge_statement( + statement_id: int, + *, + actor, + idempotency_key: uuid.UUID | None = None, +) -> PayrollStatementAcknowledgement: + """Acknowledge receiving a current published statement, idempotently.""" + + if actor is None or not getattr(actor, "is_authenticated", False): + raise PayrollPermissionDenied("finance.acknowledge_own_statement") + statement_reference = PayrollStatement.objects.only( + "run_id", + "employee_id", + ).get(pk=statement_id) + run_reference = PayrollRun.objects.only("period_id").get( + pk=statement_reference.run_id + ) + period = PayrollPeriod.objects.select_for_update().get(pk=run_reference.period_id) + run = PayrollRun.objects.select_for_update().get(pk=statement_reference.run_id) + statement = PayrollStatement.objects.select_for_update().get(pk=statement_id) + if statement.employee_id != actor.pk: + _operation_error( + "STATEMENT_NOT_FOUND", + "Расчётный листок не найден.", + ) + latest_published = ( + PayrollRun.objects.filter( + period_id=period.pk, + status=PayrollRunStatus.PUBLISHED, + ) + .order_by("-revision") + .first() + ) + if latest_published is None or latest_published.pk != run.pk: + _operation_error( + "STATEMENT_NOT_CURRENT", + "Подтвердить можно только актуальный опубликованный листок.", + ) + + if idempotency_key is not None: + keyed_acknowledgement = PayrollStatementAcknowledgement.objects.filter( + idempotency_key=idempotency_key + ).first() + if keyed_acknowledgement is not None: + if keyed_acknowledgement.statement_id != statement.pk: + _operation_error( + "IDEMPOTENCY_KEY_CONFLICT", + "Ключ уже использован для другого листка.", + ) + return keyed_acknowledgement + + acknowledgement = PayrollStatementAcknowledgement.objects.filter( + statement=statement + ).first() + if acknowledgement and acknowledgement.acknowledged_at: + return acknowledgement + + now = timezone.now() + if acknowledgement is None: + acknowledgement = PayrollStatementAcknowledgement( + statement=statement, + employee=actor, + content_hash=statement.result_hash, + idempotency_key=idempotency_key or uuid.uuid4(), + viewed_at=now, + acknowledged_at=now, + ) + else: + acknowledgement.viewed_at = acknowledgement.viewed_at or now + acknowledgement.acknowledged_at = now + acknowledgement.full_clean() + try: + with transaction.atomic(): + acknowledgement.save() + except IntegrityError as exc: + existing = None + if idempotency_key is not None: + existing = PayrollStatementAcknowledgement.objects.filter( + idempotency_key=idempotency_key + ).first() + if existing is not None and existing.statement_id == statement.pk: + return existing + raise PayrollOperationError( + "ACKNOWLEDGEMENT_CONFLICT", + "Подтверждение уже обработано другой операцией.", + ) from exc + _audit( + actor=actor, + action="payroll.statement_acknowledged", + instance=acknowledgement, + period=period, + after_hash=statement.result_hash, + ) + return acknowledgement diff --git a/backend/finance/templates/admin/finance/payroll_approval_action.html b/backend/finance/templates/admin/finance/payroll_approval_action.html new file mode 100644 index 00000000..a279d7ad --- /dev/null +++ b/backend/finance/templates/admin/finance/payroll_approval_action.html @@ -0,0 +1,40 @@ +{% extends "admin/base_site.html" %} +{% load admin_urls %} + +{% block content %} +
+ {% csrf_token %} +

+ Проверьте суммы, сотрудника и основание. Будут утверждены только показанные + версии; если оператор изменит черновик, операция завершится конфликтом. +

+ + + {% if error %} +

{{ error }}

+ {% endif %} + + + + + + Отмена +
+{% endblock %} diff --git a/backend/finance/templates/admin/finance/payroll_reason_action.html b/backend/finance/templates/admin/finance/payroll_reason_action.html new file mode 100644 index 00000000..c92b7bce --- /dev/null +++ b/backend/finance/templates/admin/finance/payroll_reason_action.html @@ -0,0 +1,38 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block content %} +
+ {% csrf_token %} +

Операция будет применена к следующим объектам:

+
    + {% for object in queryset %} +
  • {{ object }}
  • + + {% endfor %} +
+ + {% if error %} +

{{ error }}

+ {% endif %} + +
+ + +
+ + + + + + Отмена +
+{% endblock %} diff --git a/backend/payroll_core/README.md b/backend/payroll_core/README.md new file mode 100644 index 00000000..8dd3c778 --- /dev/null +++ b/backend/payroll_core/README.md @@ -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. diff --git a/backend/payroll_core/__init__.py b/backend/payroll_core/__init__.py new file mode 100644 index 00000000..99495887 --- /dev/null +++ b/backend/payroll_core/__init__.py @@ -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" diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py new file mode 100644 index 00000000..1cd96a9d --- /dev/null +++ b/backend/payroll_core/calculator.py @@ -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)}."), + ) + ) diff --git a/backend/payroll_core/domain.py b/backend/payroll_core/domain.py new file mode 100644 index 00000000..9ab8de72 --- /dev/null +++ b/backend/payroll_core/domain.py @@ -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], + } diff --git a/backend/payroll_core/exceptions.py b/backend/payroll_core/exceptions.py new file mode 100644 index 00000000..cde89971 --- /dev/null +++ b/backend/payroll_core/exceptions.py @@ -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") diff --git a/backend/payroll_core/pyproject.toml b/backend/payroll_core/pyproject.toml new file mode 100644 index 00000000..ecd7ca32 --- /dev/null +++ b/backend/payroll_core/pyproject.toml @@ -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 = "."} diff --git a/backend/payroll_core/tests/test_calculator.py b/backend/payroll_core/tests/test_calculator.py new file mode 100644 index 00000000..f40c2f92 --- /dev/null +++ b/backend/payroll_core/tests/test_calculator.py @@ -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", + } diff --git a/backend/payroll_core/tests/test_import_boundaries.py b/backend/payroll_core/tests/test_import_boundaries.py new file mode 100644 index 00000000..41c212fd --- /dev/null +++ b/backend/payroll_core/tests/test_import_boundaries.py @@ -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 == [] diff --git a/backend/pytest.ini b/backend/pytest.ini index 9db99a60..e95f997d 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -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 diff --git a/backend/tests/finance/__init__.py b/backend/tests/finance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py new file mode 100644 index 00000000..3e8fea9d --- /dev/null +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -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" + ) diff --git a/backend/tests/finance/test_payroll_admin_permissions.py b/backend/tests/finance/test_payroll_admin_permissions.py new file mode 100644 index 00000000..a4fdae72 --- /dev/null +++ b/backend/tests/finance/test_payroll_admin_permissions.py @@ -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 diff --git a/backend/tests/finance/test_payroll_attendance_import.py b/backend/tests/finance/test_payroll_attendance_import.py new file mode 100644 index 00000000..93d258b7 --- /dev/null +++ b/backend/tests/finance/test_payroll_attendance_import.py @@ -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 diff --git a/backend/tests/finance/test_payroll_config.py b/backend/tests/finance/test_payroll_config.py new file mode 100644 index 00000000..5e56a540 --- /dev/null +++ b/backend/tests/finance/test_payroll_config.py @@ -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() diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py new file mode 100644 index 00000000..f2d7d740 --- /dev/null +++ b/backend/tests/finance/test_payroll_services.py @@ -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 diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx index 64ef1237..b1ed4799 100644 --- a/frontend/src/app/finances/page.tsx +++ b/frontend/src/app/finances/page.tsx @@ -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 Есть обращение; + } + if (statement.acknowledgement?.acknowledged_at) { + return ( + + + Подтверждён + + ); + } + return ( + + + Ждёт подтверждения + + ); +} + +function EmptyPayroll() { + return ( +
+
+
+ +
+

Расчётных листков пока нет

+

+ Здесь появятся ваши зарплатные расчёты после проверки и публикации финансовым отделом. +

+
+
+ ); +} export default function FinancesPage() { + const { user } = useUser(); + const [activeTab, setActiveTab] = useState<"statement" | "management">("statement"); + const [statements, setStatements] = useState([]); + const [selectedStatement, setSelectedStatement] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( - -

Раздел в разработке.

+ +
+
+

Финансы

+ {activeTab === "management" && showPayrollAdmin ? ( +
+ ) : null} +
+ + {activeTab === "management" && showPayrollAdmin ? ( +
+ ) : null} + +
+ + {showPayrollAdmin ? ( + + ) : null} +
+
+ + {activeTab === "management" && showPayrollAdmin ? ( + + ) : ( + <> + {loading ? ( +
+ +

Загружаем ваши расчётные листки…

+
+ ) : error ? ( +
+
{error}
+
+ +
+
+ ) : statements.length === 0 ? ( + + ) : ( + <> + {latestStatement ? ( +
+
+

Последний расчёт

+ +
+

+ Зарплата за {getPayrollPeriodLabel(latestStatement.period).toLocaleLowerCase("ru-RU")} +

+ +
+

К выплате

+

+ {formatPayrollMoney(latestStatement.payable, latestStatement.currency)} +

+
+ +
+
+ +
+

Расчётный период

+

{getPayrollPeriodRange(latestStatement.period)}

+
+
+
+ +
+

Дата выплаты

+

{formatPayrollDate(latestStatement.period.pay_date)}

+
+
+
+ + + {!latestStatement.acknowledgement?.acknowledged_at ? ( +

+ Сначала проверьте начисления. Подтверждается получение листка, а не перевод денег. +

+ ) : null} +
+ ) : null} + + {previousStatements.length > 0 ? ( +
+
+
+ +
+

Предыдущие расчёты

+

Откройте любой листок, чтобы посмотреть начисления

+
+
+ {pendingCount > 1 ? ( + + {pendingCount} + + ) : null} +
+ +
+ {previousStatements.map((statement) => ( + + ))} +
+
+ ) : null} + + )} + + setSelectedStatement(null)} + onAcknowledged={handleAcknowledged} + /> + + )}
); } diff --git a/frontend/src/app/finances/payroll/manage/page.tsx b/frontend/src/app/finances/payroll/manage/page.tsx new file mode 100644 index 00000000..01e46d29 --- /dev/null +++ b/frontend/src/app/finances/payroll/manage/page.tsx @@ -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 ( + + {!loading && canManagePayroll ? ( + + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 20ce6a4f..ad02c51e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 ( }> @@ -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; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 73505015..288741f1 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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 (
diff --git a/frontend/src/components/finance/PayrollStatementDetailModal.tsx b/frontend/src/components/finance/PayrollStatementDetailModal.tsx new file mode 100644 index 00000000..6ba81fa1 --- /dev/null +++ b/frontend/src/components/finance/PayrollStatementDetailModal.tsx @@ -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 = { + 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 ( +
+

{label}

+

+ {value} +

+ {subtle ?

{subtle}

: null} +
+ ); +} + +export function PayrollStatementDetailModal({ + isOpen, + statement, + onClose, + onAcknowledged, +}: PayrollStatementDetailModalProps) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [acknowledging, setAcknowledging] = useState(false); + const [acknowledgeError, setAcknowledgeError] = useState(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 ? ( +
+
+ {isAcknowledged ? ( +
+ +
+

Получение листка подтверждено

+

+ {formatPayrollDateTime(acknowledgement?.acknowledged_at)} +

+
+
+ ) : ( +
+ +

+ Подтверждение фиксирует получение расчётного листка, но не является подтверждением банковской выплаты. +

+
+ )} +
+ + {!isAcknowledged ? ( + confirmationOpen ? ( +
+ + +
+ ) : ( + + ) + ) : null} +
+ ) : null; + + return ( + + {loading ? ( +
+ +

Загружаем защищённые данные листка…

+
+ ) : loadError ? ( +
+
{loadError}
+ +
+ ) : detail ? ( +
+
+
+
+

Расчётный период

+

+ Зарплата за {getPayrollPeriodLabel(detail.period).toLocaleLowerCase("ru-RU")} +

+

{getPayrollPeriodRange(detail.period)}

+
+
+

К выплате

+

+ {formatPayrollMoney(detail.payable, detail.currency)} +

+

+ + Дата выплаты: {formatPayrollDate(detail.period.pay_date)} +

+
+
+
+ +
+
+ +

Итоги расчёта

+
+
+ + + + +
+
+ +
+
+ +

Состав расчёта

+
+ {lineGroups.length > 0 ? ( +
+ {lineGroups.map((group) => { + const Icon = groupIcons[group.key]; + return ( +
+
+ +
+ {group.label} +
+
+
+ {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 ( +
+
+
+

{line.label}

+ {line.is_retro ? ( + Перерасчёт + ) : null} +
+ {sourcePeriod ? ( +

За период: {sourcePeriod}

+ ) : null} +
+

+ {direction === "negative" ? "−" : "+"} + {formatPayrollMoney(line.amount, detail.currency)} +

+
+ ); + })} +
+
+ ); + })} +
+ ) : ( +
+

Детализация начислений отсутствует.

+
+ )} +
+ +
+ Листок опубликован {formatPayrollDateTime(detail.published_at)}. +
+ + {confirmationOpen && !isAcknowledged ? ( +
+ Проверьте суммы и состав расчёта. После подтверждения портал зафиксирует дату и время получения этого листка. +
+ ) : null} + {acknowledgeError ? ( +
{acknowledgeError}
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx new file mode 100644 index 00000000..4aabc1e8 --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -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 ( +
+ + +
+ ); +} + +function FormError({ message }: { message: string | null }) { + if (!message) return null; + return
{message}
; +} + +async function submitModalForm( + operation: () => Promise, + options: { + fallback: string; + setBusy: (busy: boolean) => void; + setError: (message: string | null) => void; + onSaved: () => void | Promise; + onStale?: () => void | Promise; + }, +) { + 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; + onSaved: () => void | Promise; + onStale?: () => void | Promise; +}) { + const [form, setForm] = useState(() => 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(null); + + const submit = (event: FormEvent) => { + event.preventDefault(); + void submitModalForm(() => onSubmit(form), { + fallback: "Не удалось сохранить расчётный период.", + setBusy, + setError, + onSaved, + onStale, + }); + }; + + return ( + +
+

+ Период задаёт границы расчёта и дату, которую сотрудники увидят в листке. +

+
+ + + + + + +
+ + + +
+ ); +} + +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; + onSaved: () => void | Promise; + onStale?: () => void | Promise; +}) { + 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(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 ( + +
+
+ + + + + +