diff --git a/backend/api/v1/finance/admin/workspace_payload.py b/backend/api/v1/finance/admin/workspace_payload.py index cd7126a0..816ad8b2 100644 --- a/backend/api/v1/finance/admin/workspace_payload.py +++ b/backend/api/v1/finance/admin/workspace_payload.py @@ -25,6 +25,7 @@ class PeriodInputState: roster_employee_ids: list[int] missing_rate_employee_ids: list[int] approved_input_count: int + approved_input_outside_roster_employee_ids: list[int] draft_input_count: int draft_input_employee_ids: list[int] pending_rate_count: int @@ -102,11 +103,17 @@ def _period_input_state(period) -> PeriodInputState: for employee_id in roster_employee_ids if employee_id not in employees_with_rate ] - approved_input_count = PayrollInputLine.objects.filter( + approved_inputs = PayrollInputLine.objects.filter( period=period, - employee__is_hidden_in_ui=False, status=ApprovalStatus.APPROVED, + ) + approved_input_count = approved_inputs.filter( + employee__is_hidden_in_ui=False, ).count() + approved_input_outside_roster_employee_ids = sorted( + set(approved_inputs.values_list("employee_id", flat=True)) + - set(roster_employee_ids) + ) draft_inputs = PayrollInputLine.objects.filter( period=period, employee__is_hidden_in_ui=False, @@ -125,6 +132,9 @@ def _period_input_state(period) -> PeriodInputState: roster_employee_ids=roster_employee_ids, missing_rate_employee_ids=missing_rate_employee_ids, approved_input_count=approved_input_count, + approved_input_outside_roster_employee_ids=( + approved_input_outside_roster_employee_ids + ), draft_input_count=draft_inputs.count(), draft_input_employee_ids=list( draft_inputs.order_by().values_list("employee_id", flat=True).distinct() @@ -172,6 +182,21 @@ def _calculation_blockers(period, state: PeriodInputState) -> list[dict]: } ) + if state.approved_input_outside_roster_employee_ids: + employee_ids = state.approved_input_outside_roster_employee_ids + blockers.append( + { + "code": "INPUT_EMPLOYEE_NOT_IN_ROSTER", + "message": ( + "Утверждённых начислений или удержаний для сотрудников " + f"вне состава периода: {len(employee_ids)}." + ), + "count": len(employee_ids), + "employee_ids": employee_ids[:20], + "employee_count": len(employee_ids), + } + ) + missing_without_draft = [ employee_id for employee_id in state.missing_rate_employee_ids diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 9ed9a5d4..d776ac4e 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -331,7 +331,7 @@ def _attendance_alert( def build_payroll_period_table(period: PayrollPeriod): - """Include every active employee and inactive employees with period work.""" + """Include active employees and inactive employees with period data.""" current_run = period.current_run statements = {} @@ -352,6 +352,12 @@ def build_payroll_period_table(period: PayrollPeriod): actual_points__gt=0, ).values_list("employee_id", flat=True) ) + approved_input_employee_ids = set( + PayrollInputLine.objects.filter( + period=period, + status=ApprovalStatus.APPROVED, + ).values_list("employee_id", flat=True) + ) employee_ids = ( set( Employee.objects.visible_in_ui() @@ -359,6 +365,7 @@ def build_payroll_period_table(period: PayrollPeriod): .values_list("id", flat=True) ) | period_employee_ids + | approved_input_employee_ids ) visible_employee_ids = set( Employee.objects.visible_in_ui() diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 080c35b3..cc50cb3d 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -1418,6 +1418,81 @@ def test_period_table_lists_all_employees_and_requires_view_all( assert missing_row["payable"] == "0.00" +def test_workspace_and_period_table_expose_approved_inputs_outside_roster( + user_factory, + auth_client_factory, +): + manager = user_factory(email="outside-roster.manager@example.test") + checker = user_factory(email="outside-roster.checker@example.test") + roster_employee = user_factory(email="outside-roster.active@example.test") + outside_employee = user_factory( + email="outside-roster.inactive@example.test", + first_name="Александр", + last_name="Внесоставный", + active=False, + ) + grant(manager, "manage_payroll_inputs", "view_all_payroll") + period = make_period(creator=manager) + make_approved_period_inputs( + period=period, + employee=roster_employee, + creator=manager, + approver=checker, + ) + PayrollInputLine.objects.create( + period=period, + employee=outside_employee, + component=PayrollComponent.objects.get(code="BONUS"), + amount="15000", + status="approved", + created_by=manager, + approved_by=checker, + approved_at=timezone.now(), + ) + client = auth_client_factory(manager) + + workspace_response = client.get( + api_url("admin-workspace"), + {"period_id": period.pk}, + ) + + assert workspace_response.status_code == 200, workspace_response.content + readiness = workspace_response.json()["readiness"]["calculation"] + blocker = next( + item + for item in readiness["blockers"] + if item["code"] == "INPUT_EMPLOYEE_NOT_IN_ROSTER" + ) + assert readiness["ready"] is False + assert blocker == { + "code": "INPUT_EMPLOYEE_NOT_IN_ROSTER", + "message": ( + "Утверждённых начислений или удержаний для сотрудников " + "вне состава периода: 1." + ), + "count": 1, + "employee_ids": [outside_employee.pk], + "employee_count": 1, + } + assert outside_employee.pk in { + item["id"] for item in workspace_response.json()["rate_employees"] + } + + table_response = client.get(api_url("admin-period-table", pk=period.pk)) + + assert table_response.status_code == 200, table_response.content + row = next( + item + for item in table_response.json()["rows"] + if item["employee"]["id"] == outside_employee.pk + ) + assert row["employee"]["display_name"] == "Александр Внесоставный" + assert row["employee"]["is_active"] is False + assert row["status"] == "incomplete" + assert row["work_status"] is None + assert row["component_amounts"]["BONUS"] == "15000.00" + + def test_period_table_cell_comments_use_shared_discussion_flow( user_factory, auth_client_factory, diff --git a/frontend/src/lib/payroll-admin.test.js b/frontend/src/lib/payroll-admin.test.js index 59162784..05c17df1 100644 --- a/frontend/src/lib/payroll-admin.test.js +++ b/frontend/src/lib/payroll-admin.test.js @@ -47,6 +47,14 @@ test("payroll readiness blockers link to the relevant filtered source data", () status: "", label: "Открыть ставки", }); + assert.deepEqual( + getPayrollBlockerTarget("INPUT_EMPLOYEE_NOT_IN_ROSTER"), + { + tab: "inputs", + status: "approved", + label: "Показать операции вне состава", + }, + ); assert.equal(getPayrollBlockerTarget("PERIOD_CLOSED"), null); }); diff --git a/frontend/src/lib/payroll-admin.ts b/frontend/src/lib/payroll-admin.ts index ec7a582a..b92e0935 100644 --- a/frontend/src/lib/payroll-admin.ts +++ b/frontend/src/lib/payroll-admin.ts @@ -49,6 +49,11 @@ const PAYROLL_BLOCKER_TARGETS: Record = { status: "draft", label: "Показать черновики операций", }, + INPUT_EMPLOYEE_NOT_IN_ROSTER: { + tab: "inputs", + status: "approved", + label: "Показать операции вне состава", + }, DRAFT_PAY_RATES: { tab: "rates", status: "draft",