"""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 import logging from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 from rest_framework import permissions from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.views import APIView from finance.enums import ( PayrollPeriodStatus, PayrollRunStatus, ) from finance.models import ( PayrollAuditEvent, PayrollPeriod, ) from finance.payroll.access import ( can_access_payroll_management, has_payroll_permission, ) from finance.payroll.components import get_payroll_component from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.reporting import ( SYSTEM_COMPONENT_CODES, ) from ..admin_serializers import ( PayrollOperationResultSerializer, PayrollRunSerializer, ) from ..views import NoStorePayrollResponseMixin, PayrollConflict Employee = get_user_model() logger = logging.getLogger(__name__) 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"], ) PAYROLL_TABLE_FIXED_COLUMN_IDS = { "employee", "rate_amount", "in_norm_point_rate", "point_rate", "target_points", "attendance_points", "personnel_points", "actual_points", "point_delta", "point_amount", "gross_before_adjustments", "adjustment_total", "gross_total", "deduction_total", "net_pay", "payment_total", "payable", "carryovers", "status", } 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 _payroll_history_action_label(action): if "voided" in action or "cancelled" in action: return "Запись аннулирована" if action.endswith("_approved"): return "Запись утверждена" if "revision" in action and action.endswith("_created"): return "Создана новая версия" if action.endswith("_created"): return "Запись добавлена" if action.endswith("_updated") or action.endswith("_replaced"): return "Запись изменена" return "Состояние записи изменено" def _validate_payroll_table_column(column_id, *, period=None): if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS: return column_id prefix, separator, component_code = column_id.partition(":") try: component = get_payroll_component(component_code, period=period) except ValueError: component = None if ( prefix == "component" and separator and component_code not in SYSTEM_COMPONENT_CODES and component is not None ): return column_id raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."}) def _lock_period_accepting_draft_edits(period_id): period = get_object_or_404( PayrollPeriod.objects.select_for_update(of=("self",)).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] serializer_class = PayrollOperationResultSerializer allowed_permissions = OPERATIONAL_PERMISSIONS def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) if not can_access_payroll_management(request.user): _permission_error("finance.payroll_management_access") if not any( has_payroll_permission(request.user, code) for code in self.allowed_permissions ): _permission_error(self.allowed_permissions[0]) def require_permission(self, request, code): if not has_payroll_permission(request.user, code): _permission_error(code) def include_amounts(self, request): return has_payroll_permission( request.user, 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)