fix(finance): guard invalid payroll point bases
All checks were successful
Deploy production / deploy (push) Successful in 13m49s

This commit is contained in:
lizerk 2026-09-11 10:33:01 +03:00
parent 158b71ac04
commit d38170640b
9 changed files with 636 additions and 4 deletions

View File

@ -8,6 +8,7 @@ access to payroll data.
from __future__ import annotations
import uuid
from collections import defaultdict
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
@ -25,6 +26,7 @@ from finance.models import (
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.adapter import point_base_accruals_for_employees
from finance.payroll.components import (
get_payroll_components,
)
@ -133,6 +135,9 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
scope_period = values.pop("scope_period", None)
point_base_behavior_changed = current.include_in_point_base != values[
"include_in_point_base"
] or (values["include_in_point_base"] and current.kind != values["kind"])
existing_lines = PayrollInputLine.objects.filter(component_code=code).exclude(
status=ApprovalStatus.VOIDED
)
@ -185,6 +190,58 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
audit_instance = settings
negative_bases = []
if point_base_behavior_changed:
affected_by_period = defaultdict(set)
active_assignments = PayrollInputLine.objects.filter(
component_code=code,
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
).values_list("period_id", "employee_id")
for period_id, employee_id in active_assignments:
affected_by_period[period_id].add(employee_id)
periods = PayrollPeriod.objects.filter(pk__in=affected_by_period).order_by(
"date_from", "pk"
)
for period in periods:
bases = point_base_accruals_for_employees(
period=period,
employee_ids=affected_by_period[period.pk],
)
negative_bases.extend(
(period, employee_id, amount)
for employee_id, amount in bases.items()
if amount < 0
)
if negative_bases:
employee_ids = {item[1] for item in negative_bases}
employees = {
employee.pk: employee
for employee in Employee.objects.filter(pk__in=employee_ids)
}
_conflict(
"NEGATIVE_POINT_BASE",
(
"Настройка создаёт отрицательную балльную базу. "
"Измените тип, отключите участие в балльной базе или "
"исправьте значения сотрудников."
),
affected_count=len(negative_bases),
affected_employees=[
{
"employee_id": employee_id,
"display_name": (
employees[employee_id].get_full_name().strip()
or f"Сотрудник #{employee_id}"
),
"period_id": period.pk,
"period_code": period.code,
"point_base": str(amount),
}
for period, employee_id, amount in negative_bases[:20]
],
)
_audit_api_mutation(
actor=request.user,
action="payroll.component_updated",

View File

@ -2,8 +2,10 @@
from __future__ import annotations
from collections import defaultdict
from decimal import ROUND_HALF_UP, Context, Decimal, localcontext
from finance.enums import ApprovalStatus, InputSource
from finance.models import (
EmployeePayRate,
PayrollInputLine,
@ -20,6 +22,8 @@ from payroll_core import (
PayrollPeriod as CorePayrollPeriod,
)
from .config import base_rate_code
POINT_BASE_NEGATIVE_KINDS = frozenset(
{
"adjustment_debit",
@ -132,6 +136,64 @@ def _point_base_accrual(
return base_amount + point_base_lines
def point_base_accruals_for_employees(
*,
period: PayrollPeriod,
employee_ids,
component_definitions=None,
) -> dict[int, Decimal]:
"""Return current editable point bases using the table-preview semantics."""
normalized_ids = {int(employee_id) for employee_id in employee_ids}
if not normalized_ids:
return {}
grouped_rates = defaultdict(list)
rates = EmployeePayRate.objects.filter(
employee_id__in=normalized_ids,
rate_code=base_rate_code(),
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
effective_from__lte=period.date_from,
).order_by("employee_id", "effective_from", "revision", "id")
for rate in rates:
grouped_rates[rate.employee_id].append(rate)
selected_rates = {
employee_id: max(
records,
key=lambda record: (
record.effective_from,
record.revision,
record.pk,
),
)
for employee_id, records in grouped_rates.items()
}
grouped_lines = defaultdict(list)
lines = (
PayrollInputLine.objects.filter(
period=period,
employee_id__in=normalized_ids,
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
)
.exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:")
.select_related("relates_to_period")
.order_by("employee_id", "component_code", "id")
)
for line in lines:
grouped_lines[line.employee_id].append(line)
components_by_code = _component_map(period, component_definitions)
return {
employee_id: _point_base_accrual(
selected_rates.get(employee_id),
grouped_lines.get(employee_id, []),
components_by_code,
)
for employee_id in normalized_ids
}
def build_core_request(
*,
period: PayrollPeriod,

View File

@ -63,10 +63,11 @@ def _in_norm_point_rate(amount, target_points, *, rounding):
if amount in (None, "") or target_points in (None, ""):
return None
target = Decimal(str(target_points))
if target <= 0:
normalized_amount = Decimal(str(amount))
if target <= 0 or normalized_amount < 0:
return None
return str(
(Decimal(str(amount)) / target).quantize(
(normalized_amount / target).quantize(
Decimal("0.0001"),
rounding=rounding,
)
@ -422,6 +423,7 @@ def build_payroll_period_table(period: PayrollPeriod):
rows = []
for employee in employees:
calculation_issue = None
statement = statements.get(employee.pk)
rate = rates.get(employee.pk)
work = work_records.get(employee.pk)
@ -513,6 +515,21 @@ def build_payroll_period_table(period: PayrollPeriod):
rounding=preview_rules.rounding.value,
component_definitions=components_by_code,
)
if preview_request.point_base_accrual < 0:
calculation_issue = {
"code": "NEGATIVE_POINT_BASE",
"message": (
"Балльная база отрицательная. Перерасчёт по баллам "
"остановлен до исправления значений или настроек колонок."
),
"point_base": str(preview_request.point_base_accrual),
}
preview_request = replace(
preview_request,
point_base_accrual=Decimal("0"),
point_rate=Decimal("0"),
point_rate_overridden=False,
)
preview_result = calculator.calculate(preview_request, preview_rules)
point_amount = _signed_point_amount(preview_result.lines)
preview_totals = preview_result.totals
@ -542,6 +559,8 @@ def build_payroll_period_table(period: PayrollPeriod):
status = "draft"
else:
status = "ready"
if calculation_issue is not None:
status = "incomplete"
projection_target_points = Decimal(str(target_points or "0"))
daily_point_value = (
@ -609,6 +628,7 @@ def build_payroll_period_table(period: PayrollPeriod):
"opening_balance": str(opening_balance),
"closing_balance": str(closing_balance),
"payment_confirmation": payment_confirmation,
"calculation_issue": calculation_issue,
**totals,
}
)
@ -652,6 +672,9 @@ def build_payroll_period_table(period: PayrollPeriod):
for row in rows
if row["attendance_alert"] is not None
),
"calculation_issue_count": sum(
1 for row in rows if row["calculation_issue"] is not None
),
"gross_total": _row_money_total(rows, "gross_total"),
"deduction_total": _row_money_total(rows, "deduction_total"),
"payable_total": _row_money_total(rows, "payable"),

View File

@ -46,7 +46,11 @@ from .access import (
has_payroll_permission,
has_simple_admin_access,
)
from .adapter import build_core_request, employee_snapshot
from .adapter import (
build_core_request,
employee_snapshot,
point_base_accruals_for_employees,
)
from .balances import opening_balances
from .components import get_payroll_component, get_payroll_components
from .config import (
@ -2044,6 +2048,21 @@ def set_component_input_line_value(
)
},
) from exc
point_base_accrual = point_base_accruals_for_employees(
period=period,
employee_ids=[employee_id],
)[employee_id]
if point_base_accrual < 0:
_operation_error(
"NEGATIVE_POINT_BASE",
(
"Значение создаёт отрицательную балльную базу. "
"Измените сумму или настройки колонки."
),
employee_id=employee_id,
component_code=component_code,
point_base=str(point_base_accrual),
)
_audit(
actor=actor,
action="payroll.input_line_draft_created_from_table",

View File

@ -219,6 +219,84 @@ def test_manager_can_customize_system_component_behavior(
assert override["highlight_color"] == "violet"
def test_component_settings_reject_negative_point_base_and_roll_back(
user_factory,
auth_client_factory,
):
manager = user_factory(email="negative.base.settings.manager@example.test")
employee = user_factory(email="negative.base.settings.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
PayrollInputLine.objects.create(
period=period,
employee=employee,
component_code="SALARY_PAYMENT",
amount="5000.00",
created_by=manager,
)
response = auth_client_factory(manager).patch(
api_url("admin-component-detail", code="SALARY_PAYMENT"),
{
"name": "Фактическая выплата",
"kind": "payment",
"requires_reason": True,
"include_in_point_base": True,
"include_in_carryover": False,
"visible_to_employee": True,
"highlight_color": "",
"period_scope": "all",
},
format="json",
)
assert response.status_code == 409, response.content
assert response.json()["code"] == "NEGATIVE_POINT_BASE"
assert response.json()["details"]["affected_count"] == "1"
settings = FinanceSettings.get_solo()
assert "SALARY_PAYMENT" not in settings.payroll_component_overrides
def test_component_settings_can_disable_invalid_legacy_point_base(
user_factory,
auth_client_factory,
):
manager = user_factory(email="negative.base.recovery.manager@example.test")
employee = user_factory(email="negative.base.recovery.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
PayrollInputLine.objects.create(
period=period,
employee=employee,
component_code="SALARY_PAYMENT",
amount="5000.00",
created_by=manager,
)
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {
"SALARY_PAYMENT": {"include_in_point_base": True}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
response = auth_client_factory(manager).patch(
api_url("admin-component-detail", code="SALARY_PAYMENT"),
{
"name": "Фактическая выплата",
"kind": "payment",
"requires_reason": True,
"include_in_point_base": False,
"include_in_carryover": False,
"visible_to_employee": True,
"highlight_color": "",
"period_scope": "all",
},
format="json",
)
assert response.status_code == 200, response.content
assert response.json()["include_in_point_base"] is False
def test_custom_component_can_be_hidden_by_period_but_not_with_saved_values(
user_factory,
auth_client_factory,
@ -1985,6 +2063,136 @@ def test_component_cell_value_can_replace_approved_and_draft_lines_atomically(
assert list(active_lines) == [replacement]
def test_component_cell_rejects_negative_point_base_and_restores_previous_value(
user_factory,
auth_client_factory,
):
manager = user_factory(email="negative.base.cell.manager@example.test")
employee = user_factory(email="negative.base.cell.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
EmployeePayRate.objects.create(
employee=employee,
amount="2000.00",
effective_from=period.date_from,
created_by=manager,
)
previous = PayrollInputLine.objects.create(
period=period,
employee=employee,
component_code="SALARY_PAYMENT",
amount="1000.00",
created_by=manager,
)
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {
"SALARY_PAYMENT": {"include_in_point_base": True}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
response = auth_client_factory(manager).post(
api_url("admin-component-cell-set", pk=period.pk),
{
"employee_id": employee.pk,
"component_code": "SALARY_PAYMENT",
"amount": "3000.00",
},
format="json",
)
assert response.status_code == 409, response.content
assert response.json()["code"] == "NEGATIVE_POINT_BASE"
previous.refresh_from_db()
assert previous.status == "draft"
assert list(
PayrollInputLine.objects.filter(
period=period,
employee=employee,
component_code="SALARY_PAYMENT",
).values_list("amount", flat=True)
) == [Decimal("1000.00")]
def test_component_cell_allows_zero_point_base(
user_factory,
auth_client_factory,
):
manager = user_factory(email="zero.base.cell.manager@example.test")
employee = user_factory(email="zero.base.cell.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
EmployeePayRate.objects.create(
employee=employee,
amount="2000.00",
effective_from=period.date_from,
created_by=manager,
)
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {
"SALARY_PAYMENT": {"include_in_point_base": True}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
response = auth_client_factory(manager).post(
api_url("admin-component-cell-set", pk=period.pk),
{
"employee_id": employee.pk,
"component_code": "SALARY_PAYMENT",
"amount": "2000.00",
},
format="json",
)
assert response.status_code == 201, response.content
assert PayrollInputLine.objects.get(pk=response.json()["id"]).amount == Decimal(
"2000.00"
)
def test_period_table_marks_legacy_negative_point_base_without_failing(
user_factory,
auth_client_factory,
):
manager = user_factory(email="negative.base.table.manager@example.test")
employee = user_factory(email="negative.base.table.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
PayrollInputLine.objects.create(
period=period,
employee=employee,
component_code="SALARY_PAYMENT",
amount="5000.00",
created_by=manager,
)
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {
"SALARY_PAYMENT": {"include_in_point_base": True}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
response = auth_client_factory(manager).get(
api_url("admin-period-table", pk=period.pk)
)
assert response.status_code == 200, response.content
row = next(
item
for item in response.json()["rows"]
if item["employee"]["id"] == employee.pk
)
assert row["status"] == "incomplete"
assert row["calculation_issue"] == {
"code": "NEGATIVE_POINT_BASE",
"message": (
"Балльная база отрицательная. Перерасчёт по баллам "
"остановлен до исправления значений или настроек колонок."
),
"point_base": "-5000.00",
}
assert row["point_amount"] == "0.00"
assert response.json()["summary"]["calculation_issue_count"] == 1
def test_period_table_includes_all_active_and_only_inactive_employees_with_work(
user_factory,
auth_client_factory,

View File

@ -62,6 +62,7 @@ import type {
} from "@/lib/api/finance";
import { formatPayrollMoney, formatPayrollPoints } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { assessPayrollPointBaseChange } from "@/lib/payroll-point-base";
import {
movePayrollColumn,
normalizePayrollColumnOrder,
@ -711,11 +712,17 @@ function CalculationHelpModal({
function PayrollComponentSettingsModal({
component,
rows,
components,
currency,
onSave,
onDelete,
onClose,
}: {
component?: PayrollPeriodTableComponent | null;
rows: PayrollPeriodTableRow[];
components: PayrollPeriodTableComponent[];
currency: string;
onSave: (payload: PayrollCustomComponentWrite) => Promise<void>;
onDelete?: (mode: "current" | "current_and_future") => Promise<void>;
onClose: () => void;
@ -776,6 +783,12 @@ function PayrollComponentSettingsModal({
setError("Укажите название колонки.");
return;
}
if (pointBaseAssessment.negative.length) {
setError(
"Сохранение заблокировано: настройка создаёт отрицательную балльную базу.",
);
return;
}
setPending(true);
setError(null);
try {
@ -835,6 +848,11 @@ function PayrollComponentSettingsModal({
highlight_color: highlightColor,
};
const calculationPreview = componentCalculationHelp(previewComponent);
const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, {
code: component?.code || "PREVIEW",
kind,
include_in_point_base: includeInPointBase,
});
if (typeof document === "undefined") return null;
return createPortal(
@ -1059,6 +1077,43 @@ function PayrollComponentSettingsModal({
</span>
</span>
</label>
{pointBaseAssessment.negative.length ? (
<div
className="app-feedback-danger rounded-xl px-3 py-3 text-sm"
role="alert"
>
<p className="font-semibold">
Сохранение создаст отрицательную балльную базу у{" "}
{pointBaseAssessment.negative.length} сотрудника(ов).
</p>
<p className="mt-1 text-xs leading-relaxed">
При отрицательной базе цена балла и перерасчёт меняют смысл,
поэтому такая настройка недопустима.
</p>
<ul className="mt-2 space-y-1 text-xs">
{pointBaseAssessment.negative.slice(0, 5).map((item) => (
<li key={item.employeeId}>
{item.employeeName}: база{" "}
{formatMoney(item.pointBase, currency)}
</li>
))}
</ul>
</div>
) : pointBaseAssessment.zero.length ? (
<div
className="app-feedback-warning rounded-xl px-3 py-3 text-sm"
role="status"
>
<p className="font-semibold">
У {pointBaseAssessment.zero.length} сотрудника(ов) балльная база
станет нулевой.
</p>
<p className="mt-1 text-xs leading-relaxed">
Сохранение разрешено, но цена балла и балльный перерасчёт для
них будут равны нулю.
</p>
</div>
) : null}
{error ? (
<div className="app-feedback-danger rounded-lg px-3 py-2.5 text-sm">
{error}
@ -1076,7 +1131,7 @@ function PayrollComponentSettingsModal({
<button
type="submit"
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold"
disabled={pending}
disabled={pending || pointBaseAssessment.negative.length > 0}
>
{pending ? (
<Loader2 className="animate-spin" size={15} />
@ -2113,6 +2168,9 @@ export function PayrollPeriodTableView({
const showSummary = !compact || compactPanel === "summary";
const showTable = !compact || compactPanel === "table";
const showHelp = !compact || compactPanel === "help";
const calculationIssueRows = data.rows.filter(
(row) => row.calculation_issue !== null,
);
const toggleCompactPanel = (panel: CompactPeriodPanel) => {
const nextPanel = compactPanel === panel ? null : panel;
if (nextPanel !== "table") {
@ -2792,6 +2850,25 @@ export function PayrollPeriodTableView({
);
}
case columnIds.status: {
if (row.calculation_issue) {
return (
<td
className={`relative overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center ${cellVisualClass}`}
>
<CellCommentButton
count={comments.commentCount}
label={`Состояние: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
<span
className="app-status-pill app-feedback-danger px-2 text-[10px]"
title={`${row.calculation_issue.message} Балльная база: ${formatMoney(row.calculation_issue.point_base, data.currency)}.`}
>
Ошибка формулы
</span>
</td>
);
}
const meta = statusMeta[row.status];
return (
<td
@ -3244,6 +3321,26 @@ export function PayrollPeriodTableView({
</div>
) : null}
{calculationIssueRows.length ? (
<div
className="app-feedback-danger mb-3 flex items-start gap-2 rounded-lg px-3 py-2.5 text-sm"
role="alert"
>
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<div>
<p className="font-semibold">
Балльный перерасчёт остановлен для {calculationIssueRows.length}{" "}
сотрудника(ов).
</p>
<p className="mt-1 text-xs leading-relaxed">
Исправьте значения или параметры участвующих в балльной базе
колонок. Остальная таблица доступна, проблемные строки отмечены
«Ошибка формулы».
</p>
</div>
</div>
) : null}
{showHelp && data.summary.preliminary_count ? (
<div className="app-feedback-warning mb-3 rounded-lg px-3 py-2.5 text-sm">
Значения рассчитаны предварительно по текущим данным. Они обновляются
@ -3567,6 +3664,9 @@ export function PayrollPeriodTableView({
{addComponentOpen && onAddComponent ? (
<PayrollComponentSettingsModal
component={editingComponent}
rows={data.rows}
components={data.component_columns}
currency={data.currency}
onSave={(payload) =>
editingComponent && onUpdateComponent
? onUpdateComponent(editingComponent, payload)

View File

@ -469,6 +469,11 @@ export type PayrollPeriodTableRow = {
payable: string | null;
opening_balance: string;
closing_balance: string;
calculation_issue: {
code: string;
message: string;
point_base: string;
} | null;
payment_confirmation: {
confirmed_at: string;
confirmed_by: PayrollAdminUserRef;
@ -512,6 +517,7 @@ export type PayrollPeriodTable = {
preliminary_count: number;
attendance_alert_employee_count: number;
attendance_alert_day_count: number;
calculation_issue_count: number;
gross_total: string | null;
deduction_total: string | null;
payable_total: string | null;

View File

@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { assessPayrollPointBaseChange } from "./payroll-point-base.ts";
const employee = (id, rateAmount, componentAmounts) => ({
employee: { id, display_name: `Сотрудник ${id}` },
rate_amount: rateAmount,
component_amounts: componentAmounts,
});
const bonus = {
code: "BONUS",
kind: "earning",
include_in_point_base: true,
};
const payment = {
code: "SALARY_PAYMENT",
kind: "payment",
include_in_point_base: false,
};
test("blocks a component setting that makes an affected point base negative", () => {
const result = assessPayrollPointBaseChange(
[employee(1, null, { SALARY_PAYMENT: "5000.00" })],
[bonus, payment],
{ ...payment, include_in_point_base: true },
);
assert.deepEqual(result.negative, [
{ employeeId: 1, employeeName: "Сотрудник 1", pointBase: "-5000" },
]);
assert.deepEqual(result.zero, []);
});
test("warns but does not classify an exact zero point base as negative", () => {
const result = assessPayrollPointBaseChange(
[
employee(1, "5000.0000", { SALARY_PAYMENT: "5000.00" }),
employee(2, "5000.0000", { SALARY_PAYMENT: "1000.00" }),
],
[bonus, payment],
{ ...payment, include_in_point_base: true },
);
assert.deepEqual(result.negative, []);
assert.deepEqual(result.zero, [
{ employeeId: 1, employeeName: "Сотрудник 1", pointBase: "0" },
]);
});
test("ignores employees without a value in the edited component", () => {
const result = assessPayrollPointBaseChange(
[employee(1, null, {}), employee(2, "1000", { BONUS: "100" })],
[bonus, payment],
{ ...payment, include_in_point_base: true },
);
assert.deepEqual(result, { negative: [], zero: [] });
});
test("turning point-base participation off clears an existing negative conflict", () => {
const result = assessPayrollPointBaseChange(
[employee(1, null, { SALARY_PAYMENT: "5000.00" })],
[bonus, { ...payment, include_in_point_base: true }],
payment,
);
assert.deepEqual(result.negative, []);
assert.equal(result.zero.length, 1);
});

View File

@ -0,0 +1,86 @@
type PointBaseComponent = {
code: string;
kind: string;
include_in_point_base: boolean;
};
type PointBaseRow = {
employee: {
id: number;
display_name: string;
};
rate_amount: string | null;
component_amounts: Record<string, string>;
};
export type PayrollPointBaseAssessmentItem = {
employeeId: number;
employeeName: string;
pointBase: string;
};
export type PayrollPointBaseAssessment = {
negative: PayrollPointBaseAssessmentItem[];
zero: PayrollPointBaseAssessmentItem[];
};
const NEGATIVE_KINDS = new Set(["adjustment_debit", "deduction", "payment"]);
const SCALE = 4;
const ZERO = BigInt(0);
const SCALE_FACTOR = BigInt(10) ** BigInt(SCALE);
function decimalToScaled(value: string | null | undefined): bigint {
const normalized = String(value || "0").trim();
const match = normalized.match(/^([+-])?(\d+)(?:\.(\d+))?$/);
if (!match) return ZERO;
const fraction = (match[3] || "").padEnd(SCALE, "0").slice(0, SCALE);
const absolute = BigInt(match[2]) * SCALE_FACTOR + BigInt(fraction || "0");
return match[1] === "-" ? -absolute : absolute;
}
function scaledToDecimal(value: bigint): string {
const sign = value < ZERO ? "-" : "";
const absolute = value < ZERO ? -value : value;
const whole = absolute / SCALE_FACTOR;
const fraction = String(absolute % SCALE_FACTOR)
.padStart(SCALE, "0")
.replace(/0+$/, "");
return `${sign}${whole}${fraction ? `.${fraction}` : ""}`;
}
export function assessPayrollPointBaseChange(
rows: PointBaseRow[],
components: PointBaseComponent[],
candidate: PointBaseComponent,
): PayrollPointBaseAssessment {
const effectiveComponents = components.some(
(component) => component.code === candidate.code,
)
? components.map((component) =>
component.code === candidate.code ? candidate : component,
)
: [...components, candidate];
const result: PayrollPointBaseAssessment = { negative: [], zero: [] };
for (const row of rows) {
if (decimalToScaled(row.component_amounts[candidate.code]) === ZERO)
continue;
let pointBase = decimalToScaled(row.rate_amount);
for (const component of effectiveComponents) {
if (!component.include_in_point_base) continue;
const amount = decimalToScaled(row.component_amounts[component.code]);
pointBase += NEGATIVE_KINDS.has(component.kind) ? -amount : amount;
}
const item = {
employeeId: row.employee.id,
employeeName: row.employee.display_name,
pointBase: scaledToDecimal(pointBase),
};
if (pointBase < ZERO) result.negative.push(item);
else if (pointBase === ZERO) result.zero.push(item);
}
return result;
}