Apply automatic payroll input transfer when closing periods
All checks were successful
Deploy production / deploy (push) Successful in 11m16s

This commit is contained in:
admin i 2026-10-19 10:47:57 +03:00
parent 3da3100328
commit abef43136b
20 changed files with 1342 additions and 442 deletions

View File

@ -3,22 +3,19 @@
from decimal import Decimal
from django.db.models import Count
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from employees.constants import ACTION_DISMISSED
from employees.models import Employee
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind
from finance.models import (
PayrollAuditEvent,
PayrollInputCopyConfiguration,
PayrollInputLine,
)
from finance.payroll.components import get_payroll_components
from finance.payroll.scheduled_changes import (
employee_is_eligible_for_scheduled_change,
is_generated_scheduled_change,
from finance.payroll.input_copy import (
_collapse_scheduled_effective_cells,
_copyable_input_lines,
_employee_active_for_period,
)
from ..admin_serializers import (
@ -26,7 +23,6 @@ from ..admin_serializers import (
PayrollEmployeeMiniSerializer,
PayrollPeriodSerializer,
)
from .common import _audit_api_mutation
def _validate_input_copy_periods(target_period, source_period):
@ -46,95 +42,13 @@ def _validate_input_copy_periods(target_period, source_period):
)
def _copyable_input_lines(source_period, *, target_period=None):
component_codes = {
component.code
for component in get_payroll_components(source_period)
if component.kind != PayrollComponentKind.SCHEDULED_CHANGE
}
if target_period is not None:
component_codes &= {
component.code
for component in get_payroll_components(target_period)
if component.kind != PayrollComponentKind.SCHEDULED_CHANGE
}
return (
PayrollInputLine.objects.filter(
period=source_period,
employee__is_hidden_in_ui=False,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
component_code__in=component_codes,
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.select_related(
"employee",
"employee__position",
"relates_to_period",
)
.prefetch_related(
"employee__departments_links__department",
"employee__actions__source_request",
)
.order_by("employee_id", "component_code", "id")
)
def _employee_active_for_period(employee, period):
if not employee.is_active:
return False
actions = employee._prefetched_objects_cache.get("actions")
state = resolve_employee_personnel_state(
employee,
period.date_to,
actions=actions,
)
return state.status != ACTION_DISMISSED
def _collapse_scheduled_effective_cells(lines):
"""Fold a marker-adjusted source cell into one ordinary copied value."""
lines = [
line
for line in lines
if not is_generated_scheduled_change(line)
or employee_is_eligible_for_scheduled_change(line.employee, line.period)
]
grouped = {}
for line in lines:
grouped.setdefault((line.employee_id, line.component_code), []).append(line)
result = []
for (employee_id, component_code), cell_lines in grouped.items():
if not any(is_generated_scheduled_change(line) for line in cell_lines):
result.extend(cell_lines)
continue
amount = sum((line.signed_amount for line in cell_lines), Decimal("0"))
if amount <= 0:
continue
source_ids = ",".join(str(line.pk) for line in cell_lines)
effective = PayrollInputLine(
period=cell_lines[0].period,
employee=cell_lines[0].employee,
component_code=component_code,
amount=amount,
value_sign=1,
relates_to_period=None,
reason="Перенесено итоговое значение с применённым маркером",
include_in_carryover=any(line.include_in_carryover for line in cell_lines),
created_by=cell_lines[0].created_by,
)
effective._copy_source_token = (
f"effective-{employee_id}-{component_code}-{source_ids}"
)
result.append(effective)
return result
def _input_copy_configuration_payload(actor):
configuration = PayrollInputCopyConfiguration.objects.filter(owner=actor).first()
configuration = (
PayrollInputCopyConfiguration.objects.filter(
auto_apply_on_period_closure=True
).first()
or PayrollInputCopyConfiguration.objects.filter(owner=actor).first()
)
if configuration is None:
return {
"saved": False,
@ -143,7 +57,7 @@ def _input_copy_configuration_payload(actor):
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "skip",
"auto_apply_on_period_creation": False,
"auto_apply_on_period_closure": False,
}
visible_employee_ids = list(
Employee.objects.visible_in_ui()
@ -157,7 +71,7 @@ def _input_copy_configuration_payload(actor):
"employee_scope": configuration.employee_scope,
"employee_ids": visible_employee_ids,
"existing_mode": configuration.existing_mode,
"auto_apply_on_period_creation": (configuration.auto_apply_on_period_creation),
"auto_apply_on_period_closure": configuration.auto_apply_on_period_closure,
}
@ -252,171 +166,3 @@ def _input_copy_preview(target_period, source_period, *, actor):
"employees": employees,
"configuration": _input_copy_configuration_payload(actor),
}
def _apply_input_copy(*, target_period, source_period, actor, data):
copy_prefix = (
f"payroll-period-copy:{target_period.pk}:{source_period.pk}:"
f"{data['idempotency_key']}:"
)
existing = list(
PayrollInputLine.objects.filter(
period=target_period,
source_ref__startswith=copy_prefix,
)
.order_by("id")
.values_list("id", flat=True)
)
if existing:
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(existing),
"employees": PayrollInputLine.objects.filter(pk__in=existing)
.values("employee_id")
.distinct()
.count(),
"skipped": 0,
"replaced_cells": 0,
"voided": 0,
},
"record_ids": existing,
"idempotent_replay": True,
}
lines = list(
_copyable_input_lines(
source_period,
target_period=target_period,
).filter(component_code__in=data["component_codes"])
)
lines = _collapse_scheduled_effective_cells(lines)
if data["employee_scope"] == "selected":
selected_ids = set(data["employee_ids"])
lines = [line for line in lines if line.employee_id in selected_ids]
elif data["employee_scope"] == "active":
lines = [
line
for line in lines
if _employee_active_for_period(line.employee, target_period)
]
selected_cells = {(line.employee_id, line.component_code) for line in lines}
existing_lines = []
if selected_cells:
possible_existing_lines = list(
PayrollInputLine.objects.select_for_update()
.filter(
period=target_period,
employee_id__in={employee_id for employee_id, _ in selected_cells},
component_code__in={
component_code for _, component_code in selected_cells
},
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.order_by("pk")
)
existing_lines = [
line
for line in possible_existing_lines
if (line.employee_id, line.component_code) in selected_cells
and not is_generated_scheduled_change(line)
and not (
line.source == InputSource.API
and line.source_ref.startswith("payroll-carryover:")
)
]
occupied_cells = {
(line.employee_id, line.component_code) for line in existing_lines
}
skipped = 0
replaced_cells = 0
voided = 0
if data["existing_mode"] == "skip" and occupied_cells:
kept_lines = []
for line in lines:
if (line.employee_id, line.component_code) in occupied_cells:
skipped += 1
else:
kept_lines.append(line)
lines = kept_lines
elif data["existing_mode"] == "replace" and existing_lines:
replaced_cells = len(occupied_cells)
voided = len(existing_lines)
now = timezone.now()
for line in existing_lines:
previous_status = line.status
line.status = ApprovalStatus.VOIDED
line.voided_by = actor
line.voided_at = now
line.void_reason = (
f"Заменено переносом записей из периода {source_period.code}"
)
line.save(
update_fields=[
"status",
"voided_by",
"voided_at",
"void_reason",
]
)
_audit_api_mutation(
actor=actor,
action="payroll.input_line_voided_from_period_copy",
instance=line,
period=target_period,
metadata={
"previous_status": previous_status,
"source_period_id": source_period.pk,
},
)
records = PayrollInputLine.objects.bulk_create(
[
PayrollInputLine(
period=target_period,
employee=line.employee,
component_code=line.component_code,
amount=line.amount,
value_sign=line.value_sign,
relates_to_period=line.relates_to_period,
reason=line.reason,
status=ApprovalStatus.DRAFT,
source=InputSource.API,
source_ref=(
f"{copy_prefix}{getattr(line, '_copy_source_token', line.pk)}"
),
include_in_carryover=line.include_in_carryover,
created_by=actor,
)
for line in lines
]
)
PayrollAuditEvent.objects.bulk_create(
[
PayrollAuditEvent(
actor=actor,
action="payroll.input_line_draft_created",
object_type=PayrollInputLine._meta.label_lower,
object_id=str(record.pk),
period=target_period,
metadata={
"channel": "admin_api",
"copied_from_period_id": source_period.pk,
"copied_from_input_line_id": source.pk,
},
)
for record, source in zip(records, lines, strict=True)
]
)
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(records),
"employees": len({record.employee_id for record in records}),
"skipped": skipped,
"replaced_cells": replaced_cells,
"voided": voided,
},
"record_ids": [record.pk for record in records],
"idempotent_replay": False,
}

