Add balance-only payroll carryover adjustments
All checks were successful
Deploy production / deploy (push) Successful in 10m8s

This commit is contained in:
admin i 2026-10-15 16:29:32 +03:00
parent 9d278f4743
commit af26bfc6de
17 changed files with 1653 additions and 204 deletions

View File

@ -0,0 +1,80 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from rest_framework import serializers, status
from rest_framework.response import Response
from finance.balance_adjustments.services import create_adjustment, void_adjustment
from finance.enums import PayrollCarryoverDirection
from finance.models import PayrollBalanceAdjustment, PayrollPeriod
from .common import PAYROLL_PERMISSIONS, PayrollAdminAPIView
class BalanceAdjustmentWriteSerializer(serializers.Serializer):
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=get_user_model().objects.visible_in_ui(),
)
effective_period_id = serializers.PrimaryKeyRelatedField(
source="effective_period",
queryset=PayrollPeriod.objects.all(),
)
source_period_id = serializers.PrimaryKeyRelatedField(
source="source_period",
queryset=PayrollPeriod.objects.all(),
required=False,
allow_null=True,
default=None,
)
direction = serializers.ChoiceField(choices=PayrollCarryoverDirection.choices)
amount = serializers.DecimalField(
max_digits=19, decimal_places=2, min_value=Decimal("0.01")
)
reason = serializers.CharField(
max_length=1000, required=False, allow_blank=True, default=""
)
request_id = serializers.UUIDField()
class BalanceAdjustmentVoidSerializer(serializers.Serializer):
reason = serializers.CharField(
max_length=1000, required=False, allow_blank=True, default=""
)
class PayrollBalanceAdjustmentCreateView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
serializer_class = BalanceAdjustmentWriteSerializer
def post(self, request):
command = self.serializer_class(data=request.data)
command.is_valid(raise_exception=True)
adjustment, created = create_adjustment(
actor=request.user, **command.validated_data
)
return Response(
{
"id": adjustment.pk,
"effective_period_id": adjustment.effective_period_id,
"message": "Корректировка остатка сохранена. Зарплата и расчётные листки не изменены.",
},
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
class PayrollBalanceAdjustmentVoidView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
serializer_class = BalanceAdjustmentVoidSerializer
def post(self, request, pk):
get_object_or_404(
PayrollBalanceAdjustment, pk=pk, employee__is_hidden_in_ui=False
)
command = self.serializer_class(data=request.data)
command.is_valid(raise_exception=True)
adjustment = void_adjustment(pk, actor=request.user, **command.validated_data)
return Response(
{"id": adjustment.pk, "message": "Корректировка остатка аннулирована."}
)

View File

@ -6,14 +6,17 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Max
from communications.models import Message
from finance.balance_adjustments.services import locked_periods
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollPeriodStatus,
)
from finance.models import (
PayrollBalanceAdjustment,
PayrollCellCommentThread,
PayrollInputLine,
PayrollPeriod,
PayrollStatement,
)
from finance.payroll.balances import CARRYOVER_DEBIT_KINDS
@ -193,6 +196,90 @@ def _carryover_ledger(period):
"created_at": line.created_at,
}
)
frozen_starts = list(
locked_periods(
PayrollPeriod.objects.filter(currency=period.currency)
).values_list("date_from", flat=True)
)
adjustments = (
PayrollBalanceAdjustment.objects.filter(
effective_period__currency=period.currency,
effective_period__date_from__lte=period.date_from,
employee__is_hidden_in_ui=False,
)
.select_related(
"effective_period",
"source_period",
"employee",
"employee__position",
"created_by",
"voided_by",
)
.prefetch_related("employee__departments_links__department")
)
for adjustment in adjustments:
active = adjustment.voided_at is None
contribution = adjustment.contribution if active else Decimal("0.00")
balances_by_employee[adjustment.employee_id] = (
balances_by_employee.get(adjustment.employee_id, Decimal("0.00"))
+ contribution
)
included_count += int(active)
excluded_count += int(not active)
effective = adjustment.effective_period
entries.append(
{
"id": adjustment.pk,
"entry_type": "manual_adjustment",
"period": {
"id": effective.pk,
"code": effective.code,
"name": effective.name,
"date_from": effective.date_from,
"date_to": effective.date_to,
"currency": effective.currency,
},
"source_period": (
{
"id": adjustment.source_period_id,
"name": adjustment.source_period.name,
"code": adjustment.source_period.code,
}
if adjustment.source_period_id
else None
),
"employee": PayrollEmployeeMiniSerializer(adjustment.employee).data,
"component": {
"code": "MANUAL_BALANCE_ADJUSTMENT",
"name": "Ручная корректировка остатка",
},
"amount": str(adjustment.amount),
"raw_contribution": str(adjustment.contribution),
"contribution": str(contribution),
"reason": adjustment.reason,
"status": "approved" if active else "voided",
"status_label": "Действует" if active else "Аннулирована",
"include_in_carryover": active,
"counts_in_balance": active,
"created_by": PayrollUserRefSerializer(adjustment.created_by).data,
"created_at": adjustment.created_at,
"can_void": active
and not any(start >= effective.date_from for start in frozen_starts),
"voided_by": PayrollUserRefSerializer(adjustment.voided_by).data
if adjustment.voided_by_id
else None,
"voided_at": adjustment.voided_at,
"void_reason": adjustment.void_reason,
}
)
entries.sort(
key=lambda entry: (
entry["period"]["date_from"],
entry["created_at"],
entry["id"],
),
reverse=True,
)
company_owes = sum(
(max(balance, Decimal("0.00")) for balance in balances_by_employee.values()),
Decimal("0.00"),

View File

@ -48,6 +48,10 @@ from .admin import (
PayrollWorkRecordListCreateView,
PayrollWorkRecordReviseView,
)
from .admin.balance_adjustments import (
PayrollBalanceAdjustmentCreateView,
PayrollBalanceAdjustmentVoidView,
)
from .admin.off_cycle import (
PayrollOffCyclePaymentListView,
PayrollOffCyclePaymentVoidView,
@ -71,6 +75,16 @@ from .views import (
app_name = "finance-payroll"
urlpatterns = [
path(
"payroll/admin/balance-adjustments/",
PayrollBalanceAdjustmentCreateView.as_view(),
name="admin-balance-adjustment-create",
),
path(
"payroll/admin/balance-adjustments/<int:pk>/void/",
PayrollBalanceAdjustmentVoidView.as_view(),
name="admin-balance-adjustment-void",
),
path(
"payroll/admin/off-cycle-payments/",
PayrollOffCyclePaymentListView.as_view(),

View File

@ -0,0 +1,96 @@
"""Balance-only corrections, deliberately unrelated to payroll input lines."""
import uuid
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from finance.enums import PayrollCarryoverDirection
class PayrollBalanceAdjustment(models.Model):
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_balance_adjustments",
)
effective_period = models.ForeignKey(
"finance.PayrollPeriod",
on_delete=models.PROTECT,
related_name="balance_adjustments",
)
source_period = models.ForeignKey(
"finance.PayrollPeriod",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="referenced_balance_adjustments",
)
direction = models.CharField(
max_length=20, choices=PayrollCarryoverDirection.choices
)
amount = models.DecimalField(
max_digits=19,
decimal_places=2,
validators=[MinValueValidator(Decimal("0.01"))],
)
reason = models.CharField(max_length=1000, blank=True)
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="payroll_balance_adjustments_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,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="payroll_balance_adjustments_voided",
)
void_reason = models.CharField(max_length=1000, blank=True)
class Meta:
ordering = ["-effective_period__date_from", "-created_at", "-pk"]
verbose_name = "Ручная корректировка остатка"
verbose_name_plural = "Ручные корректировки остатков"
constraints = [
models.CheckConstraint(
condition=models.Q(amount__gt=0),
name="balance_adjustment_positive",
),
models.CheckConstraint(
condition=models.Q(direction__in=["company_owes", "employee_owes"]),
name="balance_adjustment_direction",
),
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)
),
name="balance_adjustment_void_consistent",
),
]
@property
def contribution(self):
return self.amount if self.direction == "company_owes" else -self.amount
def clean(self):
super().clean()
if self.source_period_id and self.effective_period_id:
if self.source_period.currency != self.effective_period.currency:
raise ValidationError(
{"source_period": "Валюты периодов должны совпадать."}
)
if self.source_period.date_to >= self.effective_period.date_from:
raise ValidationError(
{"source_period": "Выберите более ранний период."}
)

