fix(finance): freeze closed period columns
This commit is contained in:
parent
63d77c38fa
commit
5adfdbbd25
@ -13,6 +13,7 @@ from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from finance.enums import PayrollPeriodStatus
|
||||
from finance.models import (
|
||||
PayrollCarryover,
|
||||
PayrollInputLine,
|
||||
@ -30,6 +31,7 @@ from ..common import (
|
||||
PAYROLL_PERMISSIONS,
|
||||
PayrollAdminAPIView,
|
||||
_audit_api_mutation,
|
||||
_conflict,
|
||||
)
|
||||
from ..queries import (
|
||||
_carryover_ledger,
|
||||
@ -84,18 +86,24 @@ class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView):
|
||||
def patch(self, request, pk):
|
||||
command = PayrollCarryoverInclusionSerializer(data=request.data)
|
||||
command.is_valid(raise_exception=True)
|
||||
carryover_component_codes = {
|
||||
component.code
|
||||
for component in get_payroll_components()
|
||||
if component.include_in_carryover
|
||||
}
|
||||
line = get_object_or_404(
|
||||
PayrollInputLine.objects.select_for_update(of=("self",)).select_related(
|
||||
"period"
|
||||
),
|
||||
pk=pk,
|
||||
component_code__in=carryover_component_codes,
|
||||
)
|
||||
if line.period.status == PayrollPeriodStatus.CLOSED:
|
||||
_conflict(
|
||||
"PERIOD_INPUTS_LOCKED",
|
||||
"Данные закрытого периода заблокированы.",
|
||||
)
|
||||
carryover_component_codes = {
|
||||
component.code
|
||||
for component in get_payroll_components(line.period)
|
||||
if component.include_in_carryover
|
||||
}
|
||||
if line.component_code not in carryover_component_codes:
|
||||
raise Http404
|
||||
included = command.validated_data["included"]
|
||||
previous = line.include_in_carryover
|
||||
if previous != included:
|
||||
|
||||
@ -18,7 +18,7 @@ from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from finance.enums import ApprovalStatus, PayrollComponentKind
|
||||
from finance.enums import ApprovalStatus, PayrollComponentKind, PayrollPeriodStatus
|
||||
from finance.models import (
|
||||
FinanceSettings,
|
||||
PayrollCustomComponent,
|
||||
@ -55,6 +55,15 @@ from ..common import (
|
||||
from ..input_copy import _apply_input_copy
|
||||
|
||||
|
||||
def _reject_closed_component_period(period):
|
||||
if period is not None and period.status == PayrollPeriodStatus.CLOSED:
|
||||
_conflict(
|
||||
"PERIOD_COMPONENTS_LOCKED",
|
||||
"Колонки закрытого периода сохранены как неизменяемый снимок.",
|
||||
period_id=period.pk,
|
||||
)
|
||||
|
||||
|
||||
class PayrollAdminEmployeeListView(PayrollAdminAPIView):
|
||||
serializer_class = PayrollEmployeeMiniSerializer
|
||||
|
||||
@ -92,6 +101,10 @@ class PayrollAdminComponentListView(PayrollAdminAPIView):
|
||||
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
|
||||
serializer = PayrollCustomComponentWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
operation_period = PayrollPeriod.objects.filter(
|
||||
pk=request.data.get("period_id")
|
||||
).first()
|
||||
_reject_closed_component_period(operation_period)
|
||||
next_order = (
|
||||
PayrollCustomComponent.objects.order_by("-display_order")
|
||||
.values_list("display_order", flat=True)
|
||||
@ -137,6 +150,10 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
|
||||
raise ValidationError({"code": "Зарплатная колонка не найдена."}) from exc
|
||||
serializer = PayrollCustomComponentWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
operation_period = PayrollPeriod.objects.filter(
|
||||
pk=request.data.get("period_id")
|
||||
).first()
|
||||
_reject_closed_component_period(operation_period)
|
||||
values = dict(serializer.validated_data)
|
||||
scope_period = values.pop("scope_period", None)
|
||||
if (
|
||||
@ -323,6 +340,7 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
|
||||
code=code,
|
||||
)
|
||||
period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id"))
|
||||
_reject_closed_component_period(period)
|
||||
mode = request.data.get("mode")
|
||||
scheduled_references = scheduled_components_targeting(code)
|
||||
if scheduled_references:
|
||||
|
||||
@ -113,17 +113,11 @@ def _automatic_balance_summaries(rows):
|
||||
|
||||
|
||||
def _carryover_ledger(period):
|
||||
carryover_components = {
|
||||
component.code: component
|
||||
for component in get_payroll_components()
|
||||
if component.include_in_carryover
|
||||
}
|
||||
lines = (
|
||||
lines = list(
|
||||
PayrollInputLine.objects.filter(
|
||||
period__currency=period.currency,
|
||||
period__date_to__lte=period.date_to,
|
||||
employee__is_hidden_in_ui=False,
|
||||
component_code__in=carryover_components,
|
||||
)
|
||||
.exclude(
|
||||
source=InputSource.API,
|
||||
@ -138,11 +132,24 @@ def _carryover_ledger(period):
|
||||
.prefetch_related("employee__departments_links__department")
|
||||
.order_by("-period__date_to", "-created_at", "-id")
|
||||
)
|
||||
components_by_period = {
|
||||
source_period.pk: {
|
||||
component.code: component
|
||||
for component in get_payroll_components(source_period)
|
||||
if component.include_in_carryover
|
||||
}
|
||||
for source_period in {line.period for line in lines}
|
||||
}
|
||||
entries = []
|
||||
balances_by_employee = {}
|
||||
included_count = 0
|
||||
excluded_count = 0
|
||||
for line in lines:
|
||||
component = components_by_period.get(line.period_id, {}).get(
|
||||
line.component_code
|
||||
)
|
||||
if component is None:
|
||||
continue
|
||||
current_period_line = line.period_id == period.pk
|
||||
active_status = line.status == ApprovalStatus.APPROVED or (
|
||||
current_period_line
|
||||
@ -150,7 +157,6 @@ def _carryover_ledger(period):
|
||||
and line.status == ApprovalStatus.DRAFT
|
||||
)
|
||||
counts_in_balance = bool(line.include_in_carryover and active_status)
|
||||
component = carryover_components[line.component_code]
|
||||
raw_contribution = (
|
||||
line.amount if component.kind in CARRYOVER_DEBIT_KINDS else -line.amount
|
||||
)
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
SNAPSHOT_FIELDS = (
|
||||
"code",
|
||||
"name",
|
||||
"kind",
|
||||
"requires_reason",
|
||||
"display_order",
|
||||
"include_in_point_base",
|
||||
"include_in_carryover",
|
||||
"visible_to_employee",
|
||||
"period_scope",
|
||||
"scope_period_id",
|
||||
"is_custom",
|
||||
"highlight_color",
|
||||
"scheduled_target_component_code",
|
||||
)
|
||||
|
||||
BUILTIN_COMPONENTS = (
|
||||
("VACATION_PAY", "Отпускные", "earning", False, 20, False, False, True, ""),
|
||||
("BONUS", "Премия", "earning", False, 30, True, False, True, ""),
|
||||
(
|
||||
"CORRECTION_CREDIT",
|
||||
"Положительная корректировка",
|
||||
"adjustment_credit",
|
||||
True,
|
||||
40,
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
"green",
|
||||
),
|
||||
(
|
||||
"CORRECTION_DEBIT",
|
||||
"Отрицательная корректировка",
|
||||
"adjustment_debit",
|
||||
True,
|
||||
41,
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
"red",
|
||||
),
|
||||
(
|
||||
"ONE_TIME_PAYMENT",
|
||||
"Разовое начисление",
|
||||
"earning",
|
||||
True,
|
||||
50,
|
||||
False,
|
||||
False,
|
||||
True,
|
||||
"",
|
||||
),
|
||||
("DEDUCTION", "Удержание", "deduction", True, 60, False, False, True, ""),
|
||||
("ADVANCE", "Аванс", "payment", False, 70, False, False, True, ""),
|
||||
(
|
||||
"SALARY_PAYMENT",
|
||||
"Фактическая выплата",
|
||||
"payment",
|
||||
True,
|
||||
80,
|
||||
False,
|
||||
False,
|
||||
True,
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _applies_to_period(component, period, period_dates, excluded_period_ids=()):
|
||||
hidden_from_period_id = component.get("hidden_from_period_id")
|
||||
if (
|
||||
hidden_from_period_id is not None
|
||||
and period_dates.get(hidden_from_period_id) is not None
|
||||
and period.date_from >= period_dates[hidden_from_period_id]
|
||||
):
|
||||
return False
|
||||
if period.pk in excluded_period_ids:
|
||||
return False
|
||||
scope = component["period_scope"]
|
||||
if scope == "all":
|
||||
return True
|
||||
scope_period_id = component.get("scope_period_id")
|
||||
if scope_period_id is None:
|
||||
return False
|
||||
if scope == "current_only":
|
||||
return scope_period_id == period.pk
|
||||
return period_dates.get(scope_period_id, period.date_from) <= period.date_from
|
||||
|
||||
|
||||
def backfill_closed_period_snapshots(apps, schema_editor):
|
||||
# Older configurations were not versioned, so the configuration visible at
|
||||
# this deployment is the only baseline that can be retained for old periods.
|
||||
finance_settings = apps.get_model("finance", "FinanceSettings")
|
||||
payroll_custom_component = apps.get_model("finance", "PayrollCustomComponent")
|
||||
payroll_period = apps.get_model("finance", "PayrollPeriod")
|
||||
settings = finance_settings.objects.first()
|
||||
overrides = settings.payroll_component_overrides if settings else {}
|
||||
periods = list(payroll_period.objects.all())
|
||||
period_dates = {period.pk: period.date_from for period in periods}
|
||||
|
||||
builtins = []
|
||||
for (
|
||||
code,
|
||||
name,
|
||||
kind,
|
||||
requires_reason,
|
||||
display_order,
|
||||
include_in_point_base,
|
||||
include_in_carryover,
|
||||
visible_to_employee,
|
||||
highlight_color,
|
||||
) in BUILTIN_COMPONENTS:
|
||||
component = {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"requires_reason": requires_reason,
|
||||
"display_order": display_order,
|
||||
"include_in_point_base": include_in_point_base,
|
||||
"include_in_carryover": include_in_carryover,
|
||||
"visible_to_employee": visible_to_employee,
|
||||
"period_scope": "all",
|
||||
"scope_period_id": None,
|
||||
"is_custom": False,
|
||||
"highlight_color": highlight_color,
|
||||
"scheduled_target_component_code": "",
|
||||
}
|
||||
component.update(overrides.get(code, {}))
|
||||
builtins.append({field: component[field] for field in SNAPSHOT_FIELDS})
|
||||
|
||||
custom_components = []
|
||||
for component in payroll_custom_component.objects.filter(is_active=True):
|
||||
payload = {
|
||||
field: getattr(component, field)
|
||||
for field in SNAPSHOT_FIELDS
|
||||
if field != "is_custom"
|
||||
}
|
||||
payload["is_custom"] = True
|
||||
payload["hidden_from_period_id"] = component.hidden_from_period_id
|
||||
payload["excluded_period_ids"] = set(
|
||||
component.excluded_periods.values_list("pk", flat=True)
|
||||
)
|
||||
custom_components.append(payload)
|
||||
|
||||
for period in periods:
|
||||
if period.status != "closed" or period.component_snapshot:
|
||||
continue
|
||||
components = [
|
||||
component
|
||||
for component in builtins
|
||||
if _applies_to_period(component, period, period_dates)
|
||||
]
|
||||
components.extend(
|
||||
{field: component[field] for field in SNAPSHOT_FIELDS}
|
||||
for component in custom_components
|
||||
if _applies_to_period(
|
||||
component,
|
||||
period,
|
||||
period_dates,
|
||||
component["excluded_period_ids"],
|
||||
)
|
||||
)
|
||||
period.component_snapshot = {"version": 1, "components": components}
|
||||
period.save(update_fields=["component_snapshot"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("finance", "0037_defer_scheduled_changes")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="payrollperiod",
|
||||
name="component_snapshot",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
editable=False,
|
||||
verbose_name="Снимок зарплатных колонок",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_closed_period_snapshots,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -124,6 +124,12 @@ class PayrollPeriod(models.Model):
|
||||
related_name="active_for_periods",
|
||||
verbose_name="Текущая ревизия расчёта",
|
||||
)
|
||||
component_snapshot = models.JSONField(
|
||||
"Снимок зарплатных колонок",
|
||||
default=dict,
|
||||
blank=True,
|
||||
editable=False,
|
||||
)
|
||||
lock_version = models.PositiveIntegerField("Версия блокировки", default=0)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@ -223,6 +229,14 @@ class PayrollPeriod(models.Model):
|
||||
if self.pk and not self._state.adding:
|
||||
original = PayrollPeriod.objects.select_for_update().get(pk=self.pk)
|
||||
self._validate_immutable_fields(original)
|
||||
if (
|
||||
original.status == PayrollPeriodStatus.CLOSED
|
||||
and original.component_snapshot
|
||||
and self.component_snapshot != original.component_snapshot
|
||||
):
|
||||
raise ValidationError(
|
||||
"Снимок колонок закрытого периода нельзя изменять."
|
||||
)
|
||||
if update_fields is None:
|
||||
# Full-form saves may carry stale workflow fields. Those fields
|
||||
# are changed only by services with explicit update_fields.
|
||||
@ -231,6 +245,7 @@ class PayrollPeriod(models.Model):
|
||||
self.lock_version = original.lock_version
|
||||
self.created_by_id = original.created_by_id
|
||||
self.created_at = original.created_at
|
||||
self.component_snapshot = original.component_snapshot
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@ -21,31 +21,41 @@ def opening_balances(
|
||||
requested_ids = set(employee_ids)
|
||||
if not requested_ids:
|
||||
return {}
|
||||
components = {
|
||||
component.code: component
|
||||
for component in get_payroll_components()
|
||||
if component.include_in_carryover
|
||||
}
|
||||
if not components:
|
||||
return {}
|
||||
lines = (
|
||||
lines = list(
|
||||
PayrollInputLine.objects.filter(
|
||||
employee_id__in=requested_ids,
|
||||
period__currency=period.currency,
|
||||
period__date_to__lt=period.date_from,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
include_in_carryover=True,
|
||||
component_code__in=components,
|
||||
)
|
||||
.exclude(
|
||||
source=InputSource.API,
|
||||
source_ref__startswith="payroll-carryover:",
|
||||
)
|
||||
.values("employee_id", "component_code", "amount")
|
||||
.values("period_id", "employee_id", "component_code", "amount")
|
||||
)
|
||||
source_periods = {
|
||||
source.pk: source
|
||||
for source in PayrollPeriod.objects.filter(
|
||||
pk__in={line["period_id"] for line in lines}
|
||||
)
|
||||
}
|
||||
components_by_period = {
|
||||
period_id: {
|
||||
component.code: component
|
||||
for component in get_payroll_components(source_period)
|
||||
if component.include_in_carryover
|
||||
}
|
||||
for period_id, source_period in source_periods.items()
|
||||
}
|
||||
balances = {}
|
||||
for line in lines:
|
||||
component = components[line["component_code"]]
|
||||
component = components_by_period.get(line["period_id"], {}).get(
|
||||
line["component_code"]
|
||||
)
|
||||
if component is None:
|
||||
continue
|
||||
direction = 1 if component.kind in CARRYOVER_DEBIT_KINDS else -1
|
||||
balances[line["employee_id"]] = balances.get(
|
||||
line["employee_id"], Decimal("0.00")
|
||||
|
||||
@ -2,7 +2,28 @@
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from finance.enums import PayrollComponentKind, PayrollComponentPeriodScope
|
||||
from finance.enums import (
|
||||
PayrollComponentKind,
|
||||
PayrollComponentPeriodScope,
|
||||
PayrollPeriodStatus,
|
||||
)
|
||||
|
||||
PAYROLL_COMPONENT_SNAPSHOT_VERSION = 1
|
||||
PAYROLL_COMPONENT_SNAPSHOT_FIELDS = (
|
||||
"code",
|
||||
"name",
|
||||
"kind",
|
||||
"requires_reason",
|
||||
"display_order",
|
||||
"include_in_point_base",
|
||||
"include_in_carryover",
|
||||
"visible_to_employee",
|
||||
"period_scope",
|
||||
"scope_period_id",
|
||||
"is_custom",
|
||||
"highlight_color",
|
||||
"scheduled_target_component_code",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -156,7 +177,7 @@ class PayrollComponent:
|
||||
objects = _Manager()
|
||||
|
||||
|
||||
def get_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
def _get_live_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
"""Return built-ins plus active user-created component columns."""
|
||||
|
||||
from finance.models import FinanceSettings, PayrollCustomComponent, PayrollPeriod
|
||||
@ -247,6 +268,53 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
return _PayrollComponentCollection((*builtins, *custom))
|
||||
|
||||
|
||||
def build_payroll_component_snapshot(period) -> dict[str, object]:
|
||||
"""Serialize the exact component configuration visible when a period closes."""
|
||||
|
||||
components = _get_live_payroll_components(period)
|
||||
return {
|
||||
"version": PAYROLL_COMPONENT_SNAPSHOT_VERSION,
|
||||
"components": [
|
||||
{
|
||||
field: getattr(component, field)
|
||||
for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS
|
||||
}
|
||||
for component in components
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _components_from_snapshot(snapshot) -> _PayrollComponentCollection:
|
||||
if not isinstance(snapshot, dict):
|
||||
raise ValueError("Некорректный снимок зарплатных колонок.")
|
||||
if snapshot.get("version") != PAYROLL_COMPONENT_SNAPSHOT_VERSION:
|
||||
raise ValueError("Неподдерживаемая версия снимка зарплатных колонок.")
|
||||
payloads = snapshot.get("components")
|
||||
if not isinstance(payloads, list):
|
||||
raise ValueError("В снимке отсутствует список зарплатных колонок.")
|
||||
try:
|
||||
return _PayrollComponentCollection(
|
||||
PayrollComponentDefinition(
|
||||
**{field: payload[field] for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS}
|
||||
)
|
||||
for payload in payloads
|
||||
)
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise ValueError("Снимок зарплатных колонок повреждён.") from exc
|
||||
|
||||
|
||||
def get_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
"""Return frozen columns for closed periods and live columns otherwise."""
|
||||
|
||||
if (
|
||||
period is not None
|
||||
and period.status == PayrollPeriodStatus.CLOSED
|
||||
and period.component_snapshot
|
||||
):
|
||||
return _components_from_snapshot(period.component_snapshot)
|
||||
return _get_live_payroll_components(period)
|
||||
|
||||
|
||||
def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition:
|
||||
component = get_payroll_components(period).filter(code=code)
|
||||
if component:
|
||||
|
||||
@ -215,18 +215,23 @@ def applied_change_payloads(lines: list[PayrollInputLine]) -> list[dict[str, obj
|
||||
"period"
|
||||
)
|
||||
}
|
||||
source_codes = {line.component_code for line in sources.values()}
|
||||
components = {
|
||||
component.code: component
|
||||
for component in get_payroll_components()
|
||||
if component.code in source_codes
|
||||
components_by_period = {
|
||||
period_id: {
|
||||
component.code: component
|
||||
for component in get_payroll_components(source.period)
|
||||
}
|
||||
for period_id, source in {
|
||||
source.period_id: source for source in sources.values()
|
||||
}.items()
|
||||
}
|
||||
payloads = []
|
||||
for generated_line in generated:
|
||||
source = sources.get(generated_source_line_id(generated_line))
|
||||
if source is None:
|
||||
continue
|
||||
component = components.get(source.component_code)
|
||||
component = components_by_period.get(source.period_id, {}).get(
|
||||
source.component_code
|
||||
)
|
||||
if component is None:
|
||||
continue
|
||||
payloads.append(
|
||||
|
||||
@ -52,7 +52,11 @@ from .adapter import (
|
||||
point_base_accruals_for_employees,
|
||||
)
|
||||
from .balances import opening_balances
|
||||
from .components import get_payroll_component, get_payroll_components
|
||||
from .components import (
|
||||
build_payroll_component_snapshot,
|
||||
get_payroll_component,
|
||||
get_payroll_components,
|
||||
)
|
||||
from .config import (
|
||||
base_rate_code,
|
||||
build_rules,
|
||||
@ -826,8 +830,9 @@ def close_period(period_id: int, *, actor) -> PayrollPeriod:
|
||||
"INVALID_PERIOD_STATE",
|
||||
"Закрыть можно только опубликованный период.",
|
||||
)
|
||||
period.component_snapshot = build_payroll_component_snapshot(period)
|
||||
period.status = PayrollPeriodStatus.CLOSED
|
||||
period.save(update_fields=["status", "updated_at"])
|
||||
period.save(update_fields=["component_snapshot", "status", "updated_at"])
|
||||
_audit(actor=actor, action="payroll.period_closed", instance=period, period=period)
|
||||
return period
|
||||
|
||||
|
||||
@ -220,6 +220,91 @@ def test_manager_can_customize_system_component_behavior(
|
||||
assert override["highlight_color"] == "violet"
|
||||
|
||||
|
||||
def test_closed_period_table_keeps_frozen_columns_after_later_customization(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="component.snapshot.manager@example.test")
|
||||
grant(
|
||||
manager,
|
||||
"manage_payroll_inputs",
|
||||
"view_all_payroll",
|
||||
"publish_payroll",
|
||||
)
|
||||
june = make_period(creator=manager)
|
||||
june.status = "published"
|
||||
june.save(update_fields=["status", "updated_at"])
|
||||
july = PayrollPeriod.objects.create(
|
||||
code="2026-07",
|
||||
name="Июль 2026",
|
||||
date_from="2026-07-01",
|
||||
date_to="2026-07-31",
|
||||
pay_date="2026-08-05",
|
||||
currency="RUB",
|
||||
created_by=manager,
|
||||
)
|
||||
client = auth_client_factory(manager)
|
||||
closed = client.post(api_url("admin-period-close", pk=june.pk), {}, format="json")
|
||||
assert closed.status_code == 200, closed.content
|
||||
|
||||
updated = client.patch(
|
||||
api_url("admin-component-detail", code="BONUS"),
|
||||
{
|
||||
"name": "Новая премия",
|
||||
"kind": "deduction",
|
||||
"requires_reason": True,
|
||||
"include_in_point_base": False,
|
||||
"include_in_carryover": False,
|
||||
"visible_to_employee": False,
|
||||
"highlight_color": "red",
|
||||
"period_scope": "all",
|
||||
"period_id": july.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert updated.status_code == 200, updated.content
|
||||
|
||||
historical_table = client.get(api_url("admin-period-table", pk=june.pk))
|
||||
current_table = client.get(api_url("admin-period-table", pk=july.pk))
|
||||
assert historical_table.status_code == 200, historical_table.content
|
||||
assert current_table.status_code == 200, current_table.content
|
||||
historical_bonus = next(
|
||||
column
|
||||
for column in historical_table.json()["component_columns"]
|
||||
if column["code"] == "BONUS"
|
||||
)
|
||||
current_bonus = next(
|
||||
column
|
||||
for column in current_table.json()["component_columns"]
|
||||
if column["code"] == "BONUS"
|
||||
)
|
||||
assert historical_bonus["label"] == "Премия"
|
||||
assert historical_bonus["kind"] == "earning"
|
||||
assert historical_bonus["include_in_point_base"] is True
|
||||
assert historical_bonus["visible_to_employee"] is True
|
||||
assert historical_bonus["highlight_color"] == ""
|
||||
assert current_bonus["label"] == "Новая премия"
|
||||
assert current_bonus["kind"] == "deduction"
|
||||
|
||||
rejected = client.patch(
|
||||
api_url("admin-component-detail", code="BONUS"),
|
||||
{
|
||||
"name": "Попытка изменить историю",
|
||||
"kind": "earning",
|
||||
"requires_reason": False,
|
||||
"include_in_point_base": True,
|
||||
"include_in_carryover": False,
|
||||
"visible_to_employee": True,
|
||||
"highlight_color": "",
|
||||
"period_scope": "all",
|
||||
"period_id": june.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert rejected.status_code == 409, rejected.content
|
||||
assert rejected.json()["code"] == "PERIOD_COMPONENTS_LOCKED"
|
||||
|
||||
|
||||
def test_component_settings_reject_negative_point_base_and_roll_back(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
|
||||
@ -26,6 +26,7 @@ from finance.payroll.carryovers import (
|
||||
materialize_pending_carryovers,
|
||||
)
|
||||
from finance.payroll.exceptions import PayrollOperationError
|
||||
from finance.payroll.services import close_period
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -352,3 +353,68 @@ def test_component_setting_adds_existing_values_to_carryover_balance(
|
||||
)
|
||||
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
|
||||
|
||||
@ -25,6 +25,7 @@ from finance.models import (
|
||||
FinanceSettings,
|
||||
PayrollAuditEvent,
|
||||
PayrollComponent,
|
||||
PayrollCustomComponent,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
PayrollRun,
|
||||
@ -32,6 +33,7 @@ from finance.models import (
|
||||
PayrollStatementAcknowledgement,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
from finance.payroll.components import get_payroll_components
|
||||
from finance.payroll.config import DEFAULT_CONFIG
|
||||
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
|
||||
from finance.payroll.services import (
|
||||
@ -589,6 +591,97 @@ def test_closed_period_cannot_be_recalculated_or_change_its_statement(
|
||||
assert PayrollRun.objects.filter(period=june_period).count() == original_run_count
|
||||
|
||||
|
||||
def test_closing_period_freezes_component_configuration(
|
||||
june_period,
|
||||
payroll_users,
|
||||
):
|
||||
settings = FinanceSettings.get_solo()
|
||||
settings.payroll_component_overrides = {
|
||||
"BONUS": {
|
||||
"name": "Премия до закрытия",
|
||||
"kind": "earning",
|
||||
"requires_reason": False,
|
||||
"include_in_point_base": True,
|
||||
"include_in_carryover": False,
|
||||
"visible_to_employee": True,
|
||||
"highlight_color": "green",
|
||||
"period_scope": "all",
|
||||
"scope_period_id": None,
|
||||
"scheduled_target_component_code": "",
|
||||
}
|
||||
}
|
||||
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
|
||||
custom = PayrollCustomComponent.objects.create(
|
||||
name="Историческая колонка",
|
||||
kind="earning",
|
||||
requires_reason=False,
|
||||
include_in_point_base=False,
|
||||
include_in_carryover=False,
|
||||
visible_to_employee=True,
|
||||
highlight_color="blue",
|
||||
period_scope="all",
|
||||
created_by=payroll_users["operator"],
|
||||
)
|
||||
june_period.status = PayrollPeriodStatus.PUBLISHED
|
||||
june_period.save(update_fields=["status", "updated_at"])
|
||||
|
||||
close_period(june_period.pk, actor=payroll_users["publisher"])
|
||||
june_period.refresh_from_db()
|
||||
|
||||
closed_components = {
|
||||
component.code: component for component in get_payroll_components(june_period)
|
||||
}
|
||||
assert june_period.component_snapshot["version"] == 1
|
||||
assert closed_components["BONUS"].name == "Премия до закрытия"
|
||||
assert closed_components["BONUS"].highlight_color == "green"
|
||||
assert closed_components[custom.code].name == "Историческая колонка"
|
||||
|
||||
settings.payroll_component_overrides["BONUS"] = {
|
||||
**settings.payroll_component_overrides["BONUS"],
|
||||
"name": "Премия после закрытия",
|
||||
"kind": "deduction",
|
||||
"include_in_point_base": False,
|
||||
"visible_to_employee": False,
|
||||
"highlight_color": "red",
|
||||
}
|
||||
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
|
||||
custom.delete()
|
||||
|
||||
live_components = {
|
||||
component.code: component for component in get_payroll_components()
|
||||
}
|
||||
historical_components = {
|
||||
component.code: component for component in get_payroll_components(june_period)
|
||||
}
|
||||
assert live_components["BONUS"].name == "Премия после закрытия"
|
||||
assert custom.code not in live_components
|
||||
assert historical_components["BONUS"].name == "Премия до закрытия"
|
||||
assert historical_components["BONUS"].kind == "earning"
|
||||
assert historical_components["BONUS"].include_in_point_base is True
|
||||
assert historical_components["BONUS"].visible_to_employee is True
|
||||
assert historical_components["BONUS"].highlight_color == "green"
|
||||
assert historical_components[custom.code].name == "Историческая колонка"
|
||||
assert historical_components[custom.code].highlight_color == "blue"
|
||||
|
||||
|
||||
def test_closed_period_component_snapshot_cannot_be_changed(
|
||||
june_period,
|
||||
payroll_users,
|
||||
):
|
||||
june_period.status = PayrollPeriodStatus.PUBLISHED
|
||||
june_period.save(update_fields=["status", "updated_at"])
|
||||
close_period(june_period.pk, actor=payroll_users["publisher"])
|
||||
june_period.refresh_from_db()
|
||||
original_snapshot = june_period.component_snapshot
|
||||
june_period.component_snapshot = {"version": 1, "components": []}
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
june_period.save(update_fields=["component_snapshot", "updated_at"])
|
||||
|
||||
june_period.refresh_from_db()
|
||||
assert june_period.component_snapshot == original_snapshot
|
||||
|
||||
|
||||
def test_override_permission_never_replaces_operational_approval_permissions(
|
||||
user_factory,
|
||||
):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user