diff --git a/backend/api/v1/finance/admin/off_cycle.py b/backend/api/v1/finance/admin/off_cycle.py new file mode 100644 index 00000000..6f0b1ab6 --- /dev/null +++ b/backend/api/v1/finance/admin/off_cycle.py @@ -0,0 +1,239 @@ +from decimal import Decimal + +from django.db.models import Q, Sum +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import serializers +from rest_framework.response import Response + +from finance.enums import PayrollPeriodStatus +from finance.models import PayrollOffCyclePayment, PayrollPeriod, PayrollStatement +from finance.off_cycle.services import ( + record_payment, + statement_settlement, + void_payment, +) + +from ..payroll_serializers.common import PayrollUserRefSerializer +from .common import PAYROLL_PERMISSIONS, Employee, PayrollAdminAPIView + + +class PaymentWriteSerializer(serializers.Serializer): + employee_id = serializers.PrimaryKeyRelatedField( + source="employee", + queryset=Employee.objects.visible_in_ui(), + allow_null=True, + required=False, + default=None, + ) + payee_name = serializers.CharField( + max_length=250, required=False, allow_blank=True, default="" + ) + paid_on = serializers.DateField() + amount = serializers.DecimalField( + max_digits=19, decimal_places=2, min_value=Decimal("0.01") + ) + currency = serializers.RegexField(r"^[A-Z]{3}$", default="RUB") + source_period_id = serializers.PrimaryKeyRelatedField( + source="source_period", + queryset=PayrollPeriod.objects.all(), + allow_null=True, + required=False, + default=None, + ) + note = serializers.CharField(max_length=1000) + request_id = serializers.UUIDField() + + def validate(self, attrs): + if attrs["paid_on"] > timezone.localdate(): + raise serializers.ValidationError("Дата выдачи не может быть в будущем.") + if attrs["employee"]: + employee = attrs["employee"] + attrs["payee_name"] = ( + " ".join( + filter( + None, + [ + employee.last_name, + employee.first_name, + employee.patronymic, + ], + ) + ) + or employee.get_full_name().strip() + or f"Сотрудник #{employee.pk}" + ) + elif not attrs["payee_name"]: + raise serializers.ValidationError( + "Выберите сотрудника или введите имя получателя." + ) + return attrs + + +class PaymentSerializer(serializers.ModelSerializer): + settlement = serializers.SerializerMethodField() + employee_id = serializers.IntegerField(read_only=True, allow_null=True) + source_period_id = serializers.IntegerField(read_only=True, allow_null=True) + source_period_name = serializers.SerializerMethodField() + created_by = PayrollUserRefSerializer(read_only=True) + voided_by = PayrollUserRefSerializer(read_only=True, allow_null=True) + + def get_source_period_name(self, obj): + return ( + (obj.source_period.name or obj.source_period.code) + if obj.source_period + else None + ) + + def get_settlement(self, obj): + if not obj.source_statement_id: + return None + cache = self.context.setdefault("settlements", {}) + if obj.source_statement_id not in cache: + cache[obj.source_statement_id] = { + key: str(value) if isinstance(value, Decimal) else value + for key, value in statement_settlement(obj.source_statement).items() + } + return cache[obj.source_statement_id] + + class Meta: + model = PayrollOffCyclePayment + fields = [ + "id", + "employee_id", + "payee_name", + "paid_on", + "amount", + "currency", + "source_period_id", + "source_period_name", + "note", + "created_at", + "created_by", + "voided_at", + "voided_by", + "void_reason", + "settlement", + ] + read_only_fields = fields + + +class PaymentQuerySerializer(serializers.Serializer): + date_from = serializers.DateField(required=False) + date_to = serializers.DateField(required=False) + employee_id = serializers.IntegerField(min_value=1, required=False) + source_period_id = serializers.IntegerField(min_value=1, required=False) + search = serializers.CharField(max_length=250, required=False, allow_blank=True) + currency = serializers.RegexField(r"^[A-Z]{3}$", default="RUB") + include_voided = serializers.BooleanField(default=False) + page = serializers.IntegerField(min_value=1, default=1) + + def validate(self, attrs): + if ( + attrs.get("date_from") + and attrs.get("date_to") + and attrs["date_from"] > attrs["date_to"] + ): + raise serializers.ValidationError( + "Начало диапазона должно быть не позже окончания." + ) + return attrs + + +class PayrollOffCyclePaymentListView(PayrollAdminAPIView): + allowed_permissions = ( + PAYROLL_PERMISSIONS["manage_inputs"], + PAYROLL_PERMISSIONS["view_all"], + ) + serializer_class = PaymentSerializer + + def get(self, request): + query = PaymentQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + params = query.validated_data + payments = PayrollOffCyclePayment.objects.filter(currency=params["currency"]) + for field, lookup in ( + ("date_from", "paid_on__gte"), + ("date_to", "paid_on__lte"), + ("employee_id", "employee_id"), + ("source_period_id", "source_period_id"), + ): + if field in params: + payments = payments.filter(**{lookup: params[field]}) + if params.get("search"): + payments = payments.filter( + Q(payee_name__icontains=params["search"]) + | Q(note__icontains=params["search"]) + ) + if not params["include_voided"]: + payments = payments.filter(voided_at__isnull=True) + total = payments.filter(voided_at__isnull=True).aggregate(amount=Sum("amount"))[ + "amount" + ] or Decimal("0.00") + count = payments.count() + offset = (params["page"] - 1) * 50 + page = payments.select_related( + "source_period", + "created_by", + "voided_by", + "source_statement__payment_confirmation", + )[offset : offset + 50] + return Response( + { + "results": PaymentSerializer(page, many=True).data, + "count": count, + "page": params["page"], + "page_size": 50, + "total_paid": str(total), + "currency": params["currency"], + } + ) + + def post(self, request): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + command = PaymentWriteSerializer(data=request.data) + command.is_valid(raise_exception=True) + payment, created = record_payment(actor=request.user, **command.validated_data) + return Response(PaymentSerializer(payment).data, status=201 if created else 200) + + +class PayrollOffCyclePaymentVoidView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + serializer_class = PaymentSerializer + + def post(self, request, pk): + get_object_or_404(PayrollOffCyclePayment, pk=pk) + reason = serializers.CharField(max_length=1000).run_validation( + request.data.get("reason", "") + ) + payment = void_payment(pk, actor=request.user, reason=reason) + return Response(PaymentSerializer(payment).data) + + +class PayrollOffCycleSettlementView(PayrollAdminAPIView): + allowed_permissions = ( + PAYROLL_PERMISSIONS["manage_inputs"], + PAYROLL_PERMISSIONS["view_all"], + ) + + def get(self, request, period_id, employee_id): + statement = get_object_or_404( + PayrollStatement.objects.select_related("payment_confirmation").filter( + run_id=PayrollPeriod.objects.filter( + pk=period_id, + status=PayrollPeriodStatus.CLOSED, + ).values("current_run_id")[:1], + employee_id=employee_id, + ) + ) + return Response( + { + "employee_id": employee_id, + "source_period_id": period_id, + "currency": statement.currency, + **{ + key: str(value) if isinstance(value, Decimal) else value + for key, value in statement_settlement(statement).items() + }, + } + ) diff --git a/backend/api/v1/finance/admin/period_views/statements.py b/backend/api/v1/finance/admin/period_views/statements.py index 3028a029..4782e548 100644 --- a/backend/api/v1/finance/admin/period_views/statements.py +++ b/backend/api/v1/finance/admin/period_views/statements.py @@ -21,6 +21,7 @@ from finance.models import ( PayrollStatementPaymentConfirmation, PayrollTableReviewMark, ) +from finance.off_cycle.reporting import period_payment_summary, period_settlements from finance.payroll.reporting import ( build_payroll_period_table, ) @@ -57,6 +58,10 @@ class PayrollPeriodTableView(PayrollAdminAPIView): payload = build_payroll_period_table(period) payload["cell_comments"] = _cell_comment_summaries(period) payload["carryovers"] = _automatic_balance_summaries(payload["rows"]) + payload["recorded_payments"] = period_payment_summary(period, payload["rows"]) + settlements = period_settlements(period) + for row in payload["rows"]: + row["off_cycle_settlement"] = settlements.get(row["employee"]["id"]) payload["review_mark_employee_ids"] = list( PayrollTableReviewMark.objects.filter( period=period, diff --git a/backend/api/v1/finance/payroll_serializers/presentation.py b/backend/api/v1/finance/payroll_serializers/presentation.py index 9545a276..46d8e893 100644 --- a/backend/api/v1/finance/payroll_serializers/presentation.py +++ b/backend/api/v1/finance/payroll_serializers/presentation.py @@ -106,6 +106,7 @@ class PayrollAdminWorkspaceSerializer(serializers.Serializer): class PayrollPeriodTableSerializer(serializers.Serializer): + recorded_payments = serializers.JSONField(read_only=True) period_id = serializers.IntegerField(read_only=True) period_status = serializers.CharField(read_only=True) currency = serializers.CharField(read_only=True) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 0817e8e6..715853c5 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -48,6 +48,11 @@ from .admin import ( PayrollWorkRecordListCreateView, PayrollWorkRecordReviseView, ) +from .admin.off_cycle import ( + PayrollOffCyclePaymentListView, + PayrollOffCyclePaymentVoidView, + PayrollOffCycleSettlementView, +) from .analytics import ( AnalyticsOptionsView, AnalyticsReportView, @@ -66,6 +71,21 @@ from .views import ( app_name = "finance-payroll" urlpatterns = [ + path( + "payroll/admin/off-cycle-payments/", + PayrollOffCyclePaymentListView.as_view(), + name="admin-off-cycle-payments", + ), + path( + "payroll/admin/off-cycle-payments//void/", + PayrollOffCyclePaymentVoidView.as_view(), + name="admin-off-cycle-payment-void", + ), + path( + "payroll/admin/off-cycle-settlement///", + PayrollOffCycleSettlementView.as_view(), + name="admin-off-cycle-settlement", + ), path( "analytics/project-payments/", ProjectPaymentListView.as_view(), diff --git a/backend/finance/README.md b/backend/finance/README.md index 6e8b9354..22462c57 100644 --- a/backend/finance/README.md +++ b/backend/finance/README.md @@ -138,6 +138,31 @@ workflow. Responses contain no internal source references and are marked `Cache-Control: private, no-store`. +## Payments outside payroll periods + +The management workspace includes a separate actual-payment register. Operators +with `manage_payroll_inputs` can record a payment to an existing (including +inactive) employee or a manually named recipient. Viewing requires management +access plus `manage_payroll_inputs` or `view_all_payroll`. + +Each record stores the cash date, amount, currency, reason, author and optional +closed source period. For a linked employee and source period, it references the +existing statement and checks the remaining payable under row locks. Partial +payments reduce this live settlement balance without changing the historical +statement, salary inputs, published calculations or carryover formulas. The old +whole-statement payment confirmation still means the entire payable was issued. +For a manual recipient or an unlinked payment the system records cash only; it +cannot infer an outstanding debt from a name. + +The request UUID makes retries idempotent. Corrections are performed by voiding +with a reason and recording a replacement; both operations are audited. These +records never create statements or trigger payroll publication/notifications. + +Period tables show cash records separately and add them once to the recorded +payment total by cash date. People analytics show them separately from salary; +company cash outflow includes them without expensing the same salary again. +Deploy migration `0039_payrolloffcyclepayment` before enabling the updated UI. + ## Configuration Rules live in `FINANCE_PAYROLL` and can be supplied through the diff --git a/backend/finance/analytics/people.py b/backend/finance/analytics/people.py index bca76424..8cbe3f69 100644 --- a/backend/finance/analytics/people.py +++ b/backend/finance/analytics/people.py @@ -13,7 +13,7 @@ from employees.services.personnel_state import ( ) from finance.analytics.models import ReportingEntry from finance.enums import PayrollRunStatus -from finance.models import PayrollRun, PayrollStatement +from finance.models import PayrollOffCyclePayment, PayrollRun, PayrollStatement from .sales import ZERO, months @@ -79,6 +79,19 @@ def people_report(start, end, currency): taxes[entry.employee_id] += entry.amount tax_counts[entry.employee_id] += 1 + off_cycle = list( + PayrollOffCyclePayment.objects.filter( + paid_on__range=(start, end), + currency=currency, + voided_at__isnull=True, + ).select_related("source_period") + ) + cash_by_employee = defaultdict(lambda: ZERO) + cash_by_month = defaultdict(lambda: ZERO) + for payment in off_cycle: + cash_by_employee[payment.employee_id] += payment.amount + cash_by_month[payment.paid_on.strftime("%Y-%m")] += payment.amount + rows = [] month_keys = list(months(start, end)) comparison_months = sorted( @@ -115,6 +128,7 @@ def people_report(start, end, currency): and not salary and not records and not tax_counts[employee.pk] + and not cash_by_employee[employee.pk] ): continue vacation, sick = absence_days(employee, actions, start, end) @@ -154,6 +168,7 @@ def people_report(start, end, currency): "sick_days": sick, "tax_paid": taxes[employee.pk] if tax_counts[employee.pk] else None, "tax_payments": tax_counts[employee.pk], + "off_cycle_paid": cash_by_employee[employee.pk], } rows.append(row) for month in month_keys: @@ -194,6 +209,7 @@ def people_report(start, end, currency): "average_salary": mean(salaries), "salary_total": sum(salaries, ZERO), "salary_employees": len(salaries), + "off_cycle_paid": cash_by_month[month], "headcount_with_history": len(population), "average_tenure_days": mean([Decimal(p[0]) for p in population]), "average_age": mean( @@ -206,7 +222,24 @@ def people_report(start, end, currency): return { "employees": rows, "monthly": monthly, + "off_cycle_payments": [ + { + "id": payment.pk, + "name": payment.payee_name, + "paid_on": payment.paid_on, + "amount": payment.amount, + "source_period": ( + payment.source_period.name or payment.source_period.code + ) + if payment.source_period + else "—", + "note": payment.note, + } + for payment in off_cycle + ], "summary": { + "off_cycle_paid": sum((payment.amount for payment in off_cycle), ZERO), + "off_cycle_payment_count": len(off_cycle), "growth_from": comparison_months[0] if comparison_months else None, "growth_to": comparison_months[-1] if comparison_months else None, "headcount": len(current), diff --git a/backend/finance/analytics/projects.py b/backend/finance/analytics/projects.py index 90fb2781..1a65c84a 100644 --- a/backend/finance/analytics/projects.py +++ b/backend/finance/analytics/projects.py @@ -329,6 +329,17 @@ def projects_report(start, end, currency, sales, source=None, project=None, scop scope, local_by_id, ) + if journal_entry_in_scope(None, project, scope, local_by_id): + from finance.models import PayrollOffCyclePayment + + for payment in PayrollOffCyclePayment.objects.filter( + paid_on__range=(start, end), + currency=currency, + voided_at__isnull=True, + ): + row = project_row(rows, "unassigned", "Без привязки") + row["manual_paid"] += payment.amount + monthly[payment.paid_on.strftime("%Y-%m")]["manual_paid"] += payment.amount allocations = TimeAllocation.objects.filter( day__date__range=(start, end), day__status="confirmed", diff --git a/backend/finance/analytics/sales.py b/backend/finance/analytics/sales.py index 26f052b3..aac0acc1 100644 --- a/backend/finance/analytics/sales.py +++ b/backend/finance/analytics/sales.py @@ -276,6 +276,18 @@ def production_report(start, end, currency, sales, project=None): "purchases": ZERO, } product_rows[key][field] += value + # Cash facts are included once, by payment date; not expensed as salary again. + if project is None or project == "unassigned": + from finance.models import PayrollOffCyclePayment + + off_cycle = PayrollOffCyclePayment.objects.filter( + paid_on__range=(start, end), + currency=currency, + voided_at__isnull=True, + ) + for payment in off_cycle: + totals["cash_out"] += payment.amount + counts["cash_out"] += 1 return { "products": list(product_rows.values()), "monthly": list(monthly.values()), diff --git a/backend/finance/migrations/0039_payrolloffcyclepayment.py b/backend/finance/migrations/0039_payrolloffcyclepayment.py new file mode 100644 index 00000000..fe0cbb23 --- /dev/null +++ b/backend/finance/migrations/0039_payrolloffcyclepayment.py @@ -0,0 +1,143 @@ +import uuid +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0038_payrollperiod_component_snapshot"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PayrollOffCyclePayment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "payee_name", + models.CharField(max_length=250, verbose_name="Получатель"), + ), + ( + "paid_on", + models.DateField(db_index=True, verbose_name="Дата выдачи"), + ), + ( + "amount", + models.DecimalField( + max_digits=19, + decimal_places=2, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.01")) + ], + verbose_name="Выдано", + ), + ), + ( + "currency", + models.CharField( + default="RUB", + max_length=3, + validators=[ + django.core.validators.RegexValidator("^[A-Z]{3}$") + ], + ), + ), + ("note", models.CharField(max_length=1000, verbose_name="Основание")), + ( + "request_id", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("voided_at", models.DateTimeField(blank=True, null=True)), + ("void_reason", models.CharField(blank=True, max_length=1000)), + ( + "employee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="off_cycle_payments", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="off_cycle_payments_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "voided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="off_cycle_payments_voided", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "source_period", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="off_cycle_payments", + to="finance.payrollperiod", + ), + ), + ( + "source_statement", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="off_cycle_payments", + to="finance.payrollstatement", + ), + ), + ], + options={ + "verbose_name": "Выплата вне периода", + "verbose_name_plural": "Выплаты вне периода", + "ordering": ["-paid_on", "-pk"], + "constraints": [ + models.CheckConstraint( + condition=models.Q(amount__gt=0), + name="off_cycle_payment_positive", + ), + models.CheckConstraint( + condition=( + models.Q( + voided_at__isnull=True, + voided_by__isnull=True, + void_reason="", + ) + | ( + models.Q( + voided_at__isnull=False, voided_by__isnull=False + ) + & ~models.Q(void_reason="") + ) + ), + name="off_cycle_payment_void_consistent", + ), + ], + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 9ec72970..cc4d48e9 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -27,6 +27,7 @@ from .enums import ( PayrollRunStatus, VacationPayInstructionStatus, ) +from .off_cycle.models import PayrollOffCyclePayment # noqa: F401 currency_validator = RegexValidator( regex=r"^[A-Z]{3}$", diff --git a/backend/finance/off_cycle/__init__.py b/backend/finance/off_cycle/__init__.py new file mode 100644 index 00000000..5a68cbf9 --- /dev/null +++ b/backend/finance/off_cycle/__init__.py @@ -0,0 +1 @@ +"""Actual payments recorded independently of payroll calculation periods.""" diff --git a/backend/finance/off_cycle/models.py b/backend/finance/off_cycle/models.py new file mode 100644 index 00000000..1b3a32c2 --- /dev/null +++ b/backend/finance/off_cycle/models.py @@ -0,0 +1,85 @@ +import uuid +from decimal import Decimal + +from django.conf import settings +from django.core.validators import MinValueValidator, RegexValidator +from django.db import models + + +class PayrollOffCyclePayment(models.Model): + """A cash fact; never a payroll input or a new employee statement.""" + + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="off_cycle_payments", + ) + payee_name = models.CharField("Получатель", max_length=250) + paid_on = models.DateField("Дата выдачи", db_index=True) + amount = models.DecimalField( + "Выдано", + max_digits=19, + decimal_places=2, + validators=[MinValueValidator(Decimal("0.01"))], + ) + currency = models.CharField( + max_length=3, + default="RUB", + validators=[RegexValidator(r"^[A-Z]{3}$")], + ) + source_period = models.ForeignKey( + "finance.PayrollPeriod", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="off_cycle_payments", + ) + source_statement = models.ForeignKey( + "finance.PayrollStatement", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="off_cycle_payments", + ) + note = models.CharField("Основание", max_length=1000) + request_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="off_cycle_payments_created", + ) + created_at = models.DateTimeField(auto_now_add=True) + voided_at = models.DateTimeField(null=True, blank=True) + voided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="off_cycle_payments_voided", + ) + void_reason = models.CharField(max_length=1000, blank=True) + + class Meta: + ordering = ["-paid_on", "-pk"] + constraints = [ + models.CheckConstraint( + condition=models.Q(amount__gt=0), + name="off_cycle_payment_positive", + ), + models.CheckConstraint( + condition=( + models.Q( + voided_at__isnull=True, voided_by__isnull=True, void_reason="" + ) + | ( + models.Q(voided_at__isnull=False, voided_by__isnull=False) + & ~models.Q(void_reason="") + ) + ), + name="off_cycle_payment_void_consistent", + ), + ] + verbose_name = "Выплата вне периода" + verbose_name_plural = "Выплаты вне периода" diff --git a/backend/finance/off_cycle/reporting.py b/backend/finance/off_cycle/reporting.py new file mode 100644 index 00000000..2c6de4db --- /dev/null +++ b/backend/finance/off_cycle/reporting.py @@ -0,0 +1,40 @@ +from decimal import Decimal + +from django.db.models import Sum + +from finance.models import PayrollOffCyclePayment + + +def period_payment_summary(period, rows): + extra = PayrollOffCyclePayment.objects.filter( + paid_on__range=(period.date_from, period.date_to), + currency=period.currency, + voided_at__isnull=True, + ).aggregate(total=Sum("amount"))["total"] or Decimal("0.00") + payroll = sum((Decimal(row["payment_total"]) for row in rows), Decimal("0.00")) + return { + "payroll_recorded": str(payroll), + "outside_period": str(extra), + "total_recorded": str(payroll + extra), + } + + +def period_settlements(period): + # A live projection beside immutable historical amounts. + from finance.off_cycle.services import statement_settlement + + return ( + { + statement.employee_id: { + key: str(value) if isinstance(value, Decimal) else value + for key, value in statement_settlement(statement).items() + } + for statement in period.current_run.statements.filter( + off_cycle_payments__isnull=False, + ) + .select_related("payment_confirmation") + .distinct() + } + if period.current_run_id + else {} + ) diff --git a/backend/finance/off_cycle/services.py b/backend/finance/off_cycle/services.py new file mode 100644 index 00000000..8501973c --- /dev/null +++ b/backend/finance/off_cycle/services.py @@ -0,0 +1,155 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.db.models import Sum +from django.utils import timezone + +from finance.enums import PayrollPeriodStatus +from finance.models import ( + PayrollAuditEvent, + PayrollOffCyclePayment, + PayrollPeriod, + PayrollStatement, +) +from finance.payroll.access import can_access_payroll_management, has_payroll_permission +from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied + +ZERO = Decimal("0.00") + + +def _require_write_access(actor): + if not can_access_payroll_management(actor) or not has_payroll_permission( + actor, "finance.manage_payroll_inputs" + ): + raise PayrollPermissionDenied("finance.manage_payroll_inputs") + + +def statement_settlement(statement): + paid = ( + statement.off_cycle_payments.filter(voided_at__isnull=True).aggregate( + total=Sum("amount") + )["total"] + or ZERO + ) + confirmed = hasattr(statement, "payment_confirmation") + return { + "original_payable": statement.payable, + "paid_outside_period": paid, + "remaining": ZERO if confirmed else max(statement.payable - paid, ZERO), + "confirmed": confirmed, + } + + +def _audit(payment, actor, action, **metadata): + PayrollAuditEvent.objects.create( + actor=actor, + action=action, + object_type=payment._meta.label_lower, + object_id=str(payment.pk), + period=payment.source_period, + metadata={"channel": "off_cycle_payments", **metadata}, + ) + + +@transaction.atomic +def record_payment(*, actor, employee, payee_name, source_period, **fields): + _require_write_access(actor) + # The actor lock serializes retries with the same idempotency key. + get_user_model().objects.select_for_update().get(pk=actor.pk) + existing = PayrollOffCyclePayment.objects.filter( + request_id=fields["request_id"] + ).first() + if existing: + same = ( + existing.created_by_id == actor.pk + and existing.employee_id == (employee.pk if employee else None) + and existing.payee_name == payee_name + and existing.source_period_id + == (source_period.pk if source_period else None) + and all(getattr(existing, key) == value for key, value in fields.items()) + ) + if not same: + raise PayrollOperationError( + "PAYMENT_REQUEST_CONFLICT", + "Этот запрос уже использован для другой выплаты.", + ) + return existing, False + statement = None + if source_period: + source_period = PayrollPeriod.objects.select_for_update().get( + pk=source_period.pk + ) + if source_period.status != PayrollPeriodStatus.CLOSED: + raise PayrollOperationError( + "PAYMENT_SOURCE_NOT_CLOSED", + "Для погашения долга выберите закрытый период.", + ) + if fields["currency"] != source_period.currency: + raise PayrollOperationError( + "PAYMENT_CURRENCY_MISMATCH", "Валюта выплаты не совпадает с периодом." + ) + if employee: + statement = ( + PayrollStatement.objects.select_for_update() + .filter( + run_id=source_period.current_run_id, + employee=employee, + ) + .first() + ) + if statement is None: + raise PayrollOperationError( + "PAYMENT_STATEMENT_NOT_FOUND", + "У сотрудника нет итогового листка в выбранном периоде.", + ) + settlement = statement_settlement(statement) + if fields["amount"] > settlement["remaining"]: + raise PayrollOperationError( + "PAYMENT_EXCEEDS_REMAINING", + f"Сумма превышает остаток долга: {settlement['remaining']:.2f} {fields['currency']}.", + ) + payment = PayrollOffCyclePayment( + employee=employee, + payee_name=payee_name, + source_period=source_period, + source_statement=statement, + created_by=actor, + **fields, + ) + payment.full_clean(validate_unique=False) + try: + with transaction.atomic(): + payment.save() + except IntegrityError: + if PayrollOffCyclePayment.objects.filter( + request_id=fields["request_id"] + ).exists(): + raise PayrollOperationError( + "PAYMENT_REQUEST_CONFLICT", + "Этот запрос уже использован для другой выплаты.", + ) + raise + _audit(payment, actor, "payroll.off_cycle_payment_created") + return payment, True + + +@transaction.atomic +def void_payment(payment_id, *, actor, reason): + _require_write_access(actor) + # Same lock order as creation/confirmation; nullable relations are not joined. + initial = PayrollOffCyclePayment.objects.get(pk=payment_id) + if initial.source_period_id: + PayrollPeriod.objects.select_for_update().get(pk=initial.source_period_id) + if initial.source_statement_id: + PayrollStatement.objects.select_for_update().get(pk=initial.source_statement_id) + payment = PayrollOffCyclePayment.objects.select_for_update().get(pk=payment_id) + if payment.voided_at: + return payment + payment.voided_at = timezone.now() + payment.voided_by = actor + payment.void_reason = reason + payment.full_clean() + payment.save(update_fields=["voided_at", "voided_by", "void_reason"]) + _audit(payment, actor, "payroll.off_cycle_payment_voided", reason=reason) + return payment diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index d776ac4e..339a030e 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -366,6 +366,7 @@ def build_payroll_period_table(period: PayrollPeriod): ) | period_employee_ids | approved_input_employee_ids + | set(statements) ) visible_employee_ids = set( Employee.objects.visible_in_ui() diff --git a/backend/tests/finance/test_off_cycle_payments.py b/backend/tests/finance/test_off_cycle_payments.py new file mode 100644 index 00000000..362ea6e2 --- /dev/null +++ b/backend/tests/finance/test_off_cycle_payments.py @@ -0,0 +1,281 @@ +import uuid +from datetime import date +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.analytics.people import people_report +from finance.analytics.projects import projects_report +from finance.analytics.sales import production_report, sales_report +from finance.models import ( + FinanceSettings, + PayrollAuditEvent, + PayrollInputLine, + PayrollOffCyclePayment, + PayrollPeriod, + PayrollRun, + PayrollStatement, + PayrollStatementPaymentConfirmation, +) + +pytestmark = pytest.mark.django_db + + +def url(name, **kwargs): + return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None) + + +@pytest.fixture +def scenario(user_factory, auth_client_factory): + manager = user_factory(staff=True) + checker = user_factory() + employee = user_factory(first_name="Александр", last_name="Бывший", active=False) + period = PayrollPeriod.objects.create( + code="2026-09", + name="Сентябрь 2026", + date_from=date(2026, 9, 1), + date_to=date(2026, 9, 30), + status="closed", + created_by=manager, + ) + run = PayrollRun.objects.create( + period=period, + revision=1, + status="published", + requested_by=manager, + approved_by=checker, + approved_at=timezone.now(), + published_by=manager, + published_at=timezone.now(), + gross_total=30000, + deduction_total=0, + payable_total=30000, + ) + statement = PayrollStatement.objects.create( + run=run, + employee=employee, + currency="RUB", + gross_before_adjustments=30000, + adjustment_total=0, + gross_total=30000, + deduction_total=0, + net_pay=30000, + payment_total=0, + payable=30000, + ) + period.current_run = run + period.save(update_fields=["current_run"]) + client = auth_client_factory(manager) + payload = { + "employee_id": employee.pk, + "payee_name": "", + "paid_on": "2026-10-01", + "amount": "10000.00", + "currency": "RUB", + "source_period_id": period.pk, + "note": "Частичное погашение долга за сентябрь", + "request_id": str(uuid.uuid4()), + } + return client, manager, employee, period, statement, payload + + +def test_partial_payment_is_idempotent_and_does_not_change_payroll(scenario): + client, manager, employee, period, statement, payload = scenario + old_statement = PayrollStatement.objects.values().get(pk=statement.pk) + old_period = PayrollPeriod.objects.values().get(pk=period.pk) + create_url = url("admin-off-cycle-payments") + response = client.post(create_url, payload, format="json") + assert response.status_code == 201, response.data + assert response.data["payee_name"] == "Бывший Александр" + assert response.data["settlement"]["remaining"] == "20000.00" + retry = client.post(create_url, payload, format="json") + assert retry.status_code == 200, retry.data + assert retry.data["id"] == response.data["id"] + assert PayrollOffCyclePayment.objects.count() == 1 + assert not PayrollInputLine.objects.exists() + assert PayrollStatement.objects.count() == 1 + assert PayrollRun.objects.count() == 1 + assert PayrollStatement.objects.values().get(pk=statement.pk) == old_statement + assert PayrollPeriod.objects.values().get(pk=period.pk) == old_period + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.off_cycle_payment_created" + ).count() + == 1 + ) + conflict = client.post(create_url, {**payload, "amount": "10001.00"}, format="json") + assert conflict.status_code == 409 + assert conflict.data["code"] == "PAYMENT_REQUEST_CONFLICT" + settlement = client.get( + url("admin-off-cycle-settlement", period_id=period.pk, employee_id=employee.pk) + ) + assert settlement.status_code == 200, settlement.data + assert settlement.data["remaining"] == "20000.00" + table = client.get(url("admin-period-table", pk=period.pk)) + assert table.status_code == 200, table.data + row = next( + row for row in table.data["rows"] if row["employee"]["id"] == employee.pk + ) + assert row["payable"] == "30000.00" + assert row["off_cycle_settlement"]["remaining"] == "20000.00" + + +def test_overpayment_and_confirmed_payments_are_rejected_and_void_restores_debt( + scenario, +): + client, manager, employee, period, statement, payload = scenario + create_url = url("admin-off-cycle-payments") + response = client.post(create_url, payload, format="json") + overpayment = client.post( + create_url, + { + **payload, + "request_id": str(uuid.uuid4()), + "amount": "20000.01", + }, + format="json", + ) + assert overpayment.status_code == 409, overpayment.data + assert overpayment.data["code"] == "PAYMENT_EXCEEDS_REMAINING" + void_url = url("admin-off-cycle-payment-void", pk=response.data["id"]) + assert client.post(void_url, {"reason": ""}, format="json").status_code == 400 + voided = client.post(void_url, {"reason": "Ошибочная запись"}, format="json") + assert voided.status_code == 200, voided.data + assert voided.data["settlement"]["remaining"] == "30000.00" + assert ( + client.post(void_url, {"reason": "Повтор отмены"}, format="json").status_code + == 200 + ) + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.off_cycle_payment_voided" + ).count() + == 1 + ) + assert client.get(create_url).data["count"] == 0 + history = client.get(create_url, {"include_voided": "true"}) + assert history.data["count"] == 1 + assert Decimal(history.data["total_paid"]) == 0 + PayrollStatementPaymentConfirmation.objects.create( + statement=statement, confirmed_by=manager + ) + confirmed = client.post( + create_url, {**payload, "request_id": str(uuid.uuid4())}, format="json" + ) + assert confirmed.status_code == 409, confirmed.data + assert confirmed.data["code"] == "PAYMENT_EXCEEDS_REMAINING" + + +def test_manual_payee_and_inactive_employee_count_by_cash_date_once(scenario): + client, manager, employee, period, statement, payload = scenario + create_url = url("admin-off-cycle-payments") + assert client.post(create_url, payload, format="json").status_code == 201 + manual = { + **payload, + "employee_id": None, + "payee_name": "Иванов Иван", + "source_period_id": None, + "amount": "5000", + "request_id": str(uuid.uuid4()), + } + assert client.post(create_url, manual, format="json").status_code == 201 + listing = client.get( + create_url, {"date_from": "2026-10-01", "date_to": "2026-10-31"} + ) + assert listing.status_code == 200 + assert Decimal(listing.data["total_paid"]) == 15000 + october = PayrollPeriod.objects.create( + code="2026-10", date_from="2026-10-01", date_to="2026-10-31" + ) + table = client.get(url("admin-period-table", pk=october.pk)) + assert table.status_code == 200, table.data + assert Decimal(table.data["recorded_payments"]["outside_period"]) == 15000 + assert Decimal(table.data["recorded_payments"]["total_recorded"]) == 15000 + start, end = date(2026, 10, 1), date(2026, 10, 31) + people = people_report(start, end, "RUB") + assert people["summary"]["off_cycle_paid"] == 15000 + assert people["summary"]["salary_total"] == 0 + assert people["monthly"][0]["off_cycle_paid"] == 15000 + assert len(people["off_cycle_payments"]) == 2 + row = next(row for row in people["employees"] if row["id"] == employee.pk) + assert row["off_cycle_paid"] == 10000 + sales = sales_report(start, end, "RUB") + production = production_report(start, end, "RUB", sales) + assert production["totals"]["cash_out"] == 15000 + assert production["record_counts"]["cash_out"] == 2 + projects = projects_report(start, end, "RUB", sales) + assert projects["summary"]["manual_paid"] == 15000 + assert projects["monthly"][0]["manual_paid"] == 15000 + assert sales["summary"]["profit"] == 0 + september = people_report(date(2026, 9, 1), date(2026, 9, 30), "RUB") + assert september["summary"]["off_cycle_paid"] == 0 + assert september["summary"]["salary_total"] == 30000 + # Voided facts disappear from every report, not only the register. + first_payment = PayrollOffCyclePayment.objects.get(employee=employee) + assert ( + client.post( + url("admin-off-cycle-payment-void", pk=first_payment.pk), + {"reason": "Проверка отчётов"}, + format="json", + ).status_code + == 200 + ) + assert people_report(start, end, "RUB")["summary"]["off_cycle_paid"] == 5000 + assert production_report(start, end, "RUB", sales)["totals"]["cash_out"] == 5000 + assert projects_report(start, end, "RUB", sales)["summary"]["manual_paid"] == 5000 + + +def test_payment_permissions_allowlist_validation_and_pagination( + scenario, user_factory, auth_client_factory +): + client, manager, employee, period, statement, payload = scenario + create_url = url("admin-off-cycle-payments") + assert auth_client_factory(user_factory()).get(create_url).status_code == 403 + viewer = user_factory() + viewer.user_permissions.add( + Permission.objects.get( + codename="view_all_payroll", content_type__app_label="finance" + ) + ) + viewer_client = auth_client_factory(viewer) + assert viewer_client.get(create_url).status_code == 200 + assert viewer_client.post(create_url, payload, format="json").status_code == 403 + for invalid in [ + {"amount": "0"}, + {"amount": "-1"}, + {"paid_on": "2099-01-01"}, + {"note": " "}, + {"employee_id": None, "payee_name": " "}, + {"employee_id": 9999999}, + {"request_id": "invalid"}, + ]: + response = client.post(create_url, {**payload, **invalid}, format="json") + assert response.status_code == 400, response.data + period.status = "open" + period.save(update_fields=["status"]) + assert client.post(create_url, payload, format="json").status_code == 409 + settings = FinanceSettings.get_solo() + settings.management_users.add(viewer) + assert client.get(create_url).status_code == 403 + settings.management_users.clear() + PayrollOffCyclePayment.objects.bulk_create( + [ + PayrollOffCyclePayment( + payee_name=f"Получатель {index}", + paid_on=date(2026, 10, 1), + amount=1, + note="Выдача", + created_by=manager, + ) + for index in range(51) + ] + ) + page1 = client.get(create_url) + page2 = client.get(create_url, {"page": 2}) + assert len(page1.data["results"]) == 50 + assert len(page2.data["results"]) == 1 + assert Decimal(page1.data["total_paid"]) == 51 + assert Decimal(page2.data["total_paid"]) == 51 diff --git a/frontend/src/components/analytics/ReportSections.tsx b/frontend/src/components/analytics/ReportSections.tsx index bb42c341..90c7320e 100644 --- a/frontend/src/components/analytics/ReportSections.tsx +++ b/frontend/src/components/analytics/ReportSections.tsx @@ -87,14 +87,14 @@ export function SalesReport({ report }: { report: AnalyticsReport }) { hint: `${entries.record_counts.overhead} записей в журнале`, }, { - label: "Денежный поток по журналу", + label: "Денежный поток по учёту", value: entries.record_counts.cash_in || entries.record_counts.cash_out ? Number(entries.totals.cash_in) - Number(entries.totals.cash_out) : null, unit, - hint: "Поступления минус выплаты", + hint: "Поступления минус выплаты, включая реестр выплат вне периода", }, ]} /> @@ -173,6 +173,11 @@ export function PeopleReport({ report }: { report: AnalyticsReport }) { data.employees.find((e) => e.id === id)?.name || "Недостаточно данных"; const columns: ReportColumn[] = [ { key: "name", label: "Сотрудник", text: true }, + { + key: "off_cycle_paid", + additive: true, + label: `Выдано вне периода, ${report.currency}`, + }, { key: "average_salary", label: `Средняя за месяц, ${report.currency}` }, { key: "salary_months", label: "Месяцев расчёта", digits: 0 }, { key: "salary_growth_percent", label: "Изменение зарплаты, %" }, @@ -276,6 +281,11 @@ export function PeopleReport({ report }: { report: AnalyticsReport }) { additive: true, label: `Начислено, ${report.currency}`, }, + { + key: "off_cycle_paid", + additive: true, + label: `Выдано вне периода, ${report.currency}`, + }, { key: "headcount_with_history", label: "Сотрудников с кадровой историей", @@ -288,6 +298,22 @@ export function PeopleReport({ report }: { report: AnalyticsReport }) { ]} filename={`team-monthly-${report.period.from}-${report.currency}`} /> +

diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 556c837c..92b79f89 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -60,6 +60,7 @@ import { PayrollWorkRecordsTable, } from "@/components/finance/admin/PayrollAdminTables"; import { PayrollPeriodTableView } from "@/components/finance/admin/PayrollPeriodTable"; +import { PayrollOffCyclePayments } from "@/components/finance/admin/PayrollOffCyclePayments"; import { useUser } from "@/contexts/UserContext"; import { usePayrollAdminTab } from "@/hooks/usePayrollTabs"; import { apiClient } from "@/lib/api"; @@ -799,7 +800,7 @@ export function PayrollAdminWorkspace({ }); return cancel; } - if (tab === "readiness") return; + if (tab === "readiness" || tab === "off_cycle") return; const canLoadInputs = Boolean( workspace?.permissions.manage_inputs || workspace?.permissions.approve_inputs || @@ -1245,6 +1246,9 @@ export function PayrollAdminWorkspace({ const visibleTabs = PAYROLL_ADMIN_TABS.filter( (item) => (item.value !== "summary" || permissions.view_all) && + (item.value !== "off_cycle" || + permissions.manage_inputs || + permissions.view_all) && (!["rates", "work", "inputs"].includes(item.value) || canOpenInputTabs), ); const primaryAction = selectedPeriod @@ -1836,7 +1840,57 @@ export function PayrollAdminWorkspace({

) : null} - {!selectedPeriod ? ( +
+ + {visibleTabs.map((item) => { + const count = + item.value === "summary" + ? (periodTable?.summary.employee_count ?? + workspace.employees.length) + : item.value === "approval" + ? workspace.pending_approvals.rates + + workspace.pending_approvals.work_records + + workspace.pending_approvals.input_lines + : 0; + return ( + + ); + })} + +
+ + {tab === "off_cycle" && + (permissions.manage_inputs || permissions.view_all) ? ( + setTableVersion((value) => value + 1)} + /> + ) : !selectedPeriod ? (

@@ -1854,45 +1908,6 @@ export function PayrollAdminWorkspace({

) : ( <> -
- - {visibleTabs.map((item) => { - const count = - item.value === "summary" - ? (periodTable?.summary.employee_count ?? - workspace.employees.length) - : item.value === "approval" - ? workspace.pending_approvals.rates + - workspace.pending_approvals.work_records + - workspace.pending_approvals.input_lines - : 0; - return ( - - ); - })} - -
- {tab === "readiness" ? (
@@ -2274,6 +2289,15 @@ export function PayrollAdminWorkspace({ onSetReviewMark={setTableReviewMark} onClearReviewMarks={clearTableReviewMarks} /> + setTableVersion((value) => value + 1)} + />
) : null} {tab === "approval" ? ( diff --git a/frontend/src/components/finance/admin/PayrollOffCyclePayments.tsx b/frontend/src/components/finance/admin/PayrollOffCyclePayments.tsx new file mode 100644 index 00000000..aaa869ef --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollOffCyclePayments.tsx @@ -0,0 +1,853 @@ +"use client"; + +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { Loader2, Plus, RefreshCw, Search } from "lucide-react"; +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import type { + PayrollAdminEmployee, + PayrollAdminPeriod, + PayrollOffCyclePayment, + PayrollOffCyclePaymentPage, + PayrollOffCycleSettlement, +} from "@/lib/api/finance"; +import { getPayrollAdminError } from "@/lib/payroll-admin"; +import { formatPayrollDate, formatPayrollMoney } from "@/lib/payroll"; + +const inputClass = "app-input w-full rounded-lg px-3 py-2.5 text-sm"; + +function localToday() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +} + +function PaymentForm({ + employees, + periods, + currency, + onClose, + onSaved, +}: { + employees: PayrollAdminEmployee[]; + periods: PayrollAdminPeriod[]; + currency: string; + onClose: () => void; + onSaved: (payment: PayrollOffCyclePayment) => void; +}) { + const [employee, setEmployee] = useState(null); + const [name, setName] = useState(""); + const [manual, setManual] = useState(false); + const [sourcePeriodId, setSourcePeriodId] = useState(""); + const [date, setDate] = useState(localToday); + const [amount, setAmount] = useState(""); + const [note, setNote] = useState(""); + const [requestId] = useState(() => crypto.randomUUID()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [settlement, setSettlement] = + useState(null); + const [settlementLoading, setSettlementLoading] = useState(false); + const [settlementError, setSettlementError] = useState(null); + const matches = employees + .filter((item) => + item.display_name + .toLocaleLowerCase("ru") + .includes(name.toLocaleLowerCase("ru").trim()), + ) + .slice(0, 8); + const sourcePeriod = periods.find( + (period) => period.id === Number(sourcePeriodId), + ); + const effectiveCurrency = sourcePeriod?.currency || currency; + + useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + setSettlement(null); + setSettlementError(null); + if (!employee || !sourcePeriodId || manual) { + setSettlementLoading(false); + return; + } + setSettlementLoading(true); + apiClient + .getPayrollOffCycleSettlement(Number(sourcePeriodId), employee.id) + .then((result) => { + if (!cancelled) setSettlement(result); + }) + .catch(() => { + if (!cancelled) + setSettlementError( + "В выбранном периоде не найден итоговый листок сотрудника. Выберите другой период или выплату без привязки.", + ); + }) + .finally(() => { + if (!cancelled) setSettlementLoading(false); + }); + }); + return () => { + cancelled = true; + }; + }, [employee, manual, sourcePeriodId]); + + const save = async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + const payment = await apiClient.recordPayrollOffCyclePayment({ + employee_id: manual ? null : employee?.id || null, + payee_name: name.trim(), + paid_on: date, + amount: amount.replace(",", "."), + currency: effectiveCurrency, + source_period_id: sourcePeriodId ? Number(sourcePeriodId) : null, + note: note.trim(), + request_id: requestId, + }); + onSaved(payment); + } catch (saveError) { + setError( + getPayrollAdminError( + saveError, + "Не удалось сохранить выплату. Повторите попытку.", + ), + ); + } finally { + setBusy(false); + } + }; + + return ( + { + if (!busy) onClose(); + }} + title="Добавить выплату вне периода" + size="lg" + > +
+

+ Запишите фактически выданные деньги. Расчётные листки и уведомления не + создаются. +

+ +
+ + { + setName(event.target.value); + setEmployee(null); + }} + /> + {!manual && !employee ? ( +
+ {matches.map((item) => ( + + ))} + {!matches.length ? ( +

+ Совпадений нет. Для ручного ввода включите переключатель выше. +

+ ) : null} +
+ ) : employee ? ( +

+ Выплата будет связана с карточкой сотрудника. +

+ ) : null} +
+
+ + +
+ + {settlementLoading ? ( +

Проверяем остаток…

+ ) : null} + {settlementError ? ( +

+ {settlementError} +

+ ) : null} + {settlement ? ( +
+

+ По листку к выплате:{" "} + {formatPayrollMoney( + settlement.original_payable, + settlement.currency, + )} +

+

+ Выдано через реестр:{" "} + {formatPayrollMoney( + settlement.paid_outside_period, + settlement.currency, + )} +

+

+ Осталось выдать:{" "} + {formatPayrollMoney(settlement.remaining, settlement.currency)} +

+ {settlement.confirmed ? ( +

+ Вся сумма уже отмечена выплаченной в закрытом периоде. +

+ ) : null} +
+ ) : manual && sourcePeriodId ? ( +

+ Период сохранится как основание. Для автоматического остатка долга + выберите сотрудника из списка. +

+ ) : null} +