View File

@ -0,0 +1,150 @@
"""Audited balance corrections; no accruals, payments or notifications."""
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.utils import timezone
from finance.enums import PayrollPeriodStatus
from finance.models import PayrollAuditEvent, PayrollBalanceAdjustment, PayrollPeriod
from finance.payroll.access import can_access_payroll_management, has_payroll_permission
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
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 affected_periods(period):
return PayrollPeriod.objects.filter(
currency=period.currency,
date_from__gte=period.date_from,
).order_by("date_from", "pk")
def locked_periods(queryset):
return queryset.filter(
~Q(status=PayrollPeriodStatus.OPEN) | Q(current_run__isnull=False)
)
def _lock_editable_periods(period):
# Calculation/closing also lock PayrollPeriod. Lock all affected periods so
# a correction cannot race calculation or silently invalidate a later run.
# Period dates/currency may have changed since HTTP deserialization. Lock
# and reread the effective period before determining the affected range.
period = PayrollPeriod.objects.select_for_update().get(pk=period.pk)
periods = list(affected_periods(period).select_for_update())
if not periods or any(
item.status != PayrollPeriodStatus.OPEN or item.current_run_id
for item in periods
):
raise PayrollOperationError(
"BALANCE_ADJUSTMENT_PERIOD_LOCKED",
"Корректировка возможна только начиная с открытого периода без расчёта. "
"Последующие рассчитанные и закрытые периоды также нельзя изменять. "
"Выберите более поздний открытый период.",
)
return period
def _audit(adjustment, actor, action):
PayrollAuditEvent.objects.create(
actor=actor,
action=action,
object_type=adjustment._meta.label_lower,
object_id=str(adjustment.pk),
period=adjustment.effective_period,
metadata={
"channel": "balance_adjustments",
"employee_id": adjustment.employee_id,
"source_period_id": adjustment.source_period_id,
"direction": adjustment.direction,
"amount": str(adjustment.amount),
"reason": adjustment.reason,
"void_reason": adjustment.void_reason,
},
)
@transaction.atomic
def create_adjustment(
*, actor, employee, effective_period, source_period=None, **fields
):
_require_write_access(actor)
# Serialize a user's retries without blocking FK key-share locks taken by
# concurrent calculation/audit inserts referencing the same user.
get_user_model().objects.select_for_update(no_key=True).get(pk=actor.pk)
existing = PayrollBalanceAdjustment.objects.filter(
request_id=fields["request_id"]
).first()
if existing:
if not (
existing.created_by_id == actor.pk
and existing.employee_id == employee.pk
and existing.effective_period_id == effective_period.pk
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())
):
raise PayrollOperationError(
"BALANCE_ADJUSTMENT_REQUEST_CONFLICT",
"Этот запрос уже использован для другой корректировки.",
)
return existing, False
effective_period = _lock_editable_periods(effective_period)
adjustment = PayrollBalanceAdjustment(
employee=employee,
effective_period=effective_period,
source_period=source_period,
created_by=actor,
**fields,
)
try:
adjustment.full_clean(validate_unique=False)
except ValidationError as exc:
raise PayrollOperationError(
"BALANCE_ADJUSTMENT_INVALID",
" ".join(exc.messages),
) from exc
try:
with transaction.atomic():
adjustment.save()
except IntegrityError as exc:
if PayrollBalanceAdjustment.objects.filter(
request_id=fields["request_id"]
).exists():
raise PayrollOperationError(
"BALANCE_ADJUSTMENT_REQUEST_CONFLICT",
"Этот запрос уже использован для другой корректировки.",
) from exc
raise
_audit(adjustment, actor, "payroll.balance_adjustment_created")
return adjustment, True
@transaction.atomic
def void_adjustment(adjustment_id, *, actor, reason=""):
_require_write_access(actor)
initial = PayrollBalanceAdjustment.objects.select_related("effective_period").get(
pk=adjustment_id
)
if initial.voided_at:
return initial
# Same period-before-record lock order as creation/calculation.
_lock_editable_periods(initial.effective_period)
adjustment = PayrollBalanceAdjustment.objects.select_for_update().get(
pk=adjustment_id
)
if adjustment.voided_at:
return adjustment
adjustment.voided_at = timezone.now()
adjustment.voided_by = actor
adjustment.void_reason = reason
adjustment.save(update_fields=["voided_at", "voided_by", "void_reason"])
_audit(adjustment, actor, "payroll.balance_adjustment_voided")
return adjustment

View File

@ -0,0 +1,131 @@
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", "0039_payrolloffcyclepayment"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="PayrollBalanceAdjustment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"direction",
models.CharField(
choices=[
("company_owes", "Доплата сотруднику"),
("employee_owes", "Долг сотрудника"),
],
max_length=20,
),
),
(
"amount",
models.DecimalField(
decimal_places=2,
max_digits=19,
validators=[
django.core.validators.MinValueValidator(Decimal("0.01"))
],
),
),
("reason", models.CharField(blank=True, max_length=1000)),
(
"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(
on_delete=django.db.models.deletion.PROTECT,
related_name="payroll_balance_adjustments",
to=settings.AUTH_USER_MODEL,
),
),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="payroll_balance_adjustments_created",
to=settings.AUTH_USER_MODEL,
),
),
(
"voided_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="payroll_balance_adjustments_voided",
to=settings.AUTH_USER_MODEL,
),
),
(
"effective_period",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="balance_adjustments",
to="finance.payrollperiod",
),
),
(
"source_period",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="referenced_balance_adjustments",
to="finance.payrollperiod",
),
),
],
options={
"ordering": ["-effective_period__date_from", "-created_at", "-pk"],
"verbose_name": "Ручная корректировка остатка",
"verbose_name_plural": "Ручные корректировки остатков",
"constraints": [
models.CheckConstraint(
condition=models.Q(amount__gt=0),
name="balance_adjustment_positive",
),
models.CheckConstraint(
condition=models.Q(
direction__in=["company_owes", "employee_owes"]
),
name="balance_adjustment_direction",
),
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)
),
name="balance_adjustment_void_consistent",
),
],
},
),
]

