Corp/backend/tests/finance/test_payroll_carryovers.py

421 lines
15 KiB
Python

from datetime import date
from decimal import Decimal
import pytest
from django.contrib.auth.models import Permission
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django.utils import timezone
from finance.enums import (
ApprovalStatus,
PayrollCarryoverStatus,
PayrollPeriodStatus,
)
from finance.models import (
FinanceSettings,
PayrollComponent,
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.balances import opening_balances
from finance.payroll.carryovers import (
cancel_carryover,
create_carryover,
materialize_pending_carryovers,
)
from finance.payroll.exceptions import PayrollOperationError
from finance.payroll.services import close_period
pytestmark = pytest.mark.django_db
def grant(user, *codenames):
permissions = Permission.objects.filter(
content_type__app_label="finance",
codename__in=codenames,
)
assert permissions.count() == len(codenames)
user.user_permissions.add(*permissions)
for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, cache_name):
delattr(user, cache_name)
def api_url(name, **kwargs):
return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None)
def make_period(*, creator, month, status=PayrollPeriodStatus.OPEN):
starts = {6: date(2026, 6, 1), 7: date(2026, 7, 1), 8: date(2026, 8, 1)}
ends = {6: date(2026, 6, 30), 7: date(2026, 7, 31), 8: date(2026, 8, 31)}
return PayrollPeriod.objects.create(
code=f"2026-{month:02d}",
name=f"Месяц {month}",
date_from=starts[month],
date_to=ends[month],
currency="RUB",
status=status,
created_by=creator,
)
def test_pending_carryover_materializes_as_next_period_draft(user_factory):
manager = user_factory(email="carryover.manager@example.test")
employee = user_factory(email="carryover.employee@example.test")
source = make_period(
creator=manager,
month=6,
status=PayrollPeriodStatus.CLOSED,
)
carryover = create_carryover(
source_period=source,
employee=employee,
direction="company_owes",
amount=Decimal("5000.00"),
reason="Недоплата за июнь",
actor=manager,
)
assert carryover.status == PayrollCarryoverStatus.PENDING
assert carryover.target_period_id is None
target = make_period(creator=manager, month=7)
transferred = materialize_pending_carryovers(target)
assert [item.pk for item in transferred] == [carryover.pk]
carryover.refresh_from_db()
assert carryover.status == PayrollCarryoverStatus.TRANSFERRED
assert carryover.target_period == target
line = carryover.generated_input_line
assert line.period == target
assert line.employee == employee
assert line.component.code == "CORRECTION_CREDIT"
assert line.amount == Decimal("5000.00")
assert line.relates_to_period == source
assert line.status == ApprovalStatus.DRAFT
assert line.source == "api"
assert line.source_ref == f"payroll-carryover:{carryover.pk}"
assert "Недоплата за июнь" in line.reason
assert materialize_pending_carryovers(target) == []
assert PayrollInputLine.objects.filter(source_ref=line.source_ref).count() == 1
def test_employee_debt_uses_deduction_and_cancel_voids_draft(user_factory):
manager = user_factory(email="carryover.debt.manager@example.test")
employee = user_factory(email="carryover.debt.employee@example.test")
source = make_period(
creator=manager,
month=6,
status=PayrollPeriodStatus.CLOSED,
)
make_period(creator=manager, month=7)
carryover = create_carryover(
source_period=source,
employee=employee,
direction="employee_owes",
amount=Decimal("1200.00"),
reason="Возврат переплаты",
actor=manager,
)
assert carryover.status == PayrollCarryoverStatus.TRANSFERRED
assert carryover.generated_input_line.component.code == "DEDUCTION"
with CaptureQueriesContext(connection) as queries:
cancelled = cancel_carryover(carryover.pk, actor=manager)
assert cancelled.status == PayrollCarryoverStatus.CANCELLED
cancelled.generated_input_line.refresh_from_db()
assert cancelled.generated_input_line.status == ApprovalStatus.VOIDED
if connection.features.has_select_for_update:
# Cancellation must still lock the line it updates, rather than merely
# adding OF(carryover) to the former nullable join.
line_locks = [
entry["sql"]
for entry in queries
if 'FROM "finance_payrollinputline"' in entry["sql"]
and "FOR UPDATE" in entry["sql"]
]
assert len(line_locks) == 1
assert "JOIN" not in line_locks[0]
def test_pending_carryover_without_nullable_relations_can_be_cancelled(user_factory):
manager = user_factory(email="carryover.pending.manager@example.test")
employee = user_factory(email="carryover.pending.employee@example.test")
source = make_period(creator=manager, month=6, status=PayrollPeriodStatus.CLOSED)
carryover = create_carryover(
source_period=source,
employee=employee,
direction="company_owes",
amount=Decimal("55.71"),
reason="Перенос без следующего периода",
actor=manager,
)
assert carryover.target_period_id is None
assert carryover.generated_input_line_id is None
cancelled = cancel_carryover(carryover.pk, actor=manager)
repeated = cancel_carryover(carryover.pk, actor=manager)
assert cancelled.status == PayrollCarryoverStatus.CANCELLED
assert repeated.cancelled_at == cancelled.cancelled_at
assert repeated.generated_input_line_id is None
assert not PayrollInputLine.objects.filter(
source_ref=f"payroll-carryover:{carryover.pk}"
).exists()
def test_carryover_requires_closed_source_and_approved_transfer_cannot_cancel(
user_factory,
):
manager = user_factory(email="carryover.lock.manager@example.test")
employee = user_factory(email="carryover.lock.employee@example.test")
source = make_period(creator=manager, month=6)
with pytest.raises(PayrollOperationError) as open_error:
create_carryover(
source_period=source,
employee=employee,
direction="company_owes",
amount=Decimal("10.00"),
reason="Ошибка",
actor=manager,
)
assert open_error.value.code == "CARRYOVER_SOURCE_NOT_CLOSED"
source.status = PayrollPeriodStatus.CLOSED
source.save(update_fields=["status", "updated_at"])
make_period(creator=manager, month=7)
carryover = create_carryover(
source_period=source,
employee=employee,
direction="company_owes",
amount=Decimal("10.00"),
reason="Подтверждённая доплата",
actor=manager,
)
line = carryover.generated_input_line
line.status = ApprovalStatus.APPROVED
line.approved_by = manager
line.approved_at = carryover.transferred_at
line.self_approval_overridden = True
line.save(
update_fields=[
"status",
"approved_by",
"approved_at",
"self_approval_overridden",
]
)
with pytest.raises(PayrollOperationError) as approved_error:
cancel_carryover(carryover.pk, actor=manager)
assert approved_error.value.code == "CARRYOVER_ALREADY_APPROVED"
def test_manual_carryover_api_is_disabled(
user_factory,
auth_client_factory,
):
manager = user_factory(email="carryover.api.manager@example.test")
employee = user_factory(
email="carryover.api.employee@example.test",
first_name="Анна",
last_name="Балансова",
)
grant(manager, "manage_payroll_inputs", "view_all_payroll")
source = make_period(
creator=manager,
month=6,
status=PayrollPeriodStatus.CLOSED,
)
client = auth_client_factory(manager)
created = client.post(
api_url("admin-period-carryovers", pk=source.pk),
{
"employee_id": employee.pk,
"direction": "company_owes",
"amount": "3500.00",
"reason": "Исправление после закрытия",
},
format="json",
)
assert created.status_code == 410, created.content
assert created.json()["code"] == "MANUAL_CARRYOVER_DISABLED"
assert "колонок, включённых в переходящие остатки" in created.json()["message"]
def test_carryover_ledger_lists_history_and_can_exclude_a_record(
user_factory,
auth_client_factory,
):
manager = user_factory(email="carryover.ledger.manager@example.test")
employee = user_factory(
email="carryover.ledger.employee@example.test",
first_name="Анна",
last_name="Балансова",
)
grant(manager, "manage_payroll_inputs", "view_all_payroll")
june = make_period(creator=manager, month=6)
july = make_period(creator=manager, month=7)
correction = PayrollInputLine.objects.create(
period=june,
employee=employee,
component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"),
amount=Decimal("3500.00"),
reason="Недоплата за июнь",
status=ApprovalStatus.APPROVED,
created_by=manager,
approved_by=manager,
approved_at=timezone.now(),
self_approval_overridden=True,
)
client = auth_client_factory(manager)
ledger = client.get(api_url("admin-period-carryovers", pk=july.pk))
assert ledger.status_code == 200, ledger.content
assert ledger.json()["summary"]["balance"] == "3500.00"
assert len(ledger.json()["entries"]) == 1
entry = ledger.json()["entries"][0]
assert entry["id"] == correction.pk
assert entry["period"]["id"] == june.pk
assert entry["raw_contribution"] == "3500.00"
assert entry["contribution"] == "3500.00"
assert entry["counts_in_balance"] is True
assert opening_balances(july, [employee.pk])[employee.pk] == Decimal("3500.00")
excluded = client.patch(
api_url("admin-input-line-carryover-inclusion", pk=correction.pk),
{"included": False},
format="json",
)
assert excluded.status_code == 200, excluded.content
correction.refresh_from_db()
assert correction.include_in_carryover is False
assert correction.amount == Decimal("3500.00")
assert opening_balances(july, [employee.pk]) == {}
refreshed = client.get(api_url("admin-period-carryovers", pk=july.pk)).json()
assert refreshed["summary"]["balance"] == "0.00"
assert refreshed["entries"][0]["contribution"] == "0.00"
assert refreshed["entries"][0]["raw_contribution"] == "3500.00"
def test_component_setting_adds_existing_values_to_carryover_balance(
user_factory,
auth_client_factory,
):
manager = user_factory(email="carryover.component.manager@example.test")
employee = user_factory(email="carryover.component.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
june = make_period(creator=manager, month=6)
july = make_period(creator=manager, month=7)
line = PayrollInputLine.objects.create(
period=june,
employee=employee,
component=PayrollComponent.objects.get(code="ONE_TIME_PAYMENT"),
amount=Decimal("2400.00"),
reason="Разовая выплата",
status=ApprovalStatus.APPROVED,
created_by=manager,
approved_by=manager,
approved_at=timezone.now(),
self_approval_overridden=True,
)
assert line.include_in_carryover is True
assert opening_balances(july, [employee.pk]) == {}
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {
"ONE_TIME_PAYMENT": {"include_in_carryover": True}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
assert opening_balances(july, [employee.pk]) == {employee.pk: Decimal("-2400.00")}
ledger = auth_client_factory(manager).get(
api_url("admin-period-carryovers", pk=july.pk)
)
assert ledger.status_code == 200, ledger.content
assert [entry["id"] for entry in ledger.json()["entries"]] == [line.pk]
assert ledger.json()["summary"]["balance"] == "-2400.00"
excluded = auth_client_factory(manager).patch(
api_url("admin-input-line-carryover-inclusion", pk=line.pk),
{"included": False},
format="json",
)
assert excluded.status_code == 200, excluded.content
assert opening_balances(july, [employee.pk]) == {}
def test_closed_period_carryover_uses_frozen_component_settings(
user_factory,
auth_client_factory,
):
manager = user_factory(email="carryover.snapshot.manager@example.test")
employee = user_factory(email="carryover.snapshot.employee@example.test")
grant(
manager,
"manage_payroll_inputs",
"view_all_payroll",
"publish_payroll",
)
june = make_period(creator=manager, month=6)
july = make_period(creator=manager, month=7)
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {
"ONE_TIME_PAYMENT": {
"include_in_carryover": True,
"kind": "earning",
}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
line = PayrollInputLine.objects.create(
period=june,
employee=employee,
component_code="ONE_TIME_PAYMENT",
amount=Decimal("2400.00"),
reason="Разовая выплата",
status=ApprovalStatus.APPROVED,
created_by=manager,
approved_by=manager,
approved_at=timezone.now(),
self_approval_overridden=True,
)
june.status = PayrollPeriodStatus.PUBLISHED
june.save(update_fields=["status", "updated_at"])
close_period(june.pk, actor=manager)
settings.payroll_component_overrides = {
"ONE_TIME_PAYMENT": {
"include_in_carryover": False,
"kind": "payment",
}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
assert opening_balances(july, [employee.pk]) == {employee.pk: Decimal("-2400.00")}
ledger = auth_client_factory(manager).get(
api_url("admin-period-carryovers", pk=july.pk)
)
assert ledger.status_code == 200, ledger.content
assert ledger.json()["entries"][0]["component"]["kind"] == "earning"
assert ledger.json()["entries"][0]["raw_contribution"] == "-2400.00"
blocked = auth_client_factory(manager).patch(
api_url("admin-input-line-carryover-inclusion", pk=line.pk),
{"included": False},
format="json",
)
assert blocked.status_code == 409, blocked.content
assert blocked.json()["code"] == "PERIOD_INPUTS_LOCKED"
line.refresh_from_db()
assert line.include_in_carryover is True