421 lines
15 KiB
Python
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
|