Add off-cycle payroll payment register and settlement tracking

This commit is contained in:
admin i 2026-10-15 11:11:54 +03:00
parent de37b5c55d
commit 25185c327a
25 changed files with 2201 additions and 61 deletions

View File

@ -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()
},
}
)

View File

@ -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,

View File

@ -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)

View File

@ -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/<int:pk>/void/",
PayrollOffCyclePaymentVoidView.as_view(),
name="admin-off-cycle-payment-void",
),
path(
"payroll/admin/off-cycle-settlement/<int:period_id>/<int:employee_id>/",
PayrollOffCycleSettlementView.as_view(),
name="admin-off-cycle-settlement",
),
path(
"analytics/project-payments/",
ProjectPaymentListView.as_view(),

View File

@ -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

View File

@ -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),

View File

@ -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",

View File

@ -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()),

View File

@ -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",
),
],
},
),
]

View File

@ -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}$",

View File

@ -0,0 +1 @@
"""Actual payments recorded independently of payroll calculation periods."""

View File

@ -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 = "Выплаты вне периода"

View File

@ -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 {}
)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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}`}
/>
<ReportTable
title="Выплаты вне периода по дате выдачи"
rows={data.off_cycle_payments || []}
columns={[
{ key: "name", label: "Получатель", text: true },
{ key: "paid_on", label: "Дата выдачи", text: true },
{ key: "source_period", label: "За период", text: true },
{
key: "amount",
label: `Выдано, ${report.currency}`,
additive: true,
},
{ key: "note", label: "Основание", text: true },
]}
filename={`off-cycle-payments-${report.period.from}-${report.period.to}-${report.currency}`}
/>
</div>
<div className="app-feedback-warning rounded-xl p-3 text-sm">
<p>

View File

@ -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({
</div>
) : null}
{!selectedPeriod ? (
<div role="tablist" aria-label="Разделы управления зарплатой">
<HorizontalChipScroller ariaLabel="Разделы управления зарплатой">
{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 (
<button
type="button"
role="tab"
aria-selected={tab === item.value}
key={item.value}
disabled={!selectedPeriod && item.value !== "off_cycle"}
className={`${tab === item.value ? "app-pill-active" : "app-pill"} inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition disabled:opacity-50`}
onClick={() => {
setTab(item.value);
setSearch("");
setStatus("");
}}
>
{item.label}
{count > 0 ? (
<span
className={`${tab === item.value ? "app-pill-count-active" : "app-pill-count"} app-badge px-1.5 py-0.5 text-[10px] font-bold`}
>
{count}
</span>
) : null}
</button>
);
})}
</HorizontalChipScroller>
</div>
{tab === "off_cycle" &&
(permissions.manage_inputs || permissions.view_all) ? (
<PayrollOffCyclePayments
key={`payments-${selectedPeriod?.id || "all"}`}
employees={workspace.rate_employees}
periods={workspace.periods}
period={selectedPeriod}
canManage={permissions.manage_inputs}
onChanged={() => setTableVersion((value) => value + 1)}
/>
) : !selectedPeriod ? (
<section className="app-surface rounded-2xl p-8 text-center">
<CalendarEmpty />
<p className="app-text-muted mx-auto mt-2 max-w-md text-sm">
@ -1854,45 +1908,6 @@ export function PayrollAdminWorkspace({
</section>
) : (
<>
<div role="tablist" aria-label="Разделы управления зарплатой">
<HorizontalChipScroller ariaLabel="Разделы управления зарплатой">
{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 (
<button
type="button"
role="tab"
aria-selected={tab === item.value}
key={item.value}
className={`${tab === item.value ? "app-pill-active" : "app-pill"} inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition`}
onClick={() => {
setTab(item.value);
setSearch("");
setStatus("");
}}
>
{item.label}
{count > 0 ? (
<span
className={`${tab === item.value ? "app-pill-count-active" : "app-pill-count"} app-badge px-1.5 py-0.5 text-[10px] font-bold`}
>
{count}
</span>
) : null}
</button>
);
})}
</HorizontalChipScroller>
</div>
{tab === "readiness" ? (
<section className="payroll-readiness-responsive app-surface rounded-2xl p-4 sm:p-5">
<div className="payroll-readiness-layout grid gap-5">
@ -2274,6 +2289,15 @@ export function PayrollAdminWorkspace({
onSetReviewMark={setTableReviewMark}
onClearReviewMarks={clearTableReviewMarks}
/>
<PayrollOffCyclePayments
compact
key={`summary-payments-${selectedPeriod.id}`}
employees={workspace.rate_employees}
periods={workspace.periods}
period={selectedPeriod}
canManage={permissions.manage_inputs}
onChanged={() => setTableVersion((value) => value + 1)}
/>
</section>
) : null}
{tab === "approval" ? (

View File

@ -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<PayrollAdminEmployee | null>(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<string | null>(null);
const [settlement, setSettlement] =
useState<PayrollOffCycleSettlement | null>(null);
const [settlementLoading, setSettlementLoading] = useState(false);
const [settlementError, setSettlementError] = useState<string | null>(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 (
<Modal
isOpen
onClose={() => {
if (!busy) onClose();
}}
title="Добавить выплату вне периода"
size="lg"
>
<form onSubmit={save} className="space-y-4">
<p className="app-text-muted text-sm">
Запишите фактически выданные деньги. Расчётные листки и уведомления не
создаются.
</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={manual}
disabled={busy}
onChange={(event) => {
setManual(event.target.checked);
setEmployee(null);
setName("");
}}
/>{" "}
Получателя нет в списке — ввести имя вручную
</label>
<div>
<label className="block text-sm font-medium" htmlFor="off-cycle-name">
Получатель
</label>
<input
id="off-cycle-name"
className={`${inputClass} mt-1`}
required
maxLength={250}
autoComplete="off"
value={name}
disabled={busy}
placeholder={
manual
? "Фамилия Имя Отчество"
: "Поиск, включая бывших сотрудников"
}
onChange={(event) => {
setName(event.target.value);
setEmployee(null);
}}
/>
{!manual && !employee ? (
<div
className="app-surface-muted mt-1 max-h-48 overflow-y-auto rounded-lg"
aria-label="Найденные сотрудники"
>
{matches.map((item) => (
<button
key={item.id}
type="button"
disabled={busy}
className="app-action-ghost block w-full px-3 py-2 text-left text-sm"
onClick={() => {
setEmployee(item);
setName(item.display_name);
}}
>
{item.display_name}{" "}
<span className="app-text-muted text-xs">
{item.position || item.department || `№ ${item.id}`}
</span>
</button>
))}
{!matches.length ? (
<p className="app-text-muted px-3 py-2 text-sm">
Совпадений нет. Для ручного ввода включите переключатель выше.
</p>
) : null}
</div>
) : employee ? (
<p className="app-text-muted mt-1 text-xs">
Выплата будет связана с карточкой сотрудника.
</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block text-sm font-medium">
Дата выдачи
<input
type="date"
className={`${inputClass} mt-1`}
required
max={localToday()}
value={date}
disabled={busy}
onChange={(event) => setDate(event.target.value)}
/>
</label>
<label className="block text-sm font-medium">
Выдано, {effectiveCurrency}
<input
type="number"
className={`${inputClass} mt-1`}
required
min="0.01"
step="0.01"
max={settlement?.remaining}
value={amount}
disabled={busy}
onChange={(event) => setAmount(event.target.value)}
/>
</label>
</div>
<label className="block text-sm font-medium">
Погашение долга за период
<select
className="app-select mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
value={sourcePeriodId}
disabled={busy}
onChange={(event) => setSourcePeriodId(event.target.value)}
>
<option value="">Без привязки к расчётному периоду</option>
{periods
.filter((period) => period.status === "closed")
.map((period) => (
<option key={period.id} value={period.id}>
{period.name || period.code}
</option>
))}
</select>
</label>
{settlementLoading ? (
<p className="app-text-muted text-sm">Проверяем остаток…</p>
) : null}
{settlementError ? (
<p
className="app-feedback-danger rounded-lg p-3 text-sm"
role="alert"
>
{settlementError}
</p>
) : null}
{settlement ? (
<div className="app-surface-muted rounded-lg p-3 text-sm">
<p>
По листку к выплате:{" "}
{formatPayrollMoney(
settlement.original_payable,
settlement.currency,
)}
</p>
<p>
Выдано через реестр:{" "}
{formatPayrollMoney(
settlement.paid_outside_period,
settlement.currency,
)}
</p>
<p className="mt-1 font-semibold">
Осталось выдать:{" "}
{formatPayrollMoney(settlement.remaining, settlement.currency)}
</p>
{settlement.confirmed ? (
<p className="app-text-muted mt-1">
Вся сумма уже отмечена выплаченной в закрытом периоде.
</p>
) : null}
</div>
) : manual && sourcePeriodId ? (
<p className="app-text-muted text-xs">
Период сохранится как основание. Для автоматического остатка долга
выберите сотрудника из списка.
</p>
) : null}
<label className="block text-sm font-medium">
Основание / комментарий
<textarea
className={`${inputClass} mt-1`}
rows={2}
required
maxLength={1000}
value={note}
disabled={busy}
placeholder="Например: частичное погашение долга за сентябрь"
onChange={(event) => setNote(event.target.value)}
/>
</label>
{error ? (
<p
className="app-feedback-danger rounded-lg p-3 text-sm"
role="alert"
>
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
<button
type="button"
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm"
disabled={busy}
onClick={onClose}
>
Отмена
</button>
<button
type="submit"
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm disabled:opacity-50"
disabled={
busy ||
(!manual && !employee) ||
settlementLoading ||
Boolean(settlementError) ||
Boolean(settlement && Number(settlement.remaining) <= 0)
}
>
{busy ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Plus size={16} />
)}{" "}
Записать выплату
</button>
</div>
</form>
</Modal>
);
}
export function PayrollOffCyclePayments({
employees,
periods,
period,
canManage,
compact = false,
onChanged,
}: {
employees: PayrollAdminEmployee[];
periods: PayrollAdminPeriod[];
period: PayrollAdminPeriod | null;
canManage: boolean;
compact?: boolean;
onChanged?: () => void;
}) {
const [dateFrom, setDateFrom] = useState(period?.date_from || "");
const [dateTo, setDateTo] = useState(period?.date_to || "");
const [search, setSearch] = useState("");
const [includeVoided, setIncludeVoided] = useState(false);
const [page, setPage] = useState(1);
const [data, setData] = useState<PayrollOffCyclePaymentPage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [voiding, setVoiding] = useState<PayrollOffCyclePayment | null>(null);
const [voidReason, setVoidReason] = useState("");
const [voidBusy, setVoidBusy] = useState(false);
const currency = period?.currency || "RUB";
const reload = useCallback(() => {
setVersion((value) => value + 1);
}, []);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(
() => {
setLoading(true);
setError(null);
apiClient
.getPayrollOffCyclePayments({
date_from: (compact ? period?.date_from : dateFrom) || undefined,
date_to: (compact ? period?.date_to : dateTo) || undefined,
search: search || undefined,
currency,
include_voided: includeVoided,
page,
})
.then((result) => {
if (!cancelled) setData(result);
})
.catch((loadError) => {
if (!cancelled)
setError(
getPayrollAdminError(
loadError,
"Не удалось загрузить выплаты.",
),
);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
},
search ? 250 : 0,
);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [
compact,
currency,
dateFrom,
dateTo,
includeVoided,
page,
period?.date_from,
period?.date_to,
search,
version,
]);
const saved = (payment: PayrollOffCyclePayment) => {
setFormOpen(false);
setNotice(
`Записана выплата: ${payment.payee_name} · ${formatPayrollMoney(payment.amount, payment.currency)} · ${formatPayrollDate(payment.paid_on)}. Учтена по дате выдачи.`,
);
setPage(1);
setSearch("");
if (
!compact &&
((dateFrom && payment.paid_on < dateFrom) ||
(dateTo && payment.paid_on > dateTo))
) {
setDateFrom(payment.paid_on);
setDateTo(payment.paid_on);
}
reload();
onChanged?.();
};
const cancelPayment = async (event: FormEvent) => {
event.preventDefault();
if (!voiding || voidBusy) return;
setVoidBusy(true);
setError(null);
try {
await apiClient.voidPayrollOffCyclePayment(voiding.id, voidReason.trim());
setNotice(
`Запись о выплате ${voiding.payee_name} на ${formatPayrollMoney(voiding.amount, voiding.currency)} отменена и исключена из итогов.`,
);
setVoiding(null);
reload();
onChanged?.();
} catch (voidError) {
setError(getPayrollAdminError(voidError, "Не удалось отменить выплату."));
} finally {
setVoidBusy(false);
}
};
return (
<section
className={
compact
? "mt-5 border-t border-[var(--border-subtle)] pt-5"
: "app-surface rounded-2xl p-4 sm:p-5"
}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold">Выплаты вне периода</h2>
<p className="app-text-muted mt-1 text-sm">
Фактическая выдача, включая выплаты бывшим сотрудникам. Без новых
расчётных листков.
</p>
{compact && period ? (
<p className="app-text-muted mt-1 text-xs">
По дате выдачи: {formatPayrollDate(period.date_from)} —{" "}
{formatPayrollDate(period.date_to)}
</p>
) : null}
</div>
<div className="flex gap-2">
<button
type="button"
className="app-action-secondary rounded-lg p-2.5"
onClick={reload}
disabled={loading}
aria-label="Обновить выплаты"
>
<RefreshCw size={16} className={loading ? "animate-spin" : ""} />
</button>
{canManage ? (
<button
type="button"
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm"
onClick={() => setFormOpen(true)}
>
<Plus size={16} /> Добавить выплату
</button>
) : null}
</div>
</div>
{notice ? (
<p
className="app-feedback-success mt-3 rounded-lg p-3 text-sm"
role="status"
>
{notice}
</p>
) : null}
<div className="app-surface-muted mt-4 rounded-xl p-3 text-sm">
Выдано вне периода:{" "}
<strong>{formatPayrollMoney(data?.total_paid || "0", currency)}</strong>
<span className="app-text-muted ml-2">
за выбранный диапазон и фильтр
</span>
</div>
<div className="mt-4 flex flex-wrap items-end gap-3">
{!compact ? (
<>
<label className="text-xs">
Дата выдачи с
<input
type="date"
className={`${inputClass} mt-1`}
value={dateFrom}
onChange={(event) => {
setDateFrom(event.target.value);
setPage(1);
}}
/>
</label>
<label className="text-xs">
по
<input
type="date"
className={`${inputClass} mt-1`}
value={dateTo}
onChange={(event) => {
setDateTo(event.target.value);
setPage(1);
}}
/>
</label>
<button
className="app-action-secondary rounded-lg px-3 py-2.5 text-sm"
type="button"
onClick={() => {
setDateFrom("");
setDateTo("");
setPage(1);
}}
>
За всё время
</button>
</>
) : null}
<label className="relative min-w-48 flex-1">
<span className="sr-only">Поиск выплат</span>
<Search size={15} className="app-text-muted absolute left-3 top-3" />
<input
className={`${inputClass} pl-9`}
value={search}
placeholder="Имя или основание"
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</label>
<label className="flex items-center gap-2 py-2.5 text-xs">
<input
type="checkbox"
checked={includeVoided}
onChange={(event) => {
setIncludeVoided(event.target.checked);
setPage(1);
}}
/>{" "}
Показать отменённые
</label>
</div>
{error ? (
<p
className="app-feedback-danger mt-3 rounded-lg p-3 text-sm"
role="alert"
>
{error}
</p>
) : null}
{loading && !data ? (
<Loader2 className="app-accent-text mx-auto my-8 animate-spin" />
) : (
<div className="mt-4 hidden overflow-x-auto sm:block">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="app-text-muted border-b border-[var(--border-subtle)] text-xs">
<tr>
{[
"Дата выдачи",
"Получатель",
"За период",
"Выдано",
"Основание / история",
"",
].map((label) => (
<th className="px-3 py-2" key={label}>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{data?.results.map((payment) => (
<tr
key={payment.id}
className={`border-b border-[var(--border-subtle)] ${payment.voided_at ? "opacity-60" : ""}`}
>
<td className="whitespace-nowrap px-3 py-3">
{formatPayrollDate(payment.paid_on)}
</td>
<td className="px-3 py-3 font-medium">
{payment.payee_name}
<p className="app-text-muted text-xs">
{payment.employee_id
? "Связан с сотрудником"
: "Ручная запись"}
</p>
</td>
<td className="px-3 py-3">
{payment.source_period_name || "Без привязки"}
{payment.settlement ? (
<p className="app-text-muted mt-1 text-xs">
Осталось:{" "}
{formatPayrollMoney(
payment.settlement.remaining,
payment.currency,
)}
</p>
) : null}
</td>
<td
className={`whitespace-nowrap px-3 py-3 font-semibold ${payment.voided_at ? "line-through" : ""}`}
>
{formatPayrollMoney(payment.amount, payment.currency)}
</td>
<td className="max-w-sm px-3 py-3">
<p className="whitespace-pre-wrap break-words">
{payment.note}
</p>
<p className="app-text-muted mt-1 text-xs">
Записал: {payment.created_by.display_name} ·{" "}
{formatPayrollDate(payment.created_at)}
</p>
{payment.voided_at ? (
<p className="app-text-muted mt-1 text-xs">
Отменено: {payment.void_reason} ·{" "}
{payment.voided_by?.display_name} ·{" "}
{formatPayrollDate(payment.voided_at)}
</p>
) : null}
</td>
<td className="px-3 py-3">
{canManage && !payment.voided_at ? (
<button
type="button"
className="app-action-ghost rounded-lg px-2 py-1 text-xs"
onClick={() => {
setVoiding(payment);
setVoidReason("");
setError(null);
}}
>
Отменить запись
</button>
) : null}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={3} className="px-3 py-3 font-semibold">
Итого по всем найденным действующим выплатам
</td>
<td className="whitespace-nowrap px-3 py-3 font-semibold">
{formatPayrollMoney(data?.total_paid || "0", currency)}
</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
{!data?.results.length ? (
<p className="app-text-muted py-5 text-center text-sm">
Выплат по выбранным условиям пока нет.
</p>
) : null}
</div>
)}
{!loading || data ? (
<div className="mt-4 space-y-3 sm:hidden">
{data?.results.map((payment) => (
<article
key={payment.id}
className={`app-surface-muted rounded-xl p-3 ${payment.voided_at ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<h3 className="min-w-0 break-words text-sm font-semibold">
{payment.payee_name}
</h3>
<span
className={`shrink-0 text-sm font-semibold ${payment.voided_at ? "line-through" : ""}`}
>
{formatPayrollMoney(payment.amount, payment.currency)}
</span>
</div>
<p className="app-text-muted mt-1 text-xs">
{formatPayrollDate(payment.paid_on)} ·{" "}
{payment.source_period_name || "Без привязки к периоду"}
</p>
{payment.settlement ? (
<p className="mt-2 text-xs font-medium">
Осталось выдать:{" "}
{formatPayrollMoney(
payment.settlement.remaining,
payment.currency,
)}
</p>
) : null}
<p className="mt-2 whitespace-pre-wrap break-words text-sm">
{payment.note}
</p>
<p className="app-text-muted mt-2 text-xs">
Записал: {payment.created_by.display_name} ·{" "}
{formatPayrollDate(payment.created_at)}
</p>
{payment.voided_at ? (
<p className="app-text-muted mt-2 text-xs">
Отменено: {payment.void_reason} ·{" "}
{payment.voided_by?.display_name} ·{" "}
{formatPayrollDate(payment.voided_at)}
</p>
) : canManage ? (
<button
type="button"
className="app-action-secondary mt-3 rounded-lg px-3 py-2 text-xs"
onClick={() => {
setVoiding(payment);
setVoidReason("");
setError(null);
}}
>
Отменить запись
</button>
) : null}
</article>
))}
{!data?.results.length ? (
<p className="app-text-muted py-5 text-center text-sm">
Выплат по выбранным условиям пока нет.
</p>
) : null}
</div>
) : null}
{data && data.count > data.page_size ? (
<div className="mt-3 flex items-center justify-end gap-3 text-sm">
<button
type="button"
className="app-action-secondary rounded-lg px-3 py-2 disabled:opacity-50"
disabled={page <= 1 || loading}
onClick={() => setPage(page - 1)}
>
Назад
</button>
<span>
{page} / {Math.ceil(data.count / data.page_size)}
</span>
<button
type="button"
className="app-action-secondary rounded-lg px-3 py-2 disabled:opacity-50"
disabled={page * data.page_size >= data.count || loading}
onClick={() => setPage(page + 1)}
>
Далее
</button>
</div>
) : null}
{formOpen ? (
<PaymentForm
employees={employees}
periods={periods}
currency={currency}
onClose={() => setFormOpen(false)}
onSaved={saved}
/>
) : null}
{voiding ? (
<Modal
isOpen
title="Отменить запись о выплате"
onClose={() => {
if (!voidBusy) setVoiding(null);
}}
size="sm"
>
<form className="space-y-4" onSubmit={cancelPayment}>
<p className="text-sm">
{voiding.payee_name} ·{" "}
{formatPayrollMoney(voiding.amount, voiding.currency)}. Запись
сохранится в истории и перестанет учитываться в итогах.
</p>
<label className="block text-sm">
Причина отмены
<textarea
className={`${inputClass} mt-1`}
required
maxLength={1000}
value={voidReason}
disabled={voidBusy}
onChange={(event) => setVoidReason(event.target.value)}
/>
</label>
{error ? (
<p
role="alert"
className="app-feedback-danger rounded-lg p-3 text-sm"
>
{error}
</p>
) : null}
<button
type="submit"
disabled={voidBusy}
className="app-action-primary rounded-lg px-4 py-2.5 text-sm disabled:opacity-50"
>
{voidBusy ? "Отменяем…" : "Отменить запись"}
</button>
</form>
</Modal>
) : null}
</section>
);
}

