Add balance-only payroll carryover adjustments
All checks were successful
Deploy production / deploy (push) Successful in 10m8s
All checks were successful
Deploy production / deploy (push) Successful in 10m8s
This commit is contained in:
parent
9d278f4743
commit
af26bfc6de
80
backend/api/v1/finance/admin/balance_adjustments.py
Normal file
80
backend/api/v1/finance/admin/balance_adjustments.py
Normal 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": "Корректировка остатка аннулирована."}
|
||||
)
|
||||
@ -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"),
|
||||
|
||||
@ -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(),
|
||||
|
||||
0
backend/finance/balance_adjustments/__init__.py
Normal file
0
backend/finance/balance_adjustments/__init__.py
Normal file
96
backend/finance/balance_adjustments/models.py
Normal file
96
backend/finance/balance_adjustments/models.py
Normal 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": "Выберите более ранний период."}
|
||||
)
|
||||
150
backend/finance/balance_adjustments/services.py
Normal file
150
backend/finance/balance_adjustments/services.py
Normal 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
|
||||
131
backend/finance/migrations/0040_payrollbalanceadjustment.py
Normal file
131
backend/finance/migrations/0040_payrollbalanceadjustment.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
376
backend/tests/finance/test_balance_adjustments.py
Normal file
376
backend/tests/finance/test_balance_adjustments.py
Normal 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)
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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> =>
|
||||
|
||||
69
frontend/src/lib/payroll-balance-adjustments.test.js
Normal file
69
frontend/src/lib/payroll-balance-adjustments.test.js
Normal 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" }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
33
frontend/src/lib/payroll-balance-adjustments.ts
Normal file
33
frontend/src/lib/payroll-balance-adjustments.ts
Normal 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,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user