View File

@ -16,6 +16,7 @@ from django.db import models, transaction
from django.db.models import F, Q
from .analytics.models import ReportingEntry # noqa: F401
from .balance_adjustments.models import PayrollBalanceAdjustment # noqa: F401
from .enums import (
ApprovalStatus,
InputSource,

View File

@ -6,7 +6,7 @@ from collections.abc import Iterable
from decimal import Decimal
from finance.enums import ApprovalStatus, InputSource
from finance.models import PayrollInputLine, PayrollPeriod
from finance.models import PayrollBalanceAdjustment, PayrollInputLine, PayrollPeriod
from finance.payroll.components import get_payroll_components
CARRYOVER_DEBIT_KINDS = frozenset({"adjustment_debit", "deduction", "payment"})
@ -60,4 +60,16 @@ def opening_balances(
balances[line["employee_id"]] = balances.get(
line["employee_id"], Decimal("0.00")
) + (line["amount"] * direction)
# Effective-period corrections enter the opening balance exactly once per
# cumulative reconstruction. They never become payroll input lines.
for adjustment in PayrollBalanceAdjustment.objects.filter(
employee_id__in=requested_ids,
effective_period__currency=period.currency,
effective_period__date_from__lte=period.date_from,
voided_at__isnull=True,
):
balances[adjustment.employee_id] = (
balances.get(adjustment.employee_id, Decimal("0.00"))
+ adjustment.contribution
)
return balances

View File

@ -0,0 +1,376 @@
import calendar
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from decimal import Decimal
from threading import Barrier
import pytest
from django.contrib.auth import get_user_model
from django.db import close_old_connections, connection
from django.urls import reverse
from finance.balance_adjustments.services import create_adjustment, void_adjustment
from finance.models import (
FinanceSettings,
PayrollAuditEvent,
PayrollBalanceAdjustment,
PayrollInputLine,
PayrollPeriod,
PayrollStatement,
PayrollWorkRecord,
)
from finance.payroll.balances import opening_balances
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
from finance.payroll.reporting import build_payroll_period_table
from finance.payroll.services import calculate_period
from tests.finance.test_payroll_admin_api import grant, make_approved_period_inputs
pytestmark = pytest.mark.django_db
def url(name, **kwargs):
return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None)
@pytest.fixture
def setup(user_factory, auth_client_factory):
manager = user_factory()
employee = user_factory()
grant(manager, "manage_payroll_inputs", "view_all_payroll", "calculate_payroll")
periods = {}
for month in (8, 9, 10, 11):
periods[month] = PayrollPeriod.objects.create(
code=f"2026-{month:02}",
name=f"Месяц {month}",
date_from=date(2026, month, 1),
date_to=date(2026, month, calendar.monthrange(2026, month)[1]),
status="closed" if month < 10 else "open",
created_by=manager,
)
payload = {
"employee_id": employee.pk,
"effective_period_id": periods[10].pk,
"source_period_id": periods[8].pk,
"direction": "company_owes",
"amount": "1234.56",
"request_id": str(uuid.uuid4()),
}
return manager, employee, periods, payload, auth_client_factory(manager)
def create(setup, **overrides):
_, _, _, payload, client = setup
return client.post(
url("admin-balance-adjustment-create"), {**payload, **overrides}, format="json"
)
@pytest.mark.parametrize(
"direction,contribution",
[("company_owes", "1234.56"), ("employee_owes", "-1234.56")],
)
def test_effective_from_october_once_and_no_payroll_inputs(
setup, direction, contribution
):
manager, employee, periods, _, client = setup
frozen = list(
PayrollPeriod.objects.filter(pk__in=[periods[8].pk, periods[9].pk]).values()
)
result = create(setup, direction=direction)
assert result.status_code == 201, result.content
adjustment = PayrollBalanceAdjustment.objects.get()
assert adjustment.reason == ""
assert adjustment.created_by == manager
assert not PayrollInputLine.objects.exists()
assert not PayrollStatement.objects.exists()
assert (
PayrollAuditEvent.objects.filter(
action="payroll.balance_adjustment_created"
).count()
== 1
)
for month in (8, 9):
assert opening_balances(periods[month], [employee.pk]) == {}
ledger = client.get(url("admin-period-carryovers", pk=periods[month].pk)).json()
assert ledger["entries"] == []
for month in (10, 11):
assert opening_balances(periods[month], [employee.pk]) == {
employee.pk: Decimal(contribution)
}
ledger = client.get(url("admin-period-carryovers", pk=periods[month].pk)).json()
assert ledger["summary"]["balance"] == contribution
assert len(ledger["entries"]) == 1
entry = ledger["entries"][0]
assert entry["entry_type"] == "manual_adjustment"
assert entry["can_void"] is True
assert entry["source_period"]["id"] == periods[8].pk
assert frozen == list(
PayrollPeriod.objects.filter(pk__in=[periods[8].pk, periods[9].pk]).values()
)
def test_idempotency_and_conflicting_retries(setup):
assert create(setup).status_code == 201
assert create(setup).status_code == 200
result = create(setup, amount="99")
assert result.status_code == 409, result.content
assert result.json()["code"] == "BALANCE_ADJUSTMENT_REQUEST_CONFLICT"
assert PayrollBalanceAdjustment.objects.count() == 1
@pytest.mark.django_db(transaction=True)
def test_concurrent_retries_are_exactly_once_on_postgres(setup):
if connection.vendor != "postgresql":
pytest.skip("PostgreSQL row-lock integration test")
manager, employee, periods, payload, _ = setup
FinanceSettings.get_solo()
barrier = Barrier(2)
def record():
close_old_connections()
try:
actor = get_user_model().objects.get(pk=manager.pk)
person = get_user_model().objects.get(pk=employee.pk)
effective = PayrollPeriod.objects.get(pk=periods[10].pk)
source = PayrollPeriod.objects.get(pk=periods[8].pk)
barrier.wait(timeout=15)
adjustment, created = create_adjustment(
actor=actor,
employee=person,
effective_period=effective,
source_period=source,
direction="company_owes",
amount=Decimal("1234.56"),
reason="",
request_id=uuid.UUID(payload["request_id"]),
)
return adjustment.pk, created
finally:
close_old_connections()
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(record)
second = pool.submit(record)
results = [first.result(timeout=30), second.result(timeout=30)]
assert results[0][0] == results[1][0]
assert sorted(result[1] for result in results) == [False, True]
assert PayrollBalanceAdjustment.objects.count() == 1
assert (
PayrollAuditEvent.objects.filter(
action="payroll.balance_adjustment_created"
).count()
== 1
)
def test_void_keeps_history_and_cancels_only_balance(setup):
manager, employee, periods, _, client = setup
assert create(setup).status_code == 201
adjustment = PayrollBalanceAdjustment.objects.get()
result = client.post(
url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json"
)
assert result.status_code == 200, result.content
assert (
client.post(
url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json"
).status_code
== 200
)
adjustment.refresh_from_db()
assert adjustment.voided_by == manager
assert adjustment.voided_at
assert not opening_balances(periods[10], [employee.pk])
entry = client.get(url("admin-period-carryovers", pk=periods[10].pk)).json()[
"entries"
][0]
assert entry["status"] == "voided"
assert entry["contribution"] == "0.00"
assert entry["can_void"] is False
assert entry["voided_by"]["id"] == manager.pk
assert (
PayrollAuditEvent.objects.filter(
action="payroll.balance_adjustment_voided"
).count()
== 1
)
assert not PayrollInputLine.objects.exists()
@pytest.mark.parametrize(
"status", ["calculated", "review", "approved", "published", "closed"]
)
def test_calculated_and_later_frozen_periods_block_create_and_void(setup, status):
_, employee, periods, _, client = setup
assert create(setup).status_code == 201
adjustment = PayrollBalanceAdjustment.objects.get()
PayrollPeriod.objects.filter(pk=periods[11].pk).update(status=status)
assert create(setup, request_id=str(uuid.uuid4())).status_code == 409
result = client.post(
url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json"
)
assert result.status_code == 409
assert opening_balances(periods[10], [employee.pk])[employee.pk] == Decimal(
"1234.56"
)
entry = client.get(url("admin-period-carryovers", pk=periods[10].pk)).json()[
"entries"
][0]
assert entry["can_void"] is False
def test_closed_effective_period_rejected(setup):
assert create(setup, effective_period_id=setup[2][9].pk).status_code == 409
assert not PayrollBalanceAdjustment.objects.exists()
def test_future_effective_period_does_not_change_current_balance(setup):
_, employee, periods, _, client = setup
assert create(setup, effective_period_id=periods[11].pk).status_code == 201
assert opening_balances(periods[10], [employee.pk]) == {}
assert (
client.get(url("admin-period-carryovers", pk=periods[10].pk)).json()["entries"]
== []
)
assert opening_balances(periods[11], [employee.pk])[employee.pk] == Decimal(
"1234.56"
)
def test_retry_of_already_voided_adjustment_after_closing_is_read_only(setup):
_, _, periods, _, client = setup
assert create(setup).status_code == 201
adjustment = PayrollBalanceAdjustment.objects.get()
endpoint = url("admin-balance-adjustment-void", pk=adjustment.pk)
assert client.post(endpoint, {}, format="json").status_code == 200
PayrollPeriod.objects.filter(pk=periods[10].pk).update(status="closed")
assert client.post(endpoint, {}, format="json").status_code == 200
assert (
PayrollAuditEvent.objects.filter(
action="payroll.balance_adjustment_voided"
).count()
== 1
)
@pytest.mark.parametrize(
"overrides",
[
{"amount": "0"},
{"amount": "-1"},
{"amount": "1.001"},
{"direction": "unknown"},
{"request_id": "invalid"},
],
)
def test_invalid_input(setup, overrides):
assert create(setup, **overrides).status_code == 400
assert not PayrollBalanceAdjustment.objects.exists()
def test_source_is_reference_only_and_must_be_earlier_same_currency(setup):
periods = setup[2]
assert create(setup, source_period_id=periods[10].pk).status_code == 409
PayrollPeriod.objects.filter(pk=periods[8].pk).update(currency="USD")
assert create(setup).status_code == 409
assert create(setup, source_period_id=None).status_code == 201
def test_inactive_allowed_hidden_forbidden(setup):
employee = setup[1]
employee.is_active = False
employee.save(update_fields=["is_active"])
assert create(setup).status_code == 201
employee.is_hidden_in_ui = True
employee.save(update_fields=["is_hidden_in_ui"])
assert create(setup, request_id=str(uuid.uuid4())).status_code == 400
def test_permissions_and_management_allowlist(setup, user_factory, auth_client_factory):
manager, employee, periods, payload, client = setup
outsider = user_factory()
grant(outsider, "view_all_payroll")
outsider_client = auth_client_factory(outsider)
assert (
outsider_client.post(
url("admin-balance-adjustment-create"), payload, format="json"
).status_code
== 403
)
with pytest.raises(PayrollPermissionDenied):
create_adjustment(
actor=outsider, employee=employee, effective_period=periods[10]
)
settings = FinanceSettings.get_solo()
settings.management_users.add(outsider)
assert create(setup).status_code == 403
with pytest.raises(PayrollPermissionDenied):
create_adjustment(
actor=manager, employee=employee, effective_period=periods[10]
)
def test_preview_and_calculation_change_only_balance_preserve_september_snapshot(
setup, user_factory
):
manager, employee, periods, _, client = setup
approver = user_factory()
september = periods[9]
september.status = "open"
september.save(update_fields=["status"])
make_approved_period_inputs(
period=september, employee=employee, creator=manager, approver=approver
)
september_run = calculate_period(september.pk, actor=manager)
september.refresh_from_db()
september.status = "closed"
september.save(update_fields=["status"])
snapshot_before = list(PayrollStatement.objects.filter(run=september_run).values())
work = PayrollWorkRecord.objects.get(period=september, employee=employee)
work.pk = None
work.period = periods[10]
work.save()
before = next(
row
for row in build_payroll_period_table(periods[10])["rows"]
if row["employee"]["id"] == employee.pk
)
assert create(setup).status_code == 201
after = next(
row
for row in build_payroll_period_table(periods[10])["rows"]
if row["employee"]["id"] == employee.pk
)
assert Decimal(after["opening_balance"]) - Decimal(
before["opening_balance"]
) == Decimal("1234.56")
assert Decimal(after["closing_balance"]) - Decimal(
before["closing_balance"]
) == Decimal("1234.56")
for key in (
"gross_total",
"deduction_total",
"net_pay",
"payment_total",
"payable",
):
assert before[key] == after[key], key
run = calculate_period(periods[10].pk, actor=manager)
statement = PayrollStatement.objects.get(run=run, employee=employee)
assert statement.opening_balance == Decimal(after["opening_balance"])
assert statement.closing_balance == Decimal(after["closing_balance"])
assert statement.payable == Decimal(before["payable"])
assert snapshot_before == list(
PayrollStatement.objects.filter(run=september_run).values()
)
assert not PayrollInputLine.objects.exists()
adjustment = PayrollBalanceAdjustment.objects.get()
assert (
client.post(
url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json"
).status_code
== 409
)
with pytest.raises(PayrollOperationError):
void_adjustment(adjustment.pk, actor=manager)

View File

@ -0,0 +1,238 @@
"use client";
import { useMemo, useRef, useState, type FormEvent } from "react";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { Modal } from "@/components/ui/Modal";
import { apiClient } from "@/lib/api";
import type {
PayrollAdminEmployee,
PayrollAdminPeriod,
PayrollBalanceAdjustmentWrite,
} from "@/lib/api/finance";
import {
balanceAdjustmentPeriods,
balanceAdjustmentSources,
} from "@/lib/payroll-balance-adjustments";
import {
matchesPayrollEmployeeSearch,
payrollInputEmployeeOptions,
} from "@/lib/payroll-employee-search";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { FormError, ModalActions } from "./PayrollAdminModals";
const inputClass = "app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm";
export function PayrollBalanceAdjustmentModal({
employees,
periods,
initialEmployeeId,
initialPeriodId,
onClose,
onSaved,
}: {
employees: PayrollAdminEmployee[];
periods: PayrollAdminPeriod[];
initialEmployeeId: number | null;
initialPeriodId: number;
onClose: () => void;
onSaved: (periodId: number, employeeId: number) => void | Promise<void>;
}) {
const eligiblePeriods = useMemo(
() => balanceAdjustmentPeriods(periods),
[periods],
);
const [employeeId, setEmployeeId] = useState<number | null>(
initialEmployeeId,
);
const [effectiveId, setEffectiveId] = useState(
() =>
eligiblePeriods.find((period) => period.id === initialPeriodId)?.id ??
eligiblePeriods.find(
(period) =>
period.currency ===
periods.find((item) => item.id === initialPeriodId)?.currency,
)?.id ??
eligiblePeriods[0]?.id ??
0,
);
const [sourceId, setSourceId] = useState("");
const [direction, setDirection] =
useState<PayrollBalanceAdjustmentWrite["direction"]>("company_owes");
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestId = useRef<string | null>(null);
const submitting = useRef(false);
const options = useMemo(
() => payrollInputEmployeeOptions(employees, true),
[employees],
);
const effective = eligiblePeriods.find((period) => period.id === effectiveId);
const sourcePeriods = balanceAdjustmentSources(periods, effective);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (submitting.current || !employeeId || !effective) return;
submitting.current = true;
setBusy(true);
setError(null);
requestId.current ??= crypto.randomUUID();
try {
await apiClient.createPayrollBalanceAdjustment({
employee_id: employeeId,
effective_period_id: effectiveId,
source_period_id: sourceId ? Number(sourceId) : null,
direction,
amount,
reason: reason.trim(),
request_id: requestId.current,
});
await onSaved(effectiveId, employeeId);
} catch (saveError) {
setError(
getPayrollAdminError(
saveError,
"Не удалось сохранить корректировку остатка.",
),
);
} finally {
submitting.current = false;
setBusy(false);
}
};
return (
<Modal
isOpen
onClose={busy ? () => {} : onClose}
closeOnEsc={!busy}
title="Добавить корректировку остатка"
size="md"
stackLevel={1}
>
<form onSubmit={(event) => void submit(event)}>
<p className="app-text-muted mb-4 text-sm leading-relaxed">
Меняется только переходящий остаток с выбранного периода. Зарплата,
закрытые снимки и расчётные листки не изменяются; рассылки нет.
</p>
<fieldset disabled={busy} className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<SearchableSelectSingle
label="Сотрудник"
labelClassName="app-field-label"
items={options}
selectedId={employeeId}
onSelect={setEmployeeId}
placeholder="Выберите сотрудника"
disabled={busy}
searchPlaceholder="Поиск по фамилии, имени или отчеству"
filterItem={(item, query) =>
matchesPayrollEmployeeSearch(item.name, query)
}
/>
<p className="app-text-muted mt-1 text-xs">
Поиск среди всех сотрудников, включая неактивных.
</p>
</div>
<label>
<span className="app-field-label">Знак записи</span>
<select
aria-label="Знак записи"
className={inputClass}
value={direction}
onChange={(event) =>
setDirection(event.target.value as typeof direction)
}
>
<option value="company_owes">
Положительная (+)
</option>
<option value="employee_owes">
Отрицательная (−)
</option>
</select>
</label>
<label>
<span className="app-field-label">
Сумма{effective ? `, ${effective.currency}` : ""}
</span>
<input
className={inputClass}
type="number"
required
min="0.01"
step="0.01"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</label>
<label>
<span className="app-field-label">Учитывать начиная с</span>
<select
aria-label="Учитывать начиная с"
className={inputClass}
required
value={effectiveId || ""}
onChange={(event) => {
setEffectiveId(Number(event.target.value));
setSourceId("");
}}
>
{!eligiblePeriods.length ? (
<option value="">Нет открытого периода без расчёта</option>
) : null}
{eligiblePeriods.map((period) => (
<option key={period.id} value={period.id}>
{period.name || period.code}
</option>
))}
</select>
</label>
<label>
<span className="app-field-label">
Относится к периоду (необязательно)
</span>
<select
aria-label="Относится к периоду (необязательно)"
className={inputClass}
value={sourceId}
onChange={(event) => setSourceId(event.target.value)}
>
<option value="">Не указан</option>
{sourcePeriods.map((period) => (
<option key={period.id} value={period.id}>
{period.name || period.code}
</option>
))}
</select>
</label>
<label className="sm:col-span-2">
<span className="app-field-label">Комментарий (необязательно)</span>
<textarea
className={`${inputClass} min-h-24 resize-y`}
maxLength={1000}
placeholder="Например: неучтённое разовое начисление августа"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
</fieldset>
<div className="app-feedback-warning mt-4 rounded-lg p-3 text-xs leading-relaxed">
Перед добавлением убедитесь, что сумма ещё не учтена в остатке —
например, через галочку у исходной строки. Иначе она будет учтена
дважды. После расчёта периода аннулировать запись задним числом
нельзя.
</div>
<FormError message={error} />
<ModalActions
busy={busy}
disabled={!employeeId || !effective}
onClose={onClose}
submitLabel="Добавить корректировку"
/>
</form>
</Modal>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowDownLeft, ArrowUpRight, Loader2 } from "lucide-react";
import { ArrowDownLeft, ArrowUpRight, Loader2, Plus } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { apiClient } from "@/lib/api";
@ -9,9 +9,12 @@ import type {
PayrollAdminEmployee,
PayrollAdminPeriod,
PayrollCarryoverLedger,
PayrollCarryoverLedgerEntry,
} from "@/lib/api/finance";
import { formatPayrollDateTime, formatPayrollMoney } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { PayrollBalanceAdjustmentModal } from "./PayrollBalanceAdjustmentModal";
import { PayrollConfirmModal } from "./PayrollAdminModals";
type PayrollCarryoversModalProps = {
canManage: boolean;
@ -48,6 +51,10 @@ export function PayrollCarryoversModal({
const [savingId, setSavingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [voiding, setVoiding] = useState<PayrollCarryoverLedgerEntry | null>(
null,
);
const [employeeFilterId, setEmployeeFilterId] = useState(
initialEmployeeId ? String(initialEmployeeId) : "",
);
@ -148,207 +155,308 @@ export function PayrollCarryoversModal({
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Переходящие остатки"
size="xl"
>
<div className="space-y-5">
<div className="app-surface-muted rounded-xl p-4">
<div className="grid gap-3 sm:grid-cols-2">
<label>
<span className="app-field-label">Остаток на конец периода</span>
<select
className={inputClass}
value={periodId}
onChange={(event) => {
setPeriodId(Number(event.target.value));
setLedger(null);
setNotice(null);
setError(null);
setLoading(true);
}}
>
{periods.map((period) => (
<option key={period.id} value={period.id}>
{period.name || period.code}
</option>
))}
</select>
</label>
<label>
<span className="app-field-label">Сотрудник</span>
<select
className={inputClass}
value={employeeFilterId}
onChange={(event) => setEmployeeFilterId(event.target.value)}
>
<option value="">Все сотрудники</option>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{employee.display_name}
</option>
))}
</select>
</label>
</div>
<div className="app-text-muted mt-3 space-y-1 text-xs leading-relaxed">
<p>
<b className="text-[var(--foreground)]">В реестре:</b> все строки
колонок, для которых включён параметр «Учитывать в переходящих
остатках», за выбранный и предыдущие периоды.
</p>
<p>
<b className="text-[var(--foreground)]">В остатке:</b> сумма всех
учитываемых утверждённых строк за всю историю до конца выбранного
периода и активных строк самого выбранного периода.
</p>
<p>
<b className="text-[var(--foreground)]">Это не копирование:</b>{" "}
значение ячейки не появляется в колонке следующего периода;
меняется только переходящий остаток.
</p>
<p>
<b className="text-[var(--foreground)]">Отключение записи</b>{" "}
меняет только переходящий остаток. Сумма зарплаты в исходном
периоде не изменяется.
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className="app-feedback-success rounded-full px-2 py-1">
Компания должна:{" "}
{formatPayrollMoney(balances.companyOwes, currency)}
</span>
<span className="app-feedback-warning rounded-full px-2 py-1">
Сотрудники должны:{" "}
{formatPayrollMoney(balances.employeeOwes, currency)}
</span>
</div>
</div>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{error}
</div>
) : null}
{notice ? (
<div className="app-feedback-success rounded-xl p-3 text-sm">
{notice}
</div>
) : null}
{loading ? (
<div className="flex min-h-32 items-center justify-center">
<Loader2 className="app-accent-text animate-spin" size={22} />
</div>
) : entries.length ? (
<div className="space-y-3">
{entries.map((entry) => {
const rawContribution = Number(entry.raw_contribution);
const Icon = rawContribution >= 0 ? ArrowUpRight : ArrowDownLeft;
const disabled =
!canManage || savingId !== null || entry.status === "voided";
const exclusionReason = !entry.include_in_carryover
? "Исключена из остатка вручную."
: entry.status === "voided"
? "Аннулированная запись не участвует в остатке."
: "Запись начнёт участвовать после утверждения.";
return (
<article
key={entry.id}
className={`app-surface-muted rounded-xl p-3 ${entry.include_in_carryover ? "" : "opacity-70"}`}
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title="Переходящие остатки"
size="xl"
>
<div className="space-y-5">
<div className="app-surface-muted rounded-xl p-4">
<div className="grid gap-3 sm:grid-cols-2">
<label>
<span className="app-field-label">
Остаток на конец периода
</span>
<select
className={inputClass}
value={periodId}
onChange={(event) => {
setPeriodId(Number(event.target.value));
setLedger(null);
setNotice(null);
setError(null);
setLoading(true);
}}
>
<div className="flex items-start gap-3">
<span
className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${rawContribution >= 0 ? "app-feedback-success" : "app-feedback-warning"}`}
>
<Icon size={15} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
{entry.employee.display_name}
</p>
<p className="app-text-muted mt-0.5 text-xs">
{entry.period.name || entry.period.code} ·{" "}
{entry.component.name}
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-[var(--foreground)]">
{formatPayrollMoney(entry.amount, currency)}
</p>
<p
className={`text-xs ${entry.counts_in_balance ? (rawContribution >= 0 ? "text-emerald-400" : "text-amber-400") : "app-text-muted"}`}
>
Вклад:{" "}
{entry.counts_in_balance
? signedAmount(entry.contribution, currency)
: "не учитывается"}
</p>
</div>
</div>
<div className="app-text-muted mt-2 grid gap-1 text-xs sm:grid-cols-2">
<p>
Статус:{" "}
<span className="text-[var(--foreground)]">
{entry.status_label}
</span>
</p>
<p>
Создал:{" "}
<span className="text-[var(--foreground)]">
{entry.created_by.display_name}
</span>{" "}
· {formatPayrollDateTime(entry.created_at)}
</p>
</div>
{entry.reason ? (
<p className="app-text-muted mt-2 text-xs">
Основание:{" "}
<span className="text-[var(--foreground)]">
{entry.reason}
</span>
</p>
) : null}
{!entry.counts_in_balance ? (
<p className="app-text-muted mt-2 text-xs">
{exclusionReason} Возможный вклад:{" "}
{signedAmount(entry.raw_contribution, currency)}.
</p>
) : null}
<label
className={`mt-3 flex items-center gap-2 text-xs ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
{periods.map((period) => (
<option key={period.id} value={period.id}>
{period.name || period.code}
</option>
))}
</select>
</label>
<label>
<span className="app-field-label">Сотрудник</span>
<select
className={inputClass}
value={employeeFilterId}
onChange={(event) => setEmployeeFilterId(event.target.value)}
>
<option value="">Все сотрудники</option>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{employee.display_name}
</option>
))}
</select>
</label>
</div>
{canManage ? (
<button
type="button"
onClick={() => setAdding(true)}
className="app-action-primary mt-3 inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold"
>
<Plus size={16} />
Добавить корректировку остатка
</button>
) : null}
<div className="app-text-muted mt-3 space-y-1 text-xs leading-relaxed">
<p>
<b className="text-[var(--foreground)]">В реестре:</b> все
строки колонок, для которых включён параметр «Учитывать в
переходящих остатках», за выбранный и предыдущие периоды.
</p>
<p>
<b className="text-[var(--foreground)]">В остатке:</b> сумма
всех учитываемых утверждённых строк за всю историю до конца
выбранного периода и активных строк самого выбранного периода.
</p>
<p>
<b className="text-[var(--foreground)]">Это не копирование:</b>{" "}
значение ячейки не появляется в колонке следующего периода;
меняется только переходящий остаток. Ручные корректировки
учитываются начиная с указанного периода, без изменения зарплаты
и рассылки.
</p>
<p>
<b className="text-[var(--foreground)]">Отключение записи</b>{" "}
меняет только переходящий остаток. Сумма зарплаты в исходном
периоде не изменяется.
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className="app-feedback-success rounded-full px-2 py-1">
Компания должна:{" "}
{formatPayrollMoney(balances.companyOwes, currency)}
</span>
<span className="app-feedback-warning rounded-full px-2 py-1">
Сотрудники должны:{" "}
{formatPayrollMoney(balances.employeeOwes, currency)}
</span>
</div>
</div>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{error}
</div>
) : null}
{notice ? (
<div className="app-feedback-success rounded-xl p-3 text-sm">
{notice}
</div>
) : null}
{loading ? (
<div className="flex min-h-32 items-center justify-center">
<Loader2 className="app-accent-text animate-spin" size={22} />
</div>
) : entries.length ? (
<div className="space-y-3">
{entries.map((entry) => {
const rawContribution = Number(entry.raw_contribution);
const Icon =
rawContribution >= 0 ? ArrowUpRight : ArrowDownLeft;
const disabled =
!canManage || savingId !== null || entry.status === "voided";
const exclusionReason = !entry.include_in_carryover
? "Исключена из остатка вручную."
: entry.status === "voided"
? "Аннулированная запись не участвует в остатке."
: "Запись начнёт участвовать после утверждения.";
return (
<article
key={`${entry.entry_type || "input_line"}:${entry.id}`}
className={`app-surface-muted rounded-xl p-3 ${entry.include_in_carryover ? "" : "opacity-70"}`}
>
<div className="flex items-start gap-3">
<span
className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${rawContribution >= 0 ? "app-feedback-success" : "app-feedback-warning"}`}
>
<input
type="checkbox"
checked={entry.include_in_carryover}
disabled={disabled}
onChange={(event) =>
void toggleEntry(entry.id, event.target.checked)
}
/>
<span className="text-[var(--foreground)]">
Учитывать эту строку в переходящем остатке
</span>
{savingId === entry.id ? (
<Loader2 className="animate-spin" size={13} />
<Icon size={15} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
{entry.employee.display_name}
</p>
<p className="app-text-muted mt-0.5 text-xs">
{entry.period.name || entry.period.code} ·{" "}
{entry.component.name}
</p>
{entry.entry_type === "manual_adjustment" ? (
<p className="app-text-muted mt-1 text-xs">
Учитывается с:{" "}
{entry.period.name || entry.period.code}
{entry.source_period
? ` · Относится к: ${entry.source_period.name || entry.source_period.code}`
: ""}
</p>
) : null}
</div>
<div className="text-right">
<p className="text-sm font-bold text-[var(--foreground)]">
{formatPayrollMoney(entry.amount, currency)}
</p>
<p
className={`text-xs ${entry.counts_in_balance ? (rawContribution >= 0 ? "text-emerald-400" : "text-amber-400") : "app-text-muted"}`}
>
Вклад:{" "}
{entry.counts_in_balance
? signedAmount(entry.contribution, currency)
: "не учитывается"}
</p>
</div>
</div>
<div className="app-text-muted mt-2 grid gap-1 text-xs sm:grid-cols-2">
<p>
Статус:{" "}
<span className="text-[var(--foreground)]">
{entry.status_label}
</span>
</p>
<p>
Создал:{" "}
<span className="text-[var(--foreground)]">
{entry.created_by.display_name}
</span>{" "}
· {formatPayrollDateTime(entry.created_at)}
</p>
</div>
{entry.reason ? (
<p className="app-text-muted mt-2 text-xs">
Основание:{" "}
<span className="text-[var(--foreground)]">
{entry.reason}
</span>
</p>
) : null}
</label>
{!entry.counts_in_balance ? (
<p className="app-text-muted mt-2 text-xs">
{exclusionReason} Возможный вклад:{" "}
{signedAmount(entry.raw_contribution, currency)}.
</p>
) : null}
{entry.entry_type === "manual_adjustment" ? (
<div className="mt-3 space-y-2 text-xs">
{entry.voided_at ? (
<p className="app-text-muted">
Аннулировал: {entry.voided_by?.display_name} ·{" "}
{formatPayrollDateTime(entry.voided_at)}
{entry.void_reason
? ` · ${entry.void_reason}`
: ""}
</p>
) : canManage ? (
<>
<button
type="button"
disabled={!entry.can_void}
className="app-action-secondary rounded-lg px-3 py-2 disabled:opacity-50"
onClick={() => setVoiding(entry)}
>
Аннулировать корректировку
</button>
{!entry.can_void ? (
<p className="app-text-muted">
Уже включена в расчёт. Для исправления
добавьте обратную корректировку с нового
открытого периода.
</p>
) : null}
</>
) : null}
</div>
) : (
<label
className={`mt-3 flex items-center gap-2 text-xs ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<input
type="checkbox"
checked={entry.include_in_carryover}
disabled={disabled}
onChange={(event) =>
void toggleEntry(entry.id, event.target.checked)
}
/>
<span className="text-[var(--foreground)]">
Учитывать эту строку в переходящем остатке
</span>
{savingId === entry.id ? (
<Loader2 className="animate-spin" size={13} />
) : null}
</label>
)}
</div>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
Учитываемых строк за выбранный и предыдущие периоды нет.
</div>
)}
</div>
</Modal>
</article>
);
})}
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
Учитываемых строк за выбранный и предыдущие периоды нет.
</div>
)}
</div>
</Modal>
{adding ? (
<PayrollBalanceAdjustmentModal
employees={employees}
periods={periods}
initialEmployeeId={employeeFilterId ? Number(employeeFilterId) : null}
initialPeriodId={periodId}
onClose={() => setAdding(false)}
onSaved={async (effectiveId, employeeId) => {
setAdding(false);
setEmployeeFilterId(String(employeeId));
setNotice(
"Корректировка добавлена только в переходящий остаток. Рассылки нет.",
);
if (effectiveId === periodId) await loadLedger(false);
else {
setLedger(null);
setLoading(true);
setPeriodId(effectiveId);
}
await onChanged?.();
}}
/>
) : null}
{voiding ? (
<PayrollConfirmModal
isOpen
title="Аннулировать корректировку остатка"
description={`${voiding.employee.display_name}: ${formatPayrollMoney(voiding.amount, currency)}. Запись останется в истории, но перестанет участвовать в остатке.`}
confirmLabel="Аннулировать"
reasonLabel="Комментарий (необязательно)"
warning="Зарплата и закрытые снимки не изменятся. Рассылки нет."
onClose={() => setVoiding(null)}
onConfirm={(reason) =>
apiClient.voidPayrollBalanceAdjustment(voiding.id, reason)
}
onDone={async () => {
setVoiding(null);
setNotice("Корректировка аннулирована.");
await loadLedger(false);
await onChanged?.();
}}
/>
) : null}
</>
);
}

