237 lines
7.4 KiB
Python
237 lines
7.4 KiB
Python
"""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.components import (
|
||
PAYROLL_COMPONENTS_BY_CODE,
|
||
)
|
||
from finance.payroll.access import (
|
||
can_access_payroll_management,
|
||
has_payroll_permission,
|
||
)
|
||
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):
|
||
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
|
||
return column_id
|
||
prefix, separator, component_code = column_id.partition(":")
|
||
if (
|
||
prefix == "component"
|
||
and separator
|
||
and component_code not in SYSTEM_COMPONENT_CODES
|
||
and component_code in PAYROLL_COMPONENTS_BY_CODE
|
||
):
|
||
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)
|