Apply automatic payroll input transfer when closing periods
All checks were successful
Deploy production / deploy (push) Successful in 11m16s
All checks were successful
Deploy production / deploy (push) Successful in 11m16s
This commit is contained in:
parent
3da3100328
commit
abef43136b
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": (
|
||||
"Для автоприменения сначала сохраните конфигурацию."
|
||||
)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
142
backend/finance/payroll/automatic_input_copy.py
Normal file
142
backend/finance/payroll/automatic_input_copy.py
Normal 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
|
||||
292
backend/finance/payroll/input_copy.py
Normal file
292
backend/finance/payroll/input_copy.py
Normal 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,
|
||||
}
|
||||
112
backend/finance/payroll/periods.py
Normal file
112
backend/finance/payroll/periods.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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():
|
||||
|
||||
489
backend/tests/finance/test_automatic_input_copy.py
Normal file
489
backend/tests/finance/test_automatic_input_copy.py
Normal 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
|
||||
84
backend/tests/finance/test_automatic_input_copy_migration.py
Normal file
84
backend/tests/finance/test_automatic_input_copy_migration.py
Normal 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])
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
38
frontend/src/lib/payroll-period-closure.test.js
Normal file
38
frontend/src/lib/payroll-period-closure.test.js
Normal 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("Автоматически применять при создании периода"));
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user