View File

@ -15,10 +15,14 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from finance.models import (
PayrollInputCopyConfiguration,
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.automatic_input_copy import (
lock_automatic_input_copy_policy,
save_input_copy_configuration,
)
from finance.payroll.input_copy import apply_input_copy as _apply_input_copy
from finance.payroll.scheduled_changes import (
SCHEDULED_CHANGE_SOURCE_PREFIX,
materialize_scheduled_changes,
@ -44,7 +48,6 @@ from ..common import (
_optional_int,
)
from ..input_copy import (
_apply_input_copy,
_input_copy_preview,
_validate_input_copy_periods,
)
@ -169,6 +172,7 @@ class PayrollInputLinePeriodCopyView(PayrollAdminAPIView):
command = CopyPayrollInputLinesCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
data = command.validated_data
lock_automatic_input_copy_policy()
target_period = _lock_period_accepting_draft_edits(pk)
if target_period.lock_version != data["expected_period_lock_version"]:
_conflict(
@ -182,21 +186,14 @@ class PayrollInputLinePeriodCopyView(PayrollAdminAPIView):
pk=data["source_period_id"],
)
_validate_input_copy_periods(target_period, source_period)
if data["remember_configuration"]:
PayrollInputCopyConfiguration.objects.update_or_create(
owner=request.user,
defaults={
"component_codes": data["component_codes"],
"employee_scope": data["employee_scope"],
"employee_ids": data["employee_ids"],
"existing_mode": data["existing_mode"],
"auto_apply_on_period_creation": data[
"auto_apply_on_period_creation"
],
},
)
else:
PayrollInputCopyConfiguration.objects.filter(owner=request.user).delete()
save_input_copy_configuration(
actor=request.user,
data=data,
update_automatic_policy=(
"auto_apply_on_period_closure" in request.data
or "auto_apply_on_period_creation" in request.data
),
)
result = _apply_input_copy(
target_period=target_period,
source_period=source_period,

View File

@ -7,7 +7,6 @@ access to payroll data.
from __future__ import annotations
import uuid
from collections import defaultdict
from django.core.exceptions import ValidationError as DjangoValidationError
@ -22,11 +21,11 @@ from finance.enums import ApprovalStatus, PayrollComponentKind, PayrollPeriodSta
from finance.models import (
FinanceSettings,
PayrollCustomComponent,
PayrollInputCopyConfiguration,
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.adapter import point_base_accruals_for_employees
from finance.payroll.automatic_input_copy import lock_automatic_input_copy_policy
from finance.payroll.components import (
get_payroll_components,
)
@ -52,7 +51,6 @@ from ..common import (
_conflict,
_optional_int,
)
from ..input_copy import _apply_input_copy
def _reject_closed_component_period(period):
@ -429,6 +427,8 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
serializer = PayrollPeriodWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Serialize creation with closure before taking either period's lock.
lock_automatic_input_copy_policy()
period_values = dict(serializer.validated_data)
period_values.pop("expected_lock_version", None)
period = PayrollPeriod(
@ -464,41 +464,13 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView):
.order_by("-date_to", "-id")
.first()
)
automatic_copy = None
configuration = PayrollInputCopyConfiguration.objects.filter(
owner=request.user,
auto_apply_on_period_creation=True,
).first()
if configuration is not None and source_period is not None:
automatic_copy = _apply_input_copy(
target_period=period,
source_period=source_period,
actor=request.user,
data={
"component_codes": configuration.component_codes,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"existing_mode": configuration.existing_mode,
"idempotency_key": uuid.uuid4(),
},
)
_audit_api_mutation(
actor=request.user,
action="payroll.period_input_copy_auto_applied",
instance=period,
period=period,
metadata={
"source_period_id": source_period.pk,
**automatic_copy["summary"],
},
)
if source_period is not None:
materialize_scheduled_changes(
source_period=source_period,
target_period=period,
)
payload = PayrollPeriodSerializer(period).data
payload["automatic_input_copy"] = automatic_copy
payload["automatic_input_copy"] = None
return Response(
payload,
status=status.HTTP_201_CREATED,

View File

@ -78,9 +78,19 @@ class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
expected_period_lock_version = serializers.IntegerField(min_value=0)
idempotency_key = serializers.UUIDField()
remember_configuration = serializers.BooleanField(default=False)
auto_apply_on_period_creation = serializers.BooleanField(default=False)
auto_apply_on_period_closure = serializers.BooleanField(default=False)
# Accept cached older clients, but only the new closure trigger is used.
auto_apply_on_period_creation = serializers.BooleanField(
required=False, write_only=True
)
def validate(self, attrs):
legacy_auto = attrs.pop("auto_apply_on_period_creation", None)
if (
legacy_auto is not None
and "auto_apply_on_period_closure" not in self.initial_data
):
attrs["auto_apply_on_period_closure"] = legacy_auto
if not attrs.get("component_codes"):
raise serializers.ValidationError(
{"component_codes": "Выберите хотя бы один вид операции."}
@ -94,12 +104,12 @@ class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
if attrs["employee_scope"] != "selected":
attrs["employee_ids"] = []
if (
attrs["auto_apply_on_period_creation"]
attrs["auto_apply_on_period_closure"]
and not attrs["remember_configuration"]
):
raise serializers.ValidationError(
{
"auto_apply_on_period_creation": (
"auto_apply_on_period_closure": (
"Для автоприменения сначала сохраните конфигурацию."
)
}

View File

@ -28,6 +28,7 @@ Employee = get_user_model()
class PayrollPeriodSerializer(serializers.ModelSerializer):
automatic_input_copy = serializers.JSONField(read_only=True, default=None)
status_label = serializers.CharField(source="get_status_display", read_only=True)
current_run_id = serializers.IntegerField(read_only=True, allow_null=True)
created_by_id = serializers.IntegerField(read_only=True)
@ -45,6 +46,7 @@ class PayrollPeriodSerializer(serializers.ModelSerializer):
"status",
"status_label",
"current_run_id",
"automatic_input_copy",
"balance_rules_version",
"lock_version",
"created_by_id",

View File

@ -0,0 +1,48 @@
"""Move the saved automatic policy to closure without touching payroll data."""
from django.db import migrations, models
def keep_latest_automatic_policy(apps, schema_editor):
Configuration = apps.get_model("finance", "PayrollInputCopyConfiguration")
enabled = Configuration.objects.filter(auto_apply_on_period_closure=True).order_by(
"-updated_at", "-pk"
)
latest = enabled.values_list("pk", flat=True).first()
if latest is not None:
enabled.exclude(pk=latest).update(auto_apply_on_period_closure=False)
class Migration(migrations.Migration):
dependencies = [("finance", "0041_salary_participation_and_balance_rules")]
operations = [
# Keep the physical column compatible with the currently serving release.
# Only the Python field name and its semantics change in this migration.
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RenameField(
model_name="payrollinputcopyconfiguration",
old_name="auto_apply_on_period_creation",
new_name="auto_apply_on_period_closure",
),
migrations.AlterField(
model_name="payrollinputcopyconfiguration",
name="auto_apply_on_period_closure",
field=models.BooleanField(
default=False,
db_column="auto_apply_on_period_creation",
verbose_name="Автоматически применять при закрытии периода",
),
),
],
),
migrations.RunPython(keep_latest_automatic_policy, migrations.RunPython.noop),
migrations.AddConstraint(
model_name="payrollinputcopyconfiguration",
constraint=models.UniqueConstraint(
fields=("auto_apply_on_period_closure",),
condition=models.Q(auto_apply_on_period_closure=True),
name="pay_input_copy_single_auto",
),
),
]

View File

@ -1542,7 +1542,7 @@ class VacationPayInstruction(models.Model):
class PayrollInputCopyConfiguration(models.Model):
"""Per-operator defaults for copying input lines between payroll periods."""
"""Operator copy defaults; one also defines the shared automatic policy."""
owner = models.OneToOneField(
settings.AUTH_USER_MODEL,
@ -1568,15 +1568,24 @@ class PayrollInputCopyConfiguration(models.Model):
choices=(("skip", "Пропустить"), ("replace", "Заменить")),
default="skip",
)
auto_apply_on_period_creation = models.BooleanField(
"Автоматически применять при создании периода",
auto_apply_on_period_closure = models.BooleanField(
"Автоматически применять при закрытии периода",
default=False,
# Blue-green deployment keeps the previous release online during migrate.
db_column="auto_apply_on_period_creation",
)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
class Meta:
verbose_name = "Конфигурация переноса зарплатных записей"
verbose_name_plural = "Конфигурации переноса зарплатных записей"
constraints = [
models.UniqueConstraint(
fields=["auto_apply_on_period_closure"],
condition=Q(auto_apply_on_period_closure=True),
name="pay_input_copy_single_auto",
)
]
def __str__(self):
return f"Перенос зарплатных записей: {self.owner_id}"

View File

@ -0,0 +1,142 @@
"""A single saved automatic policy, applied transactionally on month closure."""
import uuid
from datetime import date
from django.core.exceptions import ValidationError
from django.db import connection, transaction
from finance.enums import PayrollPeriodStatus
from finance.models import (
FinanceSettings,
PayrollAuditEvent,
PayrollInputCopyConfiguration,
PayrollPeriod,
)
from finance.payroll.exceptions import PayrollOperationError
from finance.payroll.input_copy import apply_input_copy
from finance.payroll.periods import ensure_calendar_payroll_period
from finance.payroll.scheduled_changes import materialize_scheduled_changes
def lock_automatic_input_copy_policy():
"""Take this lock before period locks in both configuration and close paths."""
if connection.vendor == "postgresql":
# A transaction-scoped advisory lock also works before the settings row
# exists. Do not depend on racing first-time get_solo() inserts.
with connection.cursor() as cursor:
cursor.execute(
"SELECT pg_advisory_xact_lock(%s, %s)", [0x50415952, 0x434F5059]
)
return
settings = FinanceSettings.objects.first()
if settings is None:
settings, _ = FinanceSettings.objects.get_or_create(pk=1)
FinanceSettings.objects.select_for_update().get(pk=settings.pk)
def save_input_copy_configuration(*, actor, data, update_automatic_policy=True):
"""The caller holds the policy lock and performs the manual copy atomically."""
if update_automatic_policy:
PayrollInputCopyConfiguration.objects.filter(
auto_apply_on_period_closure=True
).update(auto_apply_on_period_closure=False)
if not data["remember_configuration"]:
PayrollInputCopyConfiguration.objects.filter(owner=actor).delete()
return
defaults = {
field: data[field]
for field in (
"component_codes",
"employee_scope",
"employee_ids",
"existing_mode",
)
}
# A serializer default is not an explicit request to switch automation off.
# Omitting this field also preserves it on an existing owner's configuration.
if update_automatic_policy:
defaults["auto_apply_on_period_closure"] = data["auto_apply_on_period_closure"]
PayrollInputCopyConfiguration.objects.update_or_create(
owner=actor,
defaults=defaults,
)
@transaction.atomic
def copy_inputs_after_period_close(*, source_period, actor):
"""Only called in the closing transaction, with policy and source locked."""
configuration = PayrollInputCopyConfiguration.objects.filter(
auto_apply_on_period_closure=True
).first()
if configuration is None:
return None
if source_period.status != PayrollPeriodStatus.CLOSED:
raise PayrollOperationError(
"SOURCE_PERIOD_NOT_CLOSED",
"Автоперенос выполняется только при закрытии периода.",
)
year, month = source_period.date_to.year, source_period.date_to.month
target_start = date(year + (month == 12), 1 if month == 12 else month + 1, 1)
try:
target, created = ensure_calendar_payroll_period(
target_start,
currency=source_period.currency,
actor=actor,
channel="period_closure",
metadata={"source_period_id": source_period.pk},
)
except ValidationError as exc:
raise PayrollOperationError(
"NEXT_PERIOD_CONFLICT",
"Не удалось создать следующий месяц для автопереноса. Проверьте даты и валюту существующих периодов.",
) from exc
target = PayrollPeriod.objects.select_for_update().get(pk=target.pk)
if target.status != PayrollPeriodStatus.OPEN or target.current_run_id is not None:
raise PayrollOperationError(
"NEXT_PERIOD_INPUTS_LOCKED",
"Следующий период уже рассчитан или закрыт. Его данные не изменены. Отключите автоматический перенос, чтобы закрыть исходный месяц; при необходимости выполните перенос вручную после возврата следующего периода на исправление.",
details={"target_period_id": target.pk, "target_period_code": target.code},
)
data = {
field: getattr(configuration, field)
for field in (
"component_codes",
"employee_scope",
"employee_ids",
"existing_mode",
)
}
data["idempotency_key"] = uuid.uuid5(
uuid.NAMESPACE_URL, f"eusrr:payroll-close-copy:{source_period.pk}:{target.pk}"
)
result = apply_input_copy(
target_period=target,
source_period=source_period,
actor=actor,
data=data,
approved_only=True,
channel="period_closure",
)
materialize_scheduled_changes(source_period=source_period, target_period=target)
result.update(
target_period_code=target.code,
target_period_created=created,
configuration_owner_id=configuration.owner_id,
)
PayrollAuditEvent.objects.create(
actor=actor,
action="payroll.period_input_copy_auto_applied",
object_type=source_period._meta.label_lower,
object_id=str(source_period.pk),
period=source_period,
metadata={
"channel": "period_closure",
"trigger": "period_closed",
"target_period_id": target.pk,
"target_period_created": created,
"configuration_owner_id": configuration.owner_id,
**result["summary"],
},
)
return result

View File

@ -0,0 +1,292 @@
"""Shared payroll input-copy service, independent of HTTP adapters."""
from decimal import Decimal
from django.utils import timezone
from employees.constants import ACTION_DISMISSED
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind
from finance.models import PayrollAuditEvent, PayrollInputLine
from finance.payroll.components import get_payroll_components
from finance.payroll.scheduled_changes import (
employee_is_eligible_for_scheduled_change,
is_generated_scheduled_change,
)
def _audit_copy_mutation(*, actor, action, instance, period, metadata):
PayrollAuditEvent.objects.create(
actor=actor,
action=action,
object_type=instance._meta.label_lower,
object_id=str(instance.pk),
period=period,
metadata=metadata,
)
def _copyable_input_lines(source_period, *, target_period=None):
component_codes = {
component.code
for component in get_payroll_components(source_period)
if component.kind != PayrollComponentKind.SCHEDULED_CHANGE
}
if target_period is not None:
component_codes &= {
component.code
for component in get_payroll_components(target_period)
if component.kind != PayrollComponentKind.SCHEDULED_CHANGE
}
return (
PayrollInputLine.objects.filter(
period=source_period,
employee__is_hidden_in_ui=False,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
component_code__in=component_codes,
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.select_related(
"employee",
"employee__position",
"relates_to_period",
)
.prefetch_related(
"employee__departments_links__department",
"employee__actions__source_request",
)
.order_by("employee_id", "component_code", "id")
)
def _employee_active_for_period(employee, period):
if not employee.is_active:
return False
actions = employee._prefetched_objects_cache.get("actions")
state = resolve_employee_personnel_state(
employee,
period.date_to,
actions=actions,
)
return state.status != ACTION_DISMISSED
def _collapse_scheduled_effective_cells(lines):
"""Fold a marker-adjusted source cell into one ordinary copied value."""
lines = [
line
for line in lines
if not is_generated_scheduled_change(line)
or employee_is_eligible_for_scheduled_change(line.employee, line.period)
]
grouped = {}
for line in lines:
grouped.setdefault((line.employee_id, line.component_code), []).append(line)
result = []
for (employee_id, component_code), cell_lines in grouped.items():
if not any(is_generated_scheduled_change(line) for line in cell_lines):
result.extend(cell_lines)
continue
amount = sum((line.signed_amount for line in cell_lines), Decimal("0"))
if amount <= 0:
continue
source_ids = ",".join(str(line.pk) for line in cell_lines)
effective = PayrollInputLine(
period=cell_lines[0].period,
employee=cell_lines[0].employee,
component_code=component_code,
amount=amount,
value_sign=1,
relates_to_period=None,
reason="Перенесено итоговое значение с применённым маркером",
include_in_carryover=any(line.include_in_carryover for line in cell_lines),
created_by=cell_lines[0].created_by,
)
effective._copy_source_token = (
f"effective-{employee_id}-{component_code}-{source_ids}"
)
result.append(effective)
return result
def apply_input_copy(
*,
target_period,
source_period,
actor,
data,
approved_only=False,
channel="admin_api",
):
copy_prefix = (
f"payroll-period-copy:{target_period.pk}:{source_period.pk}:"
f"{data['idempotency_key']}:"
)
existing = list(
PayrollInputLine.objects.filter(
period=target_period,
source_ref__startswith=copy_prefix,
)
.order_by("id")
.values_list("id", flat=True)
)
if existing:
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(existing),
"employees": PayrollInputLine.objects.filter(pk__in=existing)
.values("employee_id")
.distinct()
.count(),
"skipped": 0,
"replaced_cells": 0,
"voided": 0,
},
"record_ids": existing,
"idempotent_replay": True,
}
lines = list(
_copyable_input_lines(
source_period,
target_period=target_period,
).filter(component_code__in=data["component_codes"])
)
if approved_only:
lines = [line for line in lines if line.status == ApprovalStatus.APPROVED]
lines = _collapse_scheduled_effective_cells(lines)
if data["employee_scope"] == "selected":
selected_ids = set(data["employee_ids"])
lines = [line for line in lines if line.employee_id in selected_ids]
elif data["employee_scope"] == "active":
lines = [
line
for line in lines
if _employee_active_for_period(line.employee, target_period)
]
selected_cells = {(line.employee_id, line.component_code) for line in lines}
existing_lines = []
if selected_cells:
possible_existing_lines = list(
PayrollInputLine.objects.select_for_update()
.filter(
period=target_period,
employee_id__in={employee_id for employee_id, _ in selected_cells},
component_code__in={
component_code for _, component_code in selected_cells
},
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.order_by("pk")
)
existing_lines = [
line
for line in possible_existing_lines
if (line.employee_id, line.component_code) in selected_cells
and not is_generated_scheduled_change(line)
and not (
line.source == InputSource.API
and line.source_ref.startswith("payroll-carryover:")
)
]
occupied_cells = {
(line.employee_id, line.component_code) for line in existing_lines
}
skipped = 0
replaced_cells = 0
voided = 0
if data["existing_mode"] == "skip" and occupied_cells:
kept_lines = []
for line in lines:
if (line.employee_id, line.component_code) in occupied_cells:
skipped += 1
else:
kept_lines.append(line)
lines = kept_lines
elif data["existing_mode"] == "replace" and existing_lines:
replaced_cells = len(occupied_cells)
voided = len(existing_lines)
now = timezone.now()
for line in existing_lines:
previous_status = line.status
line.status = ApprovalStatus.VOIDED
line.voided_by = actor
line.voided_at = now
line.void_reason = (
f"Заменено переносом записей из периода {source_period.code}"
)
line.save(
update_fields=[
"status",
"voided_by",
"voided_at",
"void_reason",
]
)
_audit_copy_mutation(
actor=actor,
action="payroll.input_line_voided_from_period_copy",
instance=line,
period=target_period,
metadata={
"channel": channel,
"previous_status": previous_status,
"source_period_id": source_period.pk,
},
)
records = PayrollInputLine.objects.bulk_create(
[
PayrollInputLine(
period=target_period,
employee=line.employee,
component_code=line.component_code,
amount=line.amount,
value_sign=line.value_sign,
relates_to_period=line.relates_to_period,
reason=line.reason,
status=ApprovalStatus.DRAFT,
source=InputSource.API,
source_ref=(
f"{copy_prefix}{getattr(line, '_copy_source_token', line.pk)}"
),
include_in_carryover=line.include_in_carryover,
created_by=actor,
)
for line in lines
]
)
PayrollAuditEvent.objects.bulk_create(
[
PayrollAuditEvent(
actor=actor,
action="payroll.input_line_draft_created",
object_type=PayrollInputLine._meta.label_lower,
object_id=str(record.pk),
period=target_period,
metadata={
"channel": channel,
"copied_from_period_id": source_period.pk,
"copied_from_input_line_id": source.pk,
},
)
for record, source in zip(records, lines, strict=True)
]
)
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(records),
"employees": len({record.employee_id for record in records}),
"skipped": skipped,
"replaced_cells": replaced_cells,
"voided": voided,
},
"record_ids": [record.pk for record in records],
"idempotent_replay": False,
}

View File

@ -0,0 +1,112 @@
"""Calendar payroll period factory shared by scheduling and period closure."""
import logging
from calendar import monthrange
from datetime import date
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from finance.models import PayrollAuditEvent, PayrollPeriod
logger = logging.getLogger(__name__)
MONTH_NAMES_RU = (
"",
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
)
def ensure_calendar_payroll_period(
reference_date: date,
*,
currency="RUB",
actor=None,
channel="automation",
metadata=None,
):
"""Create the open payroll period for a month unless it already exists."""
date_from = reference_date.replace(day=1)
date_to = reference_date.replace(
day=monthrange(reference_date.year, reference_date.month)[1]
)
existing = PayrollPeriod.objects.filter(
date_from=date_from,
date_to=date_to,
currency=currency,
).first()
if existing is not None:
return existing, False
period = PayrollPeriod(
code=date_from.strftime("%Y-%m"),
name=f"{MONTH_NAMES_RU[date_from.month]} {date_from.year}",
date_from=date_from,
date_to=date_to,
currency=currency,
created_by=actor,
)
try:
with transaction.atomic():
period.full_clean()
period.save(force_insert=True)
PayrollAuditEvent.objects.create(
actor=actor,
action="payroll.period_created",
object_type=period._meta.label_lower,
object_id=str(period.pk),
period=period,
metadata={"channel": channel, **(metadata or {})},
)
except IntegrityError:
# Celery retries or two workers can race. The database constraint makes
# creation safe; return the winner when it covers the same month.
existing = PayrollPeriod.objects.filter(
date_from=date_from,
date_to=date_to,
currency=currency,
).first()
if existing is not None:
return existing, False
raise
except ValidationError as error:
# A concurrent opening can win between the first lookup and full_clean.
existing = PayrollPeriod.objects.filter(
date_from=date_from, date_to=date_to, currency=currency
).first()
if existing is not None:
return existing, False
logger.exception(
"Payroll period opening failed",
extra={
"event": "finance.payroll_period.open_failed",
"payroll_period_id": period.pk,
"period_code": period.code,
"error_type": type(error).__name__,
},
)
raise
logger.info(
"Payroll period opened",
extra={
"event": "finance.payroll_period.opened",
"payroll_period_id": period.pk,
"period_code": period.code,
},
)
return period, True

View File

@ -51,6 +51,10 @@ from .adapter import (
employee_snapshot,
point_base_accruals_for_employees,
)
from .automatic_input_copy import (
copy_inputs_after_period_close,
lock_automatic_input_copy_policy,
)
from .balances import opening_balances
from .components import (
build_payroll_component_snapshot,
@ -865,7 +869,10 @@ def publish_run(run_id: int, *, actor) -> PayrollRun:
@transaction.atomic
def close_period(period_id: int, *, actor) -> PayrollPeriod:
_require_permission(actor, "finance.publish_payroll")
lock_automatic_input_copy_policy()
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
if period.status == PayrollPeriodStatus.CLOSED:
return period
if period.status != PayrollPeriodStatus.PUBLISHED:
_operation_error(
"INVALID_PERIOD_STATE",
@ -875,6 +882,9 @@ def close_period(period_id: int, *, actor) -> PayrollPeriod:
period.status = PayrollPeriodStatus.CLOSED
period.save(update_fields=["component_snapshot", "status", "updated_at"])
_audit(actor=actor, action="payroll.period_closed", instance=period, period=period)
period.automatic_input_copy = copy_inputs_after_period_close(
source_period=period, actor=actor
)
return period
@ -892,6 +902,7 @@ def complete_period_workflow(
if not has_simple_admin_access(actor):
raise PayrollPermissionDenied("finance.full_payroll_admin")
lock_automatic_input_copy_policy()
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
run = (
PayrollRun.objects.select_for_update().get(pk=period.current_run_id)

View File

@ -2,105 +2,20 @@
from __future__ import annotations
import logging
from calendar import monthrange
from datetime import date
from celery import shared_task
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.utils import timezone
from .models import PayrollAuditEvent, PayrollPeriod
logger = logging.getLogger(__name__)
MONTH_NAMES_RU = (
"",
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
)
from finance.payroll.periods import ensure_calendar_payroll_period
def ensure_monthly_payroll_period(reference_date: date | None = None):
"""Create the open payroll period for a month unless it already exists."""
reference_date = reference_date or timezone.localdate()
date_from = reference_date.replace(day=1)
date_to = reference_date.replace(
day=monthrange(reference_date.year, reference_date.month)[1]
return ensure_calendar_payroll_period(
reference_date or timezone.localdate(),
metadata={"schedule": "monthly"},
)
existing = PayrollPeriod.objects.filter(
date_from=date_from,
date_to=date_to,
).first()
if existing is not None:
return existing, False
period = PayrollPeriod(
code=date_from.strftime("%Y-%m"),
name=f"{MONTH_NAMES_RU[date_from.month]} {date_from.year}",
date_from=date_from,
date_to=date_to,
currency="RUB",
created_by=None,
)
try:
with transaction.atomic():
period.full_clean()
period.save(force_insert=True)
PayrollAuditEvent.objects.create(
actor=None,
action="payroll.period_created",
object_type=period._meta.label_lower,
object_id=str(period.pk),
period=period,
metadata={"channel": "automation", "schedule": "monthly"},
)
except IntegrityError:
# Celery retries or two workers can race. The database constraint makes
# creation safe; return the winner when it covers the same month.
existing = PayrollPeriod.objects.filter(
date_from=date_from,
date_to=date_to,
).first()
if existing is not None:
return existing, False
raise
except ValidationError as error:
logger.exception(
"Payroll period opening failed",
extra={
"event": "finance.payroll_period.open_failed",
"payroll_period_id": period.pk,
"period_code": period.code,
"error_type": type(error).__name__,
},
)
raise
logger.info(
"Payroll period opened",
extra={
"event": "finance.payroll_period.opened",
"payroll_period_id": period.pk,
"period_code": period.code,
},
)
return period, True
@shared_task
def open_monthly_payroll_period():

View File

@ -0,0 +1,489 @@
import calendar
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from decimal import Decimal
from importlib import import_module
from threading import Barrier
import pytest
from django.apps import apps
from django.db import IntegrityError, close_old_connections, connection, transaction
from django.urls import reverse
from django.utils import timezone
from finance.models import (
PayrollAuditEvent,
PayrollInputCopyConfiguration,
PayrollInputLine,
PayrollPeriod,
PayrollRun,
PayrollStatement,
)
from finance.payroll.automatic_input_copy import (
lock_automatic_input_copy_policy,
save_input_copy_configuration,
)
from finance.payroll.exceptions import PayrollOperationError
from finance.payroll.services import close_period
from finance.tasks import ensure_monthly_payroll_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):
owner, closer, employee = user_factory(), user_factory(), user_factory()
grant(closer, "publish_payroll", "view_all_payroll", "manage_payroll_inputs")
source = PayrollPeriod.objects.create(
code="2026-10",
name="Октябрь",
date_from=date(2026, 10, 1),
date_to=date(2026, 10, 31),
status="published",
created_by=owner,
)
configuration = PayrollInputCopyConfiguration.objects.create(
owner=owner,
component_codes=["BONUS"],
employee_scope="selected",
employee_ids=[employee.pk],
existing_mode="skip",
auto_apply_on_period_closure=True,
)
original = PayrollInputLine.objects.create(
period=source,
employee=employee,
component_code="BONUS",
amount="1234.56",
status="approved",
approved_by=closer,
approved_at=timezone.now(),
created_by=owner,
)
return (
owner,
closer,
employee,
source,
configuration,
original,
auth_client_factory(closer),
)
def target_period(setup, *, month=11, status="open"):
return PayrollPeriod.objects.create(
code=f"2026-{month:02d}",
date_from=date(2026, month, 1),
date_to=date(2026, month, calendar.monthrange(2026, month)[1]),
status=status,
created_by=setup[0],
)
def test_close_uses_shared_policy_creates_next_month_and_only_drafts(setup):
owner, closer, employee, source, _, original, client = setup
before = PayrollInputLine.objects.filter(pk=original.pk).values().get()
response = client.post(
reverse("api:v1:finance-payroll:admin-period-close", kwargs={"pk": source.pk}),
{},
format="json",
)
assert response.status_code == 200, response.content
source.refresh_from_db()
assert source.status == "closed"
transfer = response.json()["automatic_input_copy"]
following = PayrollPeriod.objects.get(code="2026-11")
assert following.date_from == date(2026, 11, 1) and following.date_to == date(
2026, 11, 30
)
assert following.status == "open" and following.current_run_id is None
assert transfer["target_period_created"] is True
assert transfer["configuration_owner_id"] == owner.pk
assert transfer["summary"]["created"] == 1
copied = PayrollInputLine.objects.get(period=following)
assert copied.employee == employee and copied.amount == Decimal("1234.56")
assert copied.status == "draft" and copied.approved_by is None
assert copied.created_by == closer
assert PayrollInputLine.objects.filter(pk=original.pk).values().get() == before
assert not PayrollStatement.objects.exists()
event = PayrollAuditEvent.objects.get(
action="payroll.period_input_copy_auto_applied"
)
assert event.period == source and event.actor == closer
assert event.metadata["trigger"] == "period_closed"
frozen = source.component_snapshot
close_period(source.pk, actor=closer)
source.refresh_from_db()
assert source.component_snapshot == frozen
assert PayrollInputLine.objects.filter(period=following).count() == 1
assert (
PayrollAuditEvent.objects.filter(
action="payroll.period_input_copy_auto_applied"
).count()
== 1
)
@pytest.mark.parametrize(
"mode,count,voided", [("skip", 1, False), ("replace", 1, True)]
)
def test_existing_next_month_respects_saved_cell_policy(setup, mode, count, voided):
_, closer, employee, source, configuration, _, _ = setup
following = target_period(setup)
configuration.existing_mode = mode
configuration.save(update_fields=["existing_mode"])
existing = PayrollInputLine.objects.create(
period=following,
employee=employee,
component_code="BONUS",
amount="50",
created_by=closer,
)
closed = close_period(source.pk, actor=closer)
existing.refresh_from_db()
assert (existing.status == "voided") is voided
assert (
PayrollInputLine.objects.filter(period=following, status="draft").count()
== count
)
assert closed.automatic_input_copy["target_period_created"] is False
assert closed.automatic_input_copy["summary"]["created"] == int(mode == "replace")
def test_manual_and_background_opening_do_not_transfer_even_when_enabled(setup):
_, closer, _, source, _, _, client = setup
opened, _ = ensure_monthly_payroll_period(date(2026, 11, 1))
assert not PayrollInputLine.objects.filter(period=opened).exists()
assert not PayrollAuditEvent.objects.filter(
action="payroll.period_input_copy_auto_applied"
).exists()
response = client.post(
reverse("api:v1:finance-payroll:admin-period-list"),
{
"code": "2026-12",
"date_from": "2026-12-01",
"date_to": "2026-12-31",
"currency": "RUB",
},
format="json",
)
assert response.status_code == 201, response.content
assert response.json()["automatic_input_copy"] is None
assert not PayrollInputLine.objects.filter(period_id=response.json()["id"]).exists()
close_period(source.pk, actor=closer)
assert PayrollInputLine.objects.filter(period=opened).count() == 1
def test_closing_without_policy_does_not_create_future_period(setup):
_, closer, _, source, configuration, _, _ = setup
configuration.auto_apply_on_period_closure = False
configuration.save(update_fields=["auto_apply_on_period_closure"])
closed = close_period(source.pk, actor=closer)
assert closed.status == "closed" and closed.automatic_input_copy is None
assert not PayrollPeriod.objects.filter(code="2026-11").exists()
@pytest.mark.parametrize(
"status", ["calculated", "review", "approved", "published", "closed"]
)
def test_next_calculated_or_closed_period_is_not_modified_and_source_stays_open(
setup, status
):
_, closer, _, source, _, _, _ = setup
following = target_period(setup, status=status)
frozen = PayrollPeriod.objects.filter(pk=following.pk).values().get()
with pytest.raises(PayrollOperationError) as exc:
close_period(source.pk, actor=closer)
assert exc.value.code == "NEXT_PERIOD_INPUTS_LOCKED"
source.refresh_from_db()
assert source.status == "published" and not source.component_snapshot
assert PayrollPeriod.objects.filter(pk=following.pk).values().get() == frozen
assert not PayrollInputLine.objects.filter(period=following).exists()
assert not PayrollAuditEvent.objects.filter(action="payroll.period_closed").exists()
def test_copy_failure_rolls_back_source_close_next_period_and_all_audits(
setup, monkeypatch
):
_, closer, _, source, _, _, _ = setup
def fail(**kwargs):
raise RuntimeError("Copy failure")
monkeypatch.setattr("finance.payroll.automatic_input_copy.apply_input_copy", fail)
with pytest.raises(RuntimeError):
close_period(source.pk, actor=closer)
source.refresh_from_db()
assert source.status == "published" and not source.component_snapshot
assert not PayrollPeriod.objects.filter(code="2026-11").exists()
assert not PayrollAuditEvent.objects.filter(action="payroll.period_closed").exists()
def test_only_final_approved_source_values_are_automatically_copied(setup):
_, closer, employee, source, _, _, _ = setup
PayrollInputLine.objects.create(
period=source,
employee=employee,
component_code="BONUS",
amount="100",
created_by=closer,
)
close_period(source.pk, actor=closer)
assert PayrollInputLine.objects.get(period__code="2026-11").amount == Decimal(
"1234.56"
)
def test_shared_policy_is_visible_and_can_be_replaced_by_another_manager(setup):
owner, closer, _, source, old, _, client = setup
following = target_period(setup)
preview = client.get(
reverse(
"api:v1:finance-payroll:admin-period-copy-input-lines",
kwargs={"pk": following.pk},
),
{"source_period_id": source.pk},
)
assert preview.status_code == 200, preview.content
assert preview.json()["configuration"]["auto_apply_on_period_closure"] is True
assert preview.json()["configuration"]["component_codes"] == ["BONUS"]
with transaction.atomic():
lock_automatic_input_copy_policy()
save_input_copy_configuration(
actor=closer,
data={
"component_codes": ["ADVANCE"],
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "skip",
"remember_configuration": True,
"auto_apply_on_period_closure": True,
},
)
old.refresh_from_db()
assert old.owner == owner and old.auto_apply_on_period_closure is False
assert (
PayrollInputCopyConfiguration.objects.get(
auto_apply_on_period_closure=True
).owner
== closer
)
with pytest.raises(IntegrityError), transaction.atomic():
PayrollInputCopyConfiguration.objects.filter(pk=old.pk).update(
auto_apply_on_period_closure=True
)
def test_migration_retains_current_enabled_policy(setup):
_, _, _, _, configuration, _, _ = setup
import_module(
"finance.migrations.0042_input_copy_on_period_closure"
).keep_latest_automatic_policy(apps, None)
configuration.refresh_from_db()
assert configuration.auto_apply_on_period_closure is True
assert configuration.component_codes == ["BONUS"]
def test_manual_copy_without_automatic_flag_keeps_owners_enabled_policy(setup):
owner, _, _, _, configuration, _, _ = setup
with transaction.atomic():
lock_automatic_input_copy_policy()
save_input_copy_configuration(
actor=owner,
data={
"component_codes": ["BONUS"],
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "skip",
"remember_configuration": True,
# Serializer default when a client omits both automatic flags.
"auto_apply_on_period_closure": False,
},
update_automatic_policy=False,
)
configuration.refresh_from_db()
assert configuration.auto_apply_on_period_closure is True
def test_open_target_with_existing_run_is_protected(setup):
_, closer, _, source, _, _, _ = setup
following = target_period(setup)
run = PayrollRun.objects.create(period=following, revision=1, requested_by=closer)
PayrollPeriod.objects.filter(pk=following.pk).update(current_run=run)
with pytest.raises(PayrollOperationError) as exc:
close_period(source.pk, actor=closer)
assert exc.value.code == "NEXT_PERIOD_INPUTS_LOCKED"
source.refresh_from_db()
assert source.status == "published"
assert not PayrollInputLine.objects.filter(period=following).exists()
@pytest.mark.parametrize("blocked_target", [False, True])
def test_complete_workflow_transfers_or_rolls_back_every_stage(
setup, auth_client_factory, blocked_target
):
owner, closer, employee, source, _, _, _ = setup
closer.is_staff = True
closer.save(update_fields=["is_staff"])
PayrollPeriod.objects.filter(pk=source.pk).update(status="open")
make_approved_period_inputs(
period=source, employee=employee, creator=owner, approver=closer
)
if blocked_target:
following = target_period(setup, status="published")
before_target = PayrollPeriod.objects.filter(pk=following.pk).values().get()
response = auth_client_factory(closer).post(
reverse(
"api:v1:finance-payroll:admin-period-complete-workflow",
kwargs={"pk": source.pk},
),
{"expected_lock_version": 0},
format="json",
)
source.refresh_from_db()
if blocked_target:
assert response.status_code == 409, response.content
assert response.json()["code"] == "NEXT_PERIOD_INPUTS_LOCKED"
assert source.status == "open" and source.current_run_id is None
assert source.lock_version == 0 and not source.component_snapshot
assert not PayrollRun.objects.exists()
assert not PayrollStatement.objects.exists()
assert not PayrollAuditEvent.objects.exists()
assert (
PayrollPeriod.objects.filter(pk=following.pk).values().get()
== before_target
)
else:
assert response.status_code == 200, response.content
assert source.status == "closed"
assert (
response.json()["period"]["automatic_input_copy"]["summary"]["created"] == 1
)
assert PayrollRun.objects.filter(period=source, status="published").count() == 1
assert PayrollStatement.objects.filter(run__period=source).count() == 1
assert not PayrollRun.objects.filter(period__code="2026-11").exists()
assert PayrollInputLine.objects.get(period__code="2026-11").status == "draft"
@pytest.mark.parametrize("scope,expected", [("active", 0), ("selected", 1), ("all", 1)])
def test_inactive_employees_follow_saved_scope(setup, scope, expected):
_, closer, employee, source, configuration, _, _ = setup
employee.is_active = False
employee.save(update_fields=["is_active"])
configuration.employee_scope = scope
configuration.save(update_fields=["employee_scope"])
close_period(source.pk, actor=closer)
assert PayrollInputLine.objects.filter(period__code="2026-11").count() == expected
@pytest.mark.django_db(transaction=True)
def test_concurrent_close_copies_once_and_creates_one_next_period(setup):
if connection.vendor != "postgresql":
pytest.skip("Requires PostgreSQL row locks")
_, closer, _, source, _, _, _ = setup
barrier = Barrier(2)
def close():
close_old_connections()
try:
from django.contrib.auth import get_user_model
actor = get_user_model().objects.get(pk=closer.pk)
barrier.wait(timeout=15)
return close_period(source.pk, actor=actor).status
finally:
close_old_connections()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(close) for _ in range(2)]
assert [future.result(timeout=30) for future in futures] == ["closed", "closed"]
assert PayrollPeriod.objects.filter(code="2026-11").count() == 1
assert PayrollInputLine.objects.filter(period__code="2026-11").count() == 1
assert (
PayrollAuditEvent.objects.filter(
action="payroll.period_input_copy_auto_applied"
).count()
== 1
)
def test_close_creates_adjacent_month_not_an_unrelated_later_period(setup):
_, closer, _, source, _, _, _ = setup
december = target_period(setup, month=12)
close_period(source.pk, actor=closer)
assert PayrollInputLine.objects.filter(period__code="2026-11").count() == 1
assert not PayrollInputLine.objects.filter(period=december).exists()
def test_year_boundary_creates_january(setup):
_, closer, _, source, _, _, _ = setup
PayrollPeriod.objects.filter(pk=source.pk).update(
code="2026-12", date_from=date(2026, 12, 1), date_to=date(2026, 12, 31)
)
close_period(source.pk, actor=closer)
following = PayrollPeriod.objects.get(code="2027-01")
assert following.date_from == date(2027, 1, 1) and following.date_to == date(
2027, 1, 31
)
def test_target_date_overlap_rejects_close_without_partial_creation(setup):
_, closer, _, source, _, _, _ = setup
PayrollPeriod.objects.create(
code="part-november", date_from=date(2026, 11, 15), date_to=date(2026, 11, 30)
)
with pytest.raises(PayrollOperationError) as exc:
close_period(source.pk, actor=closer)
assert exc.value.code == "NEXT_PERIOD_CONFLICT"
source.refresh_from_db()
assert source.status == "published"
assert not PayrollPeriod.objects.filter(code="2026-11").exists()
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("opening", ["scheduler", "manual"])
def test_concurrent_opening_and_closing_uses_one_period_without_double_copy(
setup, opening
):
if connection.vendor != "postgresql":
pytest.skip("Requires PostgreSQL row locks")
_, closer, _, source, _, _, _ = setup
barrier = Barrier(2)
def work(close):
close_old_connections()
try:
from django.contrib.auth import get_user_model
actor = get_user_model().objects.get(pk=closer.pk)
barrier.wait(timeout=15)
if close:
return close_period(source.pk, actor=actor).status
if opening == "scheduler":
return ensure_monthly_payroll_period(date(2026, 11, 1))[0].code
from rest_framework.test import APIClient
client = APIClient()
client.force_authenticate(actor)
return client.post(
reverse("api:v1:finance-payroll:admin-period-list"),
{
"code": "2026-11",
"date_from": "2026-11-01",
"date_to": "2026-11-30",
"currency": "RUB",
},
format="json",
).status_code
finally:
close_old_connections()
with ThreadPoolExecutor(max_workers=2) as executor:
closing = executor.submit(work, True)
creating = executor.submit(work, False)
assert closing.result(timeout=30) == "closed"
assert creating.result(timeout=30) in ("2026-11", 201, 400, 409)
assert PayrollPeriod.objects.filter(code="2026-11").count() == 1
assert PayrollInputLine.objects.filter(period__code="2026-11").count() == 1

View File

@ -0,0 +1,84 @@
"""Run with migrations enabled, against the disposable test database only."""
from datetime import date, datetime, timedelta
import pytest
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.utils import timezone
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("enabled_count", [0, 1, 3])
def test_closure_migration_preserves_latest_policy_and_historical_records(
request, user_factory, enabled_count
):
if request.config.getoption("nomigrations", default=False):
pytest.skip("Requires the real migration graph; run without --nomigrations")
owners = [user_factory() for _ in range(3)]
before_target = ("finance", "0041_salary_participation_and_balance_rules")
after_target = ("finance", "0042_input_copy_on_period_closure")
executor = MigrationExecutor(connection)
executor.migrate([before_target])
try:
old_apps = executor.loader.project_state([before_target]).apps
Configuration = old_apps.get_model("finance", "PayrollInputCopyConfiguration")
Period = old_apps.get_model("finance", "PayrollPeriod")
Input = old_apps.get_model("finance", "PayrollInputLine")
source = Period.objects.create(
code="2026-08",
date_from=date(2026, 8, 1),
date_to=date(2026, 8, 31),
status="closed",
balance_rules_version=1,
component_snapshot={"version": 1, "components": []},
)
Input.objects.create(
period=source,
employee_id=owners[0].pk,
component_code="BONUS",
amount="123.45",
status="approved",
created_by_id=owners[1].pk,
approved_by_id=owners[2].pk,
approved_at=timezone.now(),
)
before_periods = list(Period.objects.order_by("pk").values())
before_inputs = list(Input.objects.order_by("pk").values())
saved_at = timezone.make_aware(datetime(2026, 10, 18, 12))
for index, owner in enumerate(owners):
config = Configuration.objects.create(
owner_id=owner.pk,
component_codes=["BONUS"],
auto_apply_on_period_creation=index < enabled_count,
)
# Equal timestamps exercise the deterministic primary-key tie break.
Configuration.objects.filter(pk=config.pk).update(
updated_at=saved_at - timedelta(days=1) if index == 0 else saved_at
)
expected_owner = owners[enabled_count - 1].pk if enabled_count else None
executor = MigrationExecutor(connection)
executor.migrate([after_target])
new_apps = executor.loader.project_state([after_target]).apps
Configuration = new_apps.get_model("finance", "PayrollInputCopyConfiguration")
enabled = Configuration.objects.filter(auto_apply_on_period_closure=True)
assert list(enabled.values_list("owner_id", flat=True)) == (
[expected_owner] if enabled_count else []
)
assert Configuration.objects.count() == 3
assert all(c.component_codes == ["BONUS"] for c in Configuration.objects.all())
# The old application release can still read its column during deployment.
OldConfiguration = old_apps.get_model("finance", "PayrollInputCopyConfiguration")
assert OldConfiguration.objects.count() == 3
assert list(
OldConfiguration.objects.filter(auto_apply_on_period_creation=True)
.values_list("owner_id", flat=True)
) == ([expected_owner] if enabled_count else [])
Period = new_apps.get_model("finance", "PayrollPeriod")
Input = new_apps.get_model("finance", "PayrollInputLine")
assert list(Period.objects.order_by("pk").values()) == before_periods
assert list(Input.objects.order_by("pk").values()) == before_inputs
finally:
# Keep the schema current for the following tests, even after a failure.
MigrationExecutor(connection).migrate([after_target])

View File

@ -867,8 +867,18 @@ def test_copy_input_lines_previews_scope_and_handles_existing_values(
format="json",
)
assert august.status_code == 201, august.content
assert august.json()["automatic_input_copy"]["source_period_id"] == target.pk
assert august.json()["automatic_input_copy"]["summary"]["created"] == 3
assert august.json()["automatic_input_copy"] is None
assert not PayrollInputLine.objects.filter(period_id=august.json()["id"]).exists()
checker = user_factory()
PayrollInputLine.objects.filter(period=target, status="draft").update(
status="approved", approved_by=checker, approved_at=timezone.now()
)
PayrollPeriod.objects.filter(pk=target.pk).update(status="published")
grant(manager, "publish_payroll")
closed = client.post(api_url("admin-period-close", pk=target.pk), {}, format="json")
assert closed.status_code == 200, closed.content
assert closed.json()["automatic_input_copy"]["source_period_id"] == target.pk
assert closed.json()["automatic_input_copy"]["summary"]["created"] == 3
august_lines = PayrollInputLine.objects.filter(
period_id=august.json()["id"],
status="draft",

View File

@ -851,7 +851,7 @@ def test_editing_and_clearing_marker_recalculates_open_target_once(
assert row["applied_scheduled_changes"] == []
def test_period_creation_copies_then_applies_pending_marker_once(
def test_period_closure_copies_then_applies_pending_marker_once(
scheduled_workspace,
):
workspace = scheduled_workspace
@ -885,7 +885,7 @@ def test_period_creation_copies_then_applies_pending_marker_once(
employee_scope="selected",
employee_ids=[employee.pk],
existing_mode="replace",
auto_apply_on_period_creation=True,
auto_apply_on_period_closure=True,
)
workspace["periods"][9].delete()
@ -903,7 +903,15 @@ def test_period_creation_copies_then_applies_pending_marker_once(
)
assert created.status_code == 201, created.content
assert created.json()["automatic_input_copy"]["summary"]["created"] == 1
assert created.json()["automatic_input_copy"] is None
PayrollInputLine.objects.filter(period=source, status="draft").update(
status="approved", approved_by=employee, approved_at=timezone.now()
)
PayrollPeriod.objects.filter(pk=source.pk).update(status="published")
grant(manager, "publish_payroll")
closed = client.post(api_url("admin-period-close", pk=source.pk), {}, format="json")
assert closed.status_code == 200, closed.content
assert closed.json()["automatic_input_copy"]["summary"]["created"] == 1
following = PayrollPeriod.objects.get(pk=created.json()["id"])
row = employee_row(period_table(client, following), employee)
assert row["component_amounts"]["BONUS"] == "1500.00"

View File

@ -709,7 +709,7 @@ export function PayrollAdminWorkspace({
);
const [accessModalOpen, setAccessModalOpen] = useState(false);
const createdPeriodIdRef = useRef<number | null>(null);
const createdPeriodInputCopyRef = useRef<PayrollInputCopyResult | null>(null);
const closedPeriodInputCopyRef = useRef<PayrollInputCopyResult | null>(null);
const selectedPeriod = workspace?.selected_period || null;
@ -1138,7 +1138,8 @@ export function PayrollAdminWorkspace({
title: "Закрыть период",
description: "Закрытый период больше нельзя пересчитывать.",
confirmLabel: "Закрыть период",
warning: "Это финальное действие для расчётного периода.",
warning:
"Это финальное действие. Если автоперенос включён, будут созданы черновики в следующем месяце; рассчитанный следующий период изменяться не будет.",
successMessage: "Период закрыт.",
execute: () => apiClient.closePayrollAdminPeriod(selectedPeriod.id),
},
@ -1177,7 +1178,7 @@ export function PayrollAdminWorkspace({
: undefined,
reasonRequired: requiresRecalculationReason,
warning:
"Расчётные листки станут доступны сотрудникам, а период будет закрыт. Если любой этап не пройдёт проверку, ни одно изменение не сохранится.",
"Расчётные листки станут доступны сотрудникам, а период будет закрыт. Если автоперенос включён, он создаст черновики в следующем месяце. Если любой этап не пройдёт проверку, ни одно изменение не сохранится.",
successMessage: "Расчёт опубликован, период закрыт.",
execute: (reason) =>
apiClient.completePayrollAdminPeriodWorkflow(selectedPeriod.id, {
@ -1309,8 +1310,6 @@ export function PayrollAdminWorkspace({
else {
const createdPeriod = await apiClient.createPayrollAdminPeriod(payload);
createdPeriodIdRef.current = createdPeriod.id;
createdPeriodInputCopyRef.current =
createdPeriod.automatic_input_copy || null;
}
};
const saveRate = async (payload: PayrollPayRateWrite) => {
@ -2427,15 +2426,10 @@ export function PayrollAdminWorkspace({
onSaved={async () => {
setPeriodModalOpen(false);
const createdPeriodId = createdPeriodIdRef.current;
const automaticInputCopy = createdPeriodInputCopyRef.current;
createdPeriodIdRef.current = null;
createdPeriodInputCopyRef.current = null;
if (createdPeriodId) {
const copiedCount = automaticInputCopy?.summary.created || 0;
setNotice(
copiedCount > 0
? `Период создан и выбран. Автоматически перенесено записей: ${copiedCount}.`
: "Период создан и выбран.",
"Период создан и выбран. Автоперенос выполняется при закрытии исходного месяца.",
);
await loadWorkspace(createdPeriodId, true);
setTableVersion((value) => value + 1);
@ -2708,9 +2702,26 @@ export function PayrollAdminWorkspace({
reasonRequired={pendingAction.reasonRequired}
warning={pendingAction.warning}
onClose={() => setPendingAction(null)}
onConfirm={pendingAction.execute}
onConfirm={async (reason) => {
closedPeriodInputCopyRef.current = null;
const result = await pendingAction.execute(reason);
const payload = result as {
period?: PayrollAdminPeriod;
automatic_input_copy?: PayrollInputCopyResult | null;
} | null;
closedPeriodInputCopyRef.current =
payload?.period?.automatic_input_copy ||
payload?.automatic_input_copy ||
null;
}}
onDone={async () => {
const message = pendingAction.successMessage;
const copy = closedPeriodInputCopyRef.current;
const message =
pendingAction.successMessage +
(copy
? ` Автоперенос в ${copy.target_period_code || "следующий месяц"}: создано черновиков — ${copy.summary.created}, пропущено заполненных — ${copy.summary.skipped}.`
: "");
closedPeriodInputCopyRef.current = null;
setPendingAction(null);
await refreshView(message);
}}

View File

@ -65,7 +65,7 @@ export function PayrollInputCopyModal({
const [existingMode, setExistingMode] =
useState<PayrollInputCopyExistingMode>("skip");
const [rememberConfiguration, setRememberConfiguration] = useState(false);
const [autoApplyOnCreation, setAutoApplyOnCreation] = useState(false);
const [autoApplyOnClosure, setAutoApplyOnClosure] = useState(false);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(Boolean(sourcePeriods.length));
const [busy, setBusy] = useState(false);
@ -105,8 +105,8 @@ export function PayrollInputCopyModal({
);
setExistingMode(payload.configuration.existing_mode);
setRememberConfiguration(payload.configuration.saved);
setAutoApplyOnCreation(
payload.configuration.auto_apply_on_period_creation,
setAutoApplyOnClosure(
payload.configuration.auto_apply_on_period_closure,
);
})
.catch((loadError: unknown) => {
@ -219,8 +219,8 @@ export function PayrollInputCopyModal({
expected_period_lock_version: period.lock_version,
idempotency_key: idempotencyKey,
remember_configuration: rememberConfiguration,
auto_apply_on_period_creation:
rememberConfiguration && autoApplyOnCreation,
auto_apply_on_period_closure:
rememberConfiguration && autoApplyOnClosure,
});
await onSaved(result);
} catch (submitError: unknown) {
@ -567,7 +567,7 @@ export function PayrollInputCopyModal({
checked={rememberConfiguration}
onChange={(event) => {
setRememberConfiguration(event.target.checked);
if (!event.target.checked) setAutoApplyOnCreation(false);
if (!event.target.checked) setAutoApplyOnClosure(false);
}}
/>
<span>
@ -586,20 +586,21 @@ export function PayrollInputCopyModal({
>
<input
type="checkbox"
checked={autoApplyOnCreation}
checked={autoApplyOnClosure}
disabled={!rememberConfiguration}
onChange={(event) =>
setAutoApplyOnCreation(event.target.checked)
setAutoApplyOnClosure(event.target.checked)
}
/>
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">
Автоматически применять при создании периода
Автоматически переносить при закрытии периода
</span>
<span className="app-text-muted mt-0.5 block text-xs">
При создании следующего периода брать ближайший предыдущий
период той же валюты и создавать черновики по этой
конфигурации.
Общая настройка финансов: при закрытии месяца любым
уполномоченным сотрудником выбранные записи переносятся
черновиками в следующий месяц. Если его ещё нет, он будет
создан. Открытие периода автоперенос не запускает.
</span>
</span>
</label>

View File

@ -907,7 +907,7 @@ export type PayrollInputCopyPreview = {
employee_scope: PayrollInputCopyEmployeeScope;
employee_ids: number[];
existing_mode: PayrollInputCopyExistingMode;
auto_apply_on_period_creation: boolean;
auto_apply_on_period_closure: boolean;
};
};
@ -920,10 +920,13 @@ export type PayrollInputCopyWrite = {
expected_period_lock_version: number;
idempotency_key: string;
remember_configuration: boolean;
auto_apply_on_period_creation: boolean;
auto_apply_on_period_closure: boolean;
};
export type PayrollInputCopyResult = {
target_period_code?: string;
target_period_created?: boolean;
configuration_owner_id?: number;
source_period_id: number;
target_period_id: number;
summary: {

View File

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createFinanceApi } from "./api/finance.ts";
test("saved automatic copy policy uses the closure trigger", async () => {
const calls = [];
const api = createFinanceApi(async (endpoint, options) => {
calls.push({ endpoint, options });
return {};
});
const payload = {
source_period_id: 6,
component_codes: ["BONUS"],
employee_scope: "active",
employee_ids: [],
existing_mode: "skip",
remember_configuration: true,
auto_apply_on_period_closure: true,
expected_period_lock_version: 0,
idempotency_key: "copy-key",
};
await api.copyPayrollAdminInputLines(7, payload);
await api.closePayrollAdminPeriod(7);
assert.equal(calls[0].endpoint, "/api/v1/finance/payroll/admin/periods/7/copy-input-lines/");
assert.deepEqual(JSON.parse(calls[0].options.body), payload);
assert(!("auto_apply_on_period_creation" in JSON.parse(calls[0].options.body)));
assert.equal(calls[1].endpoint, "/api/v1/finance/payroll/admin/periods/7/close/");
});
test("copy settings describe shared closure policy, not automatic opening", () => {
const source = readFileSync(new URL("../components/finance/admin/PayrollInputCopyModal.tsx", import.meta.url), "utf8");
assert(source.includes("Автоматически переносить при закрытии периода"));
assert(source.includes("Общая настройка финансов"));
assert(!source.includes("auto_apply_on_period_creation"));
assert(!source.includes("Автоматически применять при создании периода"));
});