fix(finance): freeze closed period columns

This commit is contained in:
lizerk 2026-09-11 17:49:40 +03:00
parent 63d77c38fa
commit 5adfdbbd25
12 changed files with 602 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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