Show payroll inputs outside period roster

This commit is contained in:
admin i 2026-10-09 18:34:36 +03:00
parent ecd65abb2d
commit de37b5c55d
5 changed files with 123 additions and 3 deletions

View File

@ -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

View File

@ -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()

View File

@ -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,

View File

@ -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);
});

View File

@ -49,6 +49,11 @@ const PAYROLL_BLOCKER_TARGETS: Record<string, PayrollBlockerTarget> = {
status: "draft",
label: "Показать черновики операций",
},
INPUT_EMPLOYEE_NOT_IN_ROSTER: {
tab: "inputs",
status: "approved",
label: "Показать операции вне состава",
},
DRAFT_PAY_RATES: {
tab: "rates",
status: "draft",