2026-09-07 19:43:48 +03:00

239 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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