Show balance-only employees and later adjustment notices
All checks were successful
Deploy production / deploy (push) Successful in 12m30s
All checks were successful
Deploy production / deploy (push) Successful in 12m30s
This commit is contained in:
parent
abef43136b
commit
5ded148251
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
]
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
109
frontend/src/lib/payroll-balance-adjustment-visibility.test.js
Normal file
109
frontend/src/lib/payroll-balance-adjustment-visibility.test.js
Normal 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"));
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user