diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index f21c8919..1d2d1c3a 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -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", diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index 2fda2a5b..1229c9d2 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -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, diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 2a529229..4c7be491 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -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"), diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 140a5528..90afbb45 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -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", diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 777c4319..242fd0f6 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -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, diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index b4dca0cf..e35dabbc 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -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; onDelete?: (mode: "current" | "current_and_future") => Promise; 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({ + {pointBaseAssessment.negative.length ? ( +
+

+ Сохранение создаст отрицательную балльную базу у{" "} + {pointBaseAssessment.negative.length} сотрудника(ов). +

+

+ При отрицательной базе цена балла и перерасчёт меняют смысл, + поэтому такая настройка недопустима. +

+
    + {pointBaseAssessment.negative.slice(0, 5).map((item) => ( +
  • + {item.employeeName}: база{" "} + {formatMoney(item.pointBase, currency)} +
  • + ))} +
+
+ ) : pointBaseAssessment.zero.length ? ( +
+

+ У {pointBaseAssessment.zero.length} сотрудника(ов) балльная база + станет нулевой. +

+

+ Сохранение разрешено, но цена балла и балльный перерасчёт для + них будут равны нулю. +

+
+ ) : null} {error ? (
{error} @@ -1076,7 +1131,7 @@ function PayrollComponentSettingsModal({