890 lines
32 KiB
Python
890 lines
32 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
|
||
|
||
|
||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||
from django.db import IntegrityError, transaction
|
||
from django.db.models import Q
|
||
from django.http import Http404
|
||
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||
from rest_framework import status
|
||
from rest_framework.exceptions import ValidationError
|
||
from rest_framework.response import Response
|
||
|
||
from finance.enums import (
|
||
ApprovalStatus,
|
||
PayrollPeriodStatus,
|
||
)
|
||
from finance.models import (
|
||
EmployeePayRate,
|
||
PayrollAuditEvent,
|
||
PayrollInputLine,
|
||
PayrollInputCopyConfiguration,
|
||
PayrollPeriod,
|
||
PayrollWorkRecord,
|
||
)
|
||
from finance.payroll.access import (
|
||
has_simple_admin_access,
|
||
)
|
||
from finance.payroll.services import (
|
||
approve_input_line,
|
||
approve_pay_rate,
|
||
approve_work_record,
|
||
bulk_set_pay_rate,
|
||
bulk_set_point_rate,
|
||
bulk_set_target_points,
|
||
clear_component_input_lines,
|
||
pay_rates_relevant_to_period,
|
||
set_component_input_line_value,
|
||
)
|
||
|
||
from ..admin_serializers import (
|
||
ApprovalCommandSerializer,
|
||
BulkPayRateCommandSerializer,
|
||
BulkPointRateCommandSerializer,
|
||
BulkTargetPointsCommandSerializer,
|
||
ClearComponentCellCommandSerializer,
|
||
CopyPayrollInputLinesCommandSerializer,
|
||
EmployeePayRateRevisionSerializer,
|
||
EmployeePayRateSerializer,
|
||
EmployeePayRateWriteSerializer,
|
||
PayrollEmployeeMiniSerializer,
|
||
PayrollInputLineSerializer,
|
||
PayrollInputLineWriteSerializer,
|
||
PayrollWorkRecordRevisionSerializer,
|
||
PayrollWorkRecordSerializer,
|
||
PayrollWorkRecordWriteSerializer,
|
||
SetComponentCellCommandSerializer,
|
||
PayrollUserRefSerializer,
|
||
_validation_detail,
|
||
)
|
||
|
||
from .common import (
|
||
INPUT_VIEW_PERMISSIONS,
|
||
PAYROLL_PERMISSIONS,
|
||
PayrollAdminAPIView,
|
||
_audit_api_mutation,
|
||
_conflict,
|
||
_lock_period_accepting_draft_edits,
|
||
_optional_int,
|
||
_payroll_history_action_label,
|
||
logger,
|
||
)
|
||
from .input_copy import (
|
||
_apply_input_copy,
|
||
_input_copy_preview,
|
||
_validate_input_copy_periods,
|
||
)
|
||
|
||
|
||
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_serializer_class(self):
|
||
return self.read_serializer_class or super().get_serializer_class()
|
||
|
||
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:
|
||
logger.exception(
|
||
"Database rejected payroll draft creation",
|
||
extra={
|
||
"event": "finance.admin_views.database.rejected.payroll.draft.creation",
|
||
"payroll_model": self.model._meta.label,
|
||
"payroll_actor_id": request.user.pk,
|
||
},
|
||
)
|
||
_conflict(
|
||
"DRAFT_SAVE_FAILED",
|
||
(
|
||
"Не удалось сохранить данные расчёта: база данных отклонила "
|
||
"запись. Обновите страницу и повторите попытку."
|
||
),
|
||
)
|
||
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 PayrollInputHistoryView(PayrollAdminAPIView):
|
||
allowed_permissions = INPUT_VIEW_PERMISSIONS
|
||
|
||
def get(self, request):
|
||
period_id = _optional_int(request, "period_id")
|
||
if period_id is None:
|
||
raise ValidationError({"period_id": "Укажите расчётный период."})
|
||
period = get_object_or_404(PayrollPeriod, pk=period_id)
|
||
kind = request.query_params.get("kind", "").strip()
|
||
|
||
if kind == "rates":
|
||
records = pay_rates_relevant_to_period(
|
||
period,
|
||
queryset=EmployeePayRate.objects.select_related(
|
||
"employee",
|
||
"employee__position",
|
||
).prefetch_related("employee__departments_links__department"),
|
||
)
|
||
object_type = EmployeePayRate._meta.label_lower
|
||
subjects = {record.pk: "Ставка" for record in records}
|
||
elif kind == "work":
|
||
records = (
|
||
PayrollWorkRecord.objects.filter(period=period)
|
||
.select_related(
|
||
"employee",
|
||
"employee__position",
|
||
)
|
||
.prefetch_related("employee__departments_links__department")
|
||
)
|
||
object_type = PayrollWorkRecord._meta.label_lower
|
||
subjects = {record.pk: "Выработка" for record in records}
|
||
elif kind == "inputs":
|
||
records = (
|
||
PayrollInputLine.objects.filter(period=period)
|
||
.select_related(
|
||
"employee",
|
||
"employee__position",
|
||
)
|
||
.prefetch_related("employee__departments_links__department")
|
||
)
|
||
object_type = PayrollInputLine._meta.label_lower
|
||
subjects = {record.pk: record.component.name for record in records}
|
||
else:
|
||
raise ValidationError(
|
||
{"kind": "Допустимые значения: rates, work или inputs."}
|
||
)
|
||
|
||
records_by_id = {record.pk: record for record in records}
|
||
events = (
|
||
PayrollAuditEvent.objects.filter(
|
||
object_type=object_type,
|
||
object_id__in=[str(record_id) for record_id in records_by_id],
|
||
)
|
||
.select_related("actor")
|
||
.order_by("-created_at", "-id")
|
||
)
|
||
result = []
|
||
for event in events:
|
||
try:
|
||
record_id = int(event.object_id)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
record = records_by_id.get(record_id)
|
||
if record is None:
|
||
continue
|
||
result.append(
|
||
{
|
||
"id": event.pk,
|
||
"record_id": record_id,
|
||
"action": event.action,
|
||
"action_label": _payroll_history_action_label(event.action),
|
||
"subject": subjects[record_id],
|
||
"employee": PayrollEmployeeMiniSerializer(record.employee).data,
|
||
"actor": (
|
||
PayrollUserRefSerializer(event.actor).data
|
||
if event.actor is not None
|
||
else None
|
||
),
|
||
"created_at": event.created_at,
|
||
}
|
||
)
|
||
return Response(result)
|
||
|
||
|
||
class DraftDetailView(PayrollAdminAPIView):
|
||
allowed_permissions = INPUT_VIEW_PERMISSIONS
|
||
model = None
|
||
read_serializer_class = None
|
||
write_serializer_class = None
|
||
|
||
def get_serializer_class(self):
|
||
return self.read_serializer_class or super().get_serializer_class()
|
||
|
||
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,
|
||
)
|
||
if not has_simple_admin_access(request.user):
|
||
scoped_queryset = scoped_queryset.filter(created_by=request.user)
|
||
if self.period_scoped:
|
||
reference_queryset = self.model.objects.only("period_id").filter(
|
||
pk=pk,
|
||
status=ApprovalStatus.DRAFT,
|
||
)
|
||
if not has_simple_admin_access(request.user):
|
||
reference_queryset = reference_queryset.filter(created_by=request.user)
|
||
reference = get_object_or_404(reference_queryset)
|
||
period = _lock_period_accepting_draft_edits(reference.period_id)
|
||
else:
|
||
period = None
|
||
# Read serializers join nullable audit/revision relations. PostgreSQL
|
||
# cannot apply FOR UPDATE to the nullable side of those outer joins,
|
||
# and only the draft itself needs to be locked here.
|
||
instance = get_object_or_404(scoped_queryset.select_for_update(of=("self",)))
|
||
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
|
||
|
||
|
||
@extend_schema_view(
|
||
get=extend_schema(operation_id="finance_payroll_admin_pay_rates_list")
|
||
)
|
||
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")
|
||
.order_by("-created_at", "-id")
|
||
)
|
||
|
||
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)
|
||
return pay_rates_relevant_to_period(period, queryset=queryset)
|
||
|
||
|
||
@extend_schema_view(
|
||
get=extend_schema(operation_id="finance_payroll_admin_pay_rates_retrieve")
|
||
)
|
||
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 PayrollBulkPointRateView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||
|
||
def post(self, request, pk):
|
||
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
|
||
command = BulkPointRateCommandSerializer(data=request.data)
|
||
command.is_valid(raise_exception=True)
|
||
result = bulk_set_point_rate(
|
||
pk,
|
||
actor=request.user,
|
||
**command.validated_data,
|
||
)
|
||
return Response(
|
||
{
|
||
"mode": result["mode"],
|
||
"point_rate": (
|
||
f"{result['point_rate']:.4f}"
|
||
if result["point_rate"] is not None
|
||
else None
|
||
),
|
||
"summary": result["summary"],
|
||
}
|
||
)
|
||
|
||
|
||
class PayrollBulkPayRateView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||
|
||
def post(self, request, pk):
|
||
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
|
||
command = BulkPayRateCommandSerializer(data=request.data)
|
||
command.is_valid(raise_exception=True)
|
||
result = bulk_set_pay_rate(
|
||
pk,
|
||
actor=request.user,
|
||
**command.validated_data,
|
||
)
|
||
return Response(
|
||
{
|
||
"amount": f"{result['amount']:.4f}",
|
||
"effective_from": result["effective_from"].isoformat(),
|
||
"summary": result["summary"],
|
||
}
|
||
)
|
||
|
||
|
||
class PayrollBulkTargetPointsView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||
|
||
def post(self, request, pk):
|
||
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
|
||
command = BulkTargetPointsCommandSerializer(data=request.data)
|
||
command.is_valid(raise_exception=True)
|
||
result = bulk_set_target_points(
|
||
pk,
|
||
actor=request.user,
|
||
**command.validated_data,
|
||
)
|
||
return Response(
|
||
{
|
||
"mode": result["mode"],
|
||
"target_points": (
|
||
f"{result['target_points']:.4f}"
|
||
if result["target_points"] is not None
|
||
else None
|
||
),
|
||
"summary": result["summary"],
|
||
}
|
||
)
|
||
|
||
|
||
@extend_schema_view(
|
||
get=extend_schema(operation_id="finance_payroll_admin_work_records_list")
|
||
)
|
||
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")
|
||
.order_by("-created_at", "-id")
|
||
)
|
||
|
||
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
|
||
|
||
|
||
@extend_schema_view(
|
||
get=extend_schema(operation_id="finance_payroll_admin_work_records_retrieve")
|
||
)
|
||
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")
|
||
|
||
|
||
@extend_schema_view(
|
||
get=extend_schema(operation_id="finance_payroll_admin_input_lines_list")
|
||
)
|
||
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",
|
||
"relates_to_period",
|
||
"created_by",
|
||
"approved_by",
|
||
)
|
||
.prefetch_related("employee__departments_links__department")
|
||
.order_by("-created_at", "-id")
|
||
)
|
||
|
||
def filter_queryset(self, request, queryset):
|
||
queryset = super().filter_queryset(request, queryset)
|
||
period_id = _optional_int(request, "period_id")
|
||
component_code = request.query_params.get("component_code", "").strip()
|
||
if period_id:
|
||
queryset = queryset.filter(period_id=period_id)
|
||
if component_code:
|
||
queryset = queryset.filter(component_code=component_code)
|
||
return queryset
|
||
|
||
|
||
@extend_schema_view(
|
||
get=extend_schema(operation_id="finance_payroll_admin_input_lines_retrieve")
|
||
)
|
||
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_code")
|
||
|
||
def get_queryset(self):
|
||
return PayrollInputLine.objects.select_related(
|
||
"period",
|
||
"employee",
|
||
"employee__position",
|
||
"relates_to_period",
|
||
"created_by",
|
||
"approved_by",
|
||
).prefetch_related("employee__departments_links__department")
|
||
|
||
|
||
class PayrollComponentCellClearView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||
|
||
def post(self, request, pk):
|
||
command = ClearComponentCellCommandSerializer(data=request.data)
|
||
command.is_valid(raise_exception=True)
|
||
summary = clear_component_input_lines(
|
||
pk,
|
||
employee_id=command.validated_data["employee_id"],
|
||
component_code=command.validated_data["component_code"],
|
||
actor=request.user,
|
||
)
|
||
return Response(summary)
|
||
|
||
|
||
class PayrollComponentCellSetView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||
|
||
def post(self, request, pk):
|
||
command = SetComponentCellCommandSerializer(data=request.data)
|
||
command.is_valid(raise_exception=True)
|
||
line = set_component_input_line_value(
|
||
pk,
|
||
employee_id=command.validated_data["employee_id"],
|
||
component_code=command.validated_data["component_code"],
|
||
amount=command.validated_data["amount"],
|
||
actor=request.user,
|
||
)
|
||
return Response(
|
||
PayrollInputLineSerializer(line).data,
|
||
status=status.HTTP_201_CREATED,
|
||
)
|
||
|
||
|
||
class PayrollInputLinePeriodCopyView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||
|
||
def get(self, request, pk):
|
||
target_period = get_object_or_404(PayrollPeriod, pk=pk)
|
||
source_period_id = _optional_int(request, "source_period_id")
|
||
if source_period_id is None:
|
||
raise ValidationError({"source_period_id": "Выберите период-источник."})
|
||
source_period = get_object_or_404(PayrollPeriod, pk=source_period_id)
|
||
return Response(
|
||
_input_copy_preview(
|
||
target_period,
|
||
source_period,
|
||
actor=request.user,
|
||
)
|
||
)
|
||
|
||
@transaction.atomic
|
||
def post(self, request, pk):
|
||
command = CopyPayrollInputLinesCommandSerializer(data=request.data)
|
||
command.is_valid(raise_exception=True)
|
||
data = command.validated_data
|
||
target_period = _lock_period_accepting_draft_edits(pk)
|
||
if target_period.lock_version != data["expected_period_lock_version"]:
|
||
_conflict(
|
||
"STALE_PERIOD",
|
||
"Период уже изменён; обновите данные и повторите перенос.",
|
||
expected_lock_version=data["expected_period_lock_version"],
|
||
actual_lock_version=target_period.lock_version,
|
||
)
|
||
source_period = get_object_or_404(
|
||
PayrollPeriod.objects.select_for_update(of=("self",)),
|
||
pk=data["source_period_id"],
|
||
)
|
||
_validate_input_copy_periods(target_period, source_period)
|
||
if data["remember_configuration"]:
|
||
PayrollInputCopyConfiguration.objects.update_or_create(
|
||
owner=request.user,
|
||
defaults={
|
||
"component_codes": data["component_codes"],
|
||
"employee_scope": data["employee_scope"],
|
||
"employee_ids": data["employee_ids"],
|
||
"existing_mode": data["existing_mode"],
|
||
"auto_apply_on_period_creation": data[
|
||
"auto_apply_on_period_creation"
|
||
],
|
||
},
|
||
)
|
||
else:
|
||
PayrollInputCopyConfiguration.objects.filter(owner=request.user).delete()
|
||
result = _apply_input_copy(
|
||
target_period=target_period,
|
||
source_period=source_period,
|
||
actor=request.user,
|
||
data=data,
|
||
)
|
||
return Response(
|
||
result,
|
||
status=(
|
||
status.HTTP_200_OK
|
||
if result["idempotent_replay"]
|
||
else status.HTTP_201_CREATED
|
||
),
|
||
)
|
||
|
||
|
||
class DraftApprovalView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],)
|
||
service = None
|
||
queryset = None
|
||
read_serializer_class = None
|
||
|
||
def get_serializer_class(self):
|
||
return self.read_serializer_class or super().get_serializer_class()
|
||
|
||
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",
|
||
"created_by",
|
||
"approved_by",
|
||
).prefetch_related("employee__departments_links__department")
|
||
read_serializer_class = PayrollInputLineSerializer
|
||
|
||
|
||
class EmployeePayRateReviseView(PayrollAdminAPIView):
|
||
serializer_class = EmployeePayRateSerializer
|
||
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(of=("self",)).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):
|
||
serializer_class = PayrollWorkRecordSerializer
|
||
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(of=("self",)).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,
|
||
"target_points_overridden": source.target_points_overridden,
|
||
"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,
|
||
}
|
||
if "target_points" in command.validated_data:
|
||
values["target_points_overridden"] = True
|
||
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,
|
||
)
|