All checks were successful
Deploy production / deploy (push) Successful in 11m10s
284 lines
10 KiB
Python
284 lines
10 KiB
Python
import calendar
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from importlib import import_module
|
|
|
|
import pytest
|
|
from django.apps import apps
|
|
from django.core.exceptions import ValidationError
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from api.v1.finance.admin.workspace_payload import _period_input_state
|
|
from api.v1.finance.serializers import OwnPayrollStatementSerializer
|
|
from finance.models import (
|
|
PayrollCustomComponent,
|
|
PayrollInputLine,
|
|
PayrollPeriod,
|
|
)
|
|
from finance.payroll.balances import opening_balances
|
|
from finance.payroll.components import (
|
|
build_payroll_component_snapshot,
|
|
get_payroll_components,
|
|
)
|
|
from finance.payroll.exceptions import PayrollOperationError
|
|
from finance.payroll.services import (
|
|
_ensure_run_is_fresh,
|
|
calculate_period,
|
|
)
|
|
from tests.finance.test_payroll_admin_api import grant, make_approved_period_inputs
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
@pytest.fixture
|
|
def setup(user_factory, auth_client_factory):
|
|
manager, employee, checker = user_factory(), user_factory(), user_factory()
|
|
grant(manager, "manage_payroll_inputs", "view_all_payroll", "calculate_payroll")
|
|
periods = {}
|
|
for month in (8, 9, 10, 11):
|
|
periods[month] = PayrollPeriod.objects.create(
|
|
code=f"2026-{month:02}",
|
|
date_from=date(2026, month, 1),
|
|
date_to=date(2026, month, calendar.monthrange(2026, month)[1]),
|
|
created_by=manager,
|
|
status="closed" if month < 10 else "open",
|
|
balance_rules_version=1 if month < 10 else 2,
|
|
)
|
|
component = PayrollCustomComponent.objects.create(
|
|
name="Выплата долга",
|
|
kind="payment",
|
|
include_in_salary=False,
|
|
include_in_carryover=True,
|
|
period_scope="all",
|
|
created_by=manager,
|
|
)
|
|
return manager, employee, checker, periods, component, auth_client_factory(manager)
|
|
|
|
|
|
def line(setup, period, **overrides):
|
|
manager, employee, _, _, component, _ = setup
|
|
return PayrollInputLine.objects.create(
|
|
period=period,
|
|
employee=employee,
|
|
component_code=component.code,
|
|
amount="5000",
|
|
status="approved",
|
|
created_by=manager,
|
|
approved_by=setup[2],
|
|
approved_at=timezone.now(),
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def test_previous_period_payments_keep_legacy_sign_and_current_payments_reduce_debt(
|
|
setup,
|
|
):
|
|
_, employee, _, periods, component, client = setup
|
|
for month in (8, 9):
|
|
# An old v1 snapshot contains no salary-participation flag.
|
|
snapshot = build_payroll_component_snapshot(periods[month])
|
|
snapshot["version"] = 1
|
|
for payload in snapshot["components"]:
|
|
payload.pop("include_in_salary")
|
|
PayrollPeriod.objects.filter(pk=periods[month].pk).update(
|
|
component_snapshot=snapshot
|
|
)
|
|
line(setup, periods[month])
|
|
line(setup, periods[10])
|
|
assert opening_balances(periods[10], [employee.pk])[employee.pk] == Decimal("10000")
|
|
assert opening_balances(periods[11], [employee.pk])[employee.pk] == Decimal("5000")
|
|
ledger = client.get(
|
|
reverse(
|
|
"api:v1:finance-payroll:admin-period-carryovers",
|
|
kwargs={"pk": periods[10].pk},
|
|
)
|
|
)
|
|
assert ledger.status_code == 200, ledger.content
|
|
contributions = {
|
|
entry["period"]["id"]: Decimal(entry["contribution"])
|
|
for entry in ledger.json()["entries"]
|
|
}
|
|
assert contributions == {
|
|
periods[8].pk: Decimal("5000"),
|
|
periods[9].pk: Decimal("5000"),
|
|
periods[10].pk: Decimal("-5000"),
|
|
}
|
|
for month in (8, 9):
|
|
periods[month].refresh_from_db()
|
|
assert (
|
|
get_payroll_components(periods[month])
|
|
.get(code=component.code)
|
|
.include_in_salary
|
|
is True
|
|
)
|
|
|
|
|
|
def test_excluded_payment_does_not_reduce_current_salary_or_point_base(setup):
|
|
manager, employee, checker, periods, component, _ = setup
|
|
make_approved_period_inputs(
|
|
period=periods[10], employee=employee, creator=manager, approver=checker
|
|
)
|
|
component.include_in_point_base = (
|
|
True # Defensive adapter protection, even against bad ORM data.
|
|
)
|
|
component.save(update_fields=["include_in_point_base"])
|
|
line(setup, periods[10])
|
|
run = calculate_period(periods[10].pk, actor=manager)
|
|
statement = run.statements.get(employee=employee)
|
|
assert statement.payable == Decimal("80000")
|
|
assert statement.gross_total == Decimal("80000")
|
|
assert statement.payment_total == Decimal("5000")
|
|
assert statement.closing_balance == Decimal("-5000")
|
|
assert statement.input_snapshot["point_base_accrual"] == "80000"
|
|
assert statement.result_snapshot["totals"]["non_salary_payment_total"] == "5000"
|
|
assert statement.input_snapshot["lines"][0]["include_in_salary"] is False
|
|
detail = OwnPayrollStatementSerializer(statement).data
|
|
assert Decimal(detail["non_salary_payment_total"]) == Decimal("5000")
|
|
assert (
|
|
next(item for item in detail["lines"] if item["code"] == component.code)[
|
|
"include_in_salary"
|
|
]
|
|
is False
|
|
)
|
|
|
|
|
|
def test_inactive_employee_payment_without_work_does_not_block_or_generate_payslip(
|
|
setup, user_factory
|
|
):
|
|
manager, employee, checker, periods, component, client = setup
|
|
make_approved_period_inputs(
|
|
period=periods[10], employee=employee, creator=manager, approver=checker
|
|
)
|
|
former = user_factory(active=False)
|
|
PayrollInputLine.objects.create(
|
|
period=periods[10],
|
|
employee=former,
|
|
component_code=component.code,
|
|
amount="3500",
|
|
status="approved",
|
|
created_by=manager,
|
|
approved_by=checker,
|
|
approved_at=timezone.now(),
|
|
)
|
|
state = _period_input_state(periods[10])
|
|
assert state.approved_input_outside_roster_employee_ids == []
|
|
run = calculate_period(periods[10].pk, actor=manager)
|
|
assert not run.statements.filter(employee=former).exists()
|
|
table = client.get(
|
|
reverse(
|
|
"api:v1:finance-payroll:admin-period-table", kwargs={"pk": periods[10].pk}
|
|
)
|
|
).json()
|
|
row = next(item for item in table["rows"] if item["employee"]["id"] == former.pk)
|
|
assert row["status"] == "balance_only"
|
|
assert Decimal(row["payment_total"]) == Decimal("3500")
|
|
assert Decimal(row["payable"]) == 0
|
|
assert Decimal(row["closing_balance"]) == Decimal("-3500")
|
|
assert Decimal(table["recorded_payments"]["total_recorded"]) == Decimal("3500")
|
|
_ensure_run_is_fresh(run, periods[10])
|
|
PayrollInputLine.objects.create(
|
|
period=periods[10],
|
|
employee=former,
|
|
component_code=component.code,
|
|
amount="100",
|
|
status="approved",
|
|
created_by=manager,
|
|
approved_by=checker,
|
|
approved_at=timezone.now(),
|
|
)
|
|
with pytest.raises(PayrollOperationError) as error:
|
|
_ensure_run_is_fresh(run, periods[10])
|
|
assert error.value.code == "RUN_STALE_RECALCULATION_REQUIRED"
|
|
|
|
|
|
@pytest.mark.parametrize("builtin", [False, True])
|
|
def test_component_setting_is_saved_and_point_base_is_cleared(setup, builtin):
|
|
_, _, _, periods, component, client = setup
|
|
code = "BONUS" if builtin else component.code
|
|
payload = {
|
|
"name": "Отдельная выплата" if builtin else component.name,
|
|
"kind": "payment",
|
|
"period_scope": "current_and_future",
|
|
"period_id": periods[10].pk,
|
|
"requires_reason": False,
|
|
"include_in_salary": False,
|
|
"include_in_carryover": True,
|
|
"include_in_point_base": True,
|
|
"visible_to_employee": False,
|
|
}
|
|
url = reverse(
|
|
"api:v1:finance-payroll:admin-component-detail", kwargs={"code": code}
|
|
)
|
|
response = client.patch(url, payload, format="json")
|
|
assert response.status_code == 200, response.content
|
|
assert response.json()["include_in_salary"] is False
|
|
assert response.json()["include_in_point_base"] is False
|
|
payload.pop("include_in_salary")
|
|
response = client.patch(url, payload, format="json")
|
|
assert response.status_code == 200, response.content
|
|
assert response.json()["include_in_salary"] is False
|
|
assert response.json()["include_in_point_base"] is False
|
|
|
|
|
|
def test_migration_preserves_old_snapshots_and_only_activates_new_open_periods(setup):
|
|
_, _, _, periods, _, _ = setup
|
|
old_snapshot = {"version": 1, "components": []}
|
|
PayrollPeriod.objects.filter(pk=periods[8].pk).update(
|
|
component_snapshot=old_snapshot
|
|
)
|
|
PayrollPeriod.objects.all().update(balance_rules_version=1)
|
|
migration = import_module(
|
|
"finance.migrations.0041_salary_participation_and_balance_rules"
|
|
)
|
|
migration.activate_current_rules(apps, None)
|
|
for month, period in periods.items():
|
|
period.refresh_from_db()
|
|
assert period.balance_rules_version == (1 if month < 10 else 2)
|
|
assert periods[8].component_snapshot == old_snapshot
|
|
september = periods[9]
|
|
assert september.component_snapshot["version"] == 1
|
|
assert all(
|
|
component.include_in_salary for component in get_payroll_components(september)
|
|
)
|
|
assert not periods[10].component_snapshot
|
|
with pytest.raises(ValidationError):
|
|
september.balance_rules_version = 2
|
|
september.save(update_fields=["balance_rules_version"])
|
|
|
|
|
|
def test_migration_keeps_existing_unclosed_run_fresh_and_freezes_its_settings(setup):
|
|
manager, employee, checker, periods, component, _ = setup
|
|
PayrollPeriod.objects.all().update(balance_rules_version=1)
|
|
current = PayrollPeriod.objects.get(pk=periods[10].pk)
|
|
component.include_in_salary = True
|
|
component.save(update_fields=["include_in_salary"])
|
|
make_approved_period_inputs(
|
|
period=current, employee=employee, creator=manager, approver=checker
|
|
)
|
|
line(setup, current)
|
|
run = calculate_period(current.pk, actor=manager)
|
|
previous = list(
|
|
run.statements.values(
|
|
"input_snapshot", "result_snapshot", "input_hash", "result_hash"
|
|
)
|
|
)
|
|
import_module(
|
|
"finance.migrations.0041_salary_participation_and_balance_rules"
|
|
).activate_current_rules(apps, None)
|
|
current.refresh_from_db()
|
|
periods[11].refresh_from_db()
|
|
assert current.balance_rules_version == 1
|
|
assert periods[11].balance_rules_version == 2
|
|
component.include_in_salary = False
|
|
component.save(update_fields=["include_in_salary"])
|
|
_ensure_run_is_fresh(run, current)
|
|
assert (
|
|
list(
|
|
run.statements.values(
|
|
"input_snapshot", "result_snapshot", "input_hash", "result_hash"
|
|
)
|
|
)
|
|
== previous
|
|
)
|