View File

@ -2861,10 +2861,18 @@ export function PayrollPeriodTableView({
);
}
const confirmation = row.payment_confirmation;
const paidThroughRegistry =
Number(row.off_cycle_settlement?.paid_outside_period || 0) > 0;
const fullyPaidThroughRegistry =
paidThroughRegistry &&
Number(row.off_cycle_settlement?.remaining) === 0;
const busy = paymentConfirmationBusyIds.has(row.employee.id);
const title = confirmation
? `Подтвердил: ${confirmation.confirmed_by.display_name} · ${new Date(confirmation.confirmed_at).toLocaleString("ru-RU")}`
: `Отметить фактическую выплату сотруднику ${row.employee.display_name}`;
const title =
fullyPaidThroughRegistry && !confirmation
? "Погашено через реестр выплат вне периода. Для исправления отмените запись в реестре."
: confirmation
? `Подтвердил: ${confirmation.confirmed_by.display_name} · ${new Date(confirmation.confirmed_at).toLocaleString("ru-RU")}`
: `Отметить фактическую выплату сотруднику ${row.employee.display_name}`;
return (
<td
className={`border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center ${cellVisualClass}`}
@ -2878,8 +2886,11 @@ export function PayrollPeriodTableView({
) : (
<input
type="checkbox"
checked={Boolean(confirmation)}
disabled={!onSetPaymentConfirmed}
checked={Boolean(confirmation) || fullyPaidThroughRegistry}
disabled={
!onSetPaymentConfirmed ||
(fullyPaidThroughRegistry && !confirmation)
}
onChange={(event) => {
if (!onSetPaymentConfirmed) return;
const confirmed = event.target.checked;
@ -2908,11 +2919,31 @@ export function PayrollPeriodTableView({
/>
)}
<span
className={confirmation ? "text-emerald-400" : "app-text-muted"}
className={
confirmation || fullyPaidThroughRegistry
? "text-emerald-400"
: "app-text-muted"
}
>
{confirmation ? "Выдана" : "Не выдана"}
{confirmation || fullyPaidThroughRegistry
? "Выдана"
: paidThroughRegistry
? "Частично"
: "Не выдана"}
</span>
</label>
{row.off_cycle_settlement ? (
<p className="app-text-muted mt-1 text-[10px]">
Через реестр:{" "}
{formatMoney(
row.off_cycle_settlement.paid_outside_period,
data.currency,
)}
<br />
Осталось:{" "}
{formatMoney(row.off_cycle_settlement.remaining, data.currency)}
</p>
) : null}
</td>
);
}
@ -3129,7 +3160,16 @@ export function PayrollPeriodTableView({
case columnIds.paymentConfirmation:
return (
<td className={`${footerCellClass} text-center`}>
{rows.filter((row) => row.payment_confirmation).length}/
{
rows.filter(
(row) =>
row.payment_confirmation ||
(Number(row.off_cycle_settlement?.paid_outside_period || 0) >
0 &&
Number(row.off_cycle_settlement?.remaining) === 0),
).length
}
/
{
rows.filter(
(row) => row.status === "calculated" && !row.totals_preliminary,
@ -3202,6 +3242,30 @@ export function PayrollPeriodTableView({
</div>
) : null}
{(showSummary || showTable) && data.recorded_payments ? (
<div className="app-surface-muted mb-4 grid gap-3 rounded-xl p-4 sm:grid-cols-3">
{[
[
"Выплаты в расчёте периода",
data.recorded_payments.payroll_recorded,
],
["Выдано вне периода", data.recorded_payments.outside_period],
["Всего учтено выплат", data.recorded_payments.total_recorded],
].map(([label, value]) => (
<div key={label}>
<p className="app-text-muted text-xs">{label}</p>
<p className="mt-1 text-lg font-semibold">
{formatMoney(value, data.currency)}
</p>
</div>
))}
<p className="app-text-muted text-xs sm:col-span-3">
Общий учёт за весь период, без фильтров таблицы. Выдачи вне периода
учтены по фактической дате и не меняют начисленную зарплату.
</p>
</div>
) : null}
{showSummary || showTable ? (
<div
className={`${compact ? "" : "flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"} mb-3`}

View File

@ -29,6 +29,7 @@ const ADMIN_TABS: ReadonlySet<string> = new Set([
"work",
"inputs",
"summary",
"off_cycle",
"approval",
]);

View File

@ -439,6 +439,7 @@ export type PayrollAttendanceAlert = {
};
export type PayrollPeriodTableRow = {
off_cycle_settlement?: PayrollOffCycleBalance | null;
employee: PayrollAdminEmployee & {
first_name: string;
last_name: string;
@ -499,6 +500,11 @@ export type PayrollPeriodTableCarryoverSummary = {
};
export type PayrollPeriodTable = {
recorded_payments?: {
payroll_recorded: string;
outside_period: string;
total_recorded: string;
};
period_id: number;
period_status: PayrollPeriodStatus;
currency: string;
@ -1075,8 +1081,92 @@ function jsonOptions(
return { method, body: JSON.stringify(payload) };
}
export type PayrollOffCyclePayment = {
settlement: PayrollOffCycleBalance | null;
id: number;
employee_id: number | null;
payee_name: string;
paid_on: string;
amount: string;
currency: string;
source_period_id: number | null;
source_period_name: string | null;
note: string;
created_at: string;
created_by: PayrollAdminUserRef;
voided_at: string | null;
voided_by: PayrollAdminUserRef | null;
void_reason: string;
};
export type PayrollOffCyclePaymentWrite = {
employee_id: number | null;
payee_name: string;
paid_on: string;
amount: string;
currency: string;
source_period_id: number | null;
note: string;
request_id: string;
};
export type PayrollOffCyclePaymentQuery = {
date_from?: string;
date_to?: string;
employee_id?: number;
source_period_id?: number;
search?: string;
currency?: string;
include_voided?: boolean;
page?: number;
};
export type PayrollOffCyclePaymentPage = {
results: PayrollOffCyclePayment[];
count: number;
page: number;
page_size: number;
total_paid: string;
currency: string;
};
export type PayrollOffCycleBalance = {
original_payable: string;
paid_outside_period: string;
remaining: string;
confirmed: boolean;
};
export type PayrollOffCycleSettlement = PayrollOffCycleBalance & {
currency: string;
};
export function createFinanceApi(request: RequestFn) {
return {
getPayrollOffCyclePayments: (
params: PayrollOffCyclePaymentQuery,
): Promise<PayrollOffCyclePaymentPage> =>
request(`${adminPrefix}/off-cycle-payments/${buildQuery(params)}`),
recordPayrollOffCyclePayment: (
payload: PayrollOffCyclePaymentWrite,
): Promise<PayrollOffCyclePayment> =>
request(
`${adminPrefix}/off-cycle-payments/`,
jsonOptions("POST", payload),
),
voidPayrollOffCyclePayment: (
id: number,
reason: string,
): Promise<PayrollOffCyclePayment> =>
request(
`${adminPrefix}/off-cycle-payments/${id}/void/`,
jsonOptions("POST", { reason }),
),
getPayrollOffCycleSettlement: (
periodId: number,
employeeId: number,
): Promise<PayrollOffCycleSettlement> =>
request(`${adminPrefix}/off-cycle-settlement/${periodId}/${employeeId}/`),
getMyPayrollStatements: (): Promise<
PayrollStatementPage | PayrollStatementSummary[]
> => request("/api/v1/finance/payroll/me/statements/?page_size=100"),

View File

@ -47,14 +47,11 @@ test("payroll readiness blockers link to the relevant filtered source data", ()
status: "",
label: "Открыть ставки",
});
assert.deepEqual(
getPayrollBlockerTarget("INPUT_EMPLOYEE_NOT_IN_ROSTER"),
{
tab: "inputs",
status: "approved",
label: "Показать операции вне состава",
},
);
assert.deepEqual(getPayrollBlockerTarget("INPUT_EMPLOYEE_NOT_IN_ROSTER"), {
tab: "inputs",
status: "approved",
label: "Показать операции вне состава",
});
assert.equal(getPayrollBlockerTarget("PERIOD_CLOSED"), null);
});
@ -246,3 +243,22 @@ test("only explicit stale payroll conflicts trigger an automatic refresh", () =>
false,
);
});
test("payroll forms show field validation errors from DRF", () => {
assert.equal(
getPayrollAdminError(
new Error('API Error: 400 {"amount":["Укажите положительную сумму."]}'),
"Ошибка сохранения",
),
"Укажите положительную сумму.",
);
assert.equal(
getPayrollAdminError(
new Error(
'API Error: 400 {"non_field_errors":["Дата выдачи не может быть в будущем."]}',
),
"Ошибка сохранения",
),
"Дата выдачи не может быть в будущем.",
);
});

View File

@ -20,6 +20,7 @@ export type PayrollAdminTab =
| "work"
| "inputs"
| "summary"
| "off_cycle"
| "approval";
export type PayrollBlockerTarget = {
@ -76,6 +77,7 @@ export const PAYROLL_ADMIN_TABS: Array<{
{ value: "work", label: "Выработка" },
{ value: "inputs", label: "Начисления и выплаты" },
{ value: "summary", label: "Итоговая таблица" },
{ value: "off_cycle", label: "Выплаты вне периода" },
{ value: "approval", label: "Проверка" },
];
@ -262,7 +264,17 @@ function readErrorPayload(value: unknown): {
}
return {
code: code || detailPayload.code,
message: message || detailPayload.message,
message:
message ||
detailPayload.message ||
Object.values(record)
.filter(Array.isArray)
.flat()
.find(
(item): item is string =>
typeof item === "string" && Boolean(item.trim()),
) ||
null,
};
}

View File

@ -44,6 +44,7 @@ export type AnalyticsReport = {
summary: AnalyticsRow;
employees: AnalyticsRow[];
monthly: AnalyticsRow[];
off_cycle_payments?: AnalyticsRow[];
};
};
export type ReportingKind =