"""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 Q 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.access import has_payroll_permission, has_simple_admin_access from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.services import ( approve_input_line, approve_pay_rate, approve_period_drafts, approve_run, approve_work_record, bulk_set_pay_rate, bulk_set_point_rate, bulk_set_target_points, calculate_period, clear_component_input_lines, close_period, publish_run, pay_rates_relevant_to_period, return_run_for_correction, submit_run_for_review, ) from finance.payroll.reporting import ( build_payroll_employee_point_breakdown, build_payroll_period_table, ) from finance.payroll.attendance import ( apply_attendance_work_preview, build_attendance_work_preview, ) from finance.payroll.workbook_import import ( apply_workbook_import, build_workbook_preview, parse_mappings, ) from .admin_serializers import ( AttendanceWorkImportCommandSerializer, ApprovalCommandSerializer, BulkPayRateCommandSerializer, BulkPointRateCommandSerializer, BulkTargetPointsCommandSerializer, CalculatePayrollCommandSerializer, ClearComponentCellCommandSerializer, EmployeePayRateRevisionSerializer, EmployeePayRateSerializer, EmployeePayRateWriteSerializer, PayrollComponentSerializer, PayrollEmployeeMiniSerializer, PayrollInputLineSerializer, PayrollInputLineWriteSerializer, PayrollPeriodSerializer, PayrollPeriodWriteSerializer, PayrollRunSerializer, PayrollWorkRecordRevisionSerializer, PayrollWorkRecordSerializer, PayrollWorkRecordWriteSerializer, ReturnPayrollCommandSerializer, WorkbookWorkImportSerializer, WorkbookWorkPreviewSerializer, _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(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] allowed_permissions = OPERATIONAL_PERMISSIONS def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) 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) class PayrollAdminWorkspaceView(PayrollAdminAPIView): """One bounded bootstrap response for the native payroll screen.""" def get(self, request): permissions_payload = { key: has_payroll_permission(request.user, permission) for key, permission in PAYROLL_PERMISSIONS.items() } permissions_payload["full_access"] = has_simple_admin_access(request.user) 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 PayrollPeriodTableView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],) def get(self, request, pk): period = get_object_or_404( PayrollPeriod.objects.select_related("current_run"), pk=pk, ) return Response(build_payroll_period_table(period)) class PayrollPeriodPointBreakdownView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],) def get(self, request, pk, employee_id): period = get_object_or_404( PayrollPeriod.objects.select_related("current_run"), pk=pk, ) employee = get_object_or_404( Employee.objects.select_related("position").prefetch_related( "departments_links__department" ), pk=employee_id, ) return Response(build_payroll_employee_point_breakdown(period, employee)) class PayrollPeriodApproveDraftsView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],) def post(self, request, pk): if not PayrollPeriod.objects.filter(pk=pk).exists(): raise Http404 summary = approve_period_drafts(pk, actor=request.user) return Response({"period_id": pk, "summary": summary}) 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, ) 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 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) return pay_rates_relevant_to_period(period, queryset=queryset) 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"], } ) 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 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_id=command.validated_data["component_id"], actor=request.user, ) return Response(summary) 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(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): 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, ) 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 PayrollWorkbookWorkPreviewView(PayrollAdminAPIView): """Inspect a legacy schedule workbook without changing payroll data.""" allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) def post(self, request, pk): command = WorkbookWorkPreviewSerializer(data=request.data) command.is_valid(raise_exception=True) period = get_object_or_404(PayrollPeriod, pk=pk) uploaded = command.validated_data["file"] return Response( build_workbook_preview( period, payload=uploaded.read(), filename=uploaded.name, ) ) class PayrollWorkbookWorkImportView(PayrollAdminAPIView): """Apply a previously inspected schedule workbook atomically.""" allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) def post(self, request, pk): command = WorkbookWorkImportSerializer(data=request.data) command.is_valid(raise_exception=True) uploaded = command.validated_data["file"] result = apply_workbook_import( pk, actor=request.user, payload=uploaded.read(), filename=uploaded.name, mappings=parse_mappings(command.validated_data["mappings"]), mode=command.validated_data["mode"], expected_file_hash=command.validated_data["expected_file_hash"], expected_period_lock_version=command.validated_data[ "expected_period_lock_version" ], ) return Response(result) 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)