Add off-cycle payroll payment register and settlement tracking
This commit is contained in:
parent
de37b5c55d
commit
25185c327a
239
backend/api/v1/finance/admin/off_cycle.py
Normal file
239
backend/api/v1/finance/admin/off_cycle.py
Normal 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()
|
||||
},
|
||||
}
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()),
|
||||
|
||||
143
backend/finance/migrations/0039_payrolloffcyclepayment.py
Normal file
143
backend/finance/migrations/0039_payrolloffcyclepayment.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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}$",
|
||||
|
||||
1
backend/finance/off_cycle/__init__.py
Normal file
1
backend/finance/off_cycle/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Actual payments recorded independently of payroll calculation periods."""
|
||||
85
backend/finance/off_cycle/models.py
Normal file
85
backend/finance/off_cycle/models.py
Normal 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 = "Выплаты вне периода"
|
||||
40
backend/finance/off_cycle/reporting.py
Normal file
40
backend/finance/off_cycle/reporting.py
Normal 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 {}
|
||||
)
|
||||
155
backend/finance/off_cycle/services.py
Normal file
155
backend/finance/off_cycle/services.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
281
backend/tests/finance/test_off_cycle_payments.py
Normal file
281
backend/tests/finance/test_off_cycle_payments.py
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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`}
|
||||
|
||||
@ -29,6 +29,7 @@ const ADMIN_TABS: ReadonlySet<string> = new Set([
|
||||
"work",
|
||||
"inputs",
|
||||
"summary",
|
||||
"off_cycle",
|
||||
"approval",
|
||||
]);
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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":["Дата выдачи не может быть в будущем."]}',
|
||||
),
|
||||
"Ошибка сохранения",
|
||||
),
|
||||
"Дата выдачи не может быть в будущем.",
|
||||
);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ export type AnalyticsReport = {
|
||||
summary: AnalyticsRow;
|
||||
employees: AnalyticsRow[];
|
||||
monthly: AnalyticsRow[];
|
||||
off_cycle_payments?: AnalyticsRow[];
|
||||
};
|
||||
};
|
||||
export type ReportingKind =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user