Show balance-only employees and later adjustment notices
All checks were successful
Deploy production / deploy (push) Successful in 12m30s

This commit is contained in:
admin i 2026-10-20 14:39:45 +03:00
parent abef43136b
commit 5ded148251
9 changed files with 551 additions and 45 deletions

View File

@ -106,6 +106,10 @@ class PayrollAdminWorkspaceSerializer(serializers.Serializer):
class PayrollPeriodTableSerializer(serializers.Serializer):
later_balance_adjustments = serializers.ListField(
child=serializers.JSONField(),
read_only=True,
)
recorded_payments = serializers.JSONField(read_only=True)
period_id = serializers.IntegerField(read_only=True)
period_status = serializers.CharField(read_only=True)

View File

@ -19,6 +19,7 @@ from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import ApprovalStatus, InputSource
from finance.models import (
EmployeePayRate,
PayrollBalanceAdjustment,
PayrollDailyWorkEntry,
PayrollInputLine,
PayrollPeriod,
@ -336,6 +337,39 @@ def _attendance_alert(
}
def _balance_adjustment_reference(adjustment):
"""Read-only links, intentionally outside payroll snapshots and totals."""
effective = adjustment.effective_period
return {
"id": adjustment.pk,
"employee": {
"id": adjustment.employee_id,
"display_name": adjustment.employee.get_full_name().strip()
or f"Сотрудник #{adjustment.employee_id}",
"is_active": adjustment.employee.is_active,
},
"effective_period": {
"id": effective.pk,
"code": effective.code,
"name": effective.name,
},
"source_period_id": adjustment.source_period_id,
"source_period": (
{
"id": adjustment.source_period_id,
"code": adjustment.source_period.code,
"name": adjustment.source_period.name,
}
if adjustment.source_period_id
else None
),
"amount": str(adjustment.amount),
"contribution": str(adjustment.contribution),
"reason": adjustment.reason,
"is_voided": adjustment.voided_at is not None,
}
def build_payroll_period_table(period: PayrollPeriod):
"""Include active employees and inactive employees with period data."""
@ -364,7 +398,7 @@ def build_payroll_period_table(period: PayrollPeriod):
status=ApprovalStatus.APPROVED,
).values_list("employee_id", flat=True)
)
employee_ids = (
existing_employee_ids = (
set(
Employee.objects.visible_in_ui()
.filter(is_active=True)
@ -374,12 +408,57 @@ def build_payroll_period_table(period: PayrollPeriod):
| approved_input_employee_ids
| set(statements)
)
# References to later adjustments are presentation metadata, never inputs
# to the original period's calculation or its immutable statements.
adjustments = list(
PayrollBalanceAdjustment.objects.filter(
Q(effective_period__date_from__lte=period.date_from)
| Q(
source_period__date_from__lte=period.date_from,
effective_period__date_from__gt=period.date_to,
),
effective_period__currency=period.currency,
employee__is_hidden_in_ui=False,
)
.select_related("employee", "effective_period", "source_period")
.order_by("effective_period__date_from", "created_at", "pk")
)
active_balance_employee_ids = {
item.employee_id
for item in adjustments
if item.voided_at is None
and item.effective_period.date_from <= period.date_from
}
current_adjustment_employee_ids = {
item.employee_id
for item in adjustments
if item.voided_at is None and item.effective_period_id == period.pk
}
visible_employee_ids = set(
Employee.objects.visible_in_ui()
.filter(id__in=employee_ids)
.filter(id__in=existing_employee_ids | active_balance_employee_ids)
.values_list("id", flat=True)
)
employee_ids = visible_employee_ids
balances = opening_balances(period, visible_employee_ids)
balance_employee_ids = {
employee_id
for employee_id in active_balance_employee_ids
if employee_id in current_adjustment_employee_ids
or balances.get(employee_id, Decimal("0")) != 0
}
employee_ids = visible_employee_ids & (existing_employee_ids | balance_employee_ids)
current_adjustments = defaultdict(list)
later_adjustments = []
for adjustment in adjustments:
reference = _balance_adjustment_reference(adjustment)
if adjustment.effective_period_id == period.pk:
current_adjustments[adjustment.employee_id].append(reference)
if (
adjustment.source_period_id is not None
and adjustment.source_period.date_from <= period.date_from
and adjustment.effective_period.date_from > period.date_to
):
later_adjustments.append(reference)
employees = list(
Employee.objects.filter(id__in=employee_ids)
.select_related("position")
@ -408,7 +487,6 @@ def build_payroll_period_table(period: PayrollPeriod):
rates = _latest_rate_by_employee(period, employee_ids)
work_records = _latest_work_by_employee(period, employee_ids)
input_lines = _input_lines_by_employee(period, employee_ids)
balances = opening_balances(period, employee_ids)
components = get_payroll_components(period)
components_by_code = {component.code: component for component in components}
component_definitions = {
@ -458,17 +536,17 @@ def build_payroll_period_table(period: PayrollPeriod):
raw_source_lines,
scheduled_codes=scheduled_component_codes,
)
salary_excluded_only = bool(
balance_only = bool(
statement is None
and work is None
and source_lines
and all(
not components_by_code[line.component_code].include_in_salary
and (source_lines or employee.pk in balance_employee_ids)
and not any(
components_by_code[line.component_code].include_in_salary
for line in source_lines
)
)
if salary_excluded_only:
# A former employee's debt payment is not a new salary or payslip.
if balance_only:
# A balance correction/payment is not a new salary or payslip.
rate = None
display_source_lines = [
line
@ -633,7 +711,7 @@ def build_payroll_period_table(period: PayrollPeriod):
status = "ready"
if calculation_issue is not None:
status = "incomplete"
if salary_excluded_only:
if balance_only:
status = "balance_only"
projection_target_points = Decimal(str(target_points or "0"))
@ -658,14 +736,18 @@ def build_payroll_period_table(period: PayrollPeriod):
period_target_points=projection_target_points,
schedule=schedule,
)
attendance_alert = _attendance_alert(
period,
employee=employee,
actions=employee.payroll_personnel_actions,
records=attendance_records_for_employee,
schedule=schedule,
target_points=target_points,
actual_points=actual_points,
attendance_alert = (
None
if balance_only
else _attendance_alert(
period,
employee=employee,
actions=employee.payroll_personnel_actions,
records=attendance_records_for_employee,
schedule=schedule,
target_points=target_points,
actual_points=actual_points,
)
)
point_base_amount = _point_base_amount(
@ -704,6 +786,7 @@ def build_payroll_period_table(period: PayrollPeriod):
"payment_confirmation": payment_confirmation,
"calculation_issue": calculation_issue,
"applied_scheduled_changes": applied_scheduled_changes,
"manual_balance_adjustments": current_adjustments.get(employee.pk, []),
**totals,
}
)
@ -732,6 +815,7 @@ def build_payroll_period_table(period: PayrollPeriod):
else None
),
"component_columns": component_columns,
"later_balance_adjustments": later_adjustments,
"rows": rows,
"summary": {
"employee_count": len(rows),

View File

@ -17,7 +17,9 @@ from finance.models import (
PayrollBalanceAdjustment,
PayrollInputLine,
PayrollPeriod,
PayrollRun,
PayrollStatement,
PayrollStatementLine,
PayrollWorkRecord,
)
from finance.payroll.balances import opening_balances
@ -374,3 +376,203 @@ def test_preview_and_calculation_change_only_balance_preserve_september_snapshot
)
with pytest.raises(PayrollOperationError):
void_adjustment(adjustment.pk, actor=manager)
@pytest.mark.parametrize(
"direction,expected", [("company_owes", "1234.56"), ("employee_owes", "-1234.56")]
)
def test_inactive_employee_with_only_manual_balance_is_visible_without_salary(
setup, user_factory, direction, expected
):
manager, employee, periods, _, client = setup
# An old approved salary rate must not turn a balance-only row into salary.
make_approved_period_inputs(
period=periods[8], employee=employee, creator=manager, approver=user_factory()
)
employee.is_active = False
employee.save(update_fields=["is_active"])
assert create(setup, direction=direction).status_code == 201
for month in (10, 11):
table = client.get(url("admin-period-table", pk=periods[month].pk))
assert table.status_code == 200, table.content
row = next(
r for r in table.json()["rows"] if r["employee"]["id"] == employee.pk
)
assert row["status"] == "balance_only"
assert row["opening_balance"] == expected
assert row["closing_balance"] == expected
assert row["rate_amount"] is None
assert row["attendance_alert"] is None
for key in (
"gross_total",
"deduction_total",
"net_pay",
"payment_total",
"payable",
):
assert Decimal(row[key]) == 0, key
assert len(row["manual_balance_adjustments"]) == int(month == 10)
assert not PayrollStatement.objects.exists()
assert not PayrollInputLine.objects.exists()
def test_current_offsetting_adjustments_are_visible_but_settled_balance_is_not_carried(
setup,
):
employee, periods = setup[1:3]
employee.is_active = False
employee.save(update_fields=["is_active"])
assert create(setup).status_code == 201
assert (
create(
setup, direction="employee_owes", request_id=str(uuid.uuid4())
).status_code
== 201
)
october = build_payroll_period_table(periods[10])
row = next(r for r in october["rows"] if r["employee"]["id"] == employee.pk)
assert row["status"] == "balance_only" and Decimal(row["closing_balance"]) == 0
assert len(row["manual_balance_adjustments"]) == 2
assert employee.pk not in {
r["employee"]["id"] for r in build_payroll_period_table(periods[11])["rows"]
}
def test_later_adjustment_is_reference_only_in_original_closed_period(
setup, user_factory
):
manager, employee, periods, _, client = setup
source = periods[8]
source.status = "open"
source.save(update_fields=["status"])
make_approved_period_inputs(
period=source, employee=employee, creator=manager, approver=user_factory()
)
calculate_period(source.pk, actor=manager)
source.refresh_from_db()
source.status = "closed"
source.save(update_fields=["status"])
before_table = client.get(url("admin-period-table", pk=source.pk)).json()
tracked_models = (
PayrollPeriod,
PayrollRun,
PayrollStatement,
PayrollStatementLine,
PayrollInputLine,
)
before = {
model: list(model.objects.order_by("pk").values()) for model in tracked_models
}
assert (
create(setup, direction="employee_owes", reason="Доплата за август").status_code
== 201
)
adjustment = PayrollBalanceAdjustment.objects.get()
for _ in range(2):
response = client.get(url("admin-period-table", pk=source.pk))
assert response.status_code == 200, response.content
after_table = response.json()
reference = after_table["later_balance_adjustments"][0]
assert reference["id"] == adjustment.pk
assert reference["employee"]["id"] == employee.pk
assert reference["effective_period"]["id"] == periods[10].pk
assert reference["source_period_id"] == source.pk
assert reference["contribution"] == "-1234.56"
assert reference["reason"] == "Доплата за август"
assert reference["is_voided"] is False
assert after_table["rows"] == before_table["rows"]
assert after_table["summary"] == before_table["summary"]
for model in tracked_models:
assert list(model.objects.order_by("pk").values()) == before[model], model
def test_reference_is_shown_even_without_historical_employee_row_and_tracks_voiding(
setup,
):
_, employee, periods, _, client = setup
employee.is_active = False
employee.save(update_fields=["is_active"])
assert create(setup).status_code == 201
adjustment = PayrollBalanceAdjustment.objects.get()
source = build_payroll_period_table(periods[8])
assert employee.pk not in {row["employee"]["id"] for row in source["rows"]}
assert source["later_balance_adjustments"][0]["employee"]["id"] == employee.pk
assert (
build_payroll_period_table(periods[9])["later_balance_adjustments"][0]["id"]
== adjustment.pk
)
assert not build_payroll_period_table(periods[10])["later_balance_adjustments"]
assert (
client.post(
url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json"
).status_code
== 200
)
after = build_payroll_period_table(periods[8])
assert after["later_balance_adjustments"][0]["is_voided"] is True
assert employee.pk not in {
row["employee"]["id"] for row in build_payroll_period_table(periods[10])["rows"]
}
employee.is_hidden_in_ui = True
employee.save(update_fields=["is_hidden_in_ui"])
assert not build_payroll_period_table(periods[8])["later_balance_adjustments"]
def test_balance_only_employee_is_not_added_to_calculation_or_payslips(
setup, user_factory
):
manager, former, periods, _, client = setup
former.is_active = False
former.save(update_fields=["is_active"])
worker = user_factory()
make_approved_period_inputs(
period=periods[10], employee=worker, creator=manager, approver=user_factory()
)
assert create(setup).status_code == 201
run = calculate_period(periods[10].pk, actor=manager)
assert set(run.statements.values_list("employee_id", flat=True)) == {worker.pk}
periods[10].refresh_from_db()
table = client.get(url("admin-period-table", pk=periods[10].pk)).json()
row = next(r for r in table["rows"] if r["employee"]["id"] == former.pk)
assert row["status"] == "balance_only"
assert row["closing_balance"] == "1234.56"
assert Decimal(row["payable"]) == 0
assert row["payment_confirmation"] is None
def test_future_notice_covers_source_and_intermediate_months_without_changing_totals(
setup,
):
_, employee, periods, _, _ = setup
july = PayrollPeriod.objects.create(
code="2026-07",
date_from=date(2026, 7, 1),
date_to=date(2026, 7, 31),
status="closed",
)
before = {
month: build_payroll_period_table(periods[month]) for month in (8, 9, 10, 11)
}
frozen = list(PayrollPeriod.objects.order_by("pk").values())
assert create(setup, effective_period_id=periods[11].pk).status_code == 201
adjustment = PayrollBalanceAdjustment.objects.get()
for month in (8, 9, 10):
after = build_payroll_period_table(periods[month])
assert after["rows"] == before[month]["rows"]
assert after["summary"] == before[month]["summary"]
reference = after["later_balance_adjustments"][0]
assert reference["id"] == adjustment.pk
assert reference["source_period"]["id"] == periods[8].pk
assert reference["effective_period"]["id"] == periods[11].pk
assert opening_balances(periods[month], [employee.pk]) == {}
assert not build_payroll_period_table(july)["later_balance_adjustments"]
assert not build_payroll_period_table(periods[11])["later_balance_adjustments"]
assert frozen == list(PayrollPeriod.objects.order_by("pk").values())
def test_no_intermediate_notice_when_source_period_was_not_specified(setup):
assert create(setup, source_period_id=None).status_code == 201
for month in (8, 9):
assert not build_payroll_period_table(setup[2][month])[
"later_balance_adjustments"
]

View File

@ -84,6 +84,7 @@ import type {
PayrollPeriodTable,
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
PayrollTableBalanceAdjustment,
PayrollPeriodWrite,
PayrollWorkRecordWrite,
PayrollWorkbookImportResult,
@ -698,6 +699,8 @@ export function PayrollAdminWorkspace({
const [attendanceWorkModalOpen, setAttendanceWorkModalOpen] = useState(false);
const [workbookImportModalOpen, setWorkbookImportModalOpen] = useState(false);
const [carryoversModalOpen, setCarryoversModalOpen] = useState(false);
const [carryoversReference, setCarryoversReference] =
useState<PayrollTableBalanceAdjustment | null>(null);
const [carryoversEmployeeId, setCarryoversEmployeeId] = useState<
number | null
>(null);
@ -2262,9 +2265,15 @@ export function PayrollAdminWorkspace({
canManageSourceData ? deleteComponentColumn : undefined
}
onOpenCarryovers={(employee) => {
setCarryoversReference(null);
setCarryoversEmployeeId(employee?.id || null);
setCarryoversModalOpen(true);
}}
onOpenBalanceAdjustment={(adjustment) => {
setCarryoversReference(adjustment);
setCarryoversEmployeeId(adjustment.employee.id);
setCarryoversModalOpen(true);
}}
onSetPaymentConfirmed={
permissions.manage_inputs &&
selectedPeriod.status === "closed"
@ -2608,12 +2617,14 @@ export function PayrollAdminWorkspace({
) : null}
{selectedPeriod && carryoversModalOpen ? (
<PayrollCarryoversModal
key={`${selectedPeriod.id}-${carryoversEmployeeId || "all"}`}
key={`${selectedPeriod.id}-${carryoversEmployeeId || "all"}-${carryoversReference?.id || "all"}`}
isOpen
selectedPeriod={selectedPeriod}
periods={workspace.periods}
employees={workspace.rate_employees}
initialEmployeeId={carryoversEmployeeId}
initialPeriodId={carryoversReference?.effective_period.id}
initialAdjustmentId={carryoversReference?.id}
canManage={permissions.manage_inputs}
onChanged={() => refreshInlineTable("inputs")}
onClose={() => setCarryoversModalOpen(false)}

View File

@ -20,6 +20,8 @@ type PayrollCarryoversModalProps = {
canManage: boolean;
employees: PayrollAdminEmployee[];
initialEmployeeId?: number | null;
initialPeriodId?: number | null;
initialAdjustmentId?: number | null;
isOpen: boolean;
onChanged?: () => void | Promise<void>;
onClose: () => void;
@ -39,13 +41,15 @@ export function PayrollCarryoversModal({
canManage,
employees,
initialEmployeeId = null,
initialPeriodId = null,
initialAdjustmentId = null,
isOpen,
onChanged,
onClose,
periods,
selectedPeriod,
}: PayrollCarryoversModalProps) {
const [periodId, setPeriodId] = useState(selectedPeriod.id);
const [periodId, setPeriodId] = useState(initialPeriodId || selectedPeriod.id);
const [ledger, setLedger] = useState<PayrollCarryoverLedger | null>(null);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<number | null>(null);
@ -130,6 +134,21 @@ export function PayrollCarryoversModal({
}, [entries]);
const currency = ledger?.currency || selectedPeriod.currency;
useEffect(() => {
if (
loading ||
!initialAdjustmentId ||
!entries.some(
(entry) =>
entry.entry_type === "manual_adjustment" &&
entry.id === initialAdjustmentId,
)
) return;
document
.getElementById(`payroll-manual-adjustment-${initialAdjustmentId}`)
?.scrollIntoView({ block: "nearest" });
}, [entries, initialAdjustmentId, loading]);
const toggleEntry = async (entryId: number, included: boolean) => {
setSavingId(entryId);
setError(null);
@ -280,7 +299,12 @@ export function PayrollCarryoversModal({
return (
<article
key={`${entry.entry_type || "input_line"}:${entry.id}`}
className={`app-surface-muted rounded-xl p-3 ${entry.include_in_carryover ? "" : "opacity-70"}`}
id={
entry.entry_type === "manual_adjustment"
? `payroll-manual-adjustment-${entry.id}`
: undefined
}
className={`app-surface-muted rounded-xl p-3 ${entry.include_in_carryover ? "" : "opacity-70"} ${entry.entry_type === "manual_adjustment" && entry.id === initialAdjustmentId ? "ring-2 ring-[var(--accent-primary)]" : ""}`}
>
<div className="flex items-start gap-3">
<span

View File

@ -0,0 +1,42 @@
"use client";
import type { PayrollTableBalanceAdjustment } from "@/lib/api/finance";
import { formatPayrollMoney } from "@/lib/payroll";
export function PayrollLaterBalanceAdjustments({
adjustments,
currency,
onOpen,
}: {
adjustments: PayrollTableBalanceAdjustment[];
currency: string;
onOpen?: (adjustment: PayrollTableBalanceAdjustment) => void;
}) {
const active = adjustments.filter((item) => !item.is_voided);
if (!active.length) return null;
const details = active
.map((item) => {
const amount = Number(item.contribution);
const period = item.effective_period.name || item.effective_period.code;
const source = item.source_period?.name || item.source_period?.code;
return `${period}: ${amount < 0 ? "−" : "+"}${formatPayrollMoney(Math.abs(amount), currency)}${source ? ` · за ${source}` : ""}`;
})
.join("\n");
return (
<button
type="button"
className="app-feedback-warning mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
disabled={!onOpen}
onClick={() => onOpen?.(active[0])}
title={`Позднейшие корректировки остатка:\n${details}\nСумма этого месяца и снимок не изменены. Нажмите, чтобы открыть запись.`}
aria-label={`${active[0].employee.display_name}: позднейшие корректировки остатка, ${active.length}. Открыть запись`}
>
<span
aria-hidden="true"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-current text-[11px] font-bold leading-none"
>
!
</span>
</button>
);
}

View File

@ -45,6 +45,7 @@ import {
import { createPortal } from "react-dom";
import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal";
import { PayrollLaterBalanceAdjustments } from "@/components/finance/admin/PayrollLaterBalanceAdjustments";
import {
PayrollCellCommentsModal,
type PayrollCommentCell,
@ -59,6 +60,7 @@ import type {
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
PayrollPeriodTableStatus,
PayrollTableBalanceAdjustment,
} from "@/lib/api/finance";
import { formatPayrollMoney, formatPayrollPoints } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
@ -1974,6 +1976,7 @@ export function PayrollPeriodTableView({
onUpdateComponent,
onDeleteComponent,
onOpenCarryovers,
onOpenBalanceAdjustment,
onSetPaymentConfirmed,
onSetReviewMark,
onClearReviewMarks,
@ -2014,6 +2017,7 @@ export function PayrollPeriodTableView({
mode: "current" | "current_and_future",
) => Promise<void>;
onOpenCarryovers?: (employee?: PayrollPeriodTableRow["employee"]) => void;
onOpenBalanceAdjustment?: (adjustment: PayrollTableBalanceAdjustment) => void;
onSetPaymentConfirmed?: (
row: PayrollPeriodTableRow,
confirmed: boolean,
@ -2982,6 +2986,9 @@ export function PayrollPeriodTableView({
}
case columnIds.carryovers: {
const summary = carryoversByEmployeeId.get(row.employee.id);
const laterAdjustments = (data.later_balance_adjustments || []).filter(
(item) => item.employee.id === row.employee.id,
);
const count = summary?.count || 0;
const balance = summary
? Number(summary.company_owes) - Number(summary.employee_owes)
@ -3008,31 +3015,38 @@ export function PayrollPeriodTableView({
label={`Остаток к переносу: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
<button
type="button"
className="app-action-ghost inline-flex min-h-10 w-full items-center justify-center gap-1.5 px-1.5 py-2 text-[10px] font-semibold"
onClick={() => onOpenCarryovers?.(row.employee)}
disabled={!onOpenCarryovers}
title={`${row.employee.display_name}: ${details}. Открыть реестр.`}
aria-label={`${row.employee.display_name}: открыть переходящие остатки, записей ${count}`}
>
<span
className={
balance !== null && balance > 0
? "text-emerald-400"
: balance !== null && balance < 0
? "text-amber-400"
: "app-text-muted"
}
<div className="flex items-center">
<button
type="button"
className="app-action-ghost inline-flex min-h-10 min-w-0 flex-1 items-center justify-center gap-1.5 px-1 py-2 text-[10px] font-semibold"
onClick={() => onOpenCarryovers?.(row.employee)}
disabled={!onOpenCarryovers}
title={`${row.employee.display_name}: ${details}. Открыть реестр.`}
aria-label={`${row.employee.display_name}: открыть переходящие остатки, записей ${count}`}
>
{formattedBalance}
</span>
{summary?.pending ? (
<span className="app-feedback-warning rounded px-1">
{summary.pending}
<span
className={
balance !== null && balance > 0
? "text-emerald-400"
: balance !== null && balance < 0
? "text-amber-400"
: "app-text-muted"
}
>
{formattedBalance}
</span>
) : null}
</button>
{summary?.pending ? (
<span className="app-feedback-warning rounded px-1">
{summary.pending}
</span>
) : null}
</button>
<PayrollLaterBalanceAdjustments
adjustments={laterAdjustments}
currency={data.currency}
onOpen={onOpenBalanceAdjustment}
/>
</div>
</td>
);
}

View File

@ -460,7 +460,22 @@ export type PayrollAttendanceAlert = {
points_in_norm: boolean;
};
export type PayrollTableBalanceAdjustment = {
id: number;
employee: Pick<PayrollAdminEmployee, "id" | "display_name"> & {
is_active: boolean;
};
effective_period: Pick<PayrollAdminPeriod, "id" | "code" | "name">;
source_period_id: number | null;
source_period?: Pick<PayrollAdminPeriod, "id" | "code" | "name"> | null;
amount: string;
contribution: string;
reason: string;
is_voided: boolean;
};
export type PayrollPeriodTableRow = {
manual_balance_adjustments?: PayrollTableBalanceAdjustment[];
non_salary_payment_total?: string;
off_cycle_settlement?: PayrollOffCycleBalance | null;
employee: PayrollAdminEmployee & {
@ -523,6 +538,7 @@ export type PayrollPeriodTableCarryoverSummary = {
};
export type PayrollPeriodTable = {
later_balance_adjustments?: PayrollTableBalanceAdjustment[];
balance_rules_version?: number;
recorded_payments?: {
payroll_recorded: string;

View File

@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PayrollLaterBalanceAdjustments } from "../components/finance/admin/PayrollLaterBalanceAdjustments.tsx";
const adjustment = {
id: 51,
employee: { id: 7, display_name: "Антон Тестовый", is_active: false },
effective_period: { id: 10, code: "2026-10", name: "Октябрь 2026" },
source_period_id: 8,
source_period: { id: 8, code: "2026-08", name: "Август 2026" },
amount: "10000.00",
contribution: "-10000.00",
reason: "Уже выдано <проверка>",
is_voided: false,
};
test("historical annotation is absent without linked adjustments", () => {
assert.equal(
renderToStaticMarkup(
createElement(PayrollLaterBalanceAdjustments, {
adjustments: [],
currency: "RUB",
}),
),
"",
);
});
test("compact warning identifies employee, source month and signed later correction", () => {
const html = renderToStaticMarkup(
createElement(PayrollLaterBalanceAdjustments, {
adjustments: [adjustment],
currency: "RUB",
onOpen: () => {},
}),
);
assert(html.includes("Антон Тестовый"));
assert(html.includes("Октябрь 2026"));
assert(html.includes("Август 2026"));
assert(html.includes("−"));
assert(html.includes("10"));
assert(html.includes("Сумма этого месяца и снимок не изменены"));
assert(!html.includes("<details"));
assert(html.includes(">!</span>"));
assert(!html.includes('disabled=""'));
});
test("voided references do not display an upcoming warning", () => {
const html = renderToStaticMarkup(
createElement(PayrollLaterBalanceAdjustments, {
adjustments: [{ ...adjustment, is_voided: true }],
currency: "RUB",
}),
);
assert.equal(html, "");
});
test("open callback receives the exact adjustment and its effective period", () => {
let opened = null;
const tree = PayrollLaterBalanceAdjustments({
adjustments: [adjustment],
currency: "RUB",
onOpen: (item) => {
opened = item;
},
});
const buttons = [];
const walk = (element) => {
if (Array.isArray(element)) return element.forEach(walk);
if (!element?.props) return;
if (element.type === "button") buttons.push(element);
walk(element.props.children);
};
walk(tree);
assert.equal(buttons.length, 1);
buttons[0].props.onClick();
assert.equal(opened, adjustment);
assert.equal(opened.effective_period.id, 10);
});
test("one marker describes all active later corrections and skips voided ones", () => {
const html = renderToStaticMarkup(
createElement(PayrollLaterBalanceAdjustments, {
adjustments: [
{
...adjustment,
id: 50,
is_voided: true,
effective_period: { id: 9, name: "Сентябрь 2026" },
},
adjustment,
{
...adjustment,
id: 52,
effective_period: { id: 11, name: "Ноябрь 2026" },
contribution: "500.00",
},
],
currency: "RUB",
onOpen: () => {},
}),
);
assert.equal((html.match(/<button/g) || []).length, 1);
assert(html.includes("Октябрь 2026"));
assert(html.includes("Ноябрь 2026"));
assert(!html.includes("Сентябрь 2026"));
});