diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py index b308696d..35b17be2 100644 --- a/backend/api/v1/finance/admin_serializers.py +++ b/backend/api/v1/finance/admin_serializers.py @@ -514,7 +514,7 @@ class PayrollWorkRecordWriteSerializer( ) employee_id = serializers.PrimaryKeyRelatedField( source="employee", - queryset=Employee.objects.filter(is_active=True), + queryset=Employee.objects.all(), ) target_points = serializers.DecimalField( max_digits=19, @@ -628,6 +628,7 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): "relates_to_period_id", "relates_to_period", "is_automatic_carryover", + "include_in_carryover", "reason", "status", "status_label", @@ -650,6 +651,10 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): ) +class PayrollCarryoverInclusionSerializer(serializers.Serializer): + included = serializers.BooleanField() + + class PayrollInputLineWriteSerializer( DraftWriteSerializerMixin, serializers.ModelSerializer, @@ -661,7 +666,7 @@ class PayrollInputLineWriteSerializer( ) employee_id = serializers.PrimaryKeyRelatedField( source="employee", - queryset=Employee.objects.filter(is_active=True), + queryset=Employee.objects.all(), ) component_id = serializers.PrimaryKeyRelatedField( source="component", @@ -696,6 +701,50 @@ class ClearComponentCellCommandSerializer(serializers.Serializer): component_id = serializers.IntegerField(min_value=1) +class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer): + confirmed = serializers.BooleanField() + + +class CopyPayrollInputLinesCommandSerializer(serializers.Serializer): + source_period_id = serializers.IntegerField(min_value=1) + component_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + allow_empty=False, + ) + employee_scope = serializers.ChoiceField( + choices=("all", "active", "selected"), + ) + existing_mode = serializers.ChoiceField(choices=("skip", "replace")) + employee_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + required=False, + default=list, + ) + expected_period_lock_version = serializers.IntegerField(min_value=0) + idempotency_key = serializers.UUIDField() + remember_configuration = serializers.BooleanField(default=False) + auto_apply_on_period_creation = serializers.BooleanField(default=False) + + def validate(self, attrs): + attrs["component_ids"] = list(dict.fromkeys(attrs["component_ids"])) + attrs["employee_ids"] = list(dict.fromkeys(attrs["employee_ids"])) + if attrs["employee_scope"] == "selected" and not attrs["employee_ids"]: + raise serializers.ValidationError( + {"employee_ids": "Выберите хотя бы одного сотрудника."} + ) + if attrs["employee_scope"] != "selected": + attrs["employee_ids"] = [] + if attrs["auto_apply_on_period_creation"] and not attrs["remember_configuration"]: + raise serializers.ValidationError( + { + "auto_apply_on_period_creation": ( + "Для автоприменения сначала сохраните конфигурацию." + ) + } + ) + return attrs + + class CalculatePayrollCommandSerializer(serializers.Serializer): expected_lock_version = serializers.IntegerField(min_value=0) recalculation_reason = serializers.CharField( diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 1268df9f..05ba159f 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -7,7 +7,8 @@ access to payroll data. from __future__ import annotations -from collections import defaultdict +import logging +import uuid from decimal import Decimal from django.contrib.contenttypes.models import ContentType @@ -17,6 +18,7 @@ from django.db import IntegrityError, transaction from django.db.models import Count, Max, Q from django.http import Http404 from django.shortcuts import get_object_or_404 +from django.utils import timezone from rest_framework import permissions, status from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.response import Response @@ -24,9 +26,12 @@ from rest_framework.views import APIView from communications import comments_helpers from communications.models import Message +from employees.constants import ACTION_DISMISSED +from employees.services.personnel_state import resolve_employee_personnel_state from finance.enums import ( ApprovalStatus, - PayrollCarryoverStatus, + InputSource, + PayrollComponentKind, PayrollPeriodStatus, PayrollRunStatus, ) @@ -38,8 +43,11 @@ from finance.models import ( PayrollCellCommentThread, PayrollComponent, PayrollInputLine, + PayrollInputCopyConfiguration, PayrollPeriod, PayrollRun, + PayrollStatement, + PayrollStatementPaymentConfirmation, PayrollWorkRecord, ) from finance.payroll.config import ( @@ -49,11 +57,7 @@ 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.carryovers import cancel_carryover from finance.payroll.access import ( can_access_payroll_management, has_payroll_permission, @@ -101,6 +105,7 @@ from .admin_serializers import ( BulkTargetPointsCommandSerializer, CalculatePayrollCommandSerializer, ClearComponentCellCommandSerializer, + CopyPayrollInputLinesCommandSerializer, EmployeePayRateRevisionSerializer, EmployeePayRateSerializer, EmployeePayRateWriteSerializer, @@ -108,12 +113,13 @@ from .admin_serializers import ( PayrollCellCommentSerializer, PayrollCellCommentWriteSerializer, PayrollCarryoverSerializer, - PayrollCarryoverWriteSerializer, + PayrollCarryoverInclusionSerializer, PayrollEmployeeMiniSerializer, PayrollInputLineSerializer, PayrollInputLineWriteSerializer, PayrollPeriodSerializer, PayrollPeriodWriteSerializer, + PayrollPaymentConfirmationCommandSerializer, PayrollRunSerializer, PayrollUserRefSerializer, PayrollWorkRecordRevisionSerializer, @@ -127,6 +133,7 @@ from .admin_serializers import ( from .views import NoStorePayrollResponseMixin, PayrollConflict Employee = get_user_model() +logger = logging.getLogger(__name__) PAYROLL_PERMISSIONS = { "manage_inputs": "finance.manage_payroll_inputs", @@ -296,51 +303,471 @@ def _serialize_cell_comment(message, request): 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 - ) + statements = PayrollStatement.objects.filter( + run=period.current_run, + ) if period.current_run_id else PayrollStatement.objects.none() return { - "outgoing": outgoing.count(), - "incoming": incoming.count(), - "pending": outgoing.filter(status=PayrollCarryoverStatus.PENDING).count(), + "outgoing": statements.exclude(closing_balance=0).count(), + "incoming": statements.exclude(opening_balance=0).count(), + "pending": 0, } -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) +def _automatic_balance_summaries(rows): + summaries = [] + for row in rows: + balance = Decimal(row["closing_balance"]) + if balance == 0: + continue + summaries.append( + { + "employee_id": row["employee"]["id"], + "count": 1, + "company_owes": str(max(balance, Decimal("0.00"))), + "employee_owes": str(max(-balance, Decimal("0.00"))), + "pending": 0, + } ) - .exclude(status=PayrollCarryoverStatus.CANCELLED) - .values("employee_id", "direction", "amount", "status") + return summaries + + +def _validate_input_copy_periods(target_period, source_period): + if source_period.pk == target_period.pk: + raise ValidationError( + {"source_period_id": "Период-источник должен отличаться от текущего."} + ) + if source_period.date_to >= target_period.date_from: + raise ValidationError( + {"source_period_id": "Переносить записи можно только из более раннего периода."} + ) + if source_period.currency != target_period.currency: + raise ValidationError( + {"source_period_id": "Валюты исходного и текущего периодов различаются."} + ) + + +def _copyable_input_lines(source_period): + return ( + PayrollInputLine.objects.filter( + period=source_period, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + component__is_active=True, + ) + .exclude( + source=InputSource.API, + source_ref__startswith="payroll-carryover:", + ) + .select_related( + "employee", + "employee__position", + "component", + "relates_to_period", + ) + .prefetch_related( + "employee__departments_links__department", + "employee__actions__source_request", + ) + .order_by("employee_id", "component__display_order", "id") ) - 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"], + + +def _employee_active_for_period(employee, period): + if not employee.is_active: + return False + actions = employee._prefetched_objects_cache.get("actions") + state = resolve_employee_personnel_state( + employee, + period.date_to, + actions=actions, + ) + return state.status != ACTION_DISMISSED + + +def _input_copy_configuration_payload(actor): + configuration = PayrollInputCopyConfiguration.objects.filter(owner=actor).first() + if configuration is None: + return { + "saved": False, + "component_ids": [], + "employee_scope": "active", + "employee_ids": [], + "existing_mode": "skip", + "auto_apply_on_period_creation": False, } - for employee_id, summary in summaries.items() + return { + "saved": True, + "component_ids": configuration.component_ids, + "employee_scope": configuration.employee_scope, + "employee_ids": configuration.employee_ids, + "existing_mode": configuration.existing_mode, + "auto_apply_on_period_creation": ( + configuration.auto_apply_on_period_creation + ), + } + + +def _input_copy_preview(target_period, source_period, *, actor): + _validate_input_copy_periods(target_period, source_period) + lines = list(_copyable_input_lines(source_period)) + component_rows = { + component.pk: { + "component": component, + "line_count": 0, + "employee_ids": set(), + "amount_total": Decimal("0.00"), + } + for component in PayrollComponent.objects.filter(is_active=True).order_by( + "display_order", + "code", + ) + } + employee_rows = {} + for line in lines: + component_row = component_rows[line.component_id] + component_row["line_count"] += 1 + component_row["employee_ids"].add(line.employee_id) + component_row["amount_total"] += line.amount + employee_row = employee_rows.get(line.employee_id) + if employee_row is None: + employee_row = { + "employee": line.employee, + "is_active": _employee_active_for_period( + line.employee, + target_period, + ), + "line_count": 0, + "component_counts": {}, + } + employee_rows[line.employee_id] = employee_row + employee_row["line_count"] += 1 + employee_row["component_counts"][str(line.component_id)] = ( + employee_row["component_counts"].get(str(line.component_id), 0) + 1 + ) + existing_counts = {} + if employee_rows and component_rows: + for row in ( + PayrollInputLine.objects.filter( + period=target_period, + employee_id__in=employee_rows, + component_id__in=component_rows, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + ) + .exclude( + source=InputSource.API, + source_ref__startswith="payroll-carryover:", + ) + .values("employee_id", "component_id") + .annotate(count=Count("id")) + ): + existing_counts.setdefault(row["employee_id"], {})[ + str(row["component_id"]) + ] = row["count"] + components = [ + { + **PayrollComponentSerializer(row["component"]).data, + "line_count": row["line_count"], + "employee_count": len(row["employee_ids"]), + "amount_total": str(row["amount_total"]), + } + for row in component_rows.values() ] + employees = [ + { + **PayrollEmployeeMiniSerializer(row["employee"]).data, + "is_active": row["is_active"], + "line_count": row["line_count"], + "component_counts": row["component_counts"], + "existing_component_counts": existing_counts.get( + row["employee"].pk, + {}, + ), + } + for row in employee_rows.values() + ] + employees.sort(key=lambda item: item["display_name"].casefold()) + return { + "source_period": PayrollPeriodSerializer(source_period).data, + "target_period": PayrollPeriodSerializer(target_period).data, + "summary": { + "eligible_lines": len(lines), + "employees": len(employees), + "active_employees": sum(item["is_active"] for item in employees), + }, + "components": components, + "employees": employees, + "configuration": _input_copy_configuration_payload(actor), + } + + +def _apply_input_copy(*, target_period, source_period, actor, data): + copy_prefix = ( + f"payroll-period-copy:{target_period.pk}:{source_period.pk}:" + f"{data['idempotency_key']}:" + ) + existing = list( + PayrollInputLine.objects.filter( + period=target_period, + source_ref__startswith=copy_prefix, + ) + .order_by("id") + .values_list("id", flat=True) + ) + if existing: + return { + "source_period_id": source_period.pk, + "target_period_id": target_period.pk, + "summary": { + "created": len(existing), + "employees": PayrollInputLine.objects.filter( + pk__in=existing + ).values("employee_id").distinct().count(), + "skipped": 0, + "replaced_cells": 0, + "voided": 0, + }, + "record_ids": existing, + "idempotent_replay": True, + } + lines = list( + _copyable_input_lines(source_period).filter( + component_id__in=data["component_ids"] + ) + ) + if data["employee_scope"] == "selected": + selected_ids = set(data["employee_ids"]) + lines = [line for line in lines if line.employee_id in selected_ids] + elif data["employee_scope"] == "active": + lines = [ + line + for line in lines + if _employee_active_for_period(line.employee, target_period) + ] + selected_cells = { + (line.employee_id, line.component_id) + for line in lines + } + existing_lines = [] + if selected_cells: + possible_existing_lines = list( + PayrollInputLine.objects.select_for_update() + .filter( + period=target_period, + employee_id__in={employee_id for employee_id, _ in selected_cells}, + component_id__in={component_id for _, component_id in selected_cells}, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + ) + .order_by("pk") + ) + existing_lines = [ + line + for line in possible_existing_lines + if (line.employee_id, line.component_id) in selected_cells + and not ( + line.source == InputSource.API + and line.source_ref.startswith("payroll-carryover:") + ) + ] + occupied_cells = { + (line.employee_id, line.component_id) + for line in existing_lines + } + skipped = 0 + replaced_cells = 0 + voided = 0 + if data["existing_mode"] == "skip" and occupied_cells: + kept_lines = [] + for line in lines: + if (line.employee_id, line.component_id) in occupied_cells: + skipped += 1 + else: + kept_lines.append(line) + lines = kept_lines + elif data["existing_mode"] == "replace" and existing_lines: + replaced_cells = len(occupied_cells) + voided = len(existing_lines) + now = timezone.now() + for line in existing_lines: + previous_status = line.status + line.status = ApprovalStatus.VOIDED + line.voided_by = actor + line.voided_at = now + line.void_reason = ( + f"Заменено переносом записей из периода {source_period.code}" + ) + line.save( + update_fields=[ + "status", + "voided_by", + "voided_at", + "void_reason", + ] + ) + _audit_api_mutation( + actor=actor, + action="payroll.input_line_voided_from_period_copy", + instance=line, + period=target_period, + metadata={ + "previous_status": previous_status, + "source_period_id": source_period.pk, + }, + ) + records = PayrollInputLine.objects.bulk_create( + [ + PayrollInputLine( + period=target_period, + employee=line.employee, + component=line.component, + amount=line.amount, + relates_to_period=line.relates_to_period, + reason=line.reason, + status=ApprovalStatus.DRAFT, + source=InputSource.API, + source_ref=f"{copy_prefix}{line.pk}", + include_in_carryover=line.include_in_carryover, + created_by=actor, + ) + for line in lines + ] + ) + PayrollAuditEvent.objects.bulk_create( + [ + PayrollAuditEvent( + actor=actor, + action="payroll.input_line_draft_created", + object_type=PayrollInputLine._meta.label_lower, + object_id=str(record.pk), + period=target_period, + metadata={ + "channel": "admin_api", + "copied_from_period_id": source_period.pk, + "copied_from_input_line_id": source.pk, + }, + ) + for record, source in zip(records, lines, strict=True) + ] + ) + return { + "source_period_id": source_period.pk, + "target_period_id": target_period.pk, + "summary": { + "created": len(records), + "employees": len({record.employee_id for record in records}), + "skipped": skipped, + "replaced_cells": replaced_cells, + "voided": voided, + }, + "record_ids": [record.pk for record in records], + "idempotent_replay": False, + } + + +def _carryover_ledger(period): + published_period_ids = set( + PayrollRun.objects.filter( + status=PayrollRunStatus.PUBLISHED, + period__currency=period.currency, + period__date_to__lte=period.date_to, + ).values_list("period_id", flat=True) + ) + target_is_published = period.pk in published_period_ids + lines = ( + PayrollInputLine.objects.filter( + period__currency=period.currency, + period__date_to__lte=period.date_to, + component__kind__in=( + PayrollComponentKind.ADJUSTMENT_CREDIT, + PayrollComponentKind.ADJUSTMENT_DEBIT, + ), + ) + .exclude( + source=InputSource.API, + source_ref__startswith="payroll-carryover:", + ) + .select_related( + "period", + "employee", + "employee__position", + "component", + "created_by", + ) + .prefetch_related("employee__departments_links__department") + .order_by("-period__date_to", "-created_at", "-id") + ) + entries = [] + balances_by_employee = {} + included_count = 0 + excluded_count = 0 + for line in lines: + current_period_line = line.period_id == period.pk + active_status = line.status == ApprovalStatus.APPROVED or ( + current_period_line + and not target_is_published + and line.status == ApprovalStatus.DRAFT + ) + active_period = current_period_line or line.period_id in published_period_ids + counts_in_balance = bool( + line.include_in_carryover and active_status and active_period + ) + raw_contribution = ( + line.amount + if line.component.kind == PayrollComponentKind.ADJUSTMENT_DEBIT + else -line.amount + ) + contribution = raw_contribution if counts_in_balance else Decimal("0.00") + balances_by_employee[line.employee_id] = ( + balances_by_employee.get(line.employee_id, Decimal("0.00")) + + contribution + ) + if line.include_in_carryover: + included_count += 1 + else: + excluded_count += 1 + entries.append( + { + "id": line.pk, + "period": { + "id": line.period_id, + "code": line.period.code, + "name": line.period.name, + "date_from": line.period.date_from, + "date_to": line.period.date_to, + "currency": line.period.currency, + }, + "employee": PayrollEmployeeMiniSerializer(line.employee).data, + "component": PayrollComponentSerializer(line.component).data, + "amount": str(line.amount), + "raw_contribution": str(raw_contribution), + "contribution": str(contribution), + "reason": line.reason, + "status": line.status, + "status_label": line.get_status_display(), + "include_in_carryover": line.include_in_carryover, + "counts_in_balance": counts_in_balance, + "created_by": PayrollUserRefSerializer(line.created_by).data, + "created_at": line.created_at, + } + ) + company_owes = sum( + (max(balance, Decimal("0.00")) for balance in balances_by_employee.values()), + Decimal("0.00"), + ) + employee_owes = sum( + (max(-balance, Decimal("0.00")) for balance in balances_by_employee.values()), + Decimal("0.00"), + ) + return { + "period_id": period.pk, + "currency": period.currency, + "summary": { + "included_count": included_count, + "excluded_count": excluded_count, + "company_owes": str(company_owes), + "employee_owes": str(employee_owes), + "balance": str(company_owes - employee_owes), + }, + "entries": entries, + } def _lock_period_accepting_draft_edits(period_id): @@ -850,7 +1277,10 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): except IntegrityError: _conflict( "PERIOD_CONFLICT", - "Параллельная операция уже создала пересекающийся период.", + ( + "Не удалось создать расчётный период: сохранённые периоды " + "пересекаются с указанными датами. Обновите список периодов." + ), ) _audit_api_mutation( actor=request.user, @@ -858,9 +1288,49 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): instance=period, period=period, ) - materialize_pending_carryovers(period) + automatic_copy = None + configuration = PayrollInputCopyConfiguration.objects.filter( + owner=request.user, + auto_apply_on_period_creation=True, + ).first() + if configuration is not None: + source_period = ( + PayrollPeriod.objects.select_for_update(of=("self",)) + .filter( + currency=period.currency, + date_to__lt=period.date_from, + ) + .exclude(pk=period.pk) + .order_by("-date_to", "-id") + .first() + ) + if source_period is not None: + automatic_copy = _apply_input_copy( + target_period=period, + source_period=source_period, + actor=request.user, + data={ + "component_ids": configuration.component_ids, + "employee_scope": configuration.employee_scope, + "employee_ids": configuration.employee_ids, + "existing_mode": configuration.existing_mode, + "idempotency_key": uuid.uuid4(), + }, + ) + _audit_api_mutation( + actor=request.user, + action="payroll.period_input_copy_auto_applied", + instance=period, + period=period, + metadata={ + "source_period_id": source_period.pk, + **automatic_copy["summary"], + }, + ) + payload = PayrollPeriodSerializer(period).data + payload["automatic_input_copy"] = automatic_copy return Response( - PayrollPeriodSerializer(period).data, + payload, status=status.HTTP_201_CREATED, ) @@ -929,57 +1399,93 @@ class PayrollPeriodTableView(PayrollAdminAPIView): ) payload = build_payroll_period_table(period) payload["cell_comments"] = _cell_comment_summaries(period) - payload["carryovers"] = _carryover_employee_summaries(period) + payload["carryovers"] = _automatic_balance_summaries(payload["rows"]) return Response(payload) +class PayrollStatementPaymentConfirmationView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + @transaction.atomic + def post(self, request, pk, employee_id): + command = PayrollPaymentConfirmationCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + period = get_object_or_404( + PayrollPeriod.objects.select_for_update().select_related("current_run"), + pk=pk, + ) + if period.status != PayrollPeriodStatus.CLOSED: + _conflict( + "PERIOD_NOT_CLOSED", + "Подтвердить фактическую выплату можно только в закрытом периоде.", + ) + if period.current_run_id is None: + _conflict( + "PAYROLL_STATEMENT_NOT_FOUND", + "В закрытом периоде нет итогового расчёта.", + ) + statement = get_object_or_404( + PayrollStatement.objects.select_for_update(), + run_id=period.current_run_id, + employee_id=employee_id, + ) + confirmed = command.validated_data["confirmed"] + if confirmed: + confirmation, _ = PayrollStatementPaymentConfirmation.objects.get_or_create( + statement=statement, + defaults={"confirmed_by": request.user}, + ) + action = "payroll.statement_payment_confirmed" + else: + PayrollStatementPaymentConfirmation.objects.filter( + statement=statement, + ).delete() + confirmation = None + action = "payroll.statement_payment_confirmation_revoked" + _audit_api_mutation( + actor=request.user, + action=action, + instance=statement, + period=period, + metadata={"employee_id": employee_id}, + ) + return Response( + { + "statement_id": statement.pk, + "employee_id": employee_id, + "confirmed": confirmation is not None, + "confirmed_at": ( + confirmation.confirmed_at.isoformat() + if confirmation is not None + else None + ), + "confirmed_by": ( + PayrollUserRefSerializer(confirmation.confirmed_by).data + if confirmation is not None + else None + ), + } + ) + + 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, - } - ) + return Response(_carryover_ledger(period)) 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, + { + "code": "MANUAL_CARRYOVER_DISABLED", + "message": ( + "Отдельные переносы не создаются. Баланс складывается из " + "положительных и отрицательных корректировок." + ), + }, + status=status.HTTP_410_GONE, ) @@ -1001,6 +1507,53 @@ class PayrollCarryoverDetailView(PayrollAdminAPIView): return Response(PayrollCarryoverSerializer(carryover).data) +class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + @transaction.atomic + def patch(self, request, pk): + command = PayrollCarryoverInclusionSerializer(data=request.data) + command.is_valid(raise_exception=True) + line = get_object_or_404( + PayrollInputLine.objects.select_for_update(of=("self",)).select_related( + "period", "component" + ), + pk=pk, + component__kind__in=( + PayrollComponentKind.ADJUSTMENT_CREDIT, + PayrollComponentKind.ADJUSTMENT_DEBIT, + ), + ) + included = command.validated_data["included"] + previous = line.include_in_carryover + if previous != included: + line.include_in_carryover = included + line.save(update_fields=["include_in_carryover", "updated_at"]) + _audit_api_mutation( + actor=request.user, + action="payroll.input_line_carryover_inclusion_updated", + instance=line, + period=line.period, + metadata={"before": previous, "after": included}, + ) + return Response( + { + "id": line.pk, + "include_in_carryover": line.include_in_carryover, + "message": ( + "Запись участвует в переходящем остатке. " + "Если период уже рассчитан, запустите перерасчёт." + if line.include_in_carryover + else ( + "Запись исключена только из переходящего остатка; " + "зарплата исходного периода не изменена. Если период " + "уже рассчитан, запустите перерасчёт." + ) + ), + } + ) + + class PayrollCellCommentsView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],) @@ -1155,9 +1708,19 @@ class DraftCollectionView(PayrollAdminAPIView): try: instance = serializer.save() except IntegrityError: + logger.exception( + "Database rejected payroll draft creation", + extra={ + "payroll_model": self.model._meta.label, + "payroll_actor_id": request.user.pk, + }, + ) _conflict( - "DRAFT_CONFLICT", - "Параллельная операция уже создала такие входные данные.", + "DRAFT_SAVE_FAILED", + ( + "Не удалось сохранить данные расчёта: база данных отклонила " + "запись. Обновите страницу и повторите попытку." + ), ) period = getattr(instance, "period", None) _audit_api_mutation( @@ -1331,7 +1894,10 @@ class DraftDetailView(PayrollAdminAPIView): except IntegrityError: _conflict( "DRAFT_CONFLICT", - "Изменение конфликтует с уже сохранёнными входными данными.", + ( + "Не удалось сохранить изменения: база данных отклонила " + "запись. Обновите данные и повторите попытку." + ), ) _audit_api_mutation( actor=request.user, @@ -1588,6 +2154,72 @@ class PayrollComponentCellClearView(PayrollAdminAPIView): return Response(summary) +class PayrollInputLinePeriodCopyView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + def get(self, request, pk): + target_period = get_object_or_404(PayrollPeriod, pk=pk) + source_period_id = _optional_int(request, "source_period_id") + if source_period_id is None: + raise ValidationError( + {"source_period_id": "Выберите период-источник."} + ) + source_period = get_object_or_404(PayrollPeriod, pk=source_period_id) + return Response( + _input_copy_preview( + target_period, + source_period, + actor=request.user, + ) + ) + + @transaction.atomic + def post(self, request, pk): + command = CopyPayrollInputLinesCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + data = command.validated_data + target_period = _lock_period_accepting_draft_edits(pk) + if target_period.lock_version != data["expected_period_lock_version"]: + _conflict( + "STALE_PERIOD", + "Период уже изменён; обновите данные и повторите перенос.", + expected_lock_version=data["expected_period_lock_version"], + actual_lock_version=target_period.lock_version, + ) + source_period = get_object_or_404( + PayrollPeriod.objects.select_for_update(of=("self",)), + pk=data["source_period_id"], + ) + _validate_input_copy_periods(target_period, source_period) + if data["remember_configuration"]: + PayrollInputCopyConfiguration.objects.update_or_create( + owner=request.user, + defaults={ + "component_ids": data["component_ids"], + "employee_scope": data["employee_scope"], + "employee_ids": data["employee_ids"], + "existing_mode": data["existing_mode"], + "auto_apply_on_period_creation": data[ + "auto_apply_on_period_creation" + ], + }, + ) + else: + PayrollInputCopyConfiguration.objects.filter(owner=request.user).delete() + result = _apply_input_copy( + target_period=target_period, + source_period=source_period, + actor=request.user, + data=data, + ) + return Response( + result, + status=( + status.HTTP_200_OK + if result["idempotent_replay"] + else status.HTTP_201_CREATED + ), + ) class DraftApprovalView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],) service = None @@ -1682,7 +2314,7 @@ class EmployeePayRateReviseView(PayrollAdminAPIView): except IntegrityError: _conflict( "REVISION_CONFLICT", - "Параллельная операция уже создала ревизию ставки.", + "Не удалось создать новую версию ставки. Обновите данные и повторите попытку.", ) _audit_api_mutation( actor=request.user, @@ -1760,7 +2392,7 @@ class PayrollWorkRecordReviseView(PayrollAdminAPIView): except IntegrityError: _conflict( "REVISION_CONFLICT", - "Параллельная операция уже создала ревизию показателей.", + "Не удалось создать новую версию показателей. Обновите данные и повторите попытку.", ) _audit_api_mutation( actor=request.user, diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py index 9529e542..d44ff98f 100644 --- a/backend/api/v1/finance/serializers.py +++ b/backend/api/v1/finance/serializers.py @@ -5,6 +5,7 @@ from rest_framework import serializers from finance.models import ( PayrollStatement, PayrollStatementAcknowledgement, + PayrollStatementPaymentConfirmation, PayrollStatementLine, PayrollDailyWorkEntry, PayrollWorkRecord, @@ -40,6 +41,26 @@ class PayrollStatementAcknowledgementSerializer(serializers.ModelSerializer): fields = ["viewed_at", "acknowledged_at", "disputed_at"] +class PayrollStatementPaymentConfirmationSerializer(serializers.ModelSerializer): + confirmed_by = serializers.SerializerMethodField() + + class Meta: + model = PayrollStatementPaymentConfirmation + fields = ["confirmed_at", "confirmed_by"] + read_only_fields = fields + + def get_confirmed_by(self, obj): + user = obj.confirmed_by + return { + "id": user.pk, + "display_name": user.get_full_name().strip() or f"Сотрудник #{user.pk}", + } + + +class OwnPayrollPaymentConfirmationWriteSerializer(serializers.Serializer): + confirmed = serializers.BooleanField() + + def period_payload(statement): period = statement.run.period return { @@ -59,6 +80,14 @@ def acknowledgement_payload(statement): return PayrollStatementAcknowledgementSerializer(acknowledgement).data +def payment_confirmation_payload(statement): + try: + confirmation = statement.payment_confirmation + except PayrollStatementPaymentConfirmation.DoesNotExist: + return None + return PayrollStatementPaymentConfirmationSerializer(confirmation).data + + class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer): """List metadata only; detailed amounts/lines require the audited endpoint.""" @@ -69,6 +98,8 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer): read_only=True, ) acknowledgement = serializers.SerializerMethodField() + payment_confirmation = serializers.SerializerMethodField() + can_confirm_payment = serializers.SerializerMethodField() class Meta: model = PayrollStatement @@ -80,6 +111,8 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer): "currency", "payable", "acknowledgement", + "payment_confirmation", + "can_confirm_payment", ] read_only_fields = fields @@ -89,6 +122,12 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer): def get_acknowledgement(self, obj): return acknowledgement_payload(obj) + def get_payment_confirmation(self, obj): + return payment_confirmation_payload(obj) + + def get_can_confirm_payment(self, obj): + return obj.run.period.status == "closed" + class OwnPayrollStatementSerializer(serializers.ModelSerializer): period = serializers.SerializerMethodField() @@ -99,6 +138,8 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): ) lines = PayrollStatementLineSerializer(many=True, read_only=True) acknowledgement = serializers.SerializerMethodField() + payment_confirmation = serializers.SerializerMethodField() + can_confirm_payment = serializers.SerializerMethodField() class Meta: model = PayrollStatement @@ -115,9 +156,13 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): "deduction_total", "net_pay", "payment_total", + "opening_balance", + "closing_balance", "payable", "lines", "acknowledgement", + "payment_confirmation", + "can_confirm_payment", ] read_only_fields = fields @@ -127,6 +172,12 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): def get_acknowledgement(self, obj): return acknowledgement_payload(obj) + def get_payment_confirmation(self, obj): + return payment_confirmation_payload(obj) + + def get_can_confirm_payment(self, obj): + return obj.run.period.status == "closed" + class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer): status_label = serializers.CharField(source="get_status_display", read_only=True) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 08d07ef9..29fada06 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -4,6 +4,7 @@ from .views import ( OwnPayrollStatementAcknowledgeView, OwnPayrollStatementDetailView, OwnPayrollStatementListView, + OwnPayrollStatementPaymentConfirmationView, OwnPayrollWorkRecordView, ) from .admin_views import ( @@ -26,8 +27,10 @@ from .admin_views import ( PayrollManagementAccessSettingsView, PayrollAttendanceWorkRecordsView, PayrollInputLineApproveView, + PayrollInputLineCarryoverInclusionView, PayrollInputLineDetailView, PayrollInputLineListCreateView, + PayrollInputLinePeriodCopyView, PayrollInputHistoryView, PayrollPeriodCalculateView, PayrollPeriodCarryoverListCreateView, @@ -35,6 +38,7 @@ from .admin_views import ( PayrollPeriodApproveDraftsView, PayrollPeriodCloseView, PayrollPeriodPointBreakdownView, + PayrollStatementPaymentConfirmationView, PayrollPeriodTableView, PayrollRunApproveView, PayrollRunDetailView, @@ -102,6 +106,11 @@ urlpatterns = [ PayrollPeriodTableView.as_view(), name="admin-period-table", ), + path( + "payroll/admin/periods//employees//payment-confirmation/", + PayrollStatementPaymentConfirmationView.as_view(), + name="admin-period-payment-confirmation", + ), path( "payroll/admin/periods//carryovers/", PayrollPeriodCarryoverListCreateView.as_view(), @@ -112,6 +121,11 @@ urlpatterns = [ PayrollCarryoverDetailView.as_view(), name="admin-carryover-detail", ), + path( + "payroll/admin/input-lines//carryover-inclusion/", + PayrollInputLineCarryoverInclusionView.as_view(), + name="admin-input-line-carryover-inclusion", + ), path( "payroll/admin/periods//employees//cells//comments/", PayrollCellCommentsView.as_view(), @@ -157,6 +171,11 @@ urlpatterns = [ PayrollBulkTargetPointsView.as_view(), name="admin-period-bulk-target-points", ), + path( + "payroll/admin/periods//copy-input-lines/", + PayrollInputLinePeriodCopyView.as_view(), + name="admin-period-copy-input-lines", + ), path( "payroll/admin/periods//close/", PayrollPeriodCloseView.as_view(), @@ -272,4 +291,9 @@ urlpatterns = [ OwnPayrollStatementAcknowledgeView.as_view(), name="own-statement-acknowledge", ), + path( + "payroll/me/statements//payment-confirmation/", + OwnPayrollStatementPaymentConfirmationView.as_view(), + name="own-statement-payment-confirmation", + ), ] diff --git a/backend/api/v1/finance/views.py b/backend/api/v1/finance/views.py index a463d335..f1665b11 100644 --- a/backend/api/v1/finance/views.py +++ b/backend/api/v1/finance/views.py @@ -1,5 +1,6 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 +from django.db import transaction from django.utils import timezone from django.utils.cache import patch_vary_headers from rest_framework import generics, permissions, status @@ -13,6 +14,7 @@ from finance.models import ( PayrollDailyWorkEntry, PayrollPeriod, PayrollStatement, + PayrollStatementPaymentConfirmation, PayrollWorkSettings, PayrollWorkRecord, ) @@ -31,8 +33,10 @@ from .serializers import ( OwnPayrollDailyWorkEntryWriteSerializer, OwnPayrollStatementSerializer, OwnPayrollStatementSummarySerializer, + OwnPayrollPaymentConfirmationWriteSerializer, OwnPayrollWorkRecordSerializer, PayrollStatementAcknowledgementSerializer, + PayrollStatementPaymentConfirmationSerializer, ) @@ -56,8 +60,13 @@ def own_published_statements(user): employee=user, run__status=PayrollRunStatus.PUBLISHED, ) - .select_related("run", "run__period") - .prefetch_related("lines", "acknowledgement") + .select_related( + "run", + "run__period", + "acknowledgement", + "payment_confirmation__confirmed_by", + ) + .prefetch_related("lines") .order_by("-run__period__date_from", "-run__revision") ) @@ -173,6 +182,70 @@ class OwnPayrollStatementAcknowledgeView( ) +class OwnPayrollStatementPaymentConfirmationView( + NoStorePayrollResponseMixin, + APIView, +): + permission_classes = [permissions.IsAuthenticated] + + @transaction.atomic + def post(self, request, public_id): + command = OwnPayrollPaymentConfirmationWriteSerializer(data=request.data) + command.is_valid(raise_exception=True) + statement = ( + PayrollStatement.objects.select_for_update() + .select_related("run", "run__period") + .filter( + public_id=public_id, + employee=request.user, + run__status=PayrollRunStatus.PUBLISHED, + ) + .first() + ) + if statement is None: + raise Http404 + if statement.run.period.status != PayrollPeriodStatus.CLOSED: + raise PayrollConflict( + detail={ + "code": "PERIOD_NOT_CLOSED", + "message": ( + "Подтвердить получение денег можно после закрытия периода." + ), + } + ) + confirmed = command.validated_data["confirmed"] + if confirmed: + confirmation, _ = PayrollStatementPaymentConfirmation.objects.get_or_create( + statement=statement, + defaults={"confirmed_by": request.user}, + ) + action = "payroll.statement_payment_confirmed" + else: + PayrollStatementPaymentConfirmation.objects.filter( + statement=statement, + ).delete() + confirmation = None + action = "payroll.statement_payment_confirmation_revoked" + PayrollAuditEvent.objects.create( + actor=request.user, + action=action, + object_type=statement._meta.label_lower, + object_id=str(statement.pk), + period=statement.run.period, + after_hash=statement.result_hash, + metadata={"channel": "employee_api"}, + ) + return Response( + { + "payment_confirmation": ( + PayrollStatementPaymentConfirmationSerializer(confirmation).data + if confirmation is not None + else None + ) + } + ) + + class OwnPayrollWorkRecordView( NoStorePayrollResponseMixin, APIView, diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 5381afbe..c07f0393 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -858,7 +858,7 @@ COMMUNICATIONS_PARTICIPANT_RESOLVER = ( # version after changing calculation semantics. FINANCE_PAYROLL = { "RULESET_ID": os.getenv("PAYROLL_RULESET_ID", "eusrr-standard"), - "RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.07.4"), + "RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.08.1"), "EFFECTIVE_FROM": os.getenv("PAYROLL_EFFECTIVE_FROM", "2026-01-01"), "EFFECTIVE_TO": os.getenv("PAYROLL_EFFECTIVE_TO") or None, # Excel-compatible point basis is base accrual plus bonus inputs. Below the @@ -871,7 +871,7 @@ FINANCE_PAYROLL = { "MONEY_QUANTUM": os.getenv("PAYROLL_MONEY_QUANTUM", "0.01"), "ROUNDING": os.getenv("PAYROLL_ROUNDING", "half_up"), "ALLOW_NEGATIVE_PAYABLE": os.getenv( - "PAYROLL_ALLOW_NEGATIVE_PAYABLE", "false" + "PAYROLL_ALLOW_NEGATIVE_PAYABLE", "true" ), "BASE_RATE_CODE": os.getenv("PAYROLL_BASE_RATE_CODE", "BASE"), # Temporary pilot mode. Set to false after the granular finance roles and diff --git a/backend/finance/README.md b/backend/finance/README.md index 65abe705..4634c5ef 100644 --- a/backend/finance/README.md +++ b/backend/finance/README.md @@ -49,6 +49,14 @@ employees above or below the point target. | Аванс | payment input line, reducing payable | | Итого / выработка / перерасчёт / к выплате | reconciliation checkpoints | +Published statements persist a signed correction balance. Only explicit manual +adjustment lines change it: adjustment debits increase the balance and adjustment +credits decrease it. Point-based recalculation, ordinary earnings, deductions and +recorded payments never create a carryover. The next period uses the last published +closing balance as its opening balance until opposite corrections net it to zero. +Historical statements from before this feature start with a zero closing balance +and are not backfilled. + Checkpoint columns can block review on mismatch but never feed money back into the calculation. diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 0adbd676..0cdf812e 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -702,6 +702,8 @@ class PayrollStatementAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): "gross_total", "deduction_total", "payment_total", + "opening_balance", + "closing_balance", "payable", ] list_filter = ["run__period", "run__status", "currency"] diff --git a/backend/finance/migrations/0019_payroll_automatic_balances.py b/backend/finance/migrations/0019_payroll_automatic_balances.py new file mode 100644 index 00000000..6b637146 --- /dev/null +++ b/backend/finance/migrations/0019_payroll_automatic_balances.py @@ -0,0 +1,53 @@ +from decimal import Decimal + +from django.db import migrations, models + + +def seed_actual_payment_component(apps, schema_editor): + component_model = apps.get_model("finance", "PayrollComponent") + component_model.objects.update_or_create( + code="SALARY_PAYMENT", + defaults={ + "name": "Фактическая выплата", + "kind": "payment", + "requires_reason": True, + "is_active": True, + "display_order": 80, + }, + ) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0018_financesettings")] + + operations = [ + migrations.RemoveConstraint( + model_name="payrollrun", + name="pay_run_payable_nonnegative", + ), + migrations.RemoveConstraint( + model_name="payrollstatement", + name="pay_statement_payable_nonnegative", + ), + migrations.AddField( + model_name="payrollstatement", + name="opening_balance", + field=models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=21, + verbose_name="Входящий баланс", + ), + ), + migrations.AddField( + model_name="payrollstatement", + name="closing_balance", + field=models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=21, + verbose_name="Исходящий баланс", + ), + ), + migrations.RunPython(seed_actual_payment_component, migrations.RunPython.noop), + ] diff --git a/backend/finance/migrations/0020_payrollinputline_updated_at.py b/backend/finance/migrations/0020_payrollinputline_updated_at.py new file mode 100644 index 00000000..a3a7833e --- /dev/null +++ b/backend/finance/migrations/0020_payrollinputline_updated_at.py @@ -0,0 +1,46 @@ +from django.db import migrations, models +from django.utils import timezone + + +def ensure_updated_at_column(apps, schema_editor): + """Reconcile databases created with the legacy input-line timestamp column.""" + + input_line_model = apps.get_model("finance", "PayrollInputLine") + table_name = input_line_model._meta.db_table + with schema_editor.connection.cursor() as cursor: + columns = schema_editor.connection.introspection.get_table_description( + cursor, + table_name, + ) + if any(column.name == "updated_at" for column in columns): + return + + database_field = models.DateTimeField(default=timezone.now) + database_field.set_attributes_from_name("updated_at") + database_field.model = input_line_model + schema_editor.add_field(input_line_model, database_field) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0019_payroll_automatic_balances")] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython( + ensure_updated_at_column, + migrations.RunPython.noop, + ), + ], + state_operations=[ + migrations.AddField( + model_name="payrollinputline", + name="updated_at", + field=models.DateTimeField( + auto_now=True, + verbose_name="Обновлена", + ), + ), + ], + ), + ] diff --git a/backend/finance/migrations/0021_payrollinputline_include_in_carryover.py b/backend/finance/migrations/0021_payrollinputline_include_in_carryover.py new file mode 100644 index 00000000..fd848bd4 --- /dev/null +++ b/backend/finance/migrations/0021_payrollinputline_include_in_carryover.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("finance", "0020_payrollinputline_updated_at")] + + operations = [ + migrations.AddField( + model_name="payrollinputline", + name="include_in_carryover", + field=models.BooleanField( + default=True, + help_text=( + "Используется только для положительных и отрицательных " + "корректировок; на расчёт зарплаты самой строки не влияет." + ), + verbose_name="Учитывать в переходящем остатке", + ), + ), + ] diff --git a/backend/finance/migrations/0022_payrollinputcopyconfiguration.py b/backend/finance/migrations/0022_payrollinputcopyconfiguration.py new file mode 100644 index 00000000..acd361a3 --- /dev/null +++ b/backend/finance/migrations/0022_payrollinputcopyconfiguration.py @@ -0,0 +1,72 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0021_payrollinputline_include_in_carryover"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PayrollInputCopyConfiguration", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("component_ids", models.JSONField(default=list, verbose_name="Виды операций")), + ( + "employee_scope", + models.CharField( + choices=[ + ("all", "Все из источника"), + ("active", "Все активные"), + ("selected", "Выбранные"), + ], + default="active", + max_length=16, + verbose_name="Охват сотрудников", + ), + ), + ("employee_ids", models.JSONField(default=list, verbose_name="Выбранные сотрудники")), + ( + "existing_mode", + models.CharField( + choices=[("skip", "Пропустить"), ("replace", "Заменить")], + default="skip", + max_length=16, + verbose_name="Заполненные значения", + ), + ), + ( + "auto_apply_on_period_creation", + models.BooleanField( + default=False, + verbose_name="Автоматически применять при создании периода", + ), + ), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлена")), + ( + "owner", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="payroll_input_copy_configuration", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "Конфигурация переноса зарплатных записей", + "verbose_name_plural": "Конфигурации переноса зарплатных записей", + }, + ), + ] diff --git a/backend/finance/migrations/0023_payrollstatementpaymentconfirmation.py b/backend/finance/migrations/0023_payrollstatementpaymentconfirmation.py new file mode 100644 index 00000000..c6d00488 --- /dev/null +++ b/backend/finance/migrations/0023_payrollstatementpaymentconfirmation.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0022_payrollinputcopyconfiguration"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PayrollStatementPaymentConfirmation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "confirmed_at", + models.DateTimeField( + auto_now_add=True, + verbose_name="Выплата подтверждена", + ), + ), + ( + "confirmed_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="confirmed_payroll_statement_payments", + to=settings.AUTH_USER_MODEL, + verbose_name="Подтвердил выплату", + ), + ), + ( + "statement", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="payment_confirmation", + to="finance.payrollstatement", + verbose_name="Расчётный листок", + ), + ), + ], + options={ + "verbose_name": "Подтверждение выплаты зарплаты", + "verbose_name_plural": "Подтверждения выплаты зарплаты", + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 6a3840f6..8489ed35 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -1017,6 +1017,14 @@ class PayrollInputLine(DraftVersionedModel): unique=True, editable=False, ) + include_in_carryover = models.BooleanField( + "Учитывать в переходящем остатке", + default=True, + help_text=( + "Используется только для положительных и отрицательных корректировок; " + "на расчёт зарплаты самой строки не влияет." + ), + ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, @@ -1024,6 +1032,7 @@ class PayrollInputLine(DraftVersionedModel): verbose_name="Создал", ) created_at = models.DateTimeField("Создана", auto_now_add=True) + updated_at = models.DateTimeField("Обновлена", auto_now=True) approved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, @@ -1116,6 +1125,47 @@ class PayrollInputLine(DraftVersionedModel): ) +class PayrollInputCopyConfiguration(models.Model): + """Per-operator defaults for copying input lines between payroll periods.""" + + owner = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="payroll_input_copy_configuration", + verbose_name="Пользователь", + ) + component_ids = models.JSONField("Виды операций", default=list) + employee_scope = models.CharField( + "Охват сотрудников", + max_length=16, + choices=( + ("all", "Все из источника"), + ("active", "Все активные"), + ("selected", "Выбранные"), + ), + default="active", + ) + employee_ids = models.JSONField("Выбранные сотрудники", default=list) + existing_mode = models.CharField( + "Заполненные значения", + max_length=16, + choices=(("skip", "Пропустить"), ("replace", "Заменить")), + default="skip", + ) + auto_apply_on_period_creation = models.BooleanField( + "Автоматически применять при создании периода", + default=False, + ) + updated_at = models.DateTimeField("Обновлена", auto_now=True) + + class Meta: + verbose_name = "Конфигурация переноса зарплатных записей" + verbose_name_plural = "Конфигурации переноса зарплатных записей" + + def __str__(self): + return f"Перенос зарплатных записей: {self.owner_id}" + + class PayrollRun(models.Model): """Immutable calculation revision plus its controlled workflow state.""" @@ -1263,10 +1313,6 @@ class PayrollRun(models.Model): condition=Q(deduction_total__gte=0), name="pay_run_deduction_nonnegative", ), - models.CheckConstraint( - condition=Q(payable_total__gte=0), - name="pay_run_payable_nonnegative", - ), ] def __str__(self): @@ -1360,6 +1406,18 @@ class PayrollStatement(models.Model): max_digits=21, decimal_places=2, ) + opening_balance = models.DecimalField( + "Входящий баланс", + max_digits=21, + decimal_places=2, + default=Decimal("0"), + ) + closing_balance = models.DecimalField( + "Исходящий баланс", + max_digits=21, + decimal_places=2, + default=Decimal("0"), + ) payable = models.DecimalField( "К выплате", max_digits=21, @@ -1402,10 +1460,6 @@ class PayrollStatement(models.Model): condition=Q(payment_total__gte=0), name="pay_statement_payment_nonnegative", ), - models.CheckConstraint( - condition=Q(payable__gte=0), - name="pay_statement_payable_nonnegative", - ), ] def __str__(self): @@ -1540,6 +1594,31 @@ class PayrollStatementAcknowledgement(models.Model): ) +class PayrollStatementPaymentConfirmation(models.Model): + """Administrative confirmation that the final payable amount was issued.""" + + statement = models.OneToOneField( + PayrollStatement, + on_delete=models.CASCADE, + related_name="payment_confirmation", + verbose_name="Расчётный листок", + ) + confirmed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="confirmed_payroll_statement_payments", + verbose_name="Подтвердил выплату", + ) + confirmed_at = models.DateTimeField("Выплата подтверждена", auto_now_add=True) + + class Meta: + verbose_name = "Подтверждение выплаты зарплаты" + verbose_name_plural = "Подтверждения выплаты зарплаты" + + def __str__(self): + return f"Выплата по листку {self.statement_id}" + + class PayrollAuditEvent(models.Model): """Append-only redacted audit trail for payroll commands and reads.""" diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index e1d3d16f..6f1e80c2 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -62,6 +62,7 @@ def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine, reason=record.reason, source_period=source_period, is_retro=source_period is not None, + include_in_carryover=record.include_in_carryover, ) ) return tuple(mapped_lines) @@ -110,6 +111,7 @@ def build_core_request( rate: EmployeePayRate, work_record: PayrollWorkRecord | None, input_lines: list[PayrollInputLine], + opening_balance: Decimal = Decimal("0"), rounding: str = ROUND_HALF_UP, ) -> PayrollRequest: target_points = work_record.target_points if work_record else None @@ -120,6 +122,7 @@ def build_core_request( currency=period.currency, base_accrual=rate.amount, base_source_ref=f"pay-rate:{rate.pk}:revision:{rate.revision}", + opening_balance=opening_balance, point_base_accrual=point_base_accrual, target_points=target_points, actual_points=(work_record.actual_points if work_record else None), @@ -145,6 +148,7 @@ def build_core_preview_request( target_points: Decimal, actual_points: Decimal, input_lines: list[PayrollInputLine], + opening_balance: Decimal = Decimal("0"), rounding: str = ROUND_HALF_UP, ) -> PayrollRequest: """Build a non-persisted calculation request from draft or partial inputs.""" @@ -160,6 +164,7 @@ def build_core_preview_request( if rate is not None else "preview:missing-pay-rate" ), + opening_balance=opening_balance, point_base_accrual=point_base_accrual, target_points=target_points, actual_points=actual_points, diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py new file mode 100644 index 00000000..04158215 --- /dev/null +++ b/backend/finance/payroll/balances.py @@ -0,0 +1,62 @@ +"""Payroll balances reconstructed from explicit correction input lines.""" + +from __future__ import annotations + +from collections.abc import Iterable +from decimal import Decimal + +from django.db.models import Q, Sum + +from finance.enums import ( + ApprovalStatus, + InputSource, + PayrollComponentKind, + PayrollRunStatus, +) +from finance.models import PayrollInputLine, PayrollPeriod + + +def opening_balances( + period: PayrollPeriod, + employee_ids: Iterable[int], +) -> dict[int, Decimal]: + """Return balances produced by included corrections in published periods.""" + + requested_ids = set(employee_ids) + if not requested_ids: + return {} + rows = ( + PayrollInputLine.objects.filter( + employee_id__in=requested_ids, + period__currency=period.currency, + period__date_to__lt=period.date_from, + period__runs__status=PayrollRunStatus.PUBLISHED, + status=ApprovalStatus.APPROVED, + include_in_carryover=True, + component__kind__in=( + PayrollComponentKind.ADJUSTMENT_CREDIT, + PayrollComponentKind.ADJUSTMENT_DEBIT, + ), + ) + .exclude( + source=InputSource.API, + source_ref__startswith="payroll-carryover:", + ) + .values("employee_id") + .annotate( + credit_total=Sum( + "amount", + filter=Q(component__kind=PayrollComponentKind.ADJUSTMENT_CREDIT), + default=Decimal("0.00"), + ), + debit_total=Sum( + "amount", + filter=Q(component__kind=PayrollComponentKind.ADJUSTMENT_DEBIT), + default=Decimal("0.00"), + ), + ) + ) + return { + row["employee_id"]: row["debit_total"] - row["credit_total"] + for row in rows + } diff --git a/backend/finance/payroll/config.py b/backend/finance/payroll/config.py index 216691bb..8ebb057a 100644 --- a/backend/finance/payroll/config.py +++ b/backend/finance/payroll/config.py @@ -13,13 +13,13 @@ from payroll_core import PayrollRules, PointPolicy, RoundingPolicy DEFAULT_CONFIG = { "RULESET_ID": "eusrr-standard", - "RULESET_VERSION": "2026.07.4", + "RULESET_VERSION": "2026.08.1", "EFFECTIVE_FROM": "2026-01-01", "EFFECTIVE_TO": None, "POINT_POLICY": PointPolicy.PROPORTIONAL_WITH_EXCESS.value, "MONEY_QUANTUM": "0.01", "ROUNDING": RoundingPolicy.HALF_UP.value, - "ALLOW_NEGATIVE_PAYABLE": False, + "ALLOW_NEGATIVE_PAYABLE": True, "BASE_RATE_CODE": "BASE", # Temporary pilot mode: portal administrators receive every payroll # operation while the granular role matrix is being tested. @@ -110,11 +110,6 @@ def build_rules() -> PayrollRules: "The finance adapter persists two decimal places; " "MONEY_QUANTUM must be 0.01" ) - if allow_negative_payable: - raise ImproperlyConfigured( - "The finance adapter does not persist negative payable amounts" - ) - return PayrollRules( ruleset_id=str(config["RULESET_ID"]), version=str(config["RULESET_VERSION"]), diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 739d1e4e..258a1d12 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -19,7 +19,7 @@ from employees.services.personnel_state import resolve_employee_personnel_state from requests_app.enums import RequestStatus from requests_app.models import Request -from finance.enums import ApprovalStatus +from finance.enums import ApprovalStatus, InputSource from finance.models import ( EmployeePayRate, PayrollComponent, @@ -31,6 +31,7 @@ from finance.models import ( ) from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request +from .balances import opening_balances from .attendance import ( calculate_attendance_day_points, calculate_attendance_points_projection, @@ -160,6 +161,7 @@ def _input_lines_by_employee(period, employee_ids): employee_id__in=employee_ids, status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED], ) + .exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:") .select_related("component", "relates_to_period") .order_by("employee_id", "component__display_order", "id") ) @@ -230,6 +232,7 @@ def build_payroll_period_table(period: PayrollPeriod): for statement in current_run.statements.select_related( "employee", "employee__position", + "payment_confirmation__confirmed_by", ).prefetch_related("lines") } @@ -272,6 +275,7 @@ def build_payroll_period_table(period: PayrollPeriod): rates = _latest_rate_by_employee(period, employee_ids) work_records = _latest_work_by_employee(period, employee_ids) input_lines = _input_lines_by_employee(period, employee_ids) + balances = opening_balances(period, employee_ids) components = list(PayrollComponent.objects.all()) component_definitions = { component.code: _component_definition( @@ -335,6 +339,23 @@ def build_payroll_period_table(period: PayrollPeriod): work is None or not work.target_points_overridden ) totals_preliminary = False + opening_balance = statement.opening_balance + closing_balance = statement.closing_balance + confirmation = getattr(statement, "payment_confirmation", None) + payment_confirmation = ( + { + "confirmed_at": confirmation.confirmed_at.isoformat(), + "confirmed_by": { + "id": confirmation.confirmed_by_id, + "display_name": ( + confirmation.confirmed_by.get_full_name().strip() + or f"Сотрудник #{confirmation.confirmed_by_id}" + ), + }, + } + if confirmation is not None + else None + ) else: component_amounts = _source_component_amounts( source_lines, @@ -364,6 +385,7 @@ def build_payroll_period_table(period: PayrollPeriod): target_points=preview_target_points, actual_points=preview_actual_points, input_lines=source_lines, + opening_balance=balances.get(employee.pk, Decimal("0")), rounding=preview_rules.rounding.value, ) preview_result = calculator.calculate(preview_request, preview_rules) @@ -381,6 +403,9 @@ def build_payroll_period_table(period: PayrollPeriod): "payable": str(preview_totals.payable), } totals_preliminary = True + opening_balance = preview_request.opening_balance + closing_balance = preview_totals.closing_balance + payment_confirmation = None has_draft = ( getattr(rate, "status", None) == ApprovalStatus.DRAFT or getattr(work, "status", None) == ApprovalStatus.DRAFT @@ -442,6 +467,9 @@ def build_payroll_period_table(period: PayrollPeriod): "point_amount": _decimal_text(point_amount), "component_amounts": component_amounts, "totals_preliminary": totals_preliminary, + "opening_balance": str(opening_balance), + "closing_balance": str(closing_balance), + "payment_confirmation": payment_confirmation, **totals, } ) @@ -456,6 +484,7 @@ def build_payroll_period_table(period: PayrollPeriod): return { "period_id": period.pk, + "period_status": period.status, "currency": period.currency, "calculation_rules": active_rules.to_dict(), "run": ( diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 53b65f15..d54a9cd2 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -41,6 +41,7 @@ from finance.models import ( ) from .adapter import build_core_request, employee_snapshot +from .balances import opening_balances from .access import ( SELF_APPROVAL_OVERRIDE_PERMISSION, can_self_approve_payroll, @@ -223,6 +224,7 @@ def _input_lines_by_employee(period, employee_ids): employee_id__in=employee_ids, status=ApprovalStatus.APPROVED, ) + .exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:") .select_related("component", "relates_to_period") .order_by("employee_id", "component__display_order", "id") ) @@ -265,6 +267,7 @@ def _calculate_current_period_inputs( employee_ids=outside_roster, ) input_lines = _input_lines_by_employee(period, employee_ids) + balances = opening_balances(period, employee_ids) requests = [] results = [] @@ -288,6 +291,7 @@ def _calculate_current_period_inputs( rate=rate, work_record=work_record, input_lines=input_lines[rate.employee_id], + opening_balance=balances.get(rate.employee_id, Decimal("0")), rounding=active_rules.rounding.value, ) try: @@ -510,6 +514,8 @@ def calculate_period( deduction_total=totals.deduction_total, net_pay=totals.net_pay, payment_total=totals.payment_total, + opening_balance=request.opening_balance, + closing_balance=totals.closing_balance, payable=totals.payable, input_hash=result.input_hash, result_hash=result.result_hash, diff --git a/backend/finance/tasks.py b/backend/finance/tasks.py index 67bd3fa3..79984b26 100644 --- a/backend/finance/tasks.py +++ b/backend/finance/tasks.py @@ -12,7 +12,6 @@ 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__) @@ -47,7 +46,6 @@ def ensure_monthly_payroll_period(reference_date: date | None = None): date_to=date_to, ).first() if existing is not None: - materialize_pending_carryovers(existing) return existing, False period = PayrollPeriod( @@ -86,7 +84,6 @@ def ensure_monthly_payroll_period(reference_date: date | None = None): raise logger.info("Opened payroll period %s", period.code) - materialize_pending_carryovers(period) return period, True diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py index bb34d447..150bec6e 100644 --- a/backend/payroll_core/calculator.py +++ b/backend/payroll_core/calculator.py @@ -58,7 +58,7 @@ def _is_finite_decimal(value: object) -> bool: class DeterministicPayrollCalculator: """Pure calculator with explicit Decimal and rounding semantics.""" - CALCULATOR_VERSION = "1.1.0" + CALCULATOR_VERSION = "1.3.0" DECIMAL_PRECISION = 80 MAX_DECIMAL_DIGITS = 30 MAX_FRACTIONAL_PLACES = 12 @@ -90,6 +90,7 @@ class DeterministicPayrollCalculator: calculated=True, ) ] + opening_balance = quantize(request.opening_balance) warnings: list[CalculationWarning] = [] point_delta: Decimal | None = None @@ -157,7 +158,7 @@ class DeterministicPayrollCalculator: ): lines.append(self._to_result_line(input_line, quantize)) - totals = self._calculate_totals(lines) + totals = self._calculate_totals(lines, opening_balance=opening_balance) if totals.payable < 0 and not rules.allow_negative_payable: raise PayrollValidationError( [ @@ -260,10 +261,15 @@ class DeterministicPayrollCalculator: source_period=input_line.source_period, is_retro=input_line.is_retro, calculated=False, + include_in_carryover=input_line.include_in_carryover, ) @staticmethod - def _calculate_totals(lines: list[PayrollLine]) -> PayrollTotals: + def _calculate_totals( + lines: list[PayrollLine], + *, + opening_balance: Decimal = Decimal("0"), + ) -> PayrollTotals: earnings = sum( (line.amount for line in lines if line.kind is LineKind.EARNING), Decimal("0"), @@ -284,6 +290,26 @@ class DeterministicPayrollCalculator: (line.amount for line in lines if line.kind is LineKind.PAYMENT), Decimal("0"), ) + manual_adjustment_credits = sum( + ( + line.amount + for line in lines + if line.kind is LineKind.ADJUSTMENT_CREDIT + and not line.calculated + and line.include_in_carryover + ), + Decimal("0"), + ) + manual_adjustment_debits = sum( + ( + line.amount + for line in lines + if line.kind is LineKind.ADJUSTMENT_DEBIT + and not line.calculated + and line.include_in_carryover + ), + Decimal("0"), + ) adjustment_total = adjustment_credits - adjustment_debits gross = earnings + adjustment_total net_pay = gross - deductions @@ -295,6 +321,11 @@ class DeterministicPayrollCalculator: net_pay=net_pay, payment_total=payments, payable=net_pay - payments, + closing_balance=( + opening_balance + + manual_adjustment_debits + - manual_adjustment_credits + ), ) @staticmethod @@ -412,6 +443,12 @@ class DeterministicPayrollCalculator: issues, minimum=Decimal("0"), ) + self._validate_decimal( + request.opening_balance, + "opening_balance", + issues, + minimum=None, + ) self._validate_decimal( request.point_base_accrual, "point_base_accrual", diff --git a/backend/payroll_core/domain.py b/backend/payroll_core/domain.py index 0117e058..c16f6a88 100644 --- a/backend/payroll_core/domain.py +++ b/backend/payroll_core/domain.py @@ -87,6 +87,7 @@ class InputLine: reason: str = "" source_period: PayrollPeriod | None = None is_retro: bool = False + include_in_carryover: bool = True def to_dict(self) -> dict[str, object]: return { @@ -101,6 +102,7 @@ class InputLine: self.source_period.to_dict() if self.source_period else None ), "is_retro": self.is_retro, + "include_in_carryover": self.include_in_carryover, } @@ -141,6 +143,7 @@ class PayrollRequest: currency: str base_accrual: Decimal base_source_ref: str + opening_balance: Decimal = Decimal("0") point_base_accrual: Decimal | None = None target_points: Decimal | None = None actual_points: Decimal | None = None @@ -157,6 +160,7 @@ class PayrollRequest: "currency": self.currency, "base_accrual": decimal_text(self.base_accrual), "base_source_ref": self.base_source_ref, + "opening_balance": decimal_text(self.opening_balance), "point_base_accrual": ( decimal_text(self.point_base_accrual) if self.point_base_accrual is not None @@ -230,6 +234,7 @@ class PayrollLine: source_period: PayrollPeriod | None = None is_retro: bool = False calculated: bool = False + include_in_carryover: bool = True def to_dict(self) -> dict[str, object]: return { @@ -245,6 +250,7 @@ class PayrollLine: ), "is_retro": self.is_retro, "calculated": self.calculated, + "include_in_carryover": self.include_in_carryover, } @@ -273,6 +279,7 @@ class PayrollTotals: net_pay: Decimal payment_total: Decimal payable: Decimal + closing_balance: Decimal @property def recalculated_gross(self) -> Decimal: @@ -290,6 +297,7 @@ class PayrollTotals: "net_pay": decimal_text(self.net_pay), "payment_total": decimal_text(self.payment_total), "payable": decimal_text(self.payable), + "closing_balance": decimal_text(self.closing_balance), } diff --git a/backend/payroll_core/tests/test_calculator.py b/backend/payroll_core/tests/test_calculator.py index 9b534640..bd340694 100644 --- a/backend/payroll_core/tests/test_calculator.py +++ b/backend/payroll_core/tests/test_calculator.py @@ -60,6 +60,7 @@ def input_line( reason="", source_period=None, is_retro=False, + include_in_carryover=True, ): return InputLine( line_id=line_id, @@ -71,6 +72,7 @@ def input_line( reason=reason, source_period=source_period, is_retro=is_retro, + include_in_carryover=include_in_carryover, ) @@ -643,6 +645,85 @@ def test_negative_expected_payable_is_allowed_when_rules_allow_it(period, rules) assert result.warnings == () +def test_balance_tracks_only_manual_corrections(period, rules): + balance_rules = replace(rules, allow_negative_payable=True) + request = make_request( + period, + base_accrual=Decimal("100000"), + opening_balance=Decimal("10000"), + lines=( + input_line( + "correction:1", + "CORRECTION_CREDIT", + LineKind.ADJUSTMENT_CREDIT, + "4000", + reason="Погашение недоплаты", + ), + input_line( + "payment:1", + "SALARY_PAYMENT", + LineKind.PAYMENT, + "104000", + ), + ), + ) + + result = DeterministicPayrollCalculator().calculate(request, balance_rules) + + assert result.totals.adjustment_total == Decimal("4000.00") + assert result.totals.gross_after_adjustments == Decimal("104000.00") + assert result.totals.payment_total == Decimal("104000.00") + assert result.totals.payable == Decimal("0.00") + assert result.totals.closing_balance == Decimal("6000.00") + + +def test_excluded_correction_changes_salary_but_not_carrying_balance(period, rules): + request = make_request( + period, + base_accrual=Decimal("100000"), + opening_balance=Decimal("7000"), + lines=( + input_line( + "correction:excluded", + "CORRECTION_CREDIT", + LineKind.ADJUSTMENT_CREDIT, + "3000", + reason="Премия вне переходящего остатка", + include_in_carryover=False, + ), + ), + ) + + result = DeterministicPayrollCalculator().calculate(request, rules) + + assert result.totals.adjustment_total == Decimal("3000.00") + assert result.totals.gross_after_adjustments == Decimal("103000.00") + assert result.totals.payable == Decimal("103000.00") + assert result.totals.closing_balance == Decimal("7000.00") + + +def test_point_recalculation_never_creates_a_carrying_balance(period, rules): + proportional_rules = replace( + rules, + point_policy=PointPolicy.PROPORTIONAL_WITH_EXCESS, + allow_negative_payable=True, + ) + request = make_request( + period, + base_accrual=Decimal("100000"), + point_base_accrual=Decimal("100000"), + target_points=Decimal("100"), + actual_points=Decimal("25"), + point_rate=Decimal("0"), + ) + + result = DeterministicPayrollCalculator().calculate(request, proportional_rules) + + assert result.totals.gross_after_adjustments == Decimal("25000.00") + assert result.totals.payable == Decimal("25000.00") + assert result.totals.closing_balance == Decimal("0.00") + + @pytest.mark.parametrize( "invalid_period", [ diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 9470eca7..e6fc5df7 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -92,6 +92,237 @@ def make_approved_period_inputs( ) +def test_copy_input_lines_previews_scope_and_handles_existing_values( + user_factory, + auth_client_factory, +): + manager = user_factory(email="input.copy.manager@example.test") + active_employee = user_factory(email="input.copy.active@example.test") + inactive_employee = user_factory( + email="input.copy.inactive@example.test", + active=False, + ) + grant(manager, "manage_payroll_inputs") + source = make_period(creator=manager) + target = PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from="2026-07-01", + date_to="2026-07-31", + pay_date="2026-08-05", + currency="RUB", + created_by=manager, + ) + bonus = PayrollComponent.objects.get(code="BONUS") + advance = PayrollComponent.objects.get(code="ADVANCE") + correction_credit = PayrollComponent.objects.get(code="CORRECTION_CREDIT") + for employee in (active_employee, inactive_employee): + PayrollInputLine.objects.create( + period=source, + employee=employee, + component=bonus, + amount="1000.00", + created_by=manager, + ) + PayrollInputLine.objects.create( + period=source, + employee=employee, + component=advance, + amount="500.00", + created_by=manager, + ) + existing_bonus = PayrollInputLine.objects.create( + period=target, + employee=active_employee, + component=bonus, + amount="900.00", + created_by=manager, + ) + client = auth_client_factory(manager) + + preview = client.get( + api_url("admin-period-copy-input-lines", pk=target.pk), + {"source_period_id": source.pk}, + ) + + assert preview.status_code == 200, preview.content + body = preview.json() + assert body["summary"] == { + "eligible_lines": 4, + "employees": 2, + "active_employees": 1, + } + component_preview = {item["id"]: item for item in body["components"]} + assert component_preview[correction_credit.pk]["line_count"] == 0 + assert component_preview[correction_credit.pk]["employee_count"] == 0 + assert component_preview[correction_credit.pk]["amount_total"] == "0.00" + employee_preview = { + item["id"]: item for item in body["employees"] + } + assert employee_preview[active_employee.pk]["is_active"] is True + assert employee_preview[inactive_employee.pk]["is_active"] is False + assert employee_preview[active_employee.pk]["existing_component_counts"] == { + str(bonus.pk): 1 + } + + skipped = client.post( + api_url("admin-period-copy-input-lines", pk=target.pk), + { + "source_period_id": source.pk, + "component_ids": [bonus.pk, advance.pk, correction_credit.pk], + "employee_scope": "active", + "employee_ids": [], + "existing_mode": "skip", + "expected_period_lock_version": target.lock_version, + "idempotency_key": str(uuid.uuid4()), + }, + format="json", + ) + + assert skipped.status_code == 201, skipped.content + assert skipped.json()["summary"] == { + "created": 1, + "employees": 1, + "skipped": 1, + "replaced_cells": 0, + "voided": 0, + } + assert PayrollInputLine.objects.filter( + period=target, + employee=inactive_employee, + ).count() == 0 + + replacement_key = uuid.uuid4() + replaced = client.post( + api_url("admin-period-copy-input-lines", pk=target.pk), + { + "source_period_id": source.pk, + "component_ids": [bonus.pk, advance.pk, correction_credit.pk], + "employee_scope": "active", + "employee_ids": [], + "existing_mode": "replace", + "expected_period_lock_version": target.lock_version, + "idempotency_key": str(replacement_key), + "remember_configuration": True, + "auto_apply_on_period_creation": True, + }, + format="json", + ) + + assert replaced.status_code == 201, replaced.content + assert replaced.json()["summary"] == { + "created": 2, + "employees": 1, + "skipped": 0, + "replaced_cells": 2, + "voided": 2, + } + existing_bonus.refresh_from_db() + assert existing_bonus.status == "voided" + active_target_lines = PayrollInputLine.objects.filter( + period=target, + employee=active_employee, + status="draft", + ) + assert active_target_lines.count() == 2 + assert set(active_target_lines.values_list("component_id", flat=True)) == { + bonus.pk, + advance.pk, + } + + replay = client.post( + api_url("admin-period-copy-input-lines", pk=target.pk), + { + "source_period_id": source.pk, + "component_ids": [bonus.pk, advance.pk, correction_credit.pk], + "employee_scope": "active", + "employee_ids": [], + "existing_mode": "replace", + "expected_period_lock_version": target.lock_version, + "idempotency_key": str(replacement_key), + "remember_configuration": True, + "auto_apply_on_period_creation": True, + }, + format="json", + ) + assert replay.status_code == 200, replay.content + assert replay.json()["idempotent_replay"] is True + assert active_target_lines.count() == 2 + + PayrollInputLine.objects.create( + period=target, + employee=active_employee, + component=correction_credit, + amount="250.00", + created_by=manager, + ) + + august = client.post( + api_url("admin-period-list"), + { + "code": "2026-08", + "name": "Август 2026", + "date_from": "2026-08-01", + "date_to": "2026-08-31", + "pay_date": "2026-09-05", + "currency": "RUB", + }, + format="json", + ) + assert august.status_code == 201, august.content + assert august.json()["automatic_input_copy"]["source_period_id"] == target.pk + assert august.json()["automatic_input_copy"]["summary"]["created"] == 3 + august_lines = PayrollInputLine.objects.filter( + period_id=august.json()["id"], + status="draft", + ) + assert august_lines.count() == 3 + assert set(august_lines.values_list("component_id", flat=True)) == { + bonus.pk, + advance.pk, + correction_credit.pk, + } + + +def test_input_line_database_error_is_not_reported_as_parallel_operation( + user_factory, + auth_client_factory, + monkeypatch, +): + manager = user_factory(email="input.error.manager@example.test") + employee = user_factory(email="input.error.employee@example.test") + grant(manager, "manage_payroll_inputs") + period = make_period(creator=manager) + component = PayrollComponent.objects.get(code="BONUS") + + def reject_save(*args, **kwargs): + raise IntegrityError("simulated database constraint") + + monkeypatch.setattr( + "api.v1.finance.admin_serializers.PayrollInputLineWriteSerializer.save", + reject_save, + ) + response = auth_client_factory(manager).post( + api_url("admin-input-line-list"), + { + "period_id": period.pk, + "employee_id": employee.pk, + "component_id": component.pk, + "amount": "1000.00", + "reason": "Премия", + }, + format="json", + ) + + assert response.status_code == 409 + assert response.json()["code"] == "DRAFT_SAVE_FAILED" + assert "Параллельная операция" not in response.json()["message"] + assert response.json()["message"] == ( + "Не удалось сохранить данные расчёта: база данных отклонила запись. " + "Обновите страницу и повторите попытку." + ) + + def test_admin_input_lists_show_newest_records_first( user_factory, auth_client_factory, @@ -557,7 +788,12 @@ def test_period_table_lists_all_employees_and_requires_view_all( assert row["component_amounts"] == {"BONUS": "1000.00"} assert row["gross_total"] == "81750.00" assert row["payable"] == "81750.00" + assert row["opening_balance"] == "0" + assert row["closing_balance"] == "0.00" assert row["totals_preliminary"] is True + assert all( + item["employee_id"] != employee.pk for item in body["carryovers"] + ) assert body["summary"]["preliminary_count"] == body["summary"]["employee_count"] missing_row = next( item for item in body["rows"] if item["employee"]["id"] == missing_employee.pk @@ -1454,7 +1690,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates( "code": "RULESET_NOT_EFFECTIVE", "message": ( "Для выбранного периода нет действующих правил расчёта. " - "Набор eusrr-standard, версия 2026.07.4, применяется с 01.07.2026. " + "Набор eusrr-standard, версия 2026.08.1, применяется с 01.07.2026. " "Измените период или подключите историческую версию правил." ), "details": { @@ -1464,7 +1700,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates( }, "ruleset": { "id": "eusrr-standard", - "version": "2026.07.4", + "version": "2026.08.1", "effective_from": "2026-07-01", }, }, @@ -1492,6 +1728,48 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates( assert PayrollRun.objects.count() == 0 +def test_period_inputs_can_be_created_for_inactive_employees( + user_factory, + auth_client_factory, +): + manager = user_factory(email="inactive.inputs.manager@example.test") + inactive_employee = user_factory( + email="inactive.inputs.employee@example.test", + active=False, + ) + grant(manager, "manage_payroll_inputs") + period = make_period(creator=manager) + client = auth_client_factory(manager) + + work_record = client.post( + api_url("admin-work-record-list"), + { + "period_id": period.pk, + "employee_id": inactive_employee.pk, + "target_points": "100.0000", + "actual_points": "90.0000", + "reason": "Корректировка после увольнения", + }, + format="json", + ) + assert work_record.status_code == 201, work_record.content + assert work_record.json()["employee_id"] == inactive_employee.pk + + input_line = client.post( + api_url("admin-input-line-list"), + { + "period_id": period.pk, + "employee_id": inactive_employee.pk, + "component_id": PayrollComponent.objects.get(code="BONUS").pk, + "amount": "1000.00", + "reason": "Премия после увольнения", + }, + format="json", + ) + assert input_line.status_code == 201, input_line.content + assert input_line.json()["employee_id"] == inactive_employee.pk + + def test_period_and_rate_drafts_use_exact_optimistic_versions_and_maker_checker( user_factory, auth_client_factory, @@ -2461,7 +2739,20 @@ def test_staff_admin_completes_entire_payroll_workflow_with_one_command( approver=administrator, ) - response = auth_client_factory(administrator).post( + administrator_client = auth_client_factory(administrator) + blocked_confirmation = administrator_client.post( + api_url( + "admin-period-payment-confirmation", + pk=period.pk, + employee_id=employee.pk, + ), + {"confirmed": True}, + format="json", + ) + assert blocked_confirmation.status_code == 409 + assert blocked_confirmation.json()["code"] == "PERIOD_NOT_CLOSED" + + response = administrator_client.post( api_url("admin-period-complete-workflow", pk=period.pk), { "expected_lock_version": period.lock_version, @@ -2503,6 +2794,62 @@ def test_staff_admin_completes_entire_payroll_workflow_with_one_command( "payroll.admin_workflow_completed", } + table_before_confirmation = administrator_client.get( + api_url("admin-period-table", pk=period.pk) + ) + assert table_before_confirmation.status_code == 200 + assert table_before_confirmation.json()["period_status"] == "closed" + employee_row = next( + row + for row in table_before_confirmation.json()["rows"] + if row["employee"]["id"] == employee.pk + ) + assert employee_row["payment_confirmation"] is None + + confirmed = administrator_client.post( + api_url( + "admin-period-payment-confirmation", + pk=period.pk, + employee_id=employee.pk, + ), + {"confirmed": True}, + format="json", + ) + assert confirmed.status_code == 200, confirmed.content + assert confirmed.json()["confirmed"] is True + assert confirmed.json()["confirmed_by"]["id"] == administrator.pk + + table_after_confirmation = administrator_client.get( + api_url("admin-period-table", pk=period.pk) + ) + confirmed_row = next( + row + for row in table_after_confirmation.json()["rows"] + if row["employee"]["id"] == employee.pk + ) + assert confirmed_row["payment_confirmation"]["confirmed_by"]["id"] == administrator.pk + + revoked = administrator_client.post( + api_url( + "admin-period-payment-confirmation", + pk=period.pk, + employee_id=employee.pk, + ), + {"confirmed": False}, + format="json", + ) + assert revoked.status_code == 200 + assert revoked.json()["confirmed"] is False + assert set( + PayrollAuditEvent.objects.filter(period=period).values_list( + "action", + flat=True, + ) + ) >= { + "payroll.statement_payment_confirmed", + "payroll.statement_payment_confirmation_revoked", + } + def test_complete_workflow_rolls_back_every_stage_when_review_fails( user_factory, diff --git a/backend/tests/finance/test_payroll_carryovers.py b/backend/tests/finance/test_payroll_carryovers.py index 60c32cb8..e714ef57 100644 --- a/backend/tests/finance/test_payroll_carryovers.py +++ b/backend/tests/finance/test_payroll_carryovers.py @@ -4,9 +4,16 @@ from decimal import Decimal import pytest from django.contrib.auth.models import Permission from django.urls import reverse +from django.utils import timezone -from finance.enums import ApprovalStatus, PayrollCarryoverStatus, PayrollPeriodStatus -from finance.models import PayrollInputLine, PayrollPeriod +from finance.enums import ( + ApprovalStatus, + PayrollCarryoverStatus, + PayrollPeriodStatus, + PayrollRunStatus, +) +from finance.models import PayrollComponent, PayrollInputLine, PayrollPeriod, PayrollRun +from finance.payroll.balances import opening_balances from finance.payroll.carryovers import ( cancel_carryover, create_carryover, @@ -165,7 +172,7 @@ def test_carryover_requires_closed_source_and_approved_transfer_cannot_cancel( assert approved_error.value.code == "CARRYOVER_ALREADY_APPROVED" -def test_carryover_admin_api_lists_incoming_and_marks_generated_line( +def test_manual_carryover_api_is_disabled( user_factory, auth_client_factory, ): @@ -181,7 +188,6 @@ def test_carryover_admin_api_lists_incoming_and_marks_generated_line( month=6, status=PayrollPeriodStatus.CLOSED, ) - target = make_period(creator=manager, month=7) client = auth_client_factory(manager) created = client.post( @@ -195,48 +201,80 @@ def test_carryover_admin_api_lists_incoming_and_marks_generated_line( 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 + assert created.status_code == 410, created.content + assert created.json()["code"] == "MANUAL_CARRYOVER_DISABLED" + assert "положительных и отрицательных корректировок" in created.json()["message"] - lines = client.get( - api_url("admin-input-line-list"), - {"period_id": target.pk}, + +def test_carryover_ledger_lists_history_and_can_exclude_a_record( + user_factory, + auth_client_factory, +): + manager = user_factory(email="carryover.ledger.manager@example.test") + employee = user_factory( + email="carryover.ledger.employee@example.test", + first_name="Анна", + last_name="Балансова", ) - assert lines.status_code == 200 - line = lines.json()[0] - assert line["is_automatic_carryover"] is True - assert line["relates_to_period"]["id"] == source.pk + grant(manager, "manage_payroll_inputs", "view_all_payroll") + june = make_period(creator=manager, month=6) + july = make_period(creator=manager, month=7) + correction = PayrollInputLine.objects.create( + period=june, + employee=employee, + component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"), + amount=Decimal("3500.00"), + reason="Недоплата за июнь", + status=ApprovalStatus.APPROVED, + created_by=manager, + approved_by=manager, + approved_at=timezone.now(), + self_approval_overridden=True, + ) + PayrollRun.objects.create( + period=june, + revision=1, + status=PayrollRunStatus.PUBLISHED, + ruleset_id="test", + ruleset_version="1", + ruleset_hash="rules", + calculator_version="test", + input_hash="input", + result_hash="result", + requested_by=manager, + approved_by=manager, + approved_at=timezone.now(), + self_approval_overridden=True, + published_by=manager, + published_at=timezone.now(), + ) + client = auth_client_factory(manager) - 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, - } - ] + ledger = client.get(api_url("admin-period-carryovers", pk=july.pk)) - 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, - } + assert ledger.status_code == 200, ledger.content + assert ledger.json()["summary"]["balance"] == "3500.00" + assert len(ledger.json()["entries"]) == 1 + entry = ledger.json()["entries"][0] + assert entry["id"] == correction.pk + assert entry["period"]["id"] == june.pk + assert entry["raw_contribution"] == "3500.00" + assert entry["contribution"] == "3500.00" + assert entry["counts_in_balance"] is True + assert opening_balances(july, [employee.pk])[employee.pk] == Decimal("3500.00") + + excluded = client.patch( + api_url("admin-input-line-carryover-inclusion", pk=correction.pk), + {"included": False}, + format="json", + ) + + assert excluded.status_code == 200, excluded.content + correction.refresh_from_db() + assert correction.include_in_carryover is False + assert correction.amount == Decimal("3500.00") + assert opening_balances(july, [employee.pk]) == {} + refreshed = client.get(api_url("admin-period-carryovers", pk=july.pk)).json() + assert refreshed["summary"]["balance"] == "0.00" + assert refreshed["entries"][0]["contribution"] == "0.00" + assert refreshed["entries"][0]["raw_contribution"] == "3500.00" diff --git a/backend/tests/finance/test_payroll_config.py b/backend/tests/finance/test_payroll_config.py index 01e91016..5698359e 100644 --- a/backend/tests/finance/test_payroll_config.py +++ b/backend/tests/finance/test_payroll_config.py @@ -37,7 +37,7 @@ def test_payroll_adapter_is_safe_by_default(settings): assert rules.point_policy is PointPolicy.PROPORTIONAL_WITH_EXCESS assert rules.money_quantum == Decimal("0.01") - assert rules.allow_negative_payable is False + assert rules.allow_negative_payable is True def test_simple_admin_access_defaults_to_enabled_and_accepts_boolean_strings(settings): @@ -98,11 +98,10 @@ def test_config_accepts_enum_values(settings): @pytest.mark.parametrize( - "override", - [ - {"MONEY_QUANTUM": "0.001"}, - {"ALLOW_NEGATIVE_PAYABLE": "true"}, - {"ROUNDING": "bankers-ish"}, + "override", + [ + {"MONEY_QUANTUM": "0.001"}, + {"ROUNDING": "bankers-ish"}, {"POINT_POLICY": "invented"}, ], ) diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index a0266241..3c3bfa4d 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -38,6 +38,7 @@ from finance.payroll.services import ( approve_run, approve_work_record, calculate_period, + close_period, publish_run, return_run_for_correction, submit_run_for_review, @@ -180,6 +181,161 @@ def publish_calculation(run, users): return publish_run(run.pk, actor=users["publisher"]) +def test_only_manual_corrections_move_between_periods_and_can_be_settled( + june_period, + payroll_users, +): + operator = payroll_users["operator"] + approver = payroll_users["input_approver"] + employee = payroll_users["employee"] + rate = EmployeePayRate.objects.create( + employee=employee, + amount=Decimal("100000"), + point_rate=Decimal("0"), + currency="RUB", + effective_from=date(2026, 1, 1), + created_by=operator, + ) + approve_pay_rate(rate.pk, actor=approver, expected_lock_version=rate.lock_version) + payment_component = PayrollComponent.objects.get(code="SALARY_PAYMENT") + debit_component = PayrollComponent.objects.get(code="CORRECTION_DEBIT") + credit_component = PayrollComponent.objects.get(code="CORRECTION_CREDIT") + + june_work = PayrollWorkRecord.objects.create( + period=june_period, + employee=employee, + target_points=Decimal("100"), + actual_points=Decimal("100"), + created_by=operator, + ) + june_payment = PayrollInputLine.objects.create( + period=june_period, + employee=employee, + component=payment_component, + amount=Decimal("90000"), + reason="Фактически перечислено за июнь", + created_by=operator, + ) + june_correction = PayrollInputLine.objects.create( + period=june_period, + employee=employee, + component=debit_component, + amount=Decimal("10000"), + reason="Недоплата относительно зарплаты по баллам", + created_by=operator, + ) + for instance, approve in ( + (june_work, approve_work_record), + (june_payment, approve_input_line), + (june_correction, approve_input_line), + ): + approve(instance.pk, actor=approver, expected_lock_version=instance.lock_version) + + june_run = calculate_period(june_period.pk, actor=operator) + june_statement = june_run.statements.get(employee=employee) + assert june_statement.opening_balance == Decimal("0.00") + assert june_statement.gross_total == Decimal("90000.00") + assert june_statement.payable == Decimal("0.00") + assert june_statement.closing_balance == Decimal("10000.00") + publish_calculation(june_run, payroll_users) + + july_period = PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from=date(2026, 7, 1), + date_to=date(2026, 7, 31), + pay_date=date(2026, 8, 5), + currency="RUB", + created_by=operator, + ) + july_work = PayrollWorkRecord.objects.create( + period=july_period, + employee=employee, + target_points=Decimal("100"), + actual_points=Decimal("100"), + created_by=operator, + ) + july_payment = PayrollInputLine.objects.create( + period=july_period, + employee=employee, + component=payment_component, + amount=Decimal("104000"), + reason="Зарплата за июль с частичным погашением недоплаты", + created_by=operator, + ) + july_correction = PayrollInputLine.objects.create( + period=july_period, + employee=employee, + component=credit_component, + amount=Decimal("4000"), + reason="Частичное погашение недоплаты за июнь", + created_by=operator, + ) + for instance, approve in ( + (july_work, approve_work_record), + (july_payment, approve_input_line), + (july_correction, approve_input_line), + ): + approve(instance.pk, actor=approver, expected_lock_version=instance.lock_version) + + july_run = calculate_period(july_period.pk, actor=operator) + july_statement = july_run.statements.get(employee=employee) + + assert july_statement.opening_balance == Decimal("10000.00") + assert july_statement.payment_total == Decimal("104000.00") + assert july_statement.closing_balance == Decimal("6000.00") + assert july_statement.payable == Decimal("0.00") + assert july_statement.gross_total == Decimal("104000.00") + publish_calculation(july_run, payroll_users) + + august_period = PayrollPeriod.objects.create( + code="2026-08", + name="Август 2026", + date_from=date(2026, 8, 1), + date_to=date(2026, 8, 31), + pay_date=date(2026, 9, 5), + currency="RUB", + created_by=operator, + ) + august_work = PayrollWorkRecord.objects.create( + period=august_period, + employee=employee, + target_points=Decimal("100"), + actual_points=Decimal("100"), + created_by=operator, + ) + august_payment = PayrollInputLine.objects.create( + period=august_period, + employee=employee, + component=payment_component, + amount=Decimal("106000"), + reason="Зарплата за август с окончательным погашением недоплаты", + created_by=operator, + ) + august_correction = PayrollInputLine.objects.create( + period=august_period, + employee=employee, + component=credit_component, + amount=Decimal("6000"), + reason="Окончательное погашение недоплаты за июнь", + created_by=operator, + ) + for instance, approve in ( + (august_work, approve_work_record), + (august_payment, approve_input_line), + (august_correction, approve_input_line), + ): + approve(instance.pk, actor=approver, expected_lock_version=instance.lock_version) + + august_run = calculate_period(august_period.pk, actor=operator) + august_statement = august_run.statements.get(employee=employee) + + assert august_statement.opening_balance == Decimal("6000.00") + assert august_statement.payment_total == Decimal("106000.00") + assert august_statement.payable == Decimal("0.00") + assert august_statement.closing_balance == Decimal("0.00") + + def test_excel_row_is_persisted_as_a_deterministic_statement( june_period, payroll_users, @@ -501,7 +657,7 @@ def test_ruleset_not_effective_keeps_domain_code_and_localized_message( assert error.value.code == "RULESET_NOT_EFFECTIVE" assert error.value.message == ( "Для выбранного периода нет действующих правил расчёта. " - "Набор eusrr-standard, версия 2026.07.4, применяется с 01.07.2026. " + "Набор eusrr-standard, версия 2026.08.1, применяется с 01.07.2026. " "Измените период или подключите историческую версию правил." ) assert error.value.details["employee_id"] == payroll_users["employee"].pk @@ -511,7 +667,7 @@ def test_ruleset_not_effective_keeps_domain_code_and_localized_message( } assert error.value.details["ruleset"] == { "id": "eusrr-standard", - "version": "2026.07.4", + "version": "2026.08.1", "effective_from": "2026-07-01", } assert {issue["code"] for issue in error.value.details["issues"]} == { @@ -681,6 +837,19 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it( stranger = user_factory(email="api.stranger@example.test") stranger_client = auth_client_factory(stranger) + payment_confirmation_url = reverse( + "api:v1:finance-payroll:own-statement-payment-confirmation", + kwargs={"public_id": statement.public_id}, + ) + blocked_confirmation = employee_client.post( + payment_confirmation_url, + {"confirmed": True}, + format="json", + ) + assert blocked_confirmation.status_code == 409 + assert blocked_confirmation.json()["code"] == "PERIOD_NOT_CLOSED" + close_period(june_period.pk, actor=payroll_users["publisher"]) + list_url = reverse("api:v1:finance-payroll:own-statement-list") response = employee_client.get(list_url) assert response.status_code == 200 @@ -688,6 +857,8 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it( assert len(items) == 1 assert items[0]["public_id"] == str(statement.public_id) assert items[0]["payable"] == "115000.00" + assert items[0]["can_confirm_payment"] is True + assert items[0]["payment_confirmation"] is None assert "lines" not in items[0] assert "gross_total" not in items[0] assert "employee" not in items[0] @@ -718,6 +889,42 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it( ack_response = employee_client.post(acknowledgement_url, {}, format="json") assert ack_response.status_code == 200 assert ack_response.json()["acknowledged_at"] is not None + + payment_confirmation = employee_client.post( + payment_confirmation_url, + {"confirmed": True}, + format="json", + ) + assert payment_confirmation.status_code == 200 + confirmation_payload = payment_confirmation.json()["payment_confirmation"] + assert confirmation_payload["confirmed_at"] is not None + assert confirmation_payload["confirmed_by"]["id"] == payroll_users["employee"].pk + refreshed_detail = employee_client.get(detail_url) + assert refreshed_detail.json()["payment_confirmation"] == confirmation_payload + assert ( + stranger_client.post( + payment_confirmation_url, + {"confirmed": True}, + format="json", + ).status_code + == 404 + ) + revoked_confirmation = employee_client.post( + payment_confirmation_url, + {"confirmed": False}, + format="json", + ) + assert revoked_confirmation.status_code == 200 + assert revoked_confirmation.json()["payment_confirmation"] is None + assert set( + PayrollAuditEvent.objects.filter( + actor=payroll_users["employee"], + object_id=str(statement.pk), + ).values_list("action", flat=True) + ) >= { + "payroll.statement_payment_confirmed", + "payroll.statement_payment_confirmation_revoked", + } assert ( stranger_client.post( acknowledgement_url, diff --git a/backend/tests/finance/test_payroll_tasks.py b/backend/tests/finance/test_payroll_tasks.py index a8867459..33afed1e 100644 --- a/backend/tests/finance/test_payroll_tasks.py +++ b/backend/tests/finance/test_payroll_tasks.py @@ -68,7 +68,9 @@ def test_monthly_task_uses_local_current_month(monkeypatch): assert period.date_to == date(2026, 9, 30) -def test_monthly_period_opening_materializes_pending_carryover(user_factory): +def test_monthly_period_opening_does_not_materialize_legacy_manual_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( @@ -94,5 +96,6 @@ def test_monthly_period_opening_materializes_pending_carryover(user_factory): assert created is True carryover.refresh_from_db() - assert carryover.target_period == target - assert carryover.generated_input_line.period == target + assert target.status == PayrollPeriodStatus.OPEN + assert carryover.target_period_id is None + assert carryover.generated_input_line_id is None diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx index cb50ece6..b206fd53 100644 --- a/frontend/src/app/finances/page.tsx +++ b/frontend/src/app/finances/page.tsx @@ -21,7 +21,7 @@ import { useUser } from "@/contexts/UserContext"; import { usePayrollDesktopWideMode } from "@/hooks/usePayrollDesktopWideMode"; import { usePayrollFinanceTab } from "@/hooks/usePayrollTabs"; import { apiClient } from "@/lib/api"; -import type { PayrollAcknowledgement, PayrollStatementSummary } from "@/lib/api/finance"; +import type { PayrollAcknowledgement, PayrollPaymentConfirmation, PayrollStatementSummary } from "@/lib/api/finance"; import { canOpenPayrollAdmin } from "@/lib/permissions"; import { formatPayrollDate, @@ -38,21 +38,22 @@ function listErrorMessage(error: unknown): string { } function StatementStatus({ statement }: { statement: PayrollStatementSummary }) { - if (statement.acknowledgement?.disputed_at) { - return Есть обращение; - } - if (statement.acknowledgement?.acknowledged_at) { - return ( - - - Подтверждён - - ); - } return ( - - - Ждёт подтверждения + + {statement.acknowledgement?.disputed_at ? ( + Есть обращение + ) : statement.acknowledgement?.acknowledged_at ? ( + Листок подтверждён + ) : ( + Ждёт проверки листка + )} + {statement.can_confirm_payment ? ( + statement.payment_confirmation ? ( + Деньги получены + ) : ( + Выплата не подтверждена + ) + ) : null} ); } @@ -115,6 +116,21 @@ export default function FinancesPage() { current?.public_id === publicId ? { ...current, acknowledgement } : current )); }; + const handlePaymentConfirmationChanged = ( + publicId: string, + paymentConfirmation: PayrollPaymentConfirmation | null, + ) => { + setStatements((current) => current.map((statement) => ( + statement.public_id === publicId + ? { ...statement, payment_confirmation: paymentConfirmation } + : statement + ))); + setSelectedStatement((current) => ( + current?.public_id === publicId + ? { ...current, payment_confirmation: paymentConfirmation } + : current + )); + }; return ( setSelectedStatement(latestStatement)} > {latestStatement.acknowledgement?.acknowledged_at - ? "Посмотреть расчёт" + ? latestStatement.can_confirm_payment && !latestStatement.payment_confirmation + ? "Подтвердить получение денег" + : "Посмотреть расчёт" : "Проверить и подтвердить листок"} @@ -256,6 +274,10 @@ export default function FinancesPage() {

Сначала проверьте начисления. Подтверждается получение листка, а не перевод денег.

+ ) : latestStatement.can_confirm_payment && !latestStatement.payment_confirmation ? ( +

+ После фактической выплаты откройте листок и отметьте получение денег. +

) : null} ) : null} @@ -310,6 +332,7 @@ export default function FinancesPage() { statement={selectedStatement} onClose={() => setSelectedStatement(null)} onAcknowledged={handleAcknowledged} + onPaymentConfirmationChanged={handlePaymentConfirmationChanged} /> )} diff --git a/frontend/src/components/finance/PayrollStatementDetailModal.tsx b/frontend/src/components/finance/PayrollStatementDetailModal.tsx index f1a8f2df..436d5a1d 100644 --- a/frontend/src/components/finance/PayrollStatementDetailModal.tsx +++ b/frontend/src/components/finance/PayrollStatementDetailModal.tsx @@ -10,6 +10,7 @@ import { CreditCard, FileCheck2, Loader2, + ReceiptText, RefreshCcw, RotateCcw, ShieldCheck, @@ -21,6 +22,7 @@ import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; import type { PayrollAcknowledgement, + PayrollPaymentConfirmation, PayrollStatement, PayrollStatementSummary, } from "@/lib/api/finance"; @@ -40,6 +42,7 @@ type PayrollStatementDetailModalProps = { statement: PayrollStatementSummary | null; onClose: () => void; onAcknowledged: (publicId: string, acknowledgement: PayrollAcknowledgement) => void; + onPaymentConfirmationChanged: (publicId: string, confirmation: PayrollPaymentConfirmation | null) => void; }; const groupIcons: Record = { @@ -77,12 +80,15 @@ export function PayrollStatementDetailModal({ statement, onClose, onAcknowledged, + onPaymentConfirmationChanged, }: PayrollStatementDetailModalProps) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [acknowledging, setAcknowledging] = useState(false); const [acknowledgeError, setAcknowledgeError] = useState(null); + const [paymentConfirmationBusy, setPaymentConfirmationBusy] = useState(false); + const [paymentConfirmationError, setPaymentConfirmationError] = useState(null); const [confirmationOpen, setConfirmationOpen] = useState(false); const [reloadKey, setReloadKey] = useState(0); @@ -93,6 +99,7 @@ export function PayrollStatementDetailModal({ setDetail(null); setLoadError(null); setAcknowledgeError(null); + setPaymentConfirmationError(null); setConfirmationOpen(false); setLoading(true); @@ -116,6 +123,8 @@ export function PayrollStatementDetailModal({ const lineGroups = useMemo(() => groupPayrollLines(detail?.lines || []), [detail?.lines]); const acknowledgement = detail?.acknowledgement || statement?.acknowledgement || null; const isAcknowledged = Boolean(acknowledgement?.acknowledged_at); + const paymentConfirmation = detail?.payment_confirmation || statement?.payment_confirmation || null; + const canConfirmPayment = Boolean(detail?.can_confirm_payment ?? statement?.can_confirm_payment); const handleAcknowledge = async () => { if (!detail || acknowledging || isAcknowledged) return; @@ -133,6 +142,27 @@ export function PayrollStatementDetailModal({ } }; + const handlePaymentConfirmation = async (confirmed: boolean) => { + if (!detail || paymentConfirmationBusy || !canConfirmPayment) return; + setPaymentConfirmationBusy(true); + setPaymentConfirmationError(null); + try { + const result = await apiClient.setMyPayrollStatementPaymentConfirmation( + detail.public_id, + confirmed, + ); + setDetail((current) => current ? { + ...current, + payment_confirmation: result.payment_confirmation, + } : current); + onPaymentConfirmationChanged(detail.public_id, result.payment_confirmation); + } catch (error: unknown) { + setPaymentConfirmationError(errorMessage(error)); + } finally { + setPaymentConfirmationBusy(false); + } + }; + const footer = detail ? (
@@ -197,7 +227,7 @@ export function PayrollStatementDetailModal({ onClose={onClose} title={statement ? `Расчётный листок · ${getPayrollPeriodLabel(statement.period)}` : "Расчётный листок"} size="lg" - closeOnEsc={!acknowledging} + closeOnEsc={!acknowledging && !paymentConfirmationBusy} footer={footer} > {loading ? ( @@ -246,11 +276,13 @@ export function PayrollStatementDetailModal({

Итоги расчёта

-
+
+ +
@@ -309,6 +341,39 @@ export function PayrollStatementDetailModal({ )} +
+
+
+ +
+

+ {paymentConfirmation ? "Получение денег подтверждено" : "Получение денег не подтверждено"} +

+

+ {paymentConfirmation + ? `${formatPayrollDateTime(paymentConfirmation.confirmed_at)} · ${paymentConfirmation.confirmed_by.display_name}` + : canConfirmPayment + ? `Отметьте после фактического получения ${formatPayrollMoney(detail.payable, detail.currency)}.` + : "Отметка станет доступна после закрытия расчётного периода."} +

+
+
+ {canConfirmPayment ? ( + + ) : null} +
+ {paymentConfirmationError ?
{paymentConfirmationError}
: null} +
+
Листок опубликован {formatPayrollDateTime(detail.published_at)}.
diff --git a/frontend/src/components/finance/admin/PayrollAdminTables.tsx b/frontend/src/components/finance/admin/PayrollAdminTables.tsx index 67000b72..50b68ea7 100644 --- a/frontend/src/components/finance/admin/PayrollAdminTables.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminTables.tsx @@ -97,10 +97,12 @@ export function PayrollInputHistory({ }) { const [expanded, setExpanded] = useState(false); const visibleEvents = expanded ? events : events.slice(0, 5); + const canToggleHistory = !loading && !error && events.length > 5; + const toggleLabel = expanded ? "Свернуть историю" : `Показать всю историю (${events.length})`; return ( -
-
+
+
@@ -108,6 +110,17 @@ export function PayrollInputHistory({

История изменений

Все действия с записями, новые события сверху

+ {canToggleHistory ? ( + + ) : null} {!loading && !error ? {events.length} : null}
@@ -139,13 +152,14 @@ export function PayrollInputHistory({
))}
- {events.length > 5 ? ( + {canToggleHistory ? ( ) : null} diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index b54e0509..72595a9c 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -11,6 +11,7 @@ import { CheckCircle2, ChevronRight, Clock3, + Copy, FileCheck2, FileSpreadsheet, Loader2, @@ -38,6 +39,7 @@ import { PayrollRateFormModal, PayrollWorkRecordFormModal, } from "@/components/finance/admin/PayrollAdminModals"; +import { PayrollInputCopyModal } from "@/components/finance/admin/PayrollInputCopyModal"; import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal"; import { PayrollCarryoversModal } from "@/components/finance/admin/PayrollCarryoversModal"; import { PayrollManagementAccessModal } from "@/components/finance/admin/PayrollManagementAccessModal"; @@ -66,6 +68,7 @@ import type { PayrollBulkTargetPointsResult, PayrollCalculationBlocker, PayrollInputLineWrite, + PayrollInputCopyResult, PayrollInputHistoryEvent, PayrollInputHistoryKind, PayrollPayRateWrite, @@ -251,6 +254,7 @@ function TableToolbar({ onImportWorkbook, onBulkPayRate, onBulkPointRate, + onCopyInputs, addLabel, }: { search: string; @@ -264,6 +268,7 @@ function TableToolbar({ onImportWorkbook?: () => void; onBulkPayRate?: () => void; onBulkPointRate?: () => void; + onCopyInputs?: () => void; addLabel: string; }) { const [addMenuOpen, setAddMenuOpen] = useState(false); @@ -272,7 +277,8 @@ function TableToolbar({ onFillFromAttendance || onImportWorkbook || onBulkPayRate - || onBulkPointRate, + || onBulkPointRate + || onCopyInputs, ); useEffect(() => { @@ -384,6 +390,19 @@ function TableToolbar({ Массово задать цену сверх нормы ) : null} + {onCopyInputs ? ( + + ) : null} ) : null} @@ -459,6 +478,7 @@ export function PayrollAdminWorkspace({ const [bulkPayRateModalOpen, setBulkPayRateModalOpen] = useState(false); const [bulkPointRateModalOpen, setBulkPointRateModalOpen] = useState(false); const [bulkTargetPointsModalOpen, setBulkTargetPointsModalOpen] = useState(false); + const [inputCopyModalOpen, setInputCopyModalOpen] = useState(false); const [editingRate, setEditingRate] = useState(null); const [workModalOpen, setWorkModalOpen] = useState(false); const [editingWork, setEditingWork] = useState(null); @@ -471,6 +491,7 @@ export function PayrollAdminWorkspace({ const [pendingAction, setPendingAction] = useState(null); const [accessModalOpen, setAccessModalOpen] = useState(false); const createdPeriodIdRef = useRef(null); + const createdPeriodInputCopyRef = useRef(null); const selectedPeriod = workspace?.selected_period || null; @@ -635,7 +656,7 @@ export function PayrollAdminWorkspace({ }, [loadWorkspace, selectedPeriod?.id]); const handleStale = useCallback(async () => { - setPeriodModalOpen(false); setRateModalOpen(false); setBulkPayRateModalOpen(false); setBulkPointRateModalOpen(false); setBulkTargetPointsModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null); + setPeriodModalOpen(false); setRateModalOpen(false); setBulkPayRateModalOpen(false); setBulkPointRateModalOpen(false); setBulkTargetPointsModalOpen(false); setInputCopyModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null); await refreshView("Данные изменились в другой сессии. Экран обновлён — повторите действие."); }, [refreshView]); @@ -800,6 +821,7 @@ export function PayrollAdminWorkspace({ else { const createdPeriod = await apiClient.createPayrollAdminPeriod(payload); createdPeriodIdRef.current = createdPeriod.id; + createdPeriodInputCopyRef.current = createdPeriod.automatic_input_copy || null; } }; const saveRate = async (payload: PayrollPayRateWrite) => { @@ -952,6 +974,19 @@ export function PayrollAdminWorkspace({ await refreshInlineTable("inputs"); }; + const setPaymentConfirmed = async (row: PayrollPeriodTableRow, confirmed: boolean) => { + if (!selectedPeriod || selectedPeriod.status !== "closed") return; + await apiClient.setPayrollAdminPaymentConfirmation( + selectedPeriod.id, + row.employee.id, + confirmed, + ); + setPeriodTable(await apiClient.getPayrollAdminPeriodTable(selectedPeriod.id)); + setNotice(confirmed + ? `Выплата сотруднику ${row.employee.display_name} подтверждена.` + : `Отметка о выплате сотруднику ${row.employee.display_name} снята.`); + }; + const managementInfo = (
{!embedded ? Финансы : null} @@ -1030,7 +1065,7 @@ export function PayrollAdminWorkspace({ : null; return ( -
+
{headerTargetId ? ( <> {headerTarget ? createPortal(managementInfo, headerTarget) : null} @@ -1176,7 +1211,7 @@ export function PayrollAdminWorkspace({ { setEditingWork(record); setWorkModalOpen(true); }} onApprove={openApproveWork} onRevise={openReviseWork} /> ) : null} - {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} + {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} {tab === "summary" ? (
@@ -1190,7 +1225,7 @@ export function PayrollAdminWorkspace({ }} > - Долги и доплаты + Переходящие остатки {(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0) ? ( {(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0)} @@ -1198,7 +1233,7 @@ export function PayrollAdminWorkspace({ ) : null}
- 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); }} /> + 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); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} /> ) : null} {tab === "approval" ? ( @@ -1249,7 +1284,7 @@ export function PayrollAdminWorkspace({ )} - {periodModalOpen ? setPeriodModalOpen(false)} onSubmit={savePeriod} onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; createdPeriodIdRef.current = null; if (createdPeriodId) { setNotice("Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null} + {periodModalOpen ? setPeriodModalOpen(false)} onSubmit={savePeriod} onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; const automaticInputCopy = createdPeriodInputCopyRef.current; createdPeriodIdRef.current = null; createdPeriodInputCopyRef.current = null; if (createdPeriodId) { const copiedCount = automaticInputCopy?.summary.created || 0; setNotice(copiedCount > 0 ? `Период создан и выбран. Автоматически перенесено записей: ${copiedCount}.` : "Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null} {workspace.access_settings.can_configure ? ( 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 && inputCopyModalOpen ? setInputCopyModalOpen(false)} onSaved={async (result: PayrollInputCopyResult) => { setInputCopyModalOpen(false); if (!result.summary.created) { await refreshView("Настройки переноса обновлены."); return; } const details = [result.summary.skipped ? `пропущено: ${result.summary.skipped}` : "", result.summary.replaced_cells ? `заменено полей: ${result.summary.replaced_cells}` : ""].filter(Boolean).join(", "); await refreshView(`Перенесено ${result.summary.created} записей для ${result.summary.employees} сотрудников${details ? ` (${details})` : ""}.`); }} /> : null} + {selectedPeriod && carryoversModalOpen ? refreshInlineTable("inputs")} onClose={() => setCarryoversModalOpen(false)} /> : null} {selectedPeriod && workModalOpen ? setWorkModalOpen(false)} onSubmit={saveWork} onSaved={async () => { setWorkModalOpen(false); await refreshView("Черновик выработки сохранён."); }} onStale={handleStale} /> : null} {selectedPeriod && attendanceWorkModalOpen ? ( void | Promise; + onChanged?: () => void | Promise; onClose: () => void; periods: PayrollAdminPeriod[]; selectedPeriod: PayrollAdminPeriod; @@ -28,70 +26,10 @@ type PayrollCarryoversModalProps = { 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} -
-
-
- ); +function signedAmount(value: string | number, currency: string): string { + const amount = Number(value); + if (!amount) return formatPayrollMoney("0", currency); + return `${amount > 0 ? "+" : "−"}${formatPayrollMoney(Math.abs(amount), currency)}`; } export function PayrollCarryoversModal({ @@ -105,46 +43,34 @@ export function PayrollCarryoversModal({ selectedPeriod, }: PayrollCarryoversModalProps) { const [periodId, setPeriodId] = useState(selectedPeriod.id); - const [workspace, setWorkspace] = useState(null); - const [loading, setLoading] = useState(false); - const [busy, setBusy] = useState(false); + const [ledger, setLedger] = useState(null); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); 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], - ); + const [notice, setNotice] = useState(null); + const [employeeFilterId, setEmployeeFilterId] = useState(initialEmployeeId ? String(initialEmployeeId) : ""); - useEffect(() => { - if (!isOpen) return; - setPeriodId(selectedPeriod.id); - setWorkspace(null); + const loadLedger = useCallback(async (showLoader = true) => { + if (showLoader) setLoading(true); setError(null); - setConfirmCancelId(null); - const employeeId = initialEmployeeId ? String(initialEmployeeId) : ""; - setEmployeeFilterId(employeeId); - setForm((current) => ({ ...current, employee_id: employeeId })); - }, [initialEmployeeId, isOpen, selectedPeriod.id]); + try { + setLedger(await apiClient.getPayrollAdminCarryovers(periodId)); + } catch (loadError: unknown) { + setError(getPayrollAdminError(loadError, "Не удалось загрузить переходящий остаток.")); + } finally { + if (showLoader) setLoading(false); + } + }, [periodId]); useEffect(() => { if (!isOpen) return; let cancelled = false; - setLoading(true); - setError(null); apiClient.getPayrollAdminCarryovers(periodId) .then((payload) => { - if (!cancelled) setWorkspace(payload); + if (!cancelled) setLedger(payload); }) .catch((loadError: unknown) => { - if (!cancelled) setError(getPayrollAdminError(loadError, "Не удалось загрузить долги и доплаты.")); + if (!cancelled) setError(getPayrollAdminError(loadError, "Не удалось загрузить переходящий остаток.")); }) .finally(() => { if (!cancelled) setLoading(false); @@ -154,161 +80,141 @@ export function PayrollCarryoversModal({ }; }, [isOpen, periodId]); - async function reload() { - const payload = await apiClient.getPayrollAdminCarryovers(periodId); - setWorkspace(payload); - } + const entries = useMemo(() => (ledger?.entries || []).filter( + (entry) => !employeeFilterId || entry.employee.id === Number(employeeFilterId), + ), [employeeFilterId, ledger]); + const balances = useMemo(() => { + const byEmployee = new Map(); + entries.forEach((entry) => { + byEmployee.set(entry.employee.id, (byEmployee.get(entry.employee.id) || 0) + Number(entry.contribution)); + }); + return [...byEmployee.values()].reduce( + (totals, balance) => ({ + companyOwes: totals.companyOwes + Math.max(balance, 0), + employeeOwes: totals.employeeOwes + Math.max(-balance, 0), + }), + { companyOwes: 0, employeeOwes: 0 }, + ); + }, [entries]); + const currency = ledger?.currency || selectedPeriod.currency; - async function createEntry(event: React.FormEvent) { - event.preventDefault(); + const toggleEntry = async (entryId: number, included: boolean) => { + setSavingId(entryId); + setError(null); + setNotice(null); 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, "Не удалось зарегистрировать долг или доплату.")); + const result = await apiClient.setPayrollAdminInputLineCarryoverInclusion(entryId, included); + setNotice(result.message); + await loadLedger(false); + await onChanged?.(); + } catch (saveError: unknown) { + setError(getPayrollAdminError(saveError, "Не удалось изменить участие записи в остатке.")); } finally { - setBusy(false); + setSavingId(null); } - } - - 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} +
+ + +
+
+

В реестре: все положительные и отрицательные корректировки за выбранный и предыдущие периоды.

+

В остатке: утверждённые корректировки опубликованных периодов и активные корректировки текущего периода.

+

Не входят: зарплата по баллам, обычные премии и начисления, аванс, прочие выплаты и удержания.

+

Отключение записи меняет только переходящий остаток. Сумма зарплаты в исходном периоде не изменяется.

+
+
+ Компания должна: {formatPayrollMoney(balances.companyOwes, currency)} + Сотрудники должны: {formatPayrollMoney(balances.employeeOwes, currency)}
{error ?
{error}
: null} + {notice ?
{notice}
: null} - {canManage && contextPeriod.status === "closed" ? ( -
-
- -

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

-
-
- - - -