fix(finance): guard invalid payroll point bases
All checks were successful
Deploy production / deploy (push) Successful in 13m49s
All checks were successful
Deploy production / deploy (push) Successful in 13m49s
This commit is contained in:
parent
158b71ac04
commit
d38170640b
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
71
frontend/src/lib/payroll-point-base.test.js
Normal file
71
frontend/src/lib/payroll-point-base.test.js
Normal 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);
|
||||
});
|
||||
86
frontend/src/lib/payroll-point-base.ts
Normal file
86
frontend/src/lib/payroll-point-base.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user