From 4c44882ca5c699c0ca6b1df07771968081263500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 15 Oct 2026 12:40:26 +0300 Subject: [PATCH] Simplify payroll employee selection and make operation reasons optional --- .../v1/finance/payroll_serializers/common.py | 15 ++- .../payroll_serializers/input_lines.py | 4 +- backend/finance/models.py | 10 +- backend/payroll_core/calculator.py | 12 --- backend/payroll_core/tests/test_calculator.py | 23 ++++- .../tests/finance/test_payroll_admin_api.py | 76 ++++++++++++++ .../tests/finance/test_payroll_services.py | 13 +++ .../finance/admin/PayrollAdminModals.tsx | 98 +++++++++++++------ .../finance/admin/PayrollAdminWorkspace.tsx | 31 +----- .../finance/admin/PayrollPeriodTable.tsx | 49 +--------- .../components/shared/SearchableSelect.tsx | 36 +++++-- frontend/src/hooks/usePayrollTabs.ts | 1 - frontend/src/lib/api/finance.ts | 2 + frontend/src/lib/payroll-admin.ts | 2 - .../src/lib/payroll-employee-search.test.js | 76 ++++++++++++++ frontend/src/lib/payroll-employee-search.ts | 29 ++++++ 16 files changed, 336 insertions(+), 141 deletions(-) create mode 100644 frontend/src/lib/payroll-employee-search.test.js create mode 100644 frontend/src/lib/payroll-employee-search.ts diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py index a22111e0..60eb675e 100644 --- a/backend/api/v1/finance/payroll_serializers/common.py +++ b/backend/api/v1/finance/payroll_serializers/common.py @@ -33,17 +33,30 @@ def _validation_detail(exc: DjangoValidationError): class PayrollEmployeeMiniSerializer(serializers.ModelSerializer): display_name = serializers.SerializerMethodField() + full_name = serializers.SerializerMethodField() position = serializers.SerializerMethodField() department = serializers.SerializerMethodField() class Meta: model = Employee - fields = ["id", "display_name", "position", "department"] + fields = [ + "id", + "display_name", + "full_name", + "is_active", + "position", + "department", + ] read_only_fields = fields def get_display_name(self, obj) -> str: return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}" + def get_full_name(self, obj) -> str: + return " ".join( + " ".join([obj.last_name, obj.first_name, obj.patronymic]).split() + ) or self.get_display_name(obj) + def get_position(self, obj) -> str | None: position = getattr(obj, "position", None) return position.name if position is not None else None diff --git a/backend/api/v1/finance/payroll_serializers/input_lines.py b/backend/api/v1/finance/payroll_serializers/input_lines.py index 3cff840e..7f8e2cd4 100644 --- a/backend/api/v1/finance/payroll_serializers/input_lines.py +++ b/backend/api/v1/finance/payroll_serializers/input_lines.py @@ -141,7 +141,9 @@ class PayrollInputLineWriteSerializer( ] def validate(self, attrs): - component_code = attrs.get("component_code") + component_code = attrs.get("component_code") or getattr( + self.instance, "component_code", None + ) if not component_code: raise serializers.ValidationError({"component_code": "Обязательное поле."}) period = attrs.get("period") or getattr(self.instance, "period", None) diff --git a/backend/finance/models.py b/backend/finance/models.py index cc4d48e9..121b0db3 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -1418,7 +1418,7 @@ class PayrollInputLine(DraftVersionedModel): { "reversal_of": ( "Связанное сторно ещё не поддерживается. Используйте " - "утверждённую корректировку с обязательным основанием." + "утверждённую корректировку." ) } ) @@ -1431,16 +1431,12 @@ class PayrollInputLine(DraftVersionedModel): ) except ValueError as exc: raise ValidationError({"component_code": str(exc)}) from exc - if component.requires_reason and not self.reason: - raise ValidationError({"reason": "Для компонента требуется основание."}) + # The operation note is optional, including retroactive input lines. + # Keep legacy component metadata without enforcing it on payroll inputs. if self.relates_to_period_id == self.period_id: raise ValidationError( {"relates_to_period": "Для текущего периода это поле не требуется."} ) - if self.relates_to_period_id and not self.reason: - raise ValidationError( - {"reason": "Для перерасчёта прошлого периода укажите основание."} - ) if self.value_sign not in (-1, 1): raise ValidationError({"value_sign": "Некорректный знак значения."}) if ( diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py index 2c56d026..51e3414e 100644 --- a/backend/payroll_core/calculator.py +++ b/backend/payroll_core/calculator.py @@ -756,18 +756,6 @@ class DeterministicPayrollCalculator: message="Every input line must identify its source.", ) ) - if ( - kind_is_valid - and line.kind in {LineKind.ADJUSTMENT_CREDIT, LineKind.ADJUSTMENT_DEBIT} - and not self._is_nonempty_string(line.reason) - ): - issues.append( - PayrollIssue( - code="MISSING_ADJUSTMENT_REASON", - field=f"{prefix}.reason", - message="Adjustment reason is required.", - ) - ) self._validate_retro_line( line, prefix=prefix, diff --git a/backend/payroll_core/tests/test_calculator.py b/backend/payroll_core/tests/test_calculator.py index e937b93e..667f989a 100644 --- a/backend/payroll_core/tests/test_calculator.py +++ b/backend/payroll_core/tests/test_calculator.py @@ -325,7 +325,7 @@ def test_rejects_float_and_non_finite_decimal(period, rules): } -def test_rejects_duplicate_ids_and_unsafe_adjustment(period, rules): +def test_rejects_duplicate_ids(period, rules): duplicate_lines = ( input_line("same", "BONUS", LineKind.EARNING, "1"), input_line( @@ -340,10 +340,23 @@ def test_rejects_duplicate_ids_and_unsafe_adjustment(period, rules): with pytest.raises(PayrollValidationError) as error: DeterministicPayrollCalculator().calculate(request, rules) - assert issue_codes(error) == { - "DUPLICATE_COMPONENT_ID", - "MISSING_ADJUSTMENT_REASON", - } + assert issue_codes(error) == {"DUPLICATE_COMPONENT_ID"} + + +@pytest.mark.parametrize("kind,expected", [ + (LineKind.ADJUSTMENT_CREDIT, "81000.00"), + (LineKind.ADJUSTMENT_DEBIT, "79000.00"), +]) +@pytest.mark.parametrize("retroactive", [False, True]) +def test_adjustments_calculate_without_a_reason(period, rules, kind, expected, retroactive): + request = make_request(period, lines=(input_line( + "correction:1", "CORRECTION", kind, "1000", reason="", + is_retro=retroactive, + source_period=PayrollPeriod(date(2026, 5, 1), date(2026, 5, 31)) + if retroactive else None, + ),)) + result = DeterministicPayrollCalculator().calculate(request, rules) + assert result.totals.gross_after_adjustments == Decimal(expected) def test_retro_line_requires_source_period(period, rules): diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index cc50cb3d..1776b383 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -1213,6 +1213,8 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all( assert employee == { "id": manager.pk, "display_name": "Мария Расчётова", + "full_name": "Расчётова Мария", + "is_active": True, "position": None, "department": None, } @@ -2794,6 +2796,63 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates( assert PayrollRun.objects.count() == 0 +@pytest.mark.parametrize("component_code", [ + "VACATION_PAY", "BONUS", "CORRECTION_CREDIT", "CORRECTION_DEBIT", + "ONE_TIME_PAYMENT", "DEDUCTION", "ADVANCE", +]) +@pytest.mark.parametrize("retroactive", [False, True]) +@pytest.mark.parametrize("include_empty_reason", [False, True]) +def test_input_reason_is_optional_for_every_operation_and_period( + user_factory, auth_client_factory, component_code, retroactive, + include_empty_reason, +): + maker = user_factory(email="optional.reason.maker@example.test") + checker = user_factory(email="optional.reason.checker@example.test") + employee = user_factory(email="optional.reason.employee@example.test", active=False) + grant(maker, "manage_payroll_inputs") + grant(checker, "approve_payroll_inputs") + period = make_period(creator=maker) + previous = PayrollPeriod.objects.create( + code="2026-05", date_from="2026-05-01", date_to="2026-05-31", + pay_date="2026-06-05", currency="RUB", created_by=maker, + status="closed", + ) + client = auth_client_factory(maker) + payload = { + "period_id": period.pk, "employee_id": employee.pk, + "component_code": component_code, "amount": "100.00", + "relates_to_period_id": previous.pk if retroactive else None, + } + if include_empty_reason: + payload["reason"] = "" + created = client.post(api_url("admin-input-line-list"), payload, format="json") + assert created.status_code == 201, created.content + data = created.json() + assert data["reason"] == "" + assert data["relates_to_period_id"] == payload["relates_to_period_id"] + line = PayrollInputLine.objects.get(pk=data["id"]) + line.full_clean() + + # A previously entered note can also be removed from an existing draft. + detail_url = api_url("admin-input-line-detail", pk=data["id"]) + noted = client.patch(detail_url, { + "reason": "Дополнительная заметка", "expected_lock_version": data["lock_version"], + }, format="json") + assert noted.status_code == 200, noted.content + cleared = client.patch(detail_url, { + "reason": "", "expected_lock_version": noted.json()["lock_version"], + }, format="json") + assert cleared.status_code == 200, cleared.content + assert cleared.json()["reason"] == "" + approved = auth_client_factory(checker).post( + api_url("admin-input-line-approve", pk=data["id"]), + {"expected_lock_version": cleared.json()["lock_version"]}, format="json", + ) + assert approved.status_code == 200, approved.content + assert approved.json()["status"] == "approved" + assert approved.json()["reason"] == "" + + def test_period_inputs_can_be_created_for_inactive_employees( user_factory, auth_client_factory, @@ -2802,11 +2861,26 @@ def test_period_inputs_can_be_created_for_inactive_employees( inactive_employee = user_factory( email="inactive.inputs.employee@example.test", active=False, + last_name="Семёнов", + first_name="Иван", + patronymic="Петрович", ) grant(manager, "manage_payroll_inputs") period = make_period(creator=manager) client = auth_client_factory(manager) + workspace = client.get(api_url("admin-workspace"), {"period_id": period.pk}) + assert workspace.status_code == 200 + assert inactive_employee.pk not in { + employee["id"] for employee in workspace.json()["employees"] + } + employee_option = next( + employee for employee in workspace.json()["rate_employees"] + if employee["id"] == inactive_employee.pk + ) + assert employee_option["full_name"] == "Семёнов Иван Петрович" + assert employee_option["is_active"] is False + work_record = client.post( api_url("admin-work-record-list"), { @@ -2834,6 +2908,8 @@ def test_period_inputs_can_be_created_for_inactive_employees( ) assert input_line.status_code == 201, input_line.content assert input_line.json()["employee_id"] == inactive_employee.pk + assert input_line.json()["status"] == "draft" + assert input_line.json()["employee"]["is_active"] is False def test_period_and_rate_drafts_use_exact_optimistic_versions_and_maker_checker( diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index c21e959f..1b5d7fcd 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -347,6 +347,19 @@ def test_only_manual_corrections_move_between_periods_and_can_be_settled( assert august_statement.closing_balance == Decimal("0.00") +def test_optional_reason_survives_full_calculation(june_period, payroll_users): + _, work, _, correction = create_approved_excel_inputs(june_period, payroll_users) + # Use a legacy component marked requires_reason=True, but leave the note empty. + PayrollInputLine.objects.filter(pk=correction.pk).update(reason="") + correction.refresh_from_db() + assert correction.component.requires_reason is True + correction.full_clean() + run = calculate_period(june_period.pk, actor=payroll_users["operator"]) + statement = run.statements.get(employee=payroll_users["employee"]) + assert statement.payable == work.expected_payable == Decimal("115000.00") + assert statement.lines.get(code="CORRECTION_CREDIT").reason == "" + + def test_excel_row_is_persisted_as_a_deterministic_statement( june_period, payroll_users, diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx index 60bfaf9a..14610021 100644 --- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -4,6 +4,11 @@ import { AlertTriangle, Check, Loader2, Search } from "lucide-react"; import { useMemo, useState, type FormEvent } from "react"; import { Modal } from "@/components/ui"; +import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +import { + matchesPayrollEmployeeSearch, + payrollInputEmployeeOptions, +} from "@/lib/payroll-employee-search"; import type { PayrollAdminEmployee, PayrollAdminInputLine, @@ -1574,13 +1579,30 @@ export function PayrollInputLineFormModal({ })); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - - const component = useMemo( - () => components.find((item) => item.code === form.component_code), - [components, form.component_code], + const [includeInactive, setIncludeInactive] = useState( + line?.employee.is_active === false, ); + const employeeOptions = useMemo( + () => + payrollInputEmployeeOptions( + line && !employees.some((employee) => employee.id === line.employee.id) + ? [...employees, line.employee] + : employees, + includeInactive, + ), + [employees, includeInactive, line], + ); + const submit = (event: FormEvent) => { event.preventDefault(); + if ( + !employeeOptions.some( + (employee) => employee.id === Number(form.employee_id), + ) + ) { + setError("Выберите сотрудника."); + return; + } void submitModalForm( () => onSubmit({ @@ -1612,26 +1634,46 @@ export function PayrollInputLineFormModal({ >
- + placeholder="Выберите сотрудника" + searchPlaceholder="Поиск по фамилии, имени или отчеству" + filterItem={(item, query) => + matchesPayrollEmployeeSearch(item.name, query) + } + disabled={Boolean(line)} + /> + +