From 1295d24c86ea6913970e16b6d9b147cb0630c0bb Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 4 Aug 2026 16:40:23 +0300 Subject: [PATCH] feat(finance): expand payroll period workflows --- backend/api/v1/finance/admin_serializers.py | 92 +- backend/api/v1/finance/admin_views.py | 435 ++++++++- backend/api/v1/finance/serializers.py | 8 + backend/api/v1/finance/urls.py | 30 + backend/api/v1/procurement/permissions.py | 2 +- backend/eusrr_backend/settings.py | 4 + backend/finance/enums.py | 11 + .../0015_alter_payrollperiod_created_by.py | 25 + .../0016_payrollcellcommentthread.py | 33 + .../migrations/0017_payrollcarryover.py | 45 + backend/finance/models.py | 159 +++ backend/finance/payroll/__init__.py | 2 + backend/finance/payroll/carryovers.py | 253 +++++ backend/finance/payroll/reporting.py | 3 + backend/finance/payroll/services.py | 76 ++ backend/finance/tasks.py | 98 ++ backend/procurement/services.py | 8 +- .../procurement/test_procurement_requests.py | 13 +- .../tests/finance/test_payroll_admin_api.py | 394 ++++++++ .../tests/finance/test_payroll_carryovers.py | 242 +++++ backend/tests/finance/test_payroll_tasks.py | 98 ++ frontend/src/app/attendance/page.tsx | 2 +- frontend/src/app/procurement/page.tsx | 3 - .../finance/PayrollStatementDetailModal.tsx | 2 +- .../finance/admin/PayrollAdminTables.tsx | 13 +- .../finance/admin/PayrollAdminWorkspace.tsx | 273 +++++- .../finance/admin/PayrollCarryoversModal.tsx | 315 ++++++ .../admin/PayrollCellCommentsModal.tsx | 253 +++++ .../finance/admin/PayrollPeriodTable.tsx | 917 ++++++++++++++---- frontend/src/lib/api/finance.test.js | 56 ++ frontend/src/lib/api/finance.ts | 154 ++- frontend/src/lib/payroll-admin.test.js | 15 + frontend/src/lib/payroll-admin.ts | 38 + frontend/src/lib/payroll-columns.test.js | 28 + frontend/src/lib/payroll-columns.ts | 31 + 35 files changed, 3914 insertions(+), 217 deletions(-) create mode 100644 backend/finance/migrations/0015_alter_payrollperiod_created_by.py create mode 100644 backend/finance/migrations/0016_payrollcellcommentthread.py create mode 100644 backend/finance/migrations/0017_payrollcarryover.py create mode 100644 backend/finance/payroll/carryovers.py create mode 100644 backend/finance/tasks.py create mode 100644 backend/tests/finance/test_payroll_carryovers.py create mode 100644 backend/tests/finance/test_payroll_tasks.py create mode 100644 frontend/src/components/finance/admin/PayrollCarryoversModal.tsx create mode 100644 frontend/src/components/finance/admin/PayrollCellCommentsModal.tsx create mode 100644 frontend/src/lib/payroll-columns.test.js create mode 100644 frontend/src/lib/payroll-columns.ts diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py index 4d1570de..b308696d 100644 --- a/backend/api/v1/finance/admin_serializers.py +++ b/backend/api/v1/finance/admin_serializers.py @@ -13,9 +13,16 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from rest_framework import serializers -from finance.enums import ApprovalStatus, InputSource +from api.v1.employees.serializers import EmployeeBriefSerializer +from finance.enums import ( + ApprovalStatus, + InputSource, + PayrollCarryoverDirection, + PayrollCarryoverStatus, +) from finance.models import ( EmployeePayRate, + PayrollCarryover, PayrollComponent, PayrollInputLine, PayrollPeriod, @@ -88,6 +95,17 @@ class PayrollUserRefSerializer(serializers.ModelSerializer): return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}" +class PayrollCellCommentSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + author = EmployeeBriefSerializer(read_only=True) + text = serializers.CharField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + + +class PayrollCellCommentWriteSerializer(serializers.Serializer): + text = serializers.CharField(max_length=10000, trim_whitespace=True) + + class PayrollComponentSerializer(serializers.ModelSerializer): kind_label = serializers.CharField(source="get_kind_display", read_only=True) @@ -132,6 +150,69 @@ class PayrollPeriodSerializer(serializers.ModelSerializer): read_only_fields = fields +class PayrollCarryoverPeriodSerializer(serializers.ModelSerializer): + class Meta: + model = PayrollPeriod + fields = ["id", "code", "name", "date_from", "date_to", "currency"] + read_only_fields = fields + + +class PayrollCarryoverSerializer(serializers.ModelSerializer): + employee = PayrollEmployeeMiniSerializer(read_only=True) + source_period = PayrollCarryoverPeriodSerializer(read_only=True) + target_period = PayrollCarryoverPeriodSerializer(read_only=True) + direction_label = serializers.CharField( + source="get_direction_display", + read_only=True, + ) + status_label = serializers.CharField(source="get_status_display", read_only=True) + created_by = PayrollUserRefSerializer(read_only=True) + generated_input_line_id = serializers.IntegerField(read_only=True, allow_null=True) + can_cancel = serializers.SerializerMethodField() + + class Meta: + model = PayrollCarryover + fields = [ + "id", + "source_period", + "target_period", + "employee", + "direction", + "direction_label", + "amount", + "reason", + "status", + "status_label", + "generated_input_line_id", + "created_by", + "created_at", + "transferred_at", + "cancelled_at", + "can_cancel", + ] + read_only_fields = fields + + def get_can_cancel(self, obj): + return obj.status != PayrollCarryoverStatus.CANCELLED and ( + obj.generated_input_line_id is None + or obj.generated_input_line.status == ApprovalStatus.DRAFT + ) + + +class PayrollCarryoverWriteSerializer(serializers.Serializer): + employee_id = serializers.PrimaryKeyRelatedField( + source="employee", + queryset=Employee.objects.all(), + ) + direction = serializers.ChoiceField(choices=PayrollCarryoverDirection.choices) + amount = serializers.DecimalField( + max_digits=19, + decimal_places=2, + min_value=Decimal("0.01"), + ) + reason = serializers.CharField(allow_blank=False, trim_whitespace=True) + + class PayrollPeriodWriteSerializer(serializers.ModelSerializer): expected_lock_version = serializers.IntegerField( min_value=0, @@ -525,6 +606,8 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): component = PayrollComponentSerializer(read_only=True) component_id = serializers.IntegerField(read_only=True) relates_to_period_id = serializers.IntegerField(read_only=True, allow_null=True) + relates_to_period = PayrollCarryoverPeriodSerializer(read_only=True) + is_automatic_carryover = serializers.SerializerMethodField() status_label = serializers.CharField(source="get_status_display", read_only=True) created_by_id = serializers.IntegerField(read_only=True) approved_by_id = serializers.IntegerField(read_only=True, allow_null=True) @@ -543,6 +626,8 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): "component", "amount", "relates_to_period_id", + "relates_to_period", + "is_automatic_carryover", "reason", "status", "status_label", @@ -559,6 +644,11 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): ] read_only_fields = fields + def get_is_automatic_carryover(self, obj): + return obj.source == InputSource.API and obj.source_ref.startswith( + "payroll-carryover:" + ) + class PayrollInputLineWriteSerializer( DraftWriteSerializerMixin, diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 8fbf145e..853022f7 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -7,10 +7,14 @@ access to payroll data. from __future__ import annotations +from collections import defaultdict +from decimal import Decimal + +from django.contrib.contenttypes.models import ContentType 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.db.models import Count, Max, Q from django.http import Http404 from django.shortcuts import get_object_or_404 from rest_framework import permissions, status @@ -18,10 +22,19 @@ 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 communications import comments_helpers +from communications.models import Message +from finance.enums import ( + ApprovalStatus, + PayrollCarryoverStatus, + PayrollPeriodStatus, + PayrollRunStatus, +) from finance.models import ( EmployeePayRate, PayrollAuditEvent, + PayrollCarryover, + PayrollCellCommentThread, PayrollComponent, PayrollInputLine, PayrollPeriod, @@ -35,6 +48,11 @@ from finance.payroll.config import ( ruleset_not_effective_message, ruleset_period_details, ) +from finance.payroll.carryovers import ( + cancel_carryover, + create_carryover, + materialize_pending_carryovers, +) from finance.payroll.access import has_payroll_permission, has_simple_admin_access from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.services import ( @@ -49,12 +67,14 @@ from finance.payroll.services import ( calculate_period, clear_component_input_lines, close_period, + complete_period_workflow, publish_run, pay_rates_relevant_to_period, return_run_for_correction, submit_run_for_review, ) from finance.payroll.reporting import ( + SYSTEM_COMPONENT_CODES, build_payroll_employee_point_breakdown, build_payroll_period_table, ) @@ -80,6 +100,10 @@ from .admin_serializers import ( EmployeePayRateSerializer, EmployeePayRateWriteSerializer, PayrollComponentSerializer, + PayrollCellCommentSerializer, + PayrollCellCommentWriteSerializer, + PayrollCarryoverSerializer, + PayrollCarryoverWriteSerializer, PayrollEmployeeMiniSerializer, PayrollInputLineSerializer, PayrollInputLineWriteSerializer, @@ -125,6 +149,28 @@ RUN_VIEW_PERMISSIONS = ( 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( @@ -164,6 +210,119 @@ def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None): ) +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 PayrollComponent.objects.filter( + code=component_code, + is_active=True, + ).exists() + ): + return column_id + raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."}) + + +def _cell_comment_summaries(period): + threads = list( + PayrollCellCommentThread.objects.filter(period=period).values( + "id", + "subject_employee_id", + "column_id", + ) + ) + if not threads: + return [] + thread_by_id = {thread["id"]: thread for thread in threads} + content_type = ContentType.objects.get_for_model(PayrollCellCommentThread) + totals = ( + Message.objects.filter( + chat__type="comments", + chat__context_content_type=content_type, + chat__context_object_id__in=thread_by_id, + is_deleted=False, + ) + .values("chat__context_object_id") + .annotate(count=Count("id"), latest_at=Max("created_at")) + ) + result = [] + for total in totals: + thread = thread_by_id[int(total["chat__context_object_id"])] + result.append( + { + "employee_id": thread["subject_employee_id"], + "column_id": thread["column_id"], + "count": total["count"], + "latest_at": total["latest_at"], + } + ) + return result + + +def _serialize_cell_comment(message, request): + return PayrollCellCommentSerializer( + { + "id": message.id, + "author": message.author, + "text": message.content, + "created_at": message.created_at, + }, + context={"request": request}, + ).data + + +def _carryover_summary(period): + outgoing = PayrollCarryover.objects.filter(source_period=period).exclude( + status=PayrollCarryoverStatus.CANCELLED + ) + incoming = PayrollCarryover.objects.filter(target_period=period).exclude( + status=PayrollCarryoverStatus.CANCELLED + ) + return { + "outgoing": outgoing.count(), + "incoming": incoming.count(), + "pending": outgoing.filter(status=PayrollCarryoverStatus.PENDING).count(), + } + + +def _carryover_employee_summaries(period): + summaries = defaultdict( + lambda: { + "count": 0, + "company_owes": Decimal("0.00"), + "employee_owes": Decimal("0.00"), + "pending": 0, + } + ) + records = ( + PayrollCarryover.objects.filter( + Q(source_period=period) | Q(target_period=period) + ) + .exclude(status=PayrollCarryoverStatus.CANCELLED) + .values("employee_id", "direction", "amount", "status") + ) + for record in records: + summary = summaries[record["employee_id"]] + summary["count"] += 1 + summary[record["direction"]] += record["amount"] + if record["status"] == PayrollCarryoverStatus.PENDING: + summary["pending"] += 1 + return [ + { + "employee_id": employee_id, + "count": summary["count"], + "company_owes": str(summary["company_owes"]), + "employee_owes": str(summary["employee_owes"]), + "pending": summary["pending"], + } + for employee_id, summary in summaries.items() + ] + + def _lock_period_accepting_draft_edits(period_id): period = get_object_or_404( PayrollPeriod.objects.select_for_update(of=("self",)).select_related( @@ -346,16 +505,33 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): period=selected_period, status=ApprovalStatus.APPROVED, ).count() - draft_input_count = PayrollInputLine.objects.filter( + draft_inputs = PayrollInputLine.objects.filter( period=selected_period, status=ApprovalStatus.DRAFT, - ).count() - pending_rate_count = EmployeePayRate.objects.filter( + ) + draft_input_count = draft_inputs.count() + draft_input_employee_ids = list( + draft_inputs.order_by() + .values_list("employee_id", flat=True) + .distinct() + ) + pending_rates = 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() + ) + pending_rate_count = pending_rates.count() + pending_rate_employee_ids = list( + pending_rates.order_by() + .values_list("employee_id", flat=True) + .distinct() + ) + pending_rate_at_start_employee_id_set = set( + pending_rates.filter( + effective_from__lte=selected_period.date_from, + ).values_list("employee_id", flat=True) + ) blockers = [] active_rules = build_rules() if not rules_cover_period( @@ -374,40 +550,74 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): ), } ) - if not approved_employee_ids: + if not roster_employee_ids: blockers.append( { "code": "NO_APPROVED_WORK_RECORDS", - "message": "Нет утверждённого состава сотрудников за период.", + "message": ( + "В разделе «Выработка» нет ни одной записи за " + f"{selected_period.date_from:%d.%m.%Y}–" + f"{selected_period.date_to:%d.%m.%Y}; состав " + "периода пуст." + ), } ) - blockers.extend( - { - "code": "MISSING_APPROVED_RATE", - "message": "Нет утверждённой ставки на начало периода.", - "employee_id": employee_id, - } - for employee_id in missing_rate_employee_ids[:20] - ) - if draft_work.exists(): + missing_rate_without_draft = [ + employee_id + for employee_id in missing_rate_employee_ids + if employee_id not in pending_rate_at_start_employee_id_set + ] + if missing_rate_without_draft: + blockers.append( + { + "code": "MISSING_APPROVED_RATE", + "message": ( + "Сотрудников без утверждённой ставки на " + f"{selected_period.date_from:%d.%m.%Y}: " + f"{len(missing_rate_without_draft)}." + ), + "count": len(missing_rate_without_draft), + "employee_ids": missing_rate_without_draft[:20], + "employee_count": len(missing_rate_without_draft), + } + ) + if draft_work_employee_ids: blockers.append( { "code": "DRAFT_WORK_RECORDS", - "message": "Остались неутверждённые показатели выработки.", + "message": ( + "Записей выработки в статусе «Черновик»: " + f"{len(draft_work_employee_ids)}." + ), + "count": len(draft_work_employee_ids), + "employee_ids": draft_work_employee_ids[:20], + "employee_count": len(draft_work_employee_ids), } ) if draft_input_count: blockers.append( { "code": "DRAFT_INPUT_LINES", - "message": "Остались неутверждённые начисления или удержания.", + "message": ( + "Начислений или удержаний в статусе «Черновик»: " + f"{draft_input_count}." + ), + "count": draft_input_count, + "employee_ids": draft_input_employee_ids[:20], + "employee_count": len(draft_input_employee_ids), } ) if pending_rate_count: blockers.append( { "code": "DRAFT_PAY_RATES", - "message": "Остались неутверждённые ставки сотрудников.", + "message": ( + "Ставок сотрудников в статусе «Черновик»: " + f"{pending_rate_count}." + ), + "count": pending_rate_count, + "employee_ids": pending_rate_employee_ids[:20], + "employee_count": len(pending_rate_employee_ids), } ) if selected_period.status == PayrollPeriodStatus.CLOSED: @@ -486,6 +696,11 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): ).data, "summary": summary, "pending_approvals": pending_approvals, + "carryovers": ( + _carryover_summary(selected_period) + if selected_period is not None + else {"outgoing": 0, "incoming": 0, "pending": 0} + ), } ) @@ -543,6 +758,7 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): instance=period, period=period, ) + materialize_pending_carryovers(period) return Response( PayrollPeriodSerializer(period).data, status=status.HTTP_201_CREATED, @@ -611,7 +827,156 @@ class PayrollPeriodTableView(PayrollAdminAPIView): PayrollPeriod.objects.select_related("current_run"), pk=pk, ) - return Response(build_payroll_period_table(period)) + payload = build_payroll_period_table(period) + payload["cell_comments"] = _cell_comment_summaries(period) + payload["carryovers"] = _carryover_employee_summaries(period) + return Response(payload) + + +class PayrollPeriodCarryoverListCreateView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],) + + def get(self, request, pk): + period = get_object_or_404(PayrollPeriod, pk=pk) + queryset = PayrollCarryover.objects.select_related( + "source_period", + "target_period", + "employee", + "employee__position", + "created_by", + "generated_input_line", + ) + outgoing = queryset.filter(source_period=period) + incoming = queryset.filter(target_period=period) + return Response( + { + "period_id": period.pk, + "summary": _carryover_summary(period), + "outgoing": PayrollCarryoverSerializer(outgoing, many=True).data, + "incoming": PayrollCarryoverSerializer(incoming, many=True).data, + } + ) + + def post(self, request, pk): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + period = get_object_or_404(PayrollPeriod, pk=pk) + serializer = PayrollCarryoverWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + carryover = create_carryover( + source_period=period, + actor=request.user, + **serializer.validated_data, + ) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + carryover = PayrollCarryover.objects.select_related( + "source_period", + "target_period", + "employee", + "created_by", + "generated_input_line", + ).get(pk=carryover.pk) + return Response( + PayrollCarryoverSerializer(carryover).data, + status=status.HTTP_201_CREATED, + ) + + +class PayrollCarryoverDetailView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],) + + def delete(self, request, pk): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + if not PayrollCarryover.objects.filter(pk=pk).exists(): + raise Http404 + carryover = cancel_carryover(pk, actor=request.user) + carryover = PayrollCarryover.objects.select_related( + "source_period", + "target_period", + "employee", + "created_by", + "generated_input_line", + ).get(pk=carryover.pk) + return Response(PayrollCarryoverSerializer(carryover).data) + + +class PayrollCellCommentsView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],) + + def get_context(self, pk, employee_id, column_id): + period = get_object_or_404(PayrollPeriod, pk=pk) + employee = get_object_or_404(Employee, pk=employee_id) + return period, employee, _validate_payroll_table_column(column_id) + + def get(self, request, pk, employee_id, column_id): + period, employee, column_id = self.get_context( + pk, + employee_id, + column_id, + ) + thread = PayrollCellCommentThread.objects.filter( + period=period, + subject_employee=employee, + column_id=column_id, + ).first() + if thread is None: + return Response([]) + messages = comments_helpers.get_comments(thread) + return Response( + [_serialize_cell_comment(message, request) for message in messages] + ) + + @transaction.atomic + def post(self, request, pk, employee_id, column_id): + period, employee, column_id = self.get_context( + pk, + employee_id, + column_id, + ) + serializer = PayrollCellCommentWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + thread, _ = PayrollCellCommentThread.objects.get_or_create( + period=period, + subject_employee=employee, + column_id=column_id, + ) + message = comments_helpers.create_comment( + obj=thread, + author=request.user, + content=serializer.validated_data["text"], + ) + return Response( + _serialize_cell_comment(message, request), + status=status.HTTP_201_CREATED, + ) + + +class PayrollCellCommentDetailView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],) + + def delete(self, request, pk, employee_id, column_id, comment_id): + period = get_object_or_404(PayrollPeriod, pk=pk) + employee = get_object_or_404(Employee, pk=employee_id) + column_id = _validate_payroll_table_column(column_id) + thread = get_object_or_404( + PayrollCellCommentThread, + period=period, + subject_employee=employee, + column_id=column_id, + ) + chat = comments_helpers.get_comments_chat_if_exists(thread) + if chat is None: + raise Http404 + message = get_object_or_404(Message, pk=comment_id, chat=chat, is_deleted=False) + if message.author_id != request.user.id: + raise PermissionDenied("Удалить комментарий может только его автор.") + comments_helpers.delete_comment( + message=message, + deleted_by=request.user, + soft_delete=True, + ) + return Response(status=status.HTTP_204_NO_CONTENT) class PayrollPeriodPointBreakdownView(PayrollAdminAPIView): @@ -1337,6 +1702,34 @@ class PayrollPeriodCalculateView(PayrollAdminAPIView): return Response(self.run_payload(request, run), status=status.HTTP_201_CREATED) +class PayrollPeriodCompleteWorkflowView(PayrollAdminAPIView): + """Complete calculation, review, approval, publication and closing at once.""" + + allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],) + + def post(self, request, pk): + if not has_simple_admin_access(request.user): + _permission_error("finance.full_payroll_admin") + command = CalculatePayrollCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + if not PayrollPeriod.objects.filter(pk=pk).exists(): + raise Http404 + result = complete_period_workflow( + pk, + actor=request.user, + expected_lock_version=command.validated_data["expected_lock_version"], + idempotency_key=command.validated_data.get("idempotency_key"), + recalculation_reason=command.validated_data["recalculation_reason"], + ) + return Response( + { + "period": PayrollPeriodSerializer(result["period"]).data, + "run": self.run_payload(request, result["run"]), + "completed_steps": result["completed_steps"], + } + ) + + class PayrollRunDetailView(PayrollAdminAPIView): allowed_permissions = RUN_VIEW_PERMISSIONS diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py index ff560af5..9529e542 100644 --- a/backend/api/v1/finance/serializers.py +++ b/backend/api/v1/finance/serializers.py @@ -12,6 +12,8 @@ from finance.models import ( class PayrollStatementLineSerializer(serializers.ModelSerializer): + is_automatic_carryover = serializers.SerializerMethodField() + class Meta: model = PayrollStatementLine fields = [ @@ -22,9 +24,15 @@ class PayrollStatementLineSerializer(serializers.ModelSerializer): "source_period_from", "source_period_to", "is_retro", + "is_automatic_carryover", "calculated", ] + def get_is_automatic_carryover(self, obj): + return obj.source_ref.startswith("api:payroll-carryover:") or obj.source_ref.startswith( + "payroll-carryover:" + ) + class PayrollStatementAcknowledgementSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 3ddc6ea4..5d564f6f 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -14,7 +14,10 @@ from .admin_views import ( PayrollBulkPayRateView, PayrollBulkPointRateView, PayrollBulkTargetPointsView, + PayrollCarryoverDetailView, PayrollComponentCellClearView, + PayrollCellCommentDetailView, + PayrollCellCommentsView, PayrollAdminComponentListView, PayrollAdminEmployeeListView, PayrollAdminPeriodDetailView, @@ -25,6 +28,8 @@ from .admin_views import ( PayrollInputLineDetailView, PayrollInputLineListCreateView, PayrollPeriodCalculateView, + PayrollPeriodCarryoverListCreateView, + PayrollPeriodCompleteWorkflowView, PayrollPeriodApproveDraftsView, PayrollPeriodCloseView, PayrollPeriodPointBreakdownView, @@ -75,6 +80,11 @@ urlpatterns = [ PayrollPeriodCalculateView.as_view(), name="admin-period-calculate", ), + path( + "payroll/admin/periods//complete-workflow/", + PayrollPeriodCompleteWorkflowView.as_view(), + name="admin-period-complete-workflow", + ), path( "payroll/admin/periods//approve-drafts/", PayrollPeriodApproveDraftsView.as_view(), @@ -85,6 +95,26 @@ urlpatterns = [ PayrollPeriodTableView.as_view(), name="admin-period-table", ), + path( + "payroll/admin/periods//carryovers/", + PayrollPeriodCarryoverListCreateView.as_view(), + name="admin-period-carryovers", + ), + path( + "payroll/admin/carryovers//", + PayrollCarryoverDetailView.as_view(), + name="admin-carryover-detail", + ), + path( + "payroll/admin/periods//employees//cells//comments/", + PayrollCellCommentsView.as_view(), + name="admin-cell-comments", + ), + path( + "payroll/admin/periods//employees//cells//comments//", + PayrollCellCommentDetailView.as_view(), + name="admin-cell-comment-detail", + ), path( "payroll/admin/periods//employees//point-breakdown/", PayrollPeriodPointBreakdownView.as_view(), diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index d6cb2fd3..06e51648 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -200,7 +200,7 @@ class CanManageProcurementRequest(permissions.BasePermission): dept_id = obj.department_id # Submit дополнительно валидируется resolver-ом: только сотрудник - # отдела-исполнителя, не автор заявки. + # отдела-исполнителя. Автор допускается для внутренней заявки отдела. if action == "submit": return ProcurementApprovalResolver.user_can_submit_for_approval( user, diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index f4d47ac3..eeb11bed 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -703,6 +703,10 @@ CELERYD_PIDFILE = os.path.join(tempfile.gettempdir(), "celery-worker.pid") # Расписание периодических задач CELERY_BEAT_SCHEDULE = { + "open-monthly-payroll-period": { + "task": "finance.tasks.open_monthly_payroll_period", + "schedule": crontab(day_of_month="1", hour=0, minute=5), + }, "cleanup-orphaned-attachments": { "task": "communications.tasks.cleanup_orphaned_attachments", "schedule": 3600.0, # Каждый час diff --git a/backend/finance/enums.py b/backend/finance/enums.py index 7397a4f4..dc508f8c 100644 --- a/backend/finance/enums.py +++ b/backend/finance/enums.py @@ -40,3 +40,14 @@ class PayrollRunStatus(models.TextChoices): PUBLISHED = "published", "Опубликован" RETURNED = "returned", "Возвращён на исправление" SUPERSEDED = "superseded", "Заменён новой ревизией" + + +class PayrollCarryoverDirection(models.TextChoices): + COMPANY_OWES = "company_owes", "Доплата сотруднику" + EMPLOYEE_OWES = "employee_owes", "Долг сотрудника" + + +class PayrollCarryoverStatus(models.TextChoices): + PENDING = "pending", "Ожидает следующего периода" + TRANSFERRED = "transferred", "Перенесено" + CANCELLED = "cancelled", "Отменено" diff --git a/backend/finance/migrations/0015_alter_payrollperiod_created_by.py b/backend/finance/migrations/0015_alter_payrollperiod_created_by.py new file mode 100644 index 00000000..2a5acc39 --- /dev/null +++ b/backend/finance/migrations/0015_alter_payrollperiod_created_by.py @@ -0,0 +1,25 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0014_pay_rate_automatic_excess_price"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="payrollperiod", + name="created_by", + field=models.ForeignKey( + blank=True, + help_text="Не заполнено для периодов, созданных автоматически.", + null=True, + on_delete=models.PROTECT, + related_name="created_payroll_periods", + to=settings.AUTH_USER_MODEL, + verbose_name="Создал", + ), + ), + ] diff --git a/backend/finance/migrations/0016_payrollcellcommentthread.py b/backend/finance/migrations/0016_payrollcellcommentthread.py new file mode 100644 index 00000000..9a0ec253 --- /dev/null +++ b/backend/finance/migrations/0016_payrollcellcommentthread.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.4 on 2026-08-04 10:46 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0015_alter_payrollperiod_created_by'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PayrollCellCommentThread', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('column_id', models.CharField(max_length=80, validators=[django.core.validators.RegexValidator(message='Некорректный идентификатор колонки расчётной таблицы.', regex='^[a-z][a-z0-9_]*(?::[A-Z][A-Z0-9_.-]{0,63})?$')], verbose_name='Колонка')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cell_comment_threads', to='finance.payrollperiod', verbose_name='Расчётный период')), + ('subject_employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_cell_comment_threads', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник в строке')), + ], + options={ + 'verbose_name': 'Обсуждение ячейки расчёта', + 'verbose_name_plural': 'Обсуждения ячеек расчёта', + 'ordering': ['period_id', 'subject_employee_id', 'column_id'], + 'constraints': [models.UniqueConstraint(fields=('period', 'subject_employee', 'column_id'), name='pay_cell_comment_thread_unique')], + }, + ), + ] diff --git a/backend/finance/migrations/0017_payrollcarryover.py b/backend/finance/migrations/0017_payrollcarryover.py new file mode 100644 index 00000000..b6505c58 --- /dev/null +++ b/backend/finance/migrations/0017_payrollcarryover.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.4 on 2026-08-04 11:09 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0016_payrollcellcommentthread'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PayrollCarryover', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('direction', models.CharField(choices=[('company_owes', 'Доплата сотруднику'), ('employee_owes', 'Долг сотрудника')], max_length=20, verbose_name='Вид остатка')), + ('amount', models.DecimalField(decimal_places=2, max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Сумма')), + ('reason', models.TextField(verbose_name='Основание')), + ('status', models.CharField(choices=[('pending', 'Ожидает следующего периода'), ('transferred', 'Перенесено'), ('cancelled', 'Отменено')], default='pending', max_length=16, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')), + ('transferred_at', models.DateTimeField(blank=True, null=True, verbose_name='Перенесён')), + ('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Отменён')), + ('cancelled_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cancelled_payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Отменил')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ('generated_input_line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_from_carryover', to='finance.payrollinputline', verbose_name='Созданная операция')), + ('source_period', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_carryovers', to='finance.payrollperiod', verbose_name='Исходный период')), + ('target_period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='incoming_carryovers', to='finance.payrollperiod', verbose_name='Период переноса')), + ], + options={ + 'verbose_name': 'Долг или доплата', + 'verbose_name_plural': 'Долги и доплаты', + 'ordering': ['-created_at', '-id'], + 'indexes': [models.Index(fields=['status', 'source_period'], name='pay_carry_status_source_idx'), models.Index(fields=['target_period', 'employee'], name='pay_carry_target_employee_idx')], + 'constraints': [models.CheckConstraint(condition=models.Q(('amount__gt', 0)), name='pay_carry_amount_positive')], + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index e915bf67..686d5c16 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -18,6 +18,8 @@ from django.db.models import F, Q from .enums import ( ApprovalStatus, InputSource, + PayrollCarryoverDirection, + PayrollCarryoverStatus, PayrollComponentKind, PayrollPeriodStatus, PayrollRunStatus, @@ -31,6 +33,10 @@ component_code_validator = RegexValidator( regex=r"^[A-Z][A-Z0-9_.-]{0,63}$", message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-", ) +payroll_cell_column_validator = RegexValidator( + regex=r"^[a-z][a-z0-9_]*(?::[A-Z][A-Z0-9_.-]{0,63})?$", + message="Некорректный идентификатор колонки расчётной таблицы.", +) DEFAULT_DAILY_TARGET_POINTS = Decimal("5") @@ -67,8 +73,11 @@ class PayrollPeriod(models.Model): created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, + null=True, + blank=True, related_name="created_payroll_periods", verbose_name="Создал", + help_text="Не заполнено для периодов, созданных автоматически.", ) created_at = models.DateTimeField("Создан", auto_now_add=True) updated_at = models.DateTimeField("Обновлён", auto_now=True) @@ -170,6 +179,156 @@ class PayrollPeriod(models.Model): return super().save(*args, **kwargs) +class PayrollCellCommentThread(models.Model): + """Stable address of one employee cell used by generic comments.""" + + period = models.ForeignKey( + PayrollPeriod, + on_delete=models.CASCADE, + related_name="cell_comment_threads", + verbose_name="Расчётный период", + ) + subject_employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_cell_comment_threads", + verbose_name="Сотрудник в строке", + ) + column_id = models.CharField( + "Колонка", + max_length=80, + validators=[payroll_cell_column_validator], + ) + created_at = models.DateTimeField("Создан", auto_now_add=True) + + class Meta: + verbose_name = "Обсуждение ячейки расчёта" + verbose_name_plural = "Обсуждения ячеек расчёта" + ordering = ["period_id", "subject_employee_id", "column_id"] + constraints = [ + models.UniqueConstraint( + fields=["period", "subject_employee", "column_id"], + name="pay_cell_comment_thread_unique", + ) + ] + + def __str__(self): + return ( + f"{self.period.code}: сотрудник #{self.subject_employee_id}, " + f"колонка {self.column_id}" + ) + + +class PayrollCarryover(models.Model): + """Explicit debt or additional payment transferred to the next period.""" + + source_period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + related_name="outgoing_carryovers", + verbose_name="Исходный период", + ) + target_period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="incoming_carryovers", + verbose_name="Период переноса", + ) + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_carryovers", + verbose_name="Сотрудник", + ) + direction = models.CharField( + "Вид остатка", + max_length=20, + choices=PayrollCarryoverDirection.choices, + ) + amount = models.DecimalField( + "Сумма", + max_digits=19, + decimal_places=2, + validators=[MinValueValidator(Decimal("0.01"))], + ) + reason = models.TextField("Основание") + status = models.CharField( + "Статус", + max_length=16, + choices=PayrollCarryoverStatus.choices, + default=PayrollCarryoverStatus.PENDING, + ) + generated_input_line = models.OneToOneField( + "PayrollInputLine", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="generated_from_carryover", + verbose_name="Созданная операция", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_payroll_carryovers", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создан", auto_now_add=True) + updated_at = models.DateTimeField("Обновлён", auto_now=True) + transferred_at = models.DateTimeField("Перенесён", null=True, blank=True) + cancelled_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="cancelled_payroll_carryovers", + verbose_name="Отменил", + ) + cancelled_at = models.DateTimeField("Отменён", null=True, blank=True) + + class Meta: + verbose_name = "Долг или доплата" + verbose_name_plural = "Долги и доплаты" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index( + fields=["status", "source_period"], + name="pay_carry_status_source_idx", + ), + models.Index( + fields=["target_period", "employee"], + name="pay_carry_target_employee_idx", + ), + ] + constraints = [ + models.CheckConstraint( + condition=Q(amount__gt=0), + name="pay_carry_amount_positive", + ) + ] + + def clean(self): + super().clean() + if not self.reason.strip(): + raise ValidationError({"reason": "Укажите основание переноса."}) + if self.target_period_id: + if self.target_period.date_from <= self.source_period.date_to: + raise ValidationError( + {"target_period": "Перенос возможен только в следующий период."} + ) + if self.target_period.currency != self.source_period.currency: + raise ValidationError( + {"target_period": "Валюты исходного и целевого периодов различаются."} + ) + + def __str__(self): + return ( + f"{self.get_direction_display()}: {self.employee_id}, " + f"{self.amount} {self.source_period.currency}" + ) + + class DraftVersionedModel(models.Model): """Optimistic version for maker-checker records edited as drafts.""" diff --git a/backend/finance/payroll/__init__.py b/backend/finance/payroll/__init__.py index 14ef6e96..d4c0625b 100644 --- a/backend/finance/payroll/__init__.py +++ b/backend/finance/payroll/__init__.py @@ -10,6 +10,7 @@ from .services import ( clear_component_input_lines, bulk_set_target_points, close_period, + complete_period_workflow, publish_run, return_run_for_correction, submit_run_for_review, @@ -25,6 +26,7 @@ __all__ = [ "clear_component_input_lines", "bulk_set_target_points", "close_period", + "complete_period_workflow", "publish_run", "return_run_for_correction", "submit_run_for_review", diff --git a/backend/finance/payroll/carryovers.py b/backend/finance/payroll/carryovers.py new file mode 100644 index 00000000..96465218 --- /dev/null +++ b/backend/finance/payroll/carryovers.py @@ -0,0 +1,253 @@ +"""Explicit payroll debts and additional payments carried to the next period.""" + +from __future__ import annotations + +from decimal import Decimal + +from django.db import IntegrityError, transaction +from django.utils import timezone + +from finance.enums import ( + ApprovalStatus, + InputSource, + PayrollCarryoverDirection, + PayrollCarryoverStatus, + PayrollPeriodStatus, +) +from finance.models import ( + PayrollAuditEvent, + PayrollCarryover, + PayrollComponent, + PayrollInputLine, + PayrollPeriod, +) +from finance.payroll.exceptions import PayrollOperationError + +CARRYOVER_COMPONENT_CODES = { + PayrollCarryoverDirection.COMPANY_OWES: "CORRECTION_CREDIT", + PayrollCarryoverDirection.EMPLOYEE_OWES: "DEDUCTION", +} +CARRYOVER_SOURCE_PREFIX = "payroll-carryover:" + + +def _operation_error(code: str, message: str, **details): + raise PayrollOperationError(code, message, details=details) + + +def _audit(*, actor, action, instance, period, metadata=None): + PayrollAuditEvent.objects.create( + actor=actor, + action=action, + object_type=instance._meta.label_lower, + object_id=str(instance.pk), + period=period, + metadata={"channel": "carryover", **(metadata or {})}, + ) + + +def _next_period(source_period: PayrollPeriod): + return ( + PayrollPeriod.objects.filter(date_from__gt=source_period.date_to) + .order_by("date_from", "pk") + .first() + ) + + +def _target_accepts_transfer(target_period: PayrollPeriod) -> bool: + return ( + target_period.status == PayrollPeriodStatus.OPEN + and target_period.current_run_id is None + ) + + +def _materialize_one(carryover: PayrollCarryover, target_period: PayrollPeriod): + if carryover.status != PayrollCarryoverStatus.PENDING: + return carryover + if not _target_accepts_transfer(target_period): + return carryover + if target_period.currency != carryover.source_period.currency: + _operation_error( + "CARRYOVER_CURRENCY_MISMATCH", + "Валюта следующего периода не совпадает с исходным периодом.", + carryover_id=carryover.pk, + target_period_id=target_period.pk, + ) + + component_code = CARRYOVER_COMPONENT_CODES[carryover.direction] + component = PayrollComponent.objects.select_for_update().filter( + code=component_code, + is_active=True, + ).first() + if component is None: + _operation_error( + "CARRYOVER_COMPONENT_MISSING", + "Не найден активный вид операции для автоматического переноса.", + component_code=component_code, + ) + + source_ref = f"{CARRYOVER_SOURCE_PREFIX}{carryover.pk}" + line = PayrollInputLine.objects.filter( + source=InputSource.API, + source_ref=source_ref, + ).first() + if line is None: + line = PayrollInputLine( + period=target_period, + employee=carryover.employee, + component=component, + amount=carryover.amount, + relates_to_period=carryover.source_period, + reason=( + f"Автоперенос из периода {carryover.source_period.name or carryover.source_period.code}. " + f"{carryover.reason.strip()}" + ), + status=ApprovalStatus.DRAFT, + source=InputSource.API, + source_ref=source_ref, + created_by=carryover.created_by, + ) + line.full_clean() + try: + line.save(force_insert=True) + except IntegrityError: + line = PayrollInputLine.objects.get( + source=InputSource.API, + source_ref=source_ref, + ) + + carryover.target_period = target_period + carryover.generated_input_line = line + carryover.status = PayrollCarryoverStatus.TRANSFERRED + carryover.transferred_at = timezone.now() + carryover.full_clean() + carryover.save( + update_fields=[ + "target_period", + "generated_input_line", + "status", + "transferred_at", + "updated_at", + ] + ) + _audit( + actor=None, + action="payroll.carryover_transferred", + instance=carryover, + period=target_period, + metadata={ + "source_period_id": carryover.source_period_id, + "input_line_id": line.pk, + "direction": carryover.direction, + }, + ) + return carryover + + +@transaction.atomic +def materialize_pending_carryovers(target_period: PayrollPeriod): + """Create draft input lines whose immediate next period is target_period.""" + + target_period = PayrollPeriod.objects.select_for_update().get(pk=target_period.pk) + if not _target_accepts_transfer(target_period): + return [] + pending = list( + PayrollCarryover.objects.select_for_update() + .select_related("source_period", "employee", "created_by") + .filter( + status=PayrollCarryoverStatus.PENDING, + source_period__date_to__lt=target_period.date_from, + ) + .order_by("source_period__date_to", "pk") + ) + transferred = [] + for carryover in pending: + next_period = _next_period(carryover.source_period) + if next_period is None or next_period.pk != target_period.pk: + continue + transferred.append(_materialize_one(carryover, target_period)) + return transferred + + +@transaction.atomic +def create_carryover( + *, + source_period: PayrollPeriod, + employee, + direction: str, + amount: Decimal, + reason: str, + actor, +): + source_period = PayrollPeriod.objects.select_for_update().get(pk=source_period.pk) + if source_period.status != PayrollPeriodStatus.CLOSED: + _operation_error( + "CARRYOVER_SOURCE_NOT_CLOSED", + "Долг или доплату можно зафиксировать только для закрытого периода.", + source_period_id=source_period.pk, + ) + carryover = PayrollCarryover( + source_period=source_period, + employee=employee, + direction=direction, + amount=amount, + reason=reason.strip(), + created_by=actor, + ) + carryover.full_clean() + carryover.save(force_insert=True) + _audit( + actor=actor, + action="payroll.carryover_created", + instance=carryover, + period=source_period, + metadata={"direction": direction, "amount": str(amount)}, + ) + + target_period = _next_period(source_period) + if target_period is not None: + target_period = PayrollPeriod.objects.select_for_update().get( + pk=target_period.pk + ) + carryover = _materialize_one(carryover, target_period) + return carryover + + +@transaction.atomic +def cancel_carryover(carryover_id: int, *, actor): + carryover = ( + PayrollCarryover.objects.select_for_update() + .select_related("generated_input_line", "source_period", "target_period") + .get(pk=carryover_id) + ) + if carryover.status == PayrollCarryoverStatus.CANCELLED: + return carryover + line = carryover.generated_input_line + if line is not None: + if line.status != ApprovalStatus.DRAFT: + _operation_error( + "CARRYOVER_ALREADY_APPROVED", + "Утверждённый перенос нельзя отменить; создайте обратную корректировку.", + carryover_id=carryover.pk, + input_line_id=line.pk, + ) + line.status = ApprovalStatus.VOIDED + line.voided_by = actor + line.voided_at = timezone.now() + line.void_reason = "Отменена запись долга или доплаты" + line.save( + update_fields=["status", "voided_by", "voided_at", "void_reason"] + ) + carryover.status = PayrollCarryoverStatus.CANCELLED + carryover.cancelled_by = actor + carryover.cancelled_at = timezone.now() + carryover.save( + update_fields=["status", "cancelled_by", "cancelled_at", "updated_at"] + ) + _audit( + actor=actor, + action="payroll.carryover_cancelled", + instance=carryover, + period=carryover.source_period, + metadata={"input_line_id": line.pk if line else None}, + ) + return carryover diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 873cea30..5775e2e6 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -108,6 +108,9 @@ def _employee_payload(employee): return { "id": employee.pk, "display_name": employee.get_full_name().strip() or f"Сотрудник #{employee.pk}", + "last_name": employee.last_name, + "first_name": employee.first_name, + "patronymic": employee.patronymic, "position": employee.position.name if employee.position_id else None, "department": _employee_department(employee), "is_active": employee.is_active, diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index ba80081c..53b65f15 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -768,6 +768,82 @@ def close_period(period_id: int, *, actor) -> PayrollPeriod: return period +@transaction.atomic +def complete_period_workflow( + period_id: int, + *, + actor, + expected_lock_version: int, + idempotency_key: uuid.UUID | None = None, + recalculation_reason: str = "", +) -> dict[str, object]: + """Run every remaining payroll workflow stage for a portal administrator.""" + + if not has_simple_admin_access(actor): + raise PayrollPermissionDenied("finance.full_payroll_admin") + + period = PayrollPeriod.objects.select_for_update().get(pk=period_id) + run = ( + PayrollRun.objects.select_for_update().get(pk=period.current_run_id) + if period.current_run_id is not None + else None + ) + if period.status == PayrollPeriodStatus.CLOSED: + if run is None: + _operation_error( + "CLOSED_PERIOD_RUN_MISSING", + "У закрытого периода отсутствует опубликованный расчёт.", + period_id=period.pk, + ) + return {"period": period, "run": run, "completed_steps": []} + if period.lock_version != expected_lock_version: + _operation_error( + "STALE_PERIOD", + "Период уже изменён; обновите данные перед завершением расчёта.", + expected_lock_version=expected_lock_version, + actual_lock_version=period.lock_version, + ) + + completed_steps = [] + if run is None or run.status == PayrollRunStatus.RETURNED: + run = calculate_period( + period.pk, + actor=actor, + idempotency_key=idempotency_key, + recalculation_reason=recalculation_reason, + ) + completed_steps.append("calculated") + if run.status == PayrollRunStatus.CALCULATED: + run = submit_run_for_review(run.pk, actor=actor) + completed_steps.append("review_requested") + if run.status == PayrollRunStatus.REVIEW: + run = approve_run(run.pk, actor=actor) + completed_steps.append("approved") + if run.status == PayrollRunStatus.APPROVED: + run = publish_run(run.pk, actor=actor) + completed_steps.append("published") + if run.status == PayrollRunStatus.PUBLISHED: + period = close_period(period.pk, actor=actor) + completed_steps.append("period_closed") + + if period.status != PayrollPeriodStatus.CLOSED: + _operation_error( + "ADMIN_WORKFLOW_INCOMPLETE", + "Не удалось завершить все этапы расчёта.", + period_id=period.pk, + run_status=run.status, + ) + + _audit( + actor=actor, + action="payroll.admin_workflow_completed", + instance=period, + period=period, + metadata={"steps": completed_steps, "run_id": run.pk}, + ) + return {"period": period, "run": run, "completed_steps": completed_steps} + + @transaction.atomic def bulk_set_point_rate( period_id: int, diff --git a/backend/finance/tasks.py b/backend/finance/tasks.py new file mode 100644 index 00000000..67bd3fa3 --- /dev/null +++ b/backend/finance/tasks.py @@ -0,0 +1,98 @@ +"""Periodic Finance tasks.""" + +from __future__ import annotations + +import logging +from calendar import monthrange +from datetime import date + +from celery import shared_task +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.utils import timezone + +from .models import PayrollAuditEvent, PayrollPeriod +from .payroll.carryovers import materialize_pending_carryovers + +logger = logging.getLogger(__name__) + +MONTH_NAMES_RU = ( + "", + "Январь", + "Февраль", + "Март", + "Апрель", + "Май", + "Июнь", + "Июль", + "Август", + "Сентябрь", + "Октябрь", + "Ноябрь", + "Декабрь", +) + + +def ensure_monthly_payroll_period(reference_date: date | None = None): + """Create the open payroll period for a month unless it already exists.""" + + reference_date = reference_date or timezone.localdate() + date_from = reference_date.replace(day=1) + date_to = reference_date.replace( + day=monthrange(reference_date.year, reference_date.month)[1] + ) + + existing = PayrollPeriod.objects.filter( + date_from=date_from, + date_to=date_to, + ).first() + if existing is not None: + materialize_pending_carryovers(existing) + return existing, False + + period = PayrollPeriod( + code=date_from.strftime("%Y-%m"), + name=f"{MONTH_NAMES_RU[date_from.month]} {date_from.year}", + date_from=date_from, + date_to=date_to, + currency="RUB", + created_by=None, + ) + + try: + with transaction.atomic(): + period.full_clean() + period.save(force_insert=True) + PayrollAuditEvent.objects.create( + actor=None, + action="payroll.period_created", + object_type=period._meta.label_lower, + object_id=str(period.pk), + period=period, + metadata={"channel": "automation", "schedule": "monthly"}, + ) + except IntegrityError: + # Celery retries or two workers can race. The database constraint makes + # creation safe; return the winner when it covers the same month. + existing = PayrollPeriod.objects.filter( + date_from=date_from, + date_to=date_to, + ).first() + if existing is not None: + return existing, False + raise + except ValidationError: + logger.exception("Could not open payroll period %s", period.code) + raise + + logger.info("Opened payroll period %s", period.code) + materialize_pending_carryovers(period) + return period, True + + +@shared_task +def open_monthly_payroll_period(): + """Open the current calendar month's payroll period.""" + + period, created = ensure_monthly_payroll_period() + return {"period_id": period.pk, "code": period.code, "created": created} diff --git a/backend/procurement/services.py b/backend/procurement/services.py index f3dc5138..d9a1a680 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -248,7 +248,13 @@ class ProcurementApprovalResolver: }: return False - if procurement_request.requestor_id == user.id: + requestor_is_submitting = procurement_request.requestor_id == user.id + same_customer_and_processing_department = ( + procurement_request.department_id + and procurement_request.department_id + == procurement_request.processing_department_id + ) + if requestor_is_submitting and not same_customer_and_processing_department: return False return cls.user_is_processing_department_member( diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 90396c57..3c79b4b0 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -2383,8 +2383,8 @@ class TestProcessingDepartmentWorkflow: assert detail_response.status_code == status.HTTP_200_OK assert detail_response.data["can_current_user_submit_for_approval"] is False - def test_customer_department_user_can_submit_when_processing_department_same( - self, api_client, supply_department, supply_user, + def test_requestor_can_submit_when_customer_and_processing_departments_match( + self, api_client, supply_department, procurement_item_factory, department_head_approval_route ): supply_head = Employee.objects.create_user( @@ -2440,18 +2440,11 @@ class TestProcessingDepartmentWorkflow: api_client.force_authenticate(user=requestor) requestor_detail = api_client.get(detail_url) - requestor_submit = api_client.post(submit_url) assert requestor_detail.status_code == status.HTTP_200_OK - assert requestor_detail.data["can_current_user_submit_for_approval"] is False - assert requestor_submit.status_code == status.HTTP_403_FORBIDDEN - - api_client.force_authenticate(user=supply_user) - submitter_detail = api_client.get(detail_url) + assert requestor_detail.data["can_current_user_submit_for_approval"] is True options_response = api_client.get(options_url) submit_response = api_client.post(submit_url) - assert submitter_detail.status_code == status.HTTP_200_OK - assert submitter_detail.data["can_current_user_submit_for_approval"] is True assert options_response.status_code == status.HTTP_200_OK assert submit_response.status_code == status.HTTP_200_OK procurement_request.refresh_from_db() diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 530f4d6b..fc02ef8e 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import Permission from django.db import IntegrityError, transaction from django.urls import reverse from django.utils import timezone +from django.utils.dateparse import parse_datetime from attendance.models import AttendanceAnalysisRun, AttendanceRecord from employees.constants import ACTION_DISMISSED, ACTION_HIRED, ACTION_REHIRED @@ -14,6 +15,7 @@ from employees.models import EmployeeAction from finance.models import ( EmployeePayRate, PayrollAuditEvent, + PayrollCellCommentThread, PayrollComponent, PayrollDailyWorkEntry, PayrollInputLine, @@ -59,6 +61,37 @@ def make_period(*, creator, code="2026-06"): ) +def make_approved_period_inputs( + *, + period, + employee, + creator, + approver, + expected_payable=None, +): + approved_at = timezone.now() + EmployeePayRate.objects.create( + employee=employee, + amount="80000", + effective_from="2026-01-01", + status="approved", + created_by=creator, + approved_by=approver, + approved_at=approved_at, + ) + PayrollWorkRecord.objects.create( + period=period, + employee=employee, + target_points="110", + actual_points="110", + expected_payable=expected_payable, + status="approved", + created_by=creator, + approved_by=approver, + approved_at=approved_at, + ) + + def test_work_record_target_is_optional_and_can_return_to_automatic( user_factory, auth_client_factory, @@ -192,6 +225,98 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all( assert inactive_employee.pk in {item["id"] for item in body["rate_employees"]} +def test_workspace_readiness_identifies_affected_employees_and_draft_counts( + user_factory, + auth_client_factory, +): + manager = user_factory(email="readiness.manager@example.test") + checker = user_factory(email="readiness.checker@example.test") + draft_employee = user_factory( + email="readiness.draft@example.test", + first_name="Анна", + last_name="Черновикова", + ) + missing_rate_employee = user_factory( + email="readiness.missing@example.test", + first_name="Иван", + last_name="Безставочный", + ) + grant(manager, "manage_payroll_inputs") + period = make_period(creator=manager) + approved_at = timezone.now() + + PayrollWorkRecord.objects.create( + period=period, + employee=draft_employee, + target_points="110", + actual_points="100", + created_by=manager, + ) + PayrollWorkRecord.objects.create( + period=period, + employee=missing_rate_employee, + target_points="110", + actual_points="110", + status="approved", + created_by=manager, + approved_by=checker, + approved_at=approved_at, + ) + EmployeePayRate.objects.create( + employee=draft_employee, + amount="80000", + effective_from="2026-01-01", + created_by=manager, + ) + PayrollInputLine.objects.create( + period=period, + employee=draft_employee, + component=PayrollComponent.objects.get(code="BONUS"), + amount="1000", + created_by=manager, + ) + + response = auth_client_factory(manager).get( + api_url("admin-workspace"), + {"period_id": period.pk}, + ) + + assert response.status_code == 200, response.content + blockers = { + item["code"]: item + for item in response.json()["readiness"]["calculation"]["blockers"] + } + assert "NO_APPROVED_WORK_RECORDS" not in blockers + assert blockers["MISSING_APPROVED_RATE"] == { + "code": "MISSING_APPROVED_RATE", + "message": "Сотрудников без утверждённой ставки на 01.06.2026: 1.", + "count": 1, + "employee_ids": [missing_rate_employee.pk], + "employee_count": 1, + } + assert blockers["DRAFT_WORK_RECORDS"] == { + "code": "DRAFT_WORK_RECORDS", + "message": "Записей выработки в статусе «Черновик»: 1.", + "count": 1, + "employee_ids": [draft_employee.pk], + "employee_count": 1, + } + assert blockers["DRAFT_INPUT_LINES"] == { + "code": "DRAFT_INPUT_LINES", + "message": "Начислений или удержаний в статусе «Черновик»: 1.", + "count": 1, + "employee_ids": [draft_employee.pk], + "employee_count": 1, + } + assert blockers["DRAFT_PAY_RATES"] == { + "code": "DRAFT_PAY_RATES", + "message": "Ставок сотрудников в статусе «Черновик»: 1.", + "count": 1, + "employee_ids": [draft_employee.pk], + "employee_count": 1, + } + + def test_period_table_lists_all_employees_and_requires_view_all( user_factory, auth_client_factory, @@ -254,6 +379,8 @@ def test_period_table_lists_all_employees_and_requires_view_all( ) row = next(item for item in body["rows"] if item["employee"]["id"] == employee.pk) assert row["employee"]["display_name"] == "Анна Итогова" + assert row["employee"]["last_name"] == "Итогова" + assert row["employee"]["first_name"] == "Анна" assert row["status"] == "draft" assert row["rate_amount"] == "80000.0000" assert row["in_norm_point_rate"] == "736.3636" @@ -279,6 +406,132 @@ def test_period_table_lists_all_employees_and_requires_view_all( assert missing_row["payable"] == "0.00" +def test_period_table_cell_comments_use_shared_discussion_flow( + user_factory, + auth_client_factory, +): + author = user_factory( + email="cell.comment.author@example.test", + first_name="Ирина", + last_name="Авторова", + ) + colleague = user_factory(email="cell.comment.colleague@example.test") + employee = user_factory( + email="cell.comment.employee@example.test", + first_name="Анна", + last_name="Расчётова", + ) + input_manager = user_factory(email="cell.comment.input@example.test") + grant(author, "view_all_payroll") + grant(colleague, "view_all_payroll") + grant(input_manager, "manage_payroll_inputs") + period = make_period(creator=input_manager) + comments_url = api_url( + "admin-cell-comments", + pk=period.pk, + employee_id=employee.pk, + column_id="gross_total", + ) + + denied = auth_client_factory(input_manager).post( + comments_url, + {"text": "Не должен сохраниться"}, + format="json", + ) + assert denied.status_code == 403 + + author_client = auth_client_factory(author) + created = author_client.post( + comments_url, + {"text": "Проверить итоговое начисление"}, + format="json", + ) + + assert created.status_code == 201, created.content + assert created.json()["text"] == "Проверить итоговое начисление" + assert created.json()["author"]["id"] == author.pk + assert PayrollCellCommentThread.objects.filter( + period=period, + subject_employee=employee, + column_id="gross_total", + ).count() == 1 + + listed = auth_client_factory(colleague).get(comments_url) + assert listed.status_code == 200 + assert [item["id"] for item in listed.json()] == [created.json()["id"]] + + table = auth_client_factory(colleague).get( + api_url("admin-period-table", pk=period.pk) + ) + assert table.status_code == 200 + cell_summary = table.json()["cell_comments"] + assert len(cell_summary) == 1 + assert cell_summary[0]["employee_id"] == employee.pk + assert cell_summary[0]["column_id"] == "gross_total" + assert cell_summary[0]["count"] == 1 + assert parse_datetime(cell_summary[0]["latest_at"]) == parse_datetime( + created.json()["created_at"] + ) + + detail_url = api_url( + "admin-cell-comment-detail", + pk=period.pk, + employee_id=employee.pk, + column_id="gross_total", + comment_id=created.json()["id"], + ) + forbidden_delete = auth_client_factory(colleague).delete(detail_url) + assert forbidden_delete.status_code == 403 + + deleted = author_client.delete(detail_url) + assert deleted.status_code == 204 + assert author_client.get(comments_url).json() == [] + assert auth_client_factory(colleague).get( + api_url("admin-period-table", pk=period.pk) + ).json()["cell_comments"] == [] + + +def test_cell_comments_reject_columns_not_present_in_table( + user_factory, + auth_client_factory, +): + viewer = user_factory(email="cell.comment.invalid@example.test") + employee = user_factory(email="cell.comment.invalid.employee@example.test") + grant(viewer, "view_all_payroll") + period = make_period(creator=viewer) + + response = auth_client_factory(viewer).post( + api_url( + "admin-cell-comments", + pk=period.pk, + employee_id=employee.pk, + column_id="unknown_column", + ), + {"text": "Невидимый комментарий"}, + format="json", + ) + + assert response.status_code == 400 + assert PayrollCellCommentThread.objects.count() == 0 + + carryover_comment = auth_client_factory(viewer).post( + api_url( + "admin-cell-comments", + pk=period.pk, + employee_id=employee.pk, + column_id="carryovers", + ), + {"text": "Уточнить перенос остатка"}, + format="json", + ) + assert carryover_comment.status_code == 201 + assert PayrollCellCommentThread.objects.filter( + period=period, + subject_employee=employee, + column_id="carryovers", + ).exists() + + def test_period_table_projects_attendance_and_official_personnel_points( user_factory, auth_client_factory, @@ -2008,6 +2261,147 @@ def test_native_api_runs_complete_service_workflow_and_redacts_aggregate_money( ) +def test_staff_admin_completes_entire_payroll_workflow_with_one_command( + user_factory, + auth_client_factory, +): + administrator = user_factory( + email="workflow.complete.admin@example.test", + staff=True, + ) + creator = user_factory(email="workflow.complete.creator@example.test") + employee = user_factory(email="workflow.complete.employee@example.test") + period = make_period(creator=administrator) + make_approved_period_inputs( + period=period, + employee=employee, + creator=creator, + approver=administrator, + ) + + response = auth_client_factory(administrator).post( + api_url("admin-period-complete-workflow", pk=period.pk), + { + "expected_lock_version": period.lock_version, + "idempotency_key": str(uuid.uuid4()), + "recalculation_reason": "", + }, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.json()["period"]["status"] == "closed" + assert response.json()["run"]["status"] == "published" + assert response.json()["completed_steps"] == [ + "calculated", + "review_requested", + "approved", + "published", + "period_closed", + ] + period.refresh_from_db() + run = PayrollRun.objects.get(pk=period.current_run_id) + assert period.status == "closed" + assert run.status == "published" + assert run.requested_by_id == administrator.pk + assert run.approved_by_id == administrator.pk + assert run.published_by_id == administrator.pk + assert run.self_approval_overridden is True + assert set( + PayrollAuditEvent.objects.filter(period=period).values_list( + "action", + flat=True, + ) + ) >= { + "payroll.calculated", + "payroll.review_requested", + "payroll.approved", + "payroll.published", + "payroll.period_closed", + "payroll.admin_workflow_completed", + } + + +def test_complete_workflow_rolls_back_every_stage_when_review_fails( + user_factory, + auth_client_factory, +): + administrator = user_factory( + email="workflow.rollback.admin@example.test", + staff=True, + ) + creator = user_factory(email="workflow.rollback.creator@example.test") + employee = user_factory(email="workflow.rollback.employee@example.test") + period = make_period(creator=administrator) + make_approved_period_inputs( + period=period, + employee=employee, + creator=creator, + approver=administrator, + expected_payable="1.00", + ) + + response = auth_client_factory(administrator).post( + api_url("admin-period-complete-workflow", pk=period.pk), + { + "expected_lock_version": period.lock_version, + "idempotency_key": str(uuid.uuid4()), + "recalculation_reason": "", + }, + format="json", + ) + + assert response.status_code == 409 + assert response.json()["code"] == "RECONCILIATION_MISMATCH" + period.refresh_from_db() + assert period.status == "open" + assert period.current_run_id is None + assert period.lock_version == 0 + assert not PayrollRun.objects.filter(period=period).exists() + assert not PayrollAuditEvent.objects.filter( + period=period, + action__in={ + "payroll.calculated", + "payroll.review_requested", + "payroll.approved", + "payroll.published", + "payroll.period_closed", + "payroll.admin_workflow_completed", + }, + ).exists() + + +def test_complete_workflow_is_available_only_to_full_portal_admins( + settings, + user_factory, + auth_client_factory, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": False} + operator = user_factory(email="workflow.granular@example.test") + grant( + operator, + "calculate_payroll", + "approve_payroll", + "override_payroll_approval", + "publish_payroll", + ) + period = make_period(creator=operator) + + response = auth_client_factory(operator).post( + api_url("admin-period-complete-workflow", pk=period.pk), + { + "expected_lock_version": period.lock_version, + "idempotency_key": str(uuid.uuid4()), + "recalculation_reason": "", + }, + format="json", + ) + + assert response.status_code == 403 + assert response.json()["code"] == "PERMISSION_DENIED" + assert response.json()["details"]["permission"] == "finance.full_payroll_admin" + + def test_override_holder_can_self_approve_every_serialized_payroll_object( user_factory, auth_client_factory, diff --git a/backend/tests/finance/test_payroll_carryovers.py b/backend/tests/finance/test_payroll_carryovers.py new file mode 100644 index 00000000..60c32cb8 --- /dev/null +++ b/backend/tests/finance/test_payroll_carryovers.py @@ -0,0 +1,242 @@ +from datetime import date +from decimal import Decimal + +import pytest +from django.contrib.auth.models import Permission +from django.urls import reverse + +from finance.enums import ApprovalStatus, PayrollCarryoverStatus, PayrollPeriodStatus +from finance.models import PayrollInputLine, PayrollPeriod +from finance.payroll.carryovers import ( + cancel_carryover, + create_carryover, + materialize_pending_carryovers, +) +from finance.payroll.exceptions import PayrollOperationError + +pytestmark = pytest.mark.django_db + + +def grant(user, *codenames): + permissions = Permission.objects.filter( + content_type__app_label="finance", + codename__in=codenames, + ) + assert permissions.count() == len(codenames) + user.user_permissions.add(*permissions) + for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): + if hasattr(user, cache_name): + delattr(user, cache_name) + + +def api_url(name, **kwargs): + return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None) + + +def make_period(*, creator, month, status=PayrollPeriodStatus.OPEN): + starts = {6: date(2026, 6, 1), 7: date(2026, 7, 1), 8: date(2026, 8, 1)} + ends = {6: date(2026, 6, 30), 7: date(2026, 7, 31), 8: date(2026, 8, 31)} + return PayrollPeriod.objects.create( + code=f"2026-{month:02d}", + name=f"Месяц {month}", + date_from=starts[month], + date_to=ends[month], + currency="RUB", + status=status, + created_by=creator, + ) + + +def test_pending_carryover_materializes_as_next_period_draft(user_factory): + manager = user_factory(email="carryover.manager@example.test") + employee = user_factory(email="carryover.employee@example.test") + source = make_period( + creator=manager, + month=6, + status=PayrollPeriodStatus.CLOSED, + ) + + carryover = create_carryover( + source_period=source, + employee=employee, + direction="company_owes", + amount=Decimal("5000.00"), + reason="Недоплата за июнь", + actor=manager, + ) + + assert carryover.status == PayrollCarryoverStatus.PENDING + assert carryover.target_period_id is None + target = make_period(creator=manager, month=7) + + transferred = materialize_pending_carryovers(target) + + assert [item.pk for item in transferred] == [carryover.pk] + carryover.refresh_from_db() + assert carryover.status == PayrollCarryoverStatus.TRANSFERRED + assert carryover.target_period == target + line = carryover.generated_input_line + assert line.period == target + assert line.employee == employee + assert line.component.code == "CORRECTION_CREDIT" + assert line.amount == Decimal("5000.00") + assert line.relates_to_period == source + assert line.status == ApprovalStatus.DRAFT + assert line.source == "api" + assert line.source_ref == f"payroll-carryover:{carryover.pk}" + assert "Недоплата за июнь" in line.reason + + assert materialize_pending_carryovers(target) == [] + assert PayrollInputLine.objects.filter(source_ref=line.source_ref).count() == 1 + + +def test_employee_debt_uses_deduction_and_cancel_voids_draft(user_factory): + manager = user_factory(email="carryover.debt.manager@example.test") + employee = user_factory(email="carryover.debt.employee@example.test") + source = make_period( + creator=manager, + month=6, + status=PayrollPeriodStatus.CLOSED, + ) + make_period(creator=manager, month=7) + + carryover = create_carryover( + source_period=source, + employee=employee, + direction="employee_owes", + amount=Decimal("1200.00"), + reason="Возврат переплаты", + actor=manager, + ) + + assert carryover.status == PayrollCarryoverStatus.TRANSFERRED + assert carryover.generated_input_line.component.code == "DEDUCTION" + cancelled = cancel_carryover(carryover.pk, actor=manager) + assert cancelled.status == PayrollCarryoverStatus.CANCELLED + cancelled.generated_input_line.refresh_from_db() + assert cancelled.generated_input_line.status == ApprovalStatus.VOIDED + + +def test_carryover_requires_closed_source_and_approved_transfer_cannot_cancel( + user_factory, +): + manager = user_factory(email="carryover.lock.manager@example.test") + employee = user_factory(email="carryover.lock.employee@example.test") + source = make_period(creator=manager, month=6) + + with pytest.raises(PayrollOperationError) as open_error: + create_carryover( + source_period=source, + employee=employee, + direction="company_owes", + amount=Decimal("10.00"), + reason="Ошибка", + actor=manager, + ) + assert open_error.value.code == "CARRYOVER_SOURCE_NOT_CLOSED" + + source.status = PayrollPeriodStatus.CLOSED + source.save(update_fields=["status", "updated_at"]) + make_period(creator=manager, month=7) + carryover = create_carryover( + source_period=source, + employee=employee, + direction="company_owes", + amount=Decimal("10.00"), + reason="Подтверждённая доплата", + actor=manager, + ) + line = carryover.generated_input_line + line.status = ApprovalStatus.APPROVED + line.approved_by = manager + line.approved_at = carryover.transferred_at + line.self_approval_overridden = True + line.save( + update_fields=[ + "status", + "approved_by", + "approved_at", + "self_approval_overridden", + ] + ) + + with pytest.raises(PayrollOperationError) as approved_error: + cancel_carryover(carryover.pk, actor=manager) + assert approved_error.value.code == "CARRYOVER_ALREADY_APPROVED" + + +def test_carryover_admin_api_lists_incoming_and_marks_generated_line( + user_factory, + auth_client_factory, +): + manager = user_factory(email="carryover.api.manager@example.test") + employee = user_factory( + email="carryover.api.employee@example.test", + first_name="Анна", + last_name="Балансова", + ) + grant(manager, "manage_payroll_inputs", "view_all_payroll") + source = make_period( + creator=manager, + month=6, + status=PayrollPeriodStatus.CLOSED, + ) + target = make_period(creator=manager, month=7) + client = auth_client_factory(manager) + + created = client.post( + api_url("admin-period-carryovers", pk=source.pk), + { + "employee_id": employee.pk, + "direction": "company_owes", + "amount": "3500.00", + "reason": "Исправление после закрытия", + }, + format="json", + ) + + assert created.status_code == 201, created.content + assert created.json()["status"] == "transferred" + outgoing = client.get(api_url("admin-period-carryovers", pk=source.pk)) + assert outgoing.status_code == 200 + assert outgoing.json()["summary"] == { + "outgoing": 1, + "incoming": 0, + "pending": 0, + } + incoming = client.get(api_url("admin-period-carryovers", pk=target.pk)) + assert incoming.json()["summary"] == { + "outgoing": 0, + "incoming": 1, + "pending": 0, + } + assert incoming.json()["incoming"][0]["source_period"]["id"] == source.pk + + lines = client.get( + api_url("admin-input-line-list"), + {"period_id": target.pk}, + ) + assert lines.status_code == 200 + line = lines.json()[0] + assert line["is_automatic_carryover"] is True + assert line["relates_to_period"]["id"] == source.pk + + period_table = client.get(api_url("admin-period-table", pk=target.pk)) + assert period_table.status_code == 200 + assert period_table.json()["carryovers"] == [ + { + "employee_id": employee.pk, + "count": 1, + "company_owes": "3500.00", + "employee_owes": "0.00", + "pending": 0, + } + ] + + workspace = client.get(api_url("admin-workspace"), {"period_id": target.pk}) + assert workspace.status_code == 200 + assert workspace.json()["carryovers"] == { + "outgoing": 0, + "incoming": 1, + "pending": 0, + } diff --git a/backend/tests/finance/test_payroll_tasks.py b/backend/tests/finance/test_payroll_tasks.py new file mode 100644 index 00000000..a8867459 --- /dev/null +++ b/backend/tests/finance/test_payroll_tasks.py @@ -0,0 +1,98 @@ +from datetime import date +from decimal import Decimal + +import pytest +from django.conf import settings + +from finance.enums import PayrollPeriodStatus +from finance.models import PayrollAuditEvent, PayrollPeriod +from finance.payroll.carryovers import create_carryover +from finance.tasks import ensure_monthly_payroll_period, open_monthly_payroll_period + +pytestmark = pytest.mark.django_db + + +def test_monthly_period_task_is_scheduled_for_first_day(): + task = settings.CELERY_BEAT_SCHEDULE["open-monthly-payroll-period"] + + assert task["task"] == "finance.tasks.open_monthly_payroll_period" + assert task["schedule"].day_of_month == {1} + assert task["schedule"].hour == {0} + assert task["schedule"].minute == {5} + + +def test_ensure_monthly_payroll_period_opens_full_calendar_month(): + period, created = ensure_monthly_payroll_period(date(2026, 8, 1)) + + assert created is True + assert period.code == "2026-08" + assert period.name == "Август 2026" + assert period.date_from == date(2026, 8, 1) + assert period.date_to == date(2026, 8, 31) + assert period.pay_date is None + assert period.currency == "RUB" + assert period.status == PayrollPeriodStatus.OPEN + assert period.created_by is None + + event = PayrollAuditEvent.objects.get( + action="payroll.period_created", + period=period, + ) + assert event.actor is None + assert event.metadata == {"channel": "automation", "schedule": "monthly"} + + +def test_ensure_monthly_payroll_period_is_idempotent(): + first, first_created = ensure_monthly_payroll_period(date(2028, 2, 1)) + second, second_created = ensure_monthly_payroll_period(date(2028, 2, 15)) + + assert first_created is True + assert second_created is False + assert second.pk == first.pk + assert first.date_to == date(2028, 2, 29) + assert PayrollPeriod.objects.count() == 1 + assert PayrollAuditEvent.objects.filter( + action="payroll.period_created", + period=first, + ).count() == 1 + + +def test_monthly_task_uses_local_current_month(monkeypatch): + monkeypatch.setattr("finance.tasks.timezone.localdate", lambda: date(2026, 9, 1)) + + result = open_monthly_payroll_period() + + period = PayrollPeriod.objects.get(pk=result["period_id"]) + assert result == {"period_id": period.pk, "code": "2026-09", "created": True} + assert period.date_from == date(2026, 9, 1) + assert period.date_to == date(2026, 9, 30) + + +def test_monthly_period_opening_materializes_pending_carryover(user_factory): + manager = user_factory(email="period.carryover.manager@example.test") + employee = user_factory(email="period.carryover.employee@example.test") + source = PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from=date(2026, 7, 1), + date_to=date(2026, 7, 31), + currency="RUB", + status=PayrollPeriodStatus.CLOSED, + created_by=manager, + ) + carryover = create_carryover( + source_period=source, + employee=employee, + direction="company_owes", + amount=Decimal("700.00"), + reason="Остаток июля", + actor=manager, + ) + assert carryover.target_period_id is None + + target, created = ensure_monthly_payroll_period(date(2026, 8, 1)) + + assert created is True + carryover.refresh_from_db() + assert carryover.target_period == target + assert carryover.generated_input_line.period == target diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index 6e274579..bd123d24 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -2150,7 +2150,7 @@ export default function AttendancePage() { setSelectedCell({ row, employee, cell }); openCellComments(row, employee, cell); }} - className="absolute right-1 top-1 rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[9px] leading-3 text-[var(--muted-foreground)] transition hover:border-[var(--accent-primary)] hover:text-[var(--foreground)]" + className="absolute right-1 top-1 rounded border border-sky-500/60 bg-sky-500/15 px-1 text-[9px] font-semibold leading-3 text-sky-400 transition hover:bg-sky-500/25 hover:text-sky-300" aria-label={`Открыть комментарии: ${cell.comments_count}`} title="Открыть комментарии" > diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 3ae46543..144293b2 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -473,7 +473,6 @@ export default function ProcurementPage() { const canCurrentUserSubmitRequest = useCallback( (request: ProcurementRequest) => { const processingDepartmentId = Number(request.processing_department || 0); - const requestorId = resolveUserId(request.requestor); const isProcessingDepartmentMember = ( !userDepartmentsKnown || userDepartmentIds.has(processingDepartmentId) @@ -483,12 +482,10 @@ export default function ProcurementPage() { request.can_current_user_submit_for_approval && user?.id && processingDepartmentId && - requestorId !== user.id && isProcessingDepartmentMember ); }, [ - resolveUserId, user?.id, userDepartmentIds, userDepartmentsKnown, diff --git a/frontend/src/components/finance/PayrollStatementDetailModal.tsx b/frontend/src/components/finance/PayrollStatementDetailModal.tsx index 6ba81fa1..f1a8f2df 100644 --- a/frontend/src/components/finance/PayrollStatementDetailModal.tsx +++ b/frontend/src/components/finance/PayrollStatementDetailModal.tsx @@ -283,7 +283,7 @@ export function PayrollStatementDetailModal({

{line.label}

{line.is_retro ? ( - Перерасчёт + {line.is_automatic_carryover ? "Автоперенос" : "Перерасчёт"} ) : null}
{sourcePeriod ? ( diff --git a/frontend/src/components/finance/admin/PayrollAdminTables.tsx b/frontend/src/components/finance/admin/PayrollAdminTables.tsx index 8ef3e772..e9ca6286 100644 --- a/frontend/src/components/finance/admin/PayrollAdminTables.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminTables.tsx @@ -247,7 +247,18 @@ export function PayrollInputLinesTable({ {records.map((record) => (

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

-

{record.component.name}

{record.reason ?

{record.reason}

: null}
+
+

{record.component.name}

+ {record.relates_to_period ? ( +
+ + {record.is_automatic_carryover ? "Автоперенос" : "Перерасчёт"} + + за {record.relates_to_period.name || record.relates_to_period.code} +
+ ) : null} + {record.reason ?

{record.reason}

: null} +
Сумма{formatPayrollMoney(record.amount)}
onEdit(record)} onApprove={() => onApprove(record)} /> diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 7c6b4caa..d2110eea 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -21,6 +21,7 @@ import { Send, ShieldCheck, UsersRound, + WalletCards, } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -37,6 +38,7 @@ import { PayrollWorkRecordFormModal, } from "@/components/finance/admin/PayrollAdminModals"; import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal"; +import { PayrollCarryoversModal } from "@/components/finance/admin/PayrollCarryoversModal"; import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal"; import { PayrollApprovalQueue, @@ -59,6 +61,7 @@ import type { PayrollBulkPayRateResult, PayrollBulkPointRateResult, PayrollBulkTargetPointsResult, + PayrollCalculationBlocker, PayrollInputLineWrite, PayrollPayRateWrite, PayrollPeriodTable, @@ -69,6 +72,7 @@ import type { } from "@/lib/api/finance"; import { getPayrollAdminError, + getPayrollBlockerTarget, getPrimaryPayrollRunAction, normalizePayrollAdminList, PAYROLL_ADMIN_TABS, @@ -118,6 +122,97 @@ function SummaryCards({ workspace }: { workspace: WorkspacePayload }) { ); } +function ReadinessBlockers({ + blockers, + employees, + canOpenInputTabs, + canEditPeriod, + onOpen, + onEditPeriod, +}: { + blockers: PayrollCalculationBlocker[]; + employees: WorkspacePayload["rate_employees"]; + canOpenInputTabs: boolean; + canEditPeriod: boolean; + onOpen: (blocker: PayrollCalculationBlocker, employeeName?: string) => void; + onEditPeriod: () => void; +}) { + if (!blockers.length) return null; + + const employeesById = new Map(employees.map((employee) => [employee.id, employee])); + + return ( +
+

Что мешает расчёту

+
    + {blockers.map((blocker, index) => { + const employeeIds = blocker.employee_ids + || (blocker.employee_id ? [blocker.employee_id] : []); + const affectedEmployees = employeeIds + .map((employeeId) => employeesById.get(employeeId)) + .filter((employee): employee is WorkspacePayload["rate_employees"][number] => Boolean(employee)); + const target = getPayrollBlockerTarget(blocker.code); + const canOpenTarget = Boolean(target && canOpenInputTabs); + const canOpenPeriod = blocker.code === "RULESET_NOT_EFFECTIVE" && canEditPeriod; + + return ( +
  • + +
    +

    {blocker.message}

    + {affectedEmployees.length ? ( +
    + {affectedEmployees.slice(0, 5).map((employee) => ( + canOpenTarget ? ( + + ) : ( + {employee.display_name} + ) + ))} + {(blocker.employee_count || affectedEmployees.length) > 5 ? ( + + и ещё {(blocker.employee_count || affectedEmployees.length) - Math.min(affectedEmployees.length, 5)} + + ) : null} +
    + ) : null} + {canOpenTarget && target ? ( + + ) : null} + {canOpenPeriod ? ( + + ) : null} +
    +
  • + ); + })} +
+
+ ); +} + function TableToolbar({ search, status, @@ -322,6 +417,8 @@ export function PayrollAdminWorkspace({ const [editingWork, setEditingWork] = useState(null); const [attendanceWorkModalOpen, setAttendanceWorkModalOpen] = useState(false); const [workbookImportModalOpen, setWorkbookImportModalOpen] = useState(false); + const [carryoversModalOpen, setCarryoversModalOpen] = useState(false); + const [carryoversEmployeeId, setCarryoversEmployeeId] = useState(null); const [inputModalOpen, setInputModalOpen] = useState(false); const [editingInput, setEditingInput] = useState(null); const [pendingAction, setPendingAction] = useState(null); @@ -534,6 +631,46 @@ export function PayrollAdminWorkspace({ setPendingAction(configs[action]); }; + const openCompleteWorkflow = () => { + if (!selectedPeriod || !workspace?.permissions.full_access) return; + const runStatus = workspace.current_run?.status; + const firstRemainingStep = runStatus === "published" + ? 4 + : runStatus === "approved" + ? 3 + : runStatus === "review" + ? 2 + : runStatus === "calculated" + ? 1 + : 0; + const remainingSteps = [ + "рассчитать зарплату", + "передать расчёт на проверку", + "утвердить расчёт", + "опубликовать расчётные листки", + "закрыть период", + ].slice(firstRemainingStep); + const requiresRecalculationReason = runStatus === "returned"; + + setPendingAction({ + title: "Завершить период одним действием", + description: `Система последовательно выполнит: ${remainingSteps.join(" → ")}.`, + confirmLabel: "Выполнить и закрыть", + reasonLabel: requiresRecalculationReason ? "Основание перерасчёта" : undefined, + reasonRequired: requiresRecalculationReason, + warning: "Расчётные листки станут доступны сотрудникам, а период будет закрыт. Если любой этап не пройдёт проверку, ни одно изменение не сохранится.", + successMessage: "Расчёт опубликован, период закрыт.", + execute: (reason) => apiClient.completePayrollAdminPeriodWorkflow( + selectedPeriod.id, + { + idempotency_key: globalThis.crypto.randomUUID(), + recalculation_reason: reason, + expected_lock_version: selectedPeriod.lock_version, + }, + ), + }); + }; + const openReturn = () => { const run = workspace?.current_run; if (!run) return; @@ -563,6 +700,16 @@ export function PayrollAdminWorkspace({ ); const canManageSourceData = permissions.manage_inputs && !sourceDataLocked; const canApproveSourceData = permissions.approve_inputs && !sourceDataLocked; + const openBlockerTarget = ( + blocker: PayrollCalculationBlocker, + employeeName?: string, + ) => { + const target = getPayrollBlockerTarget(blocker.code); + if (!target || !canOpenInputTabs) return; + setSearch(employeeName || ""); + setStatus(target.status); + setTab(target.tab); + }; const draftApprovalRecords = [ ...rates.filter((record) => record.status === "draft"), ...workRecords.filter((record) => record.status === "draft"), @@ -753,9 +900,25 @@ export function PayrollAdminWorkspace({ ) : null} {workspace.periods.length ? ( - +
+ + {selectedPeriod?.status === "open" && permissions.manage_inputs && !workspace.current_run ? ( + + ) : null} +
) : null} {permissions.manage_inputs ? : null}
@@ -808,17 +971,84 @@ export function PayrollAdminWorkspace({
{[ - { label: "Ставки сотрудников", value: `${workspace.readiness.rates.approved} из ${workspace.readiness.rates.total}`, ready: workspace.readiness.rates.ready, tab: "rates" as const }, - { label: "Выработка и состав", value: `${workspace.readiness.work_records.approved} из ${workspace.readiness.work_records.total}`, ready: workspace.readiness.work_records.ready, tab: "work" as const }, - { label: "Начисления и выплаты", value: workspace.readiness.input_lines.draft ? `${workspace.readiness.input_lines.draft} черн.` : `${workspace.readiness.input_lines.approved} утвержд.`, ready: workspace.readiness.input_lines.draft === 0, tab: "inputs" as const }, - ].map((item) => )} + { + label: "Ставки сотрудников", + value: workspace.readiness.rates.total + ? `${workspace.readiness.rates.approved} из ${workspace.readiness.rates.total} с утверждённой ставкой${workspace.readiness.rates.draft ? ` · черновиков: ${workspace.readiness.rates.draft}` : ""}` + : "Состав сотрудников ещё не заполнен", + ready: workspace.readiness.rates.ready, + tab: "rates" as const, + }, + { + label: "Выработка и состав", + value: workspace.readiness.work_records.total + ? `${workspace.readiness.work_records.approved} из ${workspace.readiness.work_records.total} утверждено${workspace.readiness.work_records.draft ? ` · черновиков: ${workspace.readiness.work_records.draft}` : ""}` + : "Нет записей выработки — состав периода пуст", + ready: workspace.readiness.work_records.ready, + tab: "work" as const, + }, + { + label: "Начисления и выплаты", + value: `${workspace.readiness.input_lines.approved} утверждено${workspace.readiness.input_lines.draft ? ` · черновиков: ${workspace.readiness.input_lines.draft}` : " · черновиков нет"}`, + ready: workspace.readiness.input_lines.draft === 0, + tab: "inputs" as const, + }, + ].map((item) => )}
- {workspace.readiness.calculation.blockers.length ?

Что мешает расчёту

    {workspace.readiness.calculation.blockers.map((blocker) => { const employee = blocker.employee_id ? workspace.employees.find((item) => item.id === blocker.employee_id) : null; return
  • {blocker.message}{employee ? ` — ${employee.display_name}` : ""}
  • ; })}
: null} + { + setEditingPeriod(selectedPeriod); + setPeriodModalOpen(true); + }} + /> @@ -846,7 +1076,25 @@ export function PayrollAdminWorkspace({ {tab === "summary" ? (
- setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} /> +
+ +
+ setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} />
) : null} {tab === "approval" ? ( @@ -890,7 +1138,7 @@ export function PayrollAdminWorkspace({ )} )} - {workspace.current_run ?

Текущий расчёт

{canReturn ? : null}{primaryAction && primaryAction !== "calculate" ? : null}
: null} + {workspace.current_run ?

Текущий расчёт

{canReturn ? : null}{primaryAction && primaryAction !== "calculate" ? : null}{permissions.full_access && primaryAction && primaryAction !== "calculate" && primaryAction !== "close" ? : null}
: null} {workspace.runs.length > 1 ?
История ревизий ({workspace.runs.length})
{workspace.runs.map((run) => )}
: null} ) : null} @@ -902,6 +1150,7 @@ export function PayrollAdminWorkspace({ {selectedPeriod && bulkPayRateModalOpen ? setBulkPayRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkCreatePayrollAdminPayRates(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPayRateResult) => { setBulkPayRateModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Ставка сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null} {selectedPeriod && bulkPointRateModalOpen ? setBulkPointRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminPointRate(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPointRateResult) => { setBulkPointRateModalOpen(false); const changed = result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Цена балла сверх нормы сохранена для ${changed} ставок${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null} {selectedPeriod && bulkTargetPointsModalOpen ? setBulkTargetPointsModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminTargetPoints(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkTargetPointsResult) => { setBulkTargetPointsModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Норма баллов сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null} + {selectedPeriod && carryoversModalOpen ? setCarryoversModalOpen(false)} onChanged={() => refreshView("Реестр долгов и доплат обновлён.")} /> : null} {selectedPeriod && workModalOpen ? setWorkModalOpen(false)} onSubmit={saveWork} onSaved={async () => { setWorkModalOpen(false); await refreshView("Черновик выработки сохранён."); }} onStale={handleStale} /> : null} {selectedPeriod && attendanceWorkModalOpen ? ( void | Promise; + onClose: () => void; + periods: PayrollAdminPeriod[]; + selectedPeriod: PayrollAdminPeriod; +}; + +const inputClass = "app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"; + +const carryoverStatusClasses = { + pending: "app-feedback-warning", + transferred: "app-selected", + cancelled: "app-surface-muted app-text-muted", +}; + +function CarryoverCard({ + carryover, + incoming, + cancelling, + confirmCancel, + onCancel, + onConfirmCancel, + onDismissCancel, +}: { + carryover: PayrollCarryover; + incoming: boolean; + cancelling: boolean; + confirmCancel: boolean; + onCancel: () => void; + onConfirmCancel: () => void; + onDismissCancel: () => void; +}) { + const Icon = carryover.direction === "company_owes" ? ArrowUpRight : ArrowDownLeft; + const period = incoming ? carryover.source_period : carryover.target_period; + return ( +
+
+ + + +
+
+
+

{carryover.employee.display_name}

+

{carryover.direction_label}

+
+

+ {carryover.direction === "company_owes" ? "+" : "−"}{formatPayrollMoney(carryover.amount, carryover.source_period.currency)} +

+
+
+ {carryover.status_label} + {period ? {incoming ? "Из периода" : "В период"}: {period.name || period.code} : null} +
+

{carryover.reason}

+

Создал: {carryover.created_by.display_name} · {formatPayrollDateTime(carryover.created_at)}

+ {confirmCancel ? ( +
+ Отменить перенос и связанный черновик? + + + + +
+ ) : carryover.can_cancel ? ( + + ) : null} +
+
+
+ ); +} + +export function PayrollCarryoversModal({ + canManage, + employees, + initialEmployeeId = null, + isOpen, + onChanged, + onClose, + periods, + selectedPeriod, +}: PayrollCarryoversModalProps) { + const [periodId, setPeriodId] = useState(selectedPeriod.id); + const [workspace, setWorkspace] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmCancelId, setConfirmCancelId] = useState(null); + const [cancellingId, setCancellingId] = useState(null); + const [employeeFilterId, setEmployeeFilterId] = useState(""); + const [form, setForm] = useState({ + employee_id: "", + direction: "company_owes" as PayrollCarryoverDirection, + amount: "", + reason: "", + }); + const contextPeriod = useMemo( + () => periods.find((period) => period.id === periodId) || selectedPeriod, + [periodId, periods, selectedPeriod], + ); + + useEffect(() => { + if (!isOpen) return; + setPeriodId(selectedPeriod.id); + setWorkspace(null); + setError(null); + setConfirmCancelId(null); + const employeeId = initialEmployeeId ? String(initialEmployeeId) : ""; + setEmployeeFilterId(employeeId); + setForm((current) => ({ ...current, employee_id: employeeId })); + }, [initialEmployeeId, isOpen, selectedPeriod.id]); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + setLoading(true); + setError(null); + apiClient.getPayrollAdminCarryovers(periodId) + .then((payload) => { + if (!cancelled) setWorkspace(payload); + }) + .catch((loadError: unknown) => { + if (!cancelled) setError(getPayrollAdminError(loadError, "Не удалось загрузить долги и доплаты.")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isOpen, periodId]); + + async function reload() { + const payload = await apiClient.getPayrollAdminCarryovers(periodId); + setWorkspace(payload); + } + + async function createEntry(event: React.FormEvent) { + event.preventDefault(); + try { + setBusy(true); + setError(null); + await apiClient.createPayrollAdminCarryover(periodId, { + employee_id: Number(form.employee_id), + direction: form.direction, + amount: form.amount, + reason: form.reason, + }); + setForm({ employee_id: employeeFilterId, direction: "company_owes", amount: "", reason: "" }); + await reload(); + await onChanged(); + } catch (createError) { + setError(getPayrollAdminError(createError, "Не удалось зарегистрировать долг или доплату.")); + } finally { + setBusy(false); + } + } + + async function cancelEntry(carryoverId: number) { + try { + setCancellingId(carryoverId); + setError(null); + await apiClient.cancelPayrollAdminCarryover(carryoverId); + setConfirmCancelId(null); + await reload(); + await onChanged(); + } catch (cancelError) { + setError(getPayrollAdminError(cancelError, "Не удалось отменить перенос.")); + } finally { + setCancellingId(null); + } + } + + const entries = [ + ...(workspace?.outgoing.map((item) => ({ item, incoming: false })) || []), + ...(workspace?.incoming.map((item) => ({ item, incoming: true })) || []), + ].filter(({ item }) => !employeeFilterId || item.employee.id === Number(employeeFilterId)); + const visibleSummary = { + outgoing: entries.filter((entry) => !entry.incoming).length, + incoming: entries.filter((entry) => entry.incoming).length, + pending: entries.filter((entry) => entry.item.status === "pending").length, + }; + const statusMeta = periodStatusMeta[contextPeriod.status]; + + return ( + +
+
+ + +
+ {statusMeta.label} + Исходящих: {visibleSummary.outgoing} + Входящих: {visibleSummary.incoming} + {visibleSummary.pending ? Ожидают периода: {visibleSummary.pending} : null} +
+
+ + {error ?
{error}
: null} + + {canManage && contextPeriod.status === "closed" ? ( +
+
+ +

Зарегистрировать остаток

+
+
+ + + +