View File

@ -2519,11 +2519,11 @@ export function PayrollPeriodTableView({
help={{
title: "Остаток к переносу",
logic:
"Накопленная разница из строк колонок с включённой настройкой «Учитывать в переходящих остатках». После публикации итог становится начальным остатком следующего периода той же валюты.\n\nЗначения ячеек при этом не копируются в колонки следующего периода. Плюс — накопленная недоплата, минус — накопленная переплата.",
"Накопленная разница из строк колонок с включённой настройкой «Учитывать в переходящих остатках» и действующих ручных корректировок остатка. Ручная корректировка учитывается только с выбранного открытого периода и далее, без изменения закрытых снимков.\n\nЗначения ячеек не копируются в колонки следующего периода. Плюс — накопленная недоплата, минус — накопленная переплата. Зарплату и рассылку ручная корректировка не меняет.",
formula:
"Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки, удержания и выплаты\n− учитываемые начисления и положительные корректировки\n\nОстаток следующего периода учитывает утверждённые строки предыдущих периодов",
"Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки, удержания и выплаты\n− учитываемые начисления и положительные корректировки\n\nОстаток на начало учитывает утверждённые строки предыдущих периодов и действующие ручные корректировки, начиная с указанного для них периода",
manual:
"Сам итог отдельно не редактируется. Карандаш в заголовке открывает реестр, где отдельную строку можно включить или исключить. Участие всей колонки настраивается её карандашом в блоке «Начисления и выплаты».",
"Карандаш в заголовке открывает реестр переходящих остатков. В нём можно добавить ручную корректировку с открытого периода без расчёта или аннулировать её до расчёта. Исходную строку открытого периода можно включить или исключить из остатка. Участие всей колонки настраивается её карандашом в блоке «Начисления и выплаты».",
}}
{...common}
>

View File

@ -3,6 +3,22 @@ import assert from "node:assert/strict";
import { createFinanceApi } from "./finance.ts";
test("balance-only corrections use dedicated endpoints, not payroll inputs or legacy transfers", async () => {
const calls = [];
const finance = createFinanceApi(async (endpoint, options) => {
calls.push({ endpoint, options });
return {};
});
const payload = { employee_id: 2, effective_period_id: 7, source_period_id: 5,
amount: "10.00", direction: "company_owes", reason: "", request_id: "test-key" };
await finance.createPayrollBalanceAdjustment(payload);
await finance.voidPayrollBalanceAdjustment(8);
assert.deepEqual(calls, [
{ endpoint: "/api/v1/finance/payroll/admin/balance-adjustments/", options: { method: "POST", body: JSON.stringify(payload) } },
{ endpoint: "/api/v1/finance/payroll/admin/balance-adjustments/8/void/", options: { method: "POST", body: JSON.stringify({ reason: "" }) } },
]);
});
test("finance API uses own published payroll endpoints", async () => {
const calls = [];
const request = async (endpoint, options) => {

View File

@ -343,9 +343,15 @@ export type PayrollCarryoverWorkspace = {
export type PayrollCarryoverLedgerEntry = {
id: number;
entry_type?: "input_line" | "manual_adjustment";
source_period?: Pick<PayrollCarryoverPeriod, "id" | "name" | "code"> | null;
can_void?: boolean;
voided_by?: PayrollAdminUserRef | null;
voided_at?: string | null;
void_reason?: string;
period: PayrollCarryoverPeriod;
employee: PayrollAdminEmployee;
component: PayrollComponent;
component: Pick<PayrollComponent, "code" | "name">;
amount: string;
raw_contribution: string;
contribution: string;
@ -378,6 +384,16 @@ export type PayrollCarryoverWrite = {
reason: string;
};
export type PayrollBalanceAdjustmentWrite = {
employee_id: number;
effective_period_id: number;
source_period_id: number | null;
direction: PayrollCarryoverDirection;
amount: string;
reason: string;
request_id: string;
};
export type PayrollCompleteWorkflowResult = {
period: PayrollAdminPeriod;
run: PayrollAdminRun;
@ -1271,6 +1287,28 @@ export function createFinanceApi(request: RequestFn) {
`${adminPrefix}/periods/${periodId}/employees/${employeeId}/payment-confirmation/`,
jsonOptions("POST", { confirmed }),
),
createPayrollBalanceAdjustment: (
payload: PayrollBalanceAdjustmentWrite,
): Promise<{
id: number;
effective_period_id: number;
message: string;
}> =>
request(`${adminPrefix}/balance-adjustments/`, {
method: "POST",
body: JSON.stringify(payload),
}),
voidPayrollBalanceAdjustment: (
id: number,
reason = "",
): Promise<{
id: number;
message: string;
}> =>
request(`${adminPrefix}/balance-adjustments/${id}/void/`, {
method: "POST",
body: JSON.stringify({ reason }),
}),
getPayrollAdminCarryovers: (
periodId: number,
): Promise<PayrollCarryoverLedger> =>

View File

@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
balanceAdjustmentPeriods,
balanceAdjustmentSources,
} from "./payroll-balance-adjustments.ts";
const periods = [
{
id: 1,
status: "closed",
currency: "RUB",
date_from: "2026-08-01",
date_to: "2026-08-31",
current_run_id: 1,
},
{
id: 2,
status: "open",
currency: "RUB",
date_from: "2026-10-01",
date_to: "2026-10-31",
current_run_id: null,
},
{
id: 3,
status: "open",
currency: "RUB",
date_from: "2026-11-01",
date_to: "2026-11-30",
current_run_id: null,
},
];
test("only open uncalculated periods and no later frozen snapshots", () => {
assert.deepEqual(
balanceAdjustmentPeriods(periods).map((p) => p.id),
[2, 3],
);
assert.deepEqual(
balanceAdjustmentPeriods([...periods].reverse()).map((p) => p.id),
[2, 3],
);
assert.deepEqual(
balanceAdjustmentPeriods(
periods.map((p) => (p.id === 3 ? { ...p, current_run_id: 5 } : p)),
),
[],
);
assert.deepEqual(
balanceAdjustmentPeriods([
...periods,
{ ...periods[2], id: 4, currency: "USD", status: "closed" },
]).map((p) => p.id),
[2, 3],
);
});
test("source reference is optional, earlier and same currency", () => {
assert.deepEqual(
balanceAdjustmentSources(periods, periods[1]).map((p) => p.id),
[1],
);
assert.deepEqual(balanceAdjustmentSources(periods), []);
assert.deepEqual(
balanceAdjustmentSources(periods, { ...periods[1], currency: "USD" }),
[],
);
});

View File

@ -0,0 +1,33 @@
import type { PayrollAdminPeriod } from "./api/finance";
export function balanceAdjustmentPeriods(periods: PayrollAdminPeriod[]) {
return periods
.filter(
(period) =>
period.status === "open" &&
!period.current_run_id &&
!periods.some(
(later) =>
later.currency === period.currency &&
later.date_from >= period.date_from &&
(later.status !== "open" || Boolean(later.current_run_id)),
),
)
.sort(
(left, right) =>
left.date_from.localeCompare(right.date_from) || left.id - right.id,
);
}
export function balanceAdjustmentSources(
periods: PayrollAdminPeriod[],
effective?: PayrollAdminPeriod,
) {
return effective
? periods.filter(
(period) =>
period.currency === effective.currency &&
period.date_to < effective.date_from,
)
: [];
}