Separate salary participation and preserve historical carryover rules
All checks were successful
Deploy production / deploy (push) Successful in 11m10s
All checks were successful
Deploy production / deploy (push) Successful in 11m10s
This commit is contained in:
parent
af26bfc6de
commit
3da3100328
@ -148,7 +148,14 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
|
||||
current = get_payroll_components().get(code=code)
|
||||
except LookupError as exc:
|
||||
raise ValidationError({"code": "Зарплатная колонка не найдена."}) from exc
|
||||
serializer = PayrollCustomComponentWriteSerializer(data=request.data)
|
||||
serializer = PayrollCustomComponentWriteSerializer(
|
||||
data={
|
||||
**request.data,
|
||||
"include_in_salary": request.data.get(
|
||||
"include_in_salary", current.include_in_salary
|
||||
),
|
||||
}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
operation_period = PayrollPeriod.objects.filter(
|
||||
pk=request.data.get("period_id")
|
||||
@ -156,6 +163,16 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
|
||||
_reject_closed_component_period(operation_period)
|
||||
values = dict(serializer.validated_data)
|
||||
scope_period = values.pop("scope_period", None)
|
||||
if (
|
||||
operation_period is not None
|
||||
and operation_period.balance_rules_version == 1
|
||||
and values["include_in_salary"] != current.include_in_salary
|
||||
):
|
||||
_conflict(
|
||||
"PERIOD_COMPONENTS_LOCKED",
|
||||
"Участие в зарплате можно изменить только в периоде с новыми правилами. Предыдущий расчёт сохранён.",
|
||||
period_id=operation_period.pk,
|
||||
)
|
||||
if (
|
||||
values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE
|
||||
and values.get("scheduled_target_component_code") == code
|
||||
|
||||
@ -19,8 +19,8 @@ from finance.models import (
|
||||
PayrollPeriod,
|
||||
PayrollStatement,
|
||||
)
|
||||
from finance.payroll.balances import CARRYOVER_DEBIT_KINDS
|
||||
from finance.payroll.components import get_payroll_components
|
||||
from payroll_core.balances import carryover_contribution
|
||||
|
||||
from ..admin_serializers import (
|
||||
PayrollCellCommentSerializer,
|
||||
@ -160,8 +160,8 @@ def _carryover_ledger(period):
|
||||
and line.status == ApprovalStatus.DRAFT
|
||||
)
|
||||
counts_in_balance = bool(line.include_in_carryover and active_status)
|
||||
raw_contribution = (
|
||||
line.amount if component.kind in CARRYOVER_DEBIT_KINDS else -line.amount
|
||||
raw_contribution = carryover_contribution(
|
||||
component.kind, line.amount, version=line.period.balance_rules_version
|
||||
)
|
||||
contribution = raw_contribution if counts_in_balance else Decimal("0.00")
|
||||
balances_by_employee[line.employee_id] = (
|
||||
|
||||
@ -9,6 +9,7 @@ from finance.models import (
|
||||
PayrollRun,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
from finance.payroll.components import get_payroll_components
|
||||
from finance.payroll.config import (
|
||||
base_rate_code,
|
||||
build_rules,
|
||||
@ -111,7 +112,15 @@ def _period_input_state(period) -> PeriodInputState:
|
||||
employee__is_hidden_in_ui=False,
|
||||
).count()
|
||||
approved_input_outside_roster_employee_ids = sorted(
|
||||
set(approved_inputs.values_list("employee_id", flat=True))
|
||||
set(
|
||||
approved_inputs.exclude(
|
||||
component_code__in=[
|
||||
component.code
|
||||
for component in get_payroll_components(period)
|
||||
if not component.include_in_salary
|
||||
]
|
||||
).values_list("employee_id", flat=True)
|
||||
)
|
||||
- set(roster_employee_ids)
|
||||
)
|
||||
draft_inputs = PayrollInputLine.objects.filter(
|
||||
|
||||
@ -122,6 +122,7 @@ class PayrollComponentSerializer(serializers.Serializer):
|
||||
period_scope = serializers.CharField(read_only=True)
|
||||
scope_period_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
include_in_point_base = serializers.BooleanField(read_only=True)
|
||||
include_in_salary = serializers.BooleanField(read_only=True)
|
||||
include_in_carryover = serializers.BooleanField(read_only=True)
|
||||
visible_to_employee = serializers.BooleanField(read_only=True)
|
||||
is_custom = serializers.BooleanField(read_only=True)
|
||||
@ -169,6 +170,7 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
|
||||
"kind",
|
||||
"requires_reason",
|
||||
"include_in_point_base",
|
||||
"include_in_salary",
|
||||
"include_in_carryover",
|
||||
"visible_to_employee",
|
||||
"highlight_color",
|
||||
@ -187,6 +189,7 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
|
||||
{"period_id": "Для выбранной области укажите расчётный период."}
|
||||
)
|
||||
if attrs["kind"] == PayrollComponentKind.SCHEDULED_CHANGE:
|
||||
attrs["include_in_salary"] = False
|
||||
attrs["include_in_point_base"] = False
|
||||
attrs["include_in_carryover"] = False
|
||||
attrs["visible_to_employee"] = False
|
||||
@ -232,6 +235,8 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
else:
|
||||
attrs["scheduled_target_component_code"] = ""
|
||||
if attrs.get("include_in_salary") is False:
|
||||
attrs["include_in_point_base"] = False
|
||||
return attrs
|
||||
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ class PayrollPeriodSerializer(serializers.ModelSerializer):
|
||||
"status",
|
||||
"status_label",
|
||||
"current_run_id",
|
||||
"balance_rules_version",
|
||||
"lock_version",
|
||||
"created_by_id",
|
||||
"created_at",
|
||||
|
||||
@ -109,6 +109,7 @@ class PayrollPeriodTableSerializer(serializers.Serializer):
|
||||
recorded_payments = serializers.JSONField(read_only=True)
|
||||
period_id = serializers.IntegerField(read_only=True)
|
||||
period_status = serializers.CharField(read_only=True)
|
||||
balance_rules_version = serializers.IntegerField(read_only=True)
|
||||
currency = serializers.CharField(read_only=True)
|
||||
calculation_rules = serializers.JSONField(read_only=True)
|
||||
run = serializers.JSONField(read_only=True, allow_null=True)
|
||||
|
||||
@ -14,6 +14,11 @@ from finance.models import (
|
||||
|
||||
|
||||
class PayrollStatementLineSerializer(serializers.ModelSerializer):
|
||||
include_in_salary = serializers.SerializerMethodField()
|
||||
|
||||
def get_include_in_salary(self, obj) -> bool:
|
||||
return self.context.get("salary_flags", {}).get(obj.line_id, True)
|
||||
|
||||
class Meta:
|
||||
model = PayrollStatementLine
|
||||
fields = [
|
||||
@ -25,6 +30,7 @@ class PayrollStatementLineSerializer(serializers.ModelSerializer):
|
||||
"source_period_to",
|
||||
"is_retro",
|
||||
"calculated",
|
||||
"include_in_salary",
|
||||
]
|
||||
|
||||
|
||||
@ -140,6 +146,15 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class OwnPayrollStatementSerializer(serializers.ModelSerializer):
|
||||
non_salary_payment_total = serializers.SerializerMethodField()
|
||||
|
||||
def get_non_salary_payment_total(self, obj) -> str:
|
||||
return str(
|
||||
(obj.result_snapshot or {})
|
||||
.get("totals", {})
|
||||
.get("non_salary_payment_total", "0")
|
||||
)
|
||||
|
||||
period = serializers.SerializerMethodField()
|
||||
revision = serializers.IntegerField(source="run.revision", read_only=True)
|
||||
published_at = serializers.DateTimeField(
|
||||
@ -166,6 +181,7 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
|
||||
"deduction_total",
|
||||
"net_pay",
|
||||
"payment_total",
|
||||
"non_salary_payment_total",
|
||||
"payable",
|
||||
"lines",
|
||||
"acknowledgement",
|
||||
@ -192,7 +208,13 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
|
||||
@extend_schema_field(PayrollStatementLineSerializer(many=True))
|
||||
def get_lines(self, obj):
|
||||
lines = obj.lines.filter(visible_to_employee=True)
|
||||
return PayrollStatementLineSerializer(lines, many=True).data
|
||||
salary_flags = {
|
||||
line["line_id"]: line.get("include_in_salary", True)
|
||||
for line in (obj.result_snapshot or {}).get("lines", [])
|
||||
}
|
||||
return PayrollStatementLineSerializer(
|
||||
lines, many=True, context={**self.context, "salary_flags": salary_flags}
|
||||
).data
|
||||
|
||||
|
||||
class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer):
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
"""Preserve all historical rules; opt in only uncalculated periods after them."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Max, Q
|
||||
|
||||
|
||||
def activate_current_rules(apps, schema_editor):
|
||||
Period = apps.get_model("finance", "PayrollPeriod")
|
||||
for currency in Period.objects.values_list("currency", flat=True).distinct():
|
||||
frozen_end = (
|
||||
Period.objects.filter(currency=currency)
|
||||
.filter(~Q(status="open") | Q(current_run__isnull=False))
|
||||
.aggregate(value=Max("date_to"))["value"]
|
||||
)
|
||||
current = Period.objects.filter(
|
||||
currency=currency, status="open", current_run__isnull=True
|
||||
)
|
||||
if frozen_end:
|
||||
current = current.filter(date_from__gt=frozen_end)
|
||||
current.update(balance_rules_version=2)
|
||||
|
||||
# Preserve live configuration for older unclosed/calculated periods too.
|
||||
# Reuse the immutable definitions shipped in the earlier snapshot migration.
|
||||
legacy = import_module("finance.migrations.0038_payrollperiod_component_snapshot")
|
||||
Settings = apps.get_model("finance", "FinanceSettings")
|
||||
Custom = apps.get_model("finance", "PayrollCustomComponent")
|
||||
settings = Settings.objects.first()
|
||||
overrides = settings.payroll_component_overrides if settings else {}
|
||||
builtins = []
|
||||
for values in legacy.BUILTIN_COMPONENTS:
|
||||
payload = dict(
|
||||
zip(
|
||||
(
|
||||
"code",
|
||||
"name",
|
||||
"kind",
|
||||
"requires_reason",
|
||||
"display_order",
|
||||
"include_in_point_base",
|
||||
"include_in_carryover",
|
||||
"visible_to_employee",
|
||||
"highlight_color",
|
||||
),
|
||||
values,
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
payload.update(
|
||||
period_scope="all",
|
||||
scope_period_id=None,
|
||||
is_custom=False,
|
||||
scheduled_target_component_code="",
|
||||
)
|
||||
payload.update(overrides.get(payload["code"], {}))
|
||||
builtins.append({field: payload[field] for field in legacy.SNAPSHOT_FIELDS})
|
||||
custom = []
|
||||
for component in Custom.objects.filter(is_active=True).prefetch_related(
|
||||
"excluded_periods"
|
||||
):
|
||||
payload = {
|
||||
field: getattr(component, field)
|
||||
for field in legacy.SNAPSHOT_FIELDS
|
||||
if field != "is_custom"
|
||||
}
|
||||
payload.update(
|
||||
is_custom=True,
|
||||
hidden_from_period_id=component.hidden_from_period_id,
|
||||
excluded_period_ids={item.pk for item in component.excluded_periods.all()},
|
||||
)
|
||||
custom.append(payload)
|
||||
dates = dict(Period.objects.values_list("pk", "date_from"))
|
||||
for period in Period.objects.filter(balance_rules_version=1):
|
||||
if period.component_snapshot:
|
||||
continue
|
||||
components = [
|
||||
payload
|
||||
for payload in builtins
|
||||
if legacy._applies_to_period(payload, period, dates)
|
||||
]
|
||||
components.extend(
|
||||
{field: payload[field] for field in legacy.SNAPSHOT_FIELDS}
|
||||
for payload in custom
|
||||
if legacy._applies_to_period(
|
||||
payload, period, dates, payload["excluded_period_ids"]
|
||||
)
|
||||
)
|
||||
Period.objects.filter(pk=period.pk).update(
|
||||
component_snapshot={"version": 1, "components": components}
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("finance", "0040_payrollbalanceadjustment")]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="payrollcustomcomponent",
|
||||
name="include_in_salary",
|
||||
field=models.BooleanField(
|
||||
default=True, verbose_name="Учитывать в расчёте зарплаты"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="payrollperiod",
|
||||
name="balance_rules_version",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
editable=False,
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="Версия правил переходящего остатка",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(activate_current_rules, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="payrollperiod",
|
||||
name="balance_rules_version",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=2,
|
||||
editable=False,
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="Версия правил переходящего остатка",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -105,6 +105,12 @@ class PayrollPeriod(models.Model):
|
||||
name = models.CharField("Название", max_length=120, blank=True)
|
||||
date_from = models.DateField("Начало периода")
|
||||
date_to = models.DateField("Конец периода")
|
||||
balance_rules_version = models.PositiveSmallIntegerField(
|
||||
"Версия правил переходящего остатка",
|
||||
default=2,
|
||||
editable=False,
|
||||
validators=[MinValueValidator(1)],
|
||||
)
|
||||
pay_date = models.DateField("Дата выплаты", null=True, blank=True)
|
||||
currency = models.CharField(
|
||||
"Валюта",
|
||||
@ -193,6 +199,7 @@ class PayrollPeriod(models.Model):
|
||||
self.date_to,
|
||||
self.pay_date,
|
||||
self.currency,
|
||||
self.balance_rules_version,
|
||||
)
|
||||
original_values = (
|
||||
original.code,
|
||||
@ -201,6 +208,7 @@ class PayrollPeriod(models.Model):
|
||||
original.date_to,
|
||||
original.pay_date,
|
||||
original.currency,
|
||||
original.balance_rules_version,
|
||||
)
|
||||
if immutable_values != original_values:
|
||||
raise ValidationError(
|
||||
@ -230,6 +238,10 @@ class PayrollPeriod(models.Model):
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if self.pk and not self._state.adding:
|
||||
original = PayrollPeriod.objects.select_for_update().get(pk=self.pk)
|
||||
if original.balance_rules_version != self.balance_rules_version:
|
||||
raise ValidationError(
|
||||
"Версию правил существующего периода нельзя изменять."
|
||||
)
|
||||
self._validate_immutable_fields(original)
|
||||
if (
|
||||
original.status == PayrollPeriodStatus.CLOSED
|
||||
@ -1019,6 +1031,9 @@ class PayrollCustomComponent(models.Model):
|
||||
"Учитывать в балльной базе",
|
||||
default=False,
|
||||
)
|
||||
include_in_salary = models.BooleanField(
|
||||
"Учитывать в расчёте зарплаты", default=True
|
||||
)
|
||||
include_in_carryover = models.BooleanField(
|
||||
"Учитывать в переходящих остатках",
|
||||
default=False,
|
||||
@ -1101,6 +1116,8 @@ class PayrollCustomComponent(models.Model):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
self.name = self.name.strip()
|
||||
if not self.include_in_salary:
|
||||
self.include_in_point_base = False
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Укажите название колонки."})
|
||||
if self.period_scope == PayrollComponentPeriodScope.ALL:
|
||||
@ -1149,6 +1166,7 @@ class PayrollCustomComponent(models.Model):
|
||||
}
|
||||
)
|
||||
self.include_in_point_base = False
|
||||
self.include_in_salary = False
|
||||
self.include_in_carryover = False
|
||||
self.visible_to_employee = False
|
||||
elif self.scheduled_target_component_code:
|
||||
|
||||
@ -102,6 +102,7 @@ def _mapped_input_lines(
|
||||
kind=LineKind(component.kind),
|
||||
amount=amount,
|
||||
source_ref="scheduled-effective-value",
|
||||
include_in_salary=component.include_in_salary,
|
||||
reason="; ".join(
|
||||
dict.fromkeys(record.reason for record in records if record.reason)
|
||||
),
|
||||
@ -131,6 +132,7 @@ def _mapped_input_lines(
|
||||
reason=record.reason,
|
||||
source_period=source_period,
|
||||
is_retro=source_period is not None,
|
||||
include_in_salary=component.include_in_salary,
|
||||
include_in_carryover=(
|
||||
component.include_in_carryover and record.include_in_carryover
|
||||
),
|
||||
@ -175,7 +177,7 @@ def _point_base_accrual(
|
||||
point_base_lines = Decimal("0")
|
||||
for record in input_lines:
|
||||
component = component_definitions[record.component_code]
|
||||
if not component.include_in_point_base:
|
||||
if not component.include_in_point_base or not component.include_in_salary:
|
||||
continue
|
||||
direction = -1 if component.kind in POINT_BASE_NEGATIVE_KINDS else 1
|
||||
if component.kind == PayrollComponentKind.SCHEDULED_CHANGE:
|
||||
@ -271,6 +273,7 @@ def build_core_request(
|
||||
)
|
||||
return PayrollRequest(
|
||||
employee_ref=f"employee:{rate.employee_id}",
|
||||
balance_rules_version=period.balance_rules_version,
|
||||
period=_core_period(period),
|
||||
currency=period.currency,
|
||||
base_accrual=rate.amount,
|
||||
@ -315,6 +318,7 @@ def build_core_preview_request(
|
||||
)
|
||||
return PayrollRequest(
|
||||
employee_ref=f"employee:{employee_id}",
|
||||
balance_rules_version=period.balance_rules_version,
|
||||
period=_core_period(period),
|
||||
currency=period.currency,
|
||||
base_accrual=rate.amount if rate is not None else Decimal("0"),
|
||||
|
||||
@ -8,8 +8,7 @@ from decimal import Decimal
|
||||
from finance.enums import ApprovalStatus, InputSource
|
||||
from finance.models import PayrollBalanceAdjustment, PayrollInputLine, PayrollPeriod
|
||||
from finance.payroll.components import get_payroll_components
|
||||
|
||||
CARRYOVER_DEBIT_KINDS = frozenset({"adjustment_debit", "deduction", "payment"})
|
||||
from payroll_core.balances import carryover_contribution
|
||||
|
||||
|
||||
def opening_balances(
|
||||
@ -56,10 +55,13 @@ def opening_balances(
|
||||
)
|
||||
if component is None:
|
||||
continue
|
||||
direction = 1 if component.kind in CARRYOVER_DEBIT_KINDS else -1
|
||||
balances[line["employee_id"]] = balances.get(
|
||||
line["employee_id"], Decimal("0.00")
|
||||
) + (line["amount"] * direction)
|
||||
) + carryover_contribution(
|
||||
component.kind,
|
||||
line["amount"],
|
||||
version=source_periods[line["period_id"]].balance_rules_version,
|
||||
)
|
||||
# Effective-period corrections enter the opening balance exactly once per
|
||||
# cumulative reconstruction. They never become payroll input lines.
|
||||
for adjustment in PayrollBalanceAdjustment.objects.filter(
|
||||
|
||||
@ -8,7 +8,7 @@ from finance.enums import (
|
||||
PayrollPeriodStatus,
|
||||
)
|
||||
|
||||
PAYROLL_COMPONENT_SNAPSHOT_VERSION = 1
|
||||
PAYROLL_COMPONENT_SNAPSHOT_VERSION = 2
|
||||
PAYROLL_COMPONENT_SNAPSHOT_FIELDS = (
|
||||
"code",
|
||||
"name",
|
||||
@ -16,6 +16,7 @@ PAYROLL_COMPONENT_SNAPSHOT_FIELDS = (
|
||||
"requires_reason",
|
||||
"display_order",
|
||||
"include_in_point_base",
|
||||
"include_in_salary",
|
||||
"include_in_carryover",
|
||||
"visible_to_employee",
|
||||
"period_scope",
|
||||
@ -34,6 +35,7 @@ class PayrollComponentDefinition:
|
||||
requires_reason: bool
|
||||
display_order: int
|
||||
include_in_point_base: bool = False
|
||||
include_in_salary: bool = True
|
||||
include_in_carryover: bool = False
|
||||
visible_to_employee: bool = True
|
||||
period_scope: str = PayrollComponentPeriodScope.ALL
|
||||
@ -198,6 +200,7 @@ def _get_live_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
include_in_point_base=override.get(
|
||||
"include_in_point_base", component.include_in_point_base
|
||||
),
|
||||
include_in_salary=override.get("include_in_salary", True),
|
||||
include_in_carryover=override.get(
|
||||
"include_in_carryover", component.include_in_carryover
|
||||
),
|
||||
@ -265,13 +268,27 @@ def _get_live_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
custom = [
|
||||
component for component in custom if component.applies_to_period(period)
|
||||
]
|
||||
# Old periods retain their original salary semantics even if a live
|
||||
# column is subsequently excluded from salary in the current period.
|
||||
if period.balance_rules_version == 1:
|
||||
return _PayrollComponentCollection(
|
||||
PayrollComponentDefinition(
|
||||
**{
|
||||
field: True
|
||||
if field == "include_in_salary"
|
||||
else getattr(component, field)
|
||||
for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS
|
||||
}
|
||||
)
|
||||
for component in (*builtins, *custom)
|
||||
)
|
||||
return _PayrollComponentCollection((*builtins, *custom))
|
||||
|
||||
|
||||
def build_payroll_component_snapshot(period) -> dict[str, object]:
|
||||
"""Serialize the exact component configuration visible when a period closes."""
|
||||
|
||||
components = _get_live_payroll_components(period)
|
||||
components = get_payroll_components(period)
|
||||
return {
|
||||
"version": PAYROLL_COMPONENT_SNAPSHOT_VERSION,
|
||||
"components": [
|
||||
@ -287,7 +304,7 @@ def build_payroll_component_snapshot(period) -> dict[str, object]:
|
||||
def _components_from_snapshot(snapshot) -> _PayrollComponentCollection:
|
||||
if not isinstance(snapshot, dict):
|
||||
raise ValueError("Некорректный снимок зарплатных колонок.")
|
||||
if snapshot.get("version") != PAYROLL_COMPONENT_SNAPSHOT_VERSION:
|
||||
if snapshot.get("version") not in (1, PAYROLL_COMPONENT_SNAPSHOT_VERSION):
|
||||
raise ValueError("Неподдерживаемая версия снимка зарплатных колонок.")
|
||||
payloads = snapshot.get("components")
|
||||
if not isinstance(payloads, list):
|
||||
@ -295,7 +312,12 @@ def _components_from_snapshot(snapshot) -> _PayrollComponentCollection:
|
||||
try:
|
||||
return _PayrollComponentCollection(
|
||||
PayrollComponentDefinition(
|
||||
**{field: payload[field] for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS}
|
||||
**{
|
||||
field: payload.get(field, True)
|
||||
if field == "include_in_salary" and snapshot["version"] == 1
|
||||
else payload[field]
|
||||
for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS
|
||||
}
|
||||
)
|
||||
for payload in payloads
|
||||
)
|
||||
@ -308,7 +330,10 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
|
||||
if (
|
||||
period is not None
|
||||
and period.status == PayrollPeriodStatus.CLOSED
|
||||
and (
|
||||
period.status == PayrollPeriodStatus.CLOSED
|
||||
or period.balance_rules_version == 1
|
||||
)
|
||||
and period.component_snapshot
|
||||
):
|
||||
return _components_from_snapshot(period.component_snapshot)
|
||||
|
||||
@ -81,7 +81,11 @@ def _point_base_amount(rate_amount, component_amounts, component_definitions):
|
||||
amount = Decimal(str(rate_amount))
|
||||
for code, component_amount in component_amounts.items():
|
||||
component = component_definitions.get(code)
|
||||
if not component or not component["include_in_point_base"]:
|
||||
if (
|
||||
not component
|
||||
or not component["include_in_point_base"]
|
||||
or not component["include_in_salary"]
|
||||
):
|
||||
continue
|
||||
direction = -1 if component["kind"] in POINT_BASE_NEGATIVE_KINDS else 1
|
||||
amount += Decimal(str(component_amount)) * direction
|
||||
@ -184,6 +188,7 @@ def _component_definition(
|
||||
*,
|
||||
requires_reason=False,
|
||||
include_in_point_base=False,
|
||||
include_in_salary=True,
|
||||
include_in_carryover=False,
|
||||
visible_to_employee=True,
|
||||
period_scope="all",
|
||||
@ -199,6 +204,7 @@ def _component_definition(
|
||||
"display_order": order,
|
||||
"requires_reason": requires_reason,
|
||||
"include_in_point_base": include_in_point_base,
|
||||
"include_in_salary": include_in_salary,
|
||||
"include_in_carryover": include_in_carryover,
|
||||
"visible_to_employee": visible_to_employee,
|
||||
"period_scope": period_scope,
|
||||
@ -413,6 +419,7 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
component.display_order,
|
||||
requires_reason=component.requires_reason,
|
||||
include_in_point_base=component.include_in_point_base,
|
||||
include_in_salary=component.include_in_salary,
|
||||
include_in_carryover=component.include_in_carryover,
|
||||
visible_to_employee=component.visible_to_employee,
|
||||
period_scope=component.period_scope,
|
||||
@ -451,6 +458,18 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
raw_source_lines,
|
||||
scheduled_codes=scheduled_component_codes,
|
||||
)
|
||||
salary_excluded_only = bool(
|
||||
statement is None
|
||||
and work is None
|
||||
and source_lines
|
||||
and all(
|
||||
not components_by_code[line.component_code].include_in_salary
|
||||
for line in source_lines
|
||||
)
|
||||
)
|
||||
if salary_excluded_only:
|
||||
# A former employee's debt payment is not a new salary or payslip.
|
||||
rate = None
|
||||
display_source_lines = [
|
||||
line
|
||||
for line in raw_source_lines
|
||||
@ -497,6 +516,11 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
"deduction_total": str(statement.deduction_total),
|
||||
"net_pay": str(statement.net_pay),
|
||||
"payment_total": str(statement.payment_total),
|
||||
"non_salary_payment_total": str(
|
||||
(statement.result_snapshot or {})
|
||||
.get("totals", {})
|
||||
.get("non_salary_payment_total", "0")
|
||||
),
|
||||
"payable": str(statement.payable),
|
||||
}
|
||||
rate_amount = snapshot.get("base_accrual")
|
||||
@ -587,6 +611,9 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
"deduction_total": str(preview_totals.deduction_total),
|
||||
"net_pay": str(preview_totals.net_pay),
|
||||
"payment_total": str(preview_totals.payment_total),
|
||||
"non_salary_payment_total": str(
|
||||
preview_totals.non_salary_payment_total
|
||||
),
|
||||
"payable": str(preview_totals.payable),
|
||||
}
|
||||
totals_preliminary = True
|
||||
@ -606,6 +633,8 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
status = "ready"
|
||||
if calculation_issue is not None:
|
||||
status = "incomplete"
|
||||
if salary_excluded_only:
|
||||
status = "balance_only"
|
||||
|
||||
projection_target_points = Decimal(str(target_points or "0"))
|
||||
daily_point_value = (
|
||||
@ -689,6 +718,7 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
|
||||
return {
|
||||
"period_id": period.pk,
|
||||
"balance_rules_version": period.balance_rules_version,
|
||||
"period_status": period.status,
|
||||
"currency": period.currency,
|
||||
"calculation_rules": active_rules.to_dict(),
|
||||
|
||||
@ -109,6 +109,42 @@ def _hash_items(items: list[str]) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _period_input_hash(period, results):
|
||||
hashes = [result.input_hash for _, result in results]
|
||||
if period.balance_rules_version >= 2:
|
||||
# Balance-only inputs do not produce payslips, but must still invalidate
|
||||
# an approved calculation when their amounts/settings change.
|
||||
components = {
|
||||
component.code: component for component in get_payroll_components(period)
|
||||
}
|
||||
outside_lines = (
|
||||
PayrollInputLine.objects.filter(
|
||||
period=period,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
)
|
||||
.exclude(employee_id__in=[rate.employee_id for rate, _ in results])
|
||||
.order_by("pk")
|
||||
)
|
||||
for line in outside_lines:
|
||||
component = components[line.component_code]
|
||||
payload = {
|
||||
"id": line.pk,
|
||||
"employee_id": line.employee_id,
|
||||
"component_code": line.component_code,
|
||||
"amount": str(line.amount),
|
||||
"include_in_carryover": line.include_in_carryover
|
||||
and component.include_in_carryover,
|
||||
"include_in_salary": component.include_in_salary,
|
||||
"kind": component.kind,
|
||||
}
|
||||
hashes.append(
|
||||
hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
)
|
||||
return _hash_items(hashes)
|
||||
|
||||
|
||||
def _audit(
|
||||
*,
|
||||
actor,
|
||||
@ -301,12 +337,21 @@ def _calculate_current_period_inputs(
|
||||
)
|
||||
employee_ids = sorted(work_records)
|
||||
rates = _resolve_rates(period, employee_ids)
|
||||
component_definitions = {
|
||||
component.code: component for component in get_payroll_components(period)
|
||||
}
|
||||
excluded_salary_codes = [
|
||||
code
|
||||
for code, component in component_definitions.items()
|
||||
if not component.include_in_salary
|
||||
]
|
||||
outside_roster = list(
|
||||
PayrollInputLine.objects.filter(
|
||||
period=period,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
)
|
||||
.exclude(employee_id__in=employee_ids)
|
||||
.exclude(component_code__in=excluded_salary_codes)
|
||||
.values_list("employee_id", flat=True)
|
||||
.distinct()[:20]
|
||||
)
|
||||
@ -318,10 +363,6 @@ def _calculate_current_period_inputs(
|
||||
)
|
||||
input_lines = _input_lines_by_employee(period, employee_ids)
|
||||
balances = opening_balances(period, employee_ids)
|
||||
component_definitions = {
|
||||
component.code: component for component in get_payroll_components(period)
|
||||
}
|
||||
|
||||
requests = []
|
||||
results = []
|
||||
for rate in rates:
|
||||
@ -384,7 +425,7 @@ def _ensure_run_is_fresh(run: PayrollRun, period: PayrollPeriod) -> None:
|
||||
"""Fail closed if approved inputs, rules or persisted snapshots changed."""
|
||||
|
||||
_, _, results = _calculate_current_period_inputs(period)
|
||||
current_input_hash = _hash_items([result.input_hash for _, result in results])
|
||||
current_input_hash = _period_input_hash(period, results)
|
||||
current_result_hash = _hash_items([result.result_hash for _, result in results])
|
||||
first_result = results[0][1]
|
||||
statements = {
|
||||
@ -487,7 +528,7 @@ def calculate_period(
|
||||
]
|
||||
or 0
|
||||
) + 1
|
||||
aggregate_input_hash = _hash_items([result.input_hash for _, result in results])
|
||||
aggregate_input_hash = _period_input_hash(period, results)
|
||||
aggregate_result_hash = _hash_items([result.result_hash for _, result in results])
|
||||
gross_total = sum(
|
||||
(result.totals.gross_after_adjustments for _, result in results),
|
||||
|
||||
12
backend/payroll_core/balances.py
Normal file
12
backend/payroll_core/balances.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Versioned contribution shared by calculation, reconstruction and ledger."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def carryover_contribution(kind: str, amount: Decimal, *, version: int = 2) -> Decimal:
|
||||
if version not in (1, 2):
|
||||
raise ValueError("Unsupported payroll balance rules version")
|
||||
increases = kind in {"adjustment_debit", "deduction"} or (
|
||||
kind == "payment" and version == 1
|
||||
)
|
||||
return amount if increases else -amount
|
||||
@ -9,6 +9,7 @@ from datetime import date, datetime
|
||||
from decimal import Context, Decimal, DecimalException, localcontext
|
||||
from typing import Protocol
|
||||
|
||||
from .balances import carryover_contribution
|
||||
from .domain import (
|
||||
CalculationWarning,
|
||||
ExpectedTotals,
|
||||
@ -58,7 +59,7 @@ def _is_finite_decimal(value: object) -> bool:
|
||||
class DeterministicPayrollCalculator:
|
||||
"""Pure calculator with explicit Decimal and rounding semantics."""
|
||||
|
||||
CALCULATOR_VERSION = "1.3.0"
|
||||
CALCULATOR_VERSION = "1.4.0"
|
||||
DECIMAL_PRECISION = 80
|
||||
MAX_DECIMAL_DIGITS = 30
|
||||
MAX_FRACTIONAL_PLACES = 12
|
||||
@ -158,7 +159,11 @@ class DeterministicPayrollCalculator:
|
||||
):
|
||||
lines.append(self._to_result_line(input_line, quantize))
|
||||
|
||||
totals = self._calculate_totals(lines, opening_balance=opening_balance)
|
||||
totals = self._calculate_totals(
|
||||
lines,
|
||||
opening_balance=opening_balance,
|
||||
balance_rules_version=request.balance_rules_version,
|
||||
)
|
||||
if totals.payable < 0 and not rules.allow_negative_payable:
|
||||
raise PayrollValidationError(
|
||||
[
|
||||
@ -185,6 +190,11 @@ class DeterministicPayrollCalculator:
|
||||
input_hash = _canonical_hash(request.calculation_dict())
|
||||
rules_payload = rules.to_dict()
|
||||
ruleset_hash = _canonical_hash(rules_payload)
|
||||
calculator_version = (
|
||||
"1.3.0"
|
||||
if request.balance_rules_version == 1
|
||||
else self.CALCULATOR_VERSION
|
||||
)
|
||||
result_payload = {
|
||||
"employee_ref": request.employee_ref,
|
||||
"period": request.period.to_dict(),
|
||||
@ -197,7 +207,7 @@ class DeterministicPayrollCalculator:
|
||||
"ruleset_id": rules.ruleset_id,
|
||||
"ruleset_version": rules.version,
|
||||
"ruleset_hash": ruleset_hash,
|
||||
"calculator_version": self.CALCULATOR_VERSION,
|
||||
"calculator_version": calculator_version,
|
||||
"rounding_policy": rules.rounding.value,
|
||||
"input_hash": input_hash,
|
||||
}
|
||||
@ -219,7 +229,7 @@ class DeterministicPayrollCalculator:
|
||||
ruleset_id=rules.ruleset_id,
|
||||
ruleset_version=rules.version,
|
||||
ruleset_hash=ruleset_hash,
|
||||
calculator_version=self.CALCULATOR_VERSION,
|
||||
calculator_version=calculator_version,
|
||||
rounding_policy=rules.rounding.value,
|
||||
input_hash=input_hash,
|
||||
result_hash=result_hash,
|
||||
@ -262,6 +272,7 @@ class DeterministicPayrollCalculator:
|
||||
is_retro=input_line.is_retro,
|
||||
calculated=False,
|
||||
include_in_carryover=input_line.include_in_carryover,
|
||||
include_in_salary=input_line.include_in_salary,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@ -269,45 +280,59 @@ class DeterministicPayrollCalculator:
|
||||
lines: list[PayrollLine],
|
||||
*,
|
||||
opening_balance: Decimal = Decimal("0"),
|
||||
balance_rules_version: int = 2,
|
||||
) -> PayrollTotals:
|
||||
earnings = sum(
|
||||
(line.amount for line in lines if line.kind is LineKind.EARNING),
|
||||
(
|
||||
line.amount
|
||||
for line in lines
|
||||
if line.kind is LineKind.EARNING and line.include_in_salary
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
adjustment_credits = sum(
|
||||
(line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_CREDIT),
|
||||
(
|
||||
line.amount
|
||||
for line in lines
|
||||
if line.kind is LineKind.ADJUSTMENT_CREDIT and line.include_in_salary
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
adjustment_debits = sum(
|
||||
(line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_DEBIT),
|
||||
(
|
||||
line.amount
|
||||
for line in lines
|
||||
if line.kind is LineKind.ADJUSTMENT_DEBIT and line.include_in_salary
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
deductions = sum(
|
||||
(line.amount for line in lines if line.kind is LineKind.DEDUCTION),
|
||||
(
|
||||
line.amount
|
||||
for line in lines
|
||||
if line.kind is LineKind.DEDUCTION and line.include_in_salary
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
payments = sum(
|
||||
(line.amount for line in lines if line.kind is LineKind.PAYMENT),
|
||||
Decimal("0"),
|
||||
)
|
||||
manual_carryover_credits = sum(
|
||||
non_salary_payments = sum(
|
||||
(
|
||||
line.amount
|
||||
for line in lines
|
||||
if line.kind in (LineKind.EARNING, LineKind.ADJUSTMENT_CREDIT)
|
||||
and not line.calculated
|
||||
and line.include_in_carryover
|
||||
if line.kind is LineKind.PAYMENT and not line.include_in_salary
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
manual_carryover_debits = sum(
|
||||
carryover_change = sum(
|
||||
(
|
||||
line.amount
|
||||
carryover_contribution(
|
||||
line.kind.value, line.amount, version=balance_rules_version
|
||||
)
|
||||
for line in lines
|
||||
if line.kind
|
||||
in (LineKind.ADJUSTMENT_DEBIT, LineKind.DEDUCTION, LineKind.PAYMENT)
|
||||
and not line.calculated
|
||||
and line.include_in_carryover
|
||||
if not line.calculated and line.include_in_carryover
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
@ -321,10 +346,9 @@ class DeterministicPayrollCalculator:
|
||||
deduction_total=deductions,
|
||||
net_pay=net_pay,
|
||||
payment_total=payments,
|
||||
payable=net_pay - payments,
|
||||
closing_balance=(
|
||||
opening_balance + manual_carryover_debits - manual_carryover_credits
|
||||
),
|
||||
payable=net_pay - payments + non_salary_payments,
|
||||
non_salary_payment_total=non_salary_payments,
|
||||
closing_balance=opening_balance + carryover_change,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@ -428,6 +452,16 @@ class DeterministicPayrollCalculator:
|
||||
message="Employee reference is required.",
|
||||
)
|
||||
)
|
||||
if type(
|
||||
request.balance_rules_version
|
||||
) is not int or request.balance_rules_version not in (1, 2):
|
||||
issues.append(
|
||||
PayrollIssue(
|
||||
code="INVALID_BALANCE_RULES_VERSION",
|
||||
field="balance_rules_version",
|
||||
message="Unsupported carryover rules version.",
|
||||
)
|
||||
)
|
||||
period_is_valid = self._validate_period(request.period, "period", issues)
|
||||
if not isinstance(request.currency, str) or not _CURRENCY_RE.fullmatch(
|
||||
request.currency
|
||||
@ -693,6 +727,15 @@ class DeterministicPayrollCalculator:
|
||||
)
|
||||
return
|
||||
|
||||
for flag in ("include_in_salary", "include_in_carryover"):
|
||||
if not isinstance(getattr(line, flag), bool):
|
||||
issues.append(
|
||||
PayrollIssue(
|
||||
code="INVALID_COMPONENT_FLAG",
|
||||
field=f"{prefix}.{flag}",
|
||||
message="Component participation flags must be boolean.",
|
||||
)
|
||||
)
|
||||
if not self._is_nonempty_string(line.line_id):
|
||||
issues.append(
|
||||
PayrollIssue(
|
||||
|
||||
@ -89,6 +89,7 @@ class InputLine:
|
||||
source_period: PayrollPeriod | None = None
|
||||
is_retro: bool = False
|
||||
include_in_carryover: bool = True
|
||||
include_in_salary: bool = True
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@ -104,6 +105,7 @@ class InputLine:
|
||||
),
|
||||
"is_retro": self.is_retro,
|
||||
"include_in_carryover": self.include_in_carryover,
|
||||
**({"include_in_salary": False} if not self.include_in_salary else {}),
|
||||
}
|
||||
|
||||
|
||||
@ -152,11 +154,17 @@ class PayrollRequest:
|
||||
point_rate_overridden: bool = True
|
||||
lines: tuple[InputLine, ...] = dataclass_field(default_factory=tuple)
|
||||
expected_totals: ExpectedTotals | None = None
|
||||
balance_rules_version: int = 2
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
ordered_lines = sorted(self.lines, key=lambda line: line.line_id)
|
||||
return {
|
||||
"employee_ref": self.employee_ref,
|
||||
**(
|
||||
{"balance_rules_version": self.balance_rules_version}
|
||||
if self.balance_rules_version != 1
|
||||
else {}
|
||||
),
|
||||
"period": self.period.to_dict(),
|
||||
"currency": self.currency,
|
||||
"base_accrual": decimal_text(self.base_accrual),
|
||||
@ -236,6 +244,7 @@ class PayrollLine:
|
||||
is_retro: bool = False
|
||||
calculated: bool = False
|
||||
include_in_carryover: bool = True
|
||||
include_in_salary: bool = True
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@ -252,6 +261,7 @@ class PayrollLine:
|
||||
"is_retro": self.is_retro,
|
||||
"calculated": self.calculated,
|
||||
"include_in_carryover": self.include_in_carryover,
|
||||
**({"include_in_salary": False} if not self.include_in_salary else {}),
|
||||
}
|
||||
|
||||
|
||||
@ -281,6 +291,11 @@ class PayrollTotals:
|
||||
payment_total: Decimal
|
||||
payable: Decimal
|
||||
closing_balance: Decimal
|
||||
non_salary_payment_total: Decimal = Decimal("0")
|
||||
|
||||
@property
|
||||
def salary_payment_total(self):
|
||||
return self.payment_total - self.non_salary_payment_total
|
||||
|
||||
@property
|
||||
def recalculated_gross(self) -> Decimal:
|
||||
@ -299,6 +314,15 @@ class PayrollTotals:
|
||||
"payment_total": decimal_text(self.payment_total),
|
||||
"payable": decimal_text(self.payable),
|
||||
"closing_balance": decimal_text(self.closing_balance),
|
||||
**(
|
||||
{
|
||||
"non_salary_payment_total": decimal_text(
|
||||
self.non_salary_payment_total
|
||||
)
|
||||
}
|
||||
if self.non_salary_payment_total
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
107
backend/payroll_core/tests/test_salary_participation.py
Normal file
107
backend/payroll_core/tests/test_salary_participation.py
Normal file
@ -0,0 +1,107 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from payroll_core import (
|
||||
DeterministicPayrollCalculator,
|
||||
InputLine,
|
||||
LineKind,
|
||||
PayrollPeriod,
|
||||
PayrollRequest,
|
||||
PayrollRules,
|
||||
)
|
||||
|
||||
|
||||
def request(*, kind=LineKind.PAYMENT, salary=True, carryover=True, version=2):
|
||||
return PayrollRequest(
|
||||
employee_ref="employee:42",
|
||||
period=PayrollPeriod(date(2026, 8, 1), date(2026, 8, 31)),
|
||||
currency="RUB",
|
||||
base_accrual=Decimal("80000"),
|
||||
base_source_ref="rate:42",
|
||||
opening_balance=Decimal("5000"),
|
||||
balance_rules_version=version,
|
||||
lines=(
|
||||
InputLine(
|
||||
line_id="pay:1",
|
||||
code="ADVANCE",
|
||||
label="Аванс",
|
||||
kind=kind,
|
||||
amount=Decimal("10000"),
|
||||
source_ref="fixture:1",
|
||||
include_in_salary=salary,
|
||||
include_in_carryover=carryover,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
RULES = PayrollRules(
|
||||
ruleset_id="standard", version="1", effective_from=date(2026, 1, 1)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,sign",
|
||||
[
|
||||
(LineKind.EARNING, -1),
|
||||
(LineKind.ADJUSTMENT_CREDIT, -1),
|
||||
(LineKind.ADJUSTMENT_DEBIT, 1),
|
||||
(LineKind.DEDUCTION, 1),
|
||||
(LineKind.PAYMENT, -1),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("carryover", [True, False])
|
||||
def test_salary_exclusion_is_independent_of_carryover(kind, sign, carryover):
|
||||
result = DeterministicPayrollCalculator().calculate(
|
||||
request(kind=kind, salary=False, carryover=carryover), RULES
|
||||
)
|
||||
totals = result.totals
|
||||
assert (
|
||||
totals.gross_before_adjustments
|
||||
== totals.gross_after_adjustments
|
||||
== totals.net_pay
|
||||
== totals.payable
|
||||
== Decimal("80000")
|
||||
)
|
||||
assert totals.adjustment_total == totals.deduction_total == 0
|
||||
assert totals.payment_total == (Decimal("10000") if kind == LineKind.PAYMENT else 0)
|
||||
assert totals.non_salary_payment_total == totals.payment_total
|
||||
assert totals.closing_balance == Decimal("5000") + (
|
||||
Decimal("10000") * sign if carryover else 0
|
||||
)
|
||||
assert result.lines[-1].include_in_salary is False
|
||||
|
||||
|
||||
def test_salary_payment_still_subtracts_from_payable():
|
||||
result = DeterministicPayrollCalculator().calculate(request(), RULES)
|
||||
assert result.totals.payable == Decimal("70000")
|
||||
assert result.totals.payment_total == Decimal("10000")
|
||||
assert result.totals.non_salary_payment_total == 0
|
||||
assert result.totals.closing_balance == Decimal("-5000")
|
||||
|
||||
|
||||
def test_old_request_and_result_hashes_are_byte_compatible():
|
||||
result = DeterministicPayrollCalculator().calculate(request(version=1), RULES)
|
||||
# Golden hashes produced by the unmodified 1.3.0 calculator.
|
||||
assert (
|
||||
result.input_hash
|
||||
== "d398df4ebbed1f5469479dceea0a9a9fbb8dff00b57b1debab2256282ec22d06"
|
||||
)
|
||||
assert (
|
||||
result.result_hash
|
||||
== "ead44fd4cac168734c24e58de20f654e2c0406d4862ef3d9aaad68b800320b27"
|
||||
)
|
||||
assert result.calculator_version == "1.3.0"
|
||||
assert result.totals.closing_balance == Decimal("15000")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", list(LineKind))
|
||||
def test_only_payment_changes_carryover_direction(kind):
|
||||
calculator = DeterministicPayrollCalculator()
|
||||
old = calculator.calculate(request(kind=kind, version=1), RULES)
|
||||
current = calculator.calculate(request(kind=kind), RULES)
|
||||
assert current.totals.payable == old.totals.payable
|
||||
difference = Decimal("-20000") if kind == LineKind.PAYMENT else Decimal("0")
|
||||
assert current.totals.closing_balance - old.totals.closing_balance == difference
|
||||
@ -209,6 +209,7 @@ def test_manager_can_customize_system_component_behavior(
|
||||
"period_scope": "current_and_future",
|
||||
"scope_period_id": period.pk,
|
||||
"include_in_point_base": False,
|
||||
"include_in_salary": True,
|
||||
"include_in_carryover": True,
|
||||
"visible_to_employee": False,
|
||||
"is_custom": False,
|
||||
|
||||
@ -644,7 +644,7 @@ def test_closing_period_freezes_component_configuration(
|
||||
closed_components = {
|
||||
component.code: component for component in get_payroll_components(june_period)
|
||||
}
|
||||
assert june_period.component_snapshot["version"] == 1
|
||||
assert june_period.component_snapshot["version"] == 2
|
||||
assert closed_components["BONUS"].name == "Премия до закрытия"
|
||||
assert closed_components["BONUS"].highlight_color == "green"
|
||||
assert closed_components[custom.code].name == "Историческая колонка"
|
||||
|
||||
283
backend/tests/finance/test_salary_participation.py
Normal file
283
backend/tests/finance/test_salary_participation.py
Normal file
@ -0,0 +1,283 @@
|
||||
import calendar
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from importlib import import_module
|
||||
|
||||
import pytest
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from api.v1.finance.admin.workspace_payload import _period_input_state
|
||||
from api.v1.finance.serializers import OwnPayrollStatementSerializer
|
||||
from finance.models import (
|
||||
PayrollCustomComponent,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
)
|
||||
from finance.payroll.balances import opening_balances
|
||||
from finance.payroll.components import (
|
||||
build_payroll_component_snapshot,
|
||||
get_payroll_components,
|
||||
)
|
||||
from finance.payroll.exceptions import PayrollOperationError
|
||||
from finance.payroll.services import (
|
||||
_ensure_run_is_fresh,
|
||||
calculate_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):
|
||||
manager, employee, checker = user_factory(), user_factory(), user_factory()
|
||||
grant(manager, "manage_payroll_inputs", "view_all_payroll", "calculate_payroll")
|
||||
periods = {}
|
||||
for month in (8, 9, 10, 11):
|
||||
periods[month] = PayrollPeriod.objects.create(
|
||||
code=f"2026-{month:02}",
|
||||
date_from=date(2026, month, 1),
|
||||
date_to=date(2026, month, calendar.monthrange(2026, month)[1]),
|
||||
created_by=manager,
|
||||
status="closed" if month < 10 else "open",
|
||||
balance_rules_version=1 if month < 10 else 2,
|
||||
)
|
||||
component = PayrollCustomComponent.objects.create(
|
||||
name="Выплата долга",
|
||||
kind="payment",
|
||||
include_in_salary=False,
|
||||
include_in_carryover=True,
|
||||
period_scope="all",
|
||||
created_by=manager,
|
||||
)
|
||||
return manager, employee, checker, periods, component, auth_client_factory(manager)
|
||||
|
||||
|
||||
def line(setup, period, **overrides):
|
||||
manager, employee, _, _, component, _ = setup
|
||||
return PayrollInputLine.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
component_code=component.code,
|
||||
amount="5000",
|
||||
status="approved",
|
||||
created_by=manager,
|
||||
approved_by=setup[2],
|
||||
approved_at=timezone.now(),
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def test_previous_period_payments_keep_legacy_sign_and_current_payments_reduce_debt(
|
||||
setup,
|
||||
):
|
||||
_, employee, _, periods, component, client = setup
|
||||
for month in (8, 9):
|
||||
# An old v1 snapshot contains no salary-participation flag.
|
||||
snapshot = build_payroll_component_snapshot(periods[month])
|
||||
snapshot["version"] = 1
|
||||
for payload in snapshot["components"]:
|
||||
payload.pop("include_in_salary")
|
||||
PayrollPeriod.objects.filter(pk=periods[month].pk).update(
|
||||
component_snapshot=snapshot
|
||||
)
|
||||
line(setup, periods[month])
|
||||
line(setup, periods[10])
|
||||
assert opening_balances(periods[10], [employee.pk])[employee.pk] == Decimal("10000")
|
||||
assert opening_balances(periods[11], [employee.pk])[employee.pk] == Decimal("5000")
|
||||
ledger = client.get(
|
||||
reverse(
|
||||
"api:v1:finance-payroll:admin-period-carryovers",
|
||||
kwargs={"pk": periods[10].pk},
|
||||
)
|
||||
)
|
||||
assert ledger.status_code == 200, ledger.content
|
||||
contributions = {
|
||||
entry["period"]["id"]: Decimal(entry["contribution"])
|
||||
for entry in ledger.json()["entries"]
|
||||
}
|
||||
assert contributions == {
|
||||
periods[8].pk: Decimal("5000"),
|
||||
periods[9].pk: Decimal("5000"),
|
||||
periods[10].pk: Decimal("-5000"),
|
||||
}
|
||||
for month in (8, 9):
|
||||
periods[month].refresh_from_db()
|
||||
assert (
|
||||
get_payroll_components(periods[month])
|
||||
.get(code=component.code)
|
||||
.include_in_salary
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_excluded_payment_does_not_reduce_current_salary_or_point_base(setup):
|
||||
manager, employee, checker, periods, component, _ = setup
|
||||
make_approved_period_inputs(
|
||||
period=periods[10], employee=employee, creator=manager, approver=checker
|
||||
)
|
||||
component.include_in_point_base = (
|
||||
True # Defensive adapter protection, even against bad ORM data.
|
||||
)
|
||||
component.save(update_fields=["include_in_point_base"])
|
||||
line(setup, periods[10])
|
||||
run = calculate_period(periods[10].pk, actor=manager)
|
||||
statement = run.statements.get(employee=employee)
|
||||
assert statement.payable == Decimal("80000")
|
||||
assert statement.gross_total == Decimal("80000")
|
||||
assert statement.payment_total == Decimal("5000")
|
||||
assert statement.closing_balance == Decimal("-5000")
|
||||
assert statement.input_snapshot["point_base_accrual"] == "80000"
|
||||
assert statement.result_snapshot["totals"]["non_salary_payment_total"] == "5000"
|
||||
assert statement.input_snapshot["lines"][0]["include_in_salary"] is False
|
||||
detail = OwnPayrollStatementSerializer(statement).data
|
||||
assert Decimal(detail["non_salary_payment_total"]) == Decimal("5000")
|
||||
assert (
|
||||
next(item for item in detail["lines"] if item["code"] == component.code)[
|
||||
"include_in_salary"
|
||||
]
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_inactive_employee_payment_without_work_does_not_block_or_generate_payslip(
|
||||
setup, user_factory
|
||||
):
|
||||
manager, employee, checker, periods, component, client = setup
|
||||
make_approved_period_inputs(
|
||||
period=periods[10], employee=employee, creator=manager, approver=checker
|
||||
)
|
||||
former = user_factory(active=False)
|
||||
PayrollInputLine.objects.create(
|
||||
period=periods[10],
|
||||
employee=former,
|
||||
component_code=component.code,
|
||||
amount="3500",
|
||||
status="approved",
|
||||
created_by=manager,
|
||||
approved_by=checker,
|
||||
approved_at=timezone.now(),
|
||||
)
|
||||
state = _period_input_state(periods[10])
|
||||
assert state.approved_input_outside_roster_employee_ids == []
|
||||
run = calculate_period(periods[10].pk, actor=manager)
|
||||
assert not run.statements.filter(employee=former).exists()
|
||||
table = client.get(
|
||||
reverse(
|
||||
"api:v1:finance-payroll:admin-period-table", kwargs={"pk": periods[10].pk}
|
||||
)
|
||||
).json()
|
||||
row = next(item for item in table["rows"] if item["employee"]["id"] == former.pk)
|
||||
assert row["status"] == "balance_only"
|
||||
assert Decimal(row["payment_total"]) == Decimal("3500")
|
||||
assert Decimal(row["payable"]) == 0
|
||||
assert Decimal(row["closing_balance"]) == Decimal("-3500")
|
||||
assert Decimal(table["recorded_payments"]["total_recorded"]) == Decimal("3500")
|
||||
_ensure_run_is_fresh(run, periods[10])
|
||||
PayrollInputLine.objects.create(
|
||||
period=periods[10],
|
||||
employee=former,
|
||||
component_code=component.code,
|
||||
amount="100",
|
||||
status="approved",
|
||||
created_by=manager,
|
||||
approved_by=checker,
|
||||
approved_at=timezone.now(),
|
||||
)
|
||||
with pytest.raises(PayrollOperationError) as error:
|
||||
_ensure_run_is_fresh(run, periods[10])
|
||||
assert error.value.code == "RUN_STALE_RECALCULATION_REQUIRED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builtin", [False, True])
|
||||
def test_component_setting_is_saved_and_point_base_is_cleared(setup, builtin):
|
||||
_, _, _, periods, component, client = setup
|
||||
code = "BONUS" if builtin else component.code
|
||||
payload = {
|
||||
"name": "Отдельная выплата" if builtin else component.name,
|
||||
"kind": "payment",
|
||||
"period_scope": "current_and_future",
|
||||
"period_id": periods[10].pk,
|
||||
"requires_reason": False,
|
||||
"include_in_salary": False,
|
||||
"include_in_carryover": True,
|
||||
"include_in_point_base": True,
|
||||
"visible_to_employee": False,
|
||||
}
|
||||
url = reverse(
|
||||
"api:v1:finance-payroll:admin-component-detail", kwargs={"code": code}
|
||||
)
|
||||
response = client.patch(url, payload, format="json")
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.json()["include_in_salary"] is False
|
||||
assert response.json()["include_in_point_base"] is False
|
||||
payload.pop("include_in_salary")
|
||||
response = client.patch(url, payload, format="json")
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.json()["include_in_salary"] is False
|
||||
assert response.json()["include_in_point_base"] is False
|
||||
|
||||
|
||||
def test_migration_preserves_old_snapshots_and_only_activates_new_open_periods(setup):
|
||||
_, _, _, periods, _, _ = setup
|
||||
old_snapshot = {"version": 1, "components": []}
|
||||
PayrollPeriod.objects.filter(pk=periods[8].pk).update(
|
||||
component_snapshot=old_snapshot
|
||||
)
|
||||
PayrollPeriod.objects.all().update(balance_rules_version=1)
|
||||
migration = import_module(
|
||||
"finance.migrations.0041_salary_participation_and_balance_rules"
|
||||
)
|
||||
migration.activate_current_rules(apps, None)
|
||||
for month, period in periods.items():
|
||||
period.refresh_from_db()
|
||||
assert period.balance_rules_version == (1 if month < 10 else 2)
|
||||
assert periods[8].component_snapshot == old_snapshot
|
||||
september = periods[9]
|
||||
assert september.component_snapshot["version"] == 1
|
||||
assert all(
|
||||
component.include_in_salary for component in get_payroll_components(september)
|
||||
)
|
||||
assert not periods[10].component_snapshot
|
||||
with pytest.raises(ValidationError):
|
||||
september.balance_rules_version = 2
|
||||
september.save(update_fields=["balance_rules_version"])
|
||||
|
||||
|
||||
def test_migration_keeps_existing_unclosed_run_fresh_and_freezes_its_settings(setup):
|
||||
manager, employee, checker, periods, component, _ = setup
|
||||
PayrollPeriod.objects.all().update(balance_rules_version=1)
|
||||
current = PayrollPeriod.objects.get(pk=periods[10].pk)
|
||||
component.include_in_salary = True
|
||||
component.save(update_fields=["include_in_salary"])
|
||||
make_approved_period_inputs(
|
||||
period=current, employee=employee, creator=manager, approver=checker
|
||||
)
|
||||
line(setup, current)
|
||||
run = calculate_period(current.pk, actor=manager)
|
||||
previous = list(
|
||||
run.statements.values(
|
||||
"input_snapshot", "result_snapshot", "input_hash", "result_hash"
|
||||
)
|
||||
)
|
||||
import_module(
|
||||
"finance.migrations.0041_salary_participation_and_balance_rules"
|
||||
).activate_current_rules(apps, None)
|
||||
current.refresh_from_db()
|
||||
periods[11].refresh_from_db()
|
||||
assert current.balance_rules_version == 1
|
||||
assert periods[11].balance_rules_version == 2
|
||||
component.include_in_salary = False
|
||||
component.save(update_fields=["include_in_salary"])
|
||||
_ensure_run_is_fresh(run, current)
|
||||
assert (
|
||||
list(
|
||||
run.statements.values(
|
||||
"input_snapshot", "result_snapshot", "input_hash", "result_hash"
|
||||
)
|
||||
)
|
||||
== previous
|
||||
)
|
||||
@ -330,6 +330,16 @@ export function PayrollStatementDetailModal({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{Number(detail.non_salary_payment_total || 0) > 0 ? (
|
||||
<p className="app-text-muted mt-2 text-xs">
|
||||
В том числе вне расчёта зарплаты:{" "}
|
||||
{formatPayrollMoney(
|
||||
detail.non_salary_payment_total!,
|
||||
detail.currency,
|
||||
)}
|
||||
. Эта выплата не вычитается из зарплаты текущего периода.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@ -415,6 +425,11 @@ export function PayrollStatementDetailModal({
|
||||
Перерасчёт
|
||||
</span>
|
||||
) : null}
|
||||
{line.include_in_salary === false ? (
|
||||
<span className="app-badge px-2 py-0.5 text-[0.65rem]">
|
||||
Вне расчёта зарплаты
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{sourcePeriod ? (
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
|
||||
@ -80,6 +80,10 @@ const statusMeta: Record<
|
||||
ready: { label: "Готово", className: "app-selected" },
|
||||
draft: { label: "Есть черновики", className: "app-feedback-warning" },
|
||||
incomplete: { label: "Не заполнено", className: "app-feedback-danger" },
|
||||
balance_only: {
|
||||
label: "Вне расчёта зарплаты",
|
||||
className: "app-feedback-info",
|
||||
},
|
||||
};
|
||||
|
||||
const kindLabels = {
|
||||
@ -723,6 +727,7 @@ function PayrollComponentSettingsModal({
|
||||
rows,
|
||||
components,
|
||||
currency,
|
||||
balanceRulesVersion,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
@ -731,6 +736,7 @@ function PayrollComponentSettingsModal({
|
||||
rows: PayrollPeriodTableRow[];
|
||||
components: PayrollPeriodTableComponent[];
|
||||
currency: string;
|
||||
balanceRulesVersion?: number;
|
||||
onSave: (payload: PayrollCustomComponentWrite) => Promise<void>;
|
||||
onDelete?: (mode: "current" | "current_and_future") => Promise<void>;
|
||||
onClose: () => void;
|
||||
@ -740,6 +746,9 @@ function PayrollComponentSettingsModal({
|
||||
component?.kind || "earning",
|
||||
);
|
||||
const requiresReason = component?.requires_reason || false;
|
||||
const [includeInSalary, setIncludeInSalary] = useState(
|
||||
component?.include_in_salary ?? true,
|
||||
);
|
||||
const [includeInPointBase, setIncludeInPointBase] = useState(
|
||||
component?.include_in_point_base || false,
|
||||
);
|
||||
@ -814,7 +823,9 @@ function PayrollComponentSettingsModal({
|
||||
name: normalizedName,
|
||||
kind,
|
||||
requires_reason: requiresReason,
|
||||
include_in_point_base: isScheduled ? false : includeInPointBase,
|
||||
include_in_salary: !isScheduled && includeInSalary,
|
||||
include_in_point_base:
|
||||
!isScheduled && includeInSalary && includeInPointBase,
|
||||
include_in_carryover: isScheduled ? false : includeInCarryover,
|
||||
visible_to_employee: isScheduled ? false : visibleToEmployee,
|
||||
highlight_color: highlightColor,
|
||||
@ -858,7 +869,9 @@ function PayrollComponentSettingsModal({
|
||||
kind,
|
||||
display_order: component?.display_order || 0,
|
||||
requires_reason: requiresReason,
|
||||
include_in_point_base: isScheduled ? false : includeInPointBase,
|
||||
include_in_salary: !isScheduled && includeInSalary,
|
||||
include_in_point_base:
|
||||
!isScheduled && includeInSalary && includeInPointBase,
|
||||
include_in_carryover: isScheduled ? false : includeInCarryover,
|
||||
visible_to_employee: isScheduled ? false : visibleToEmployee,
|
||||
period_scope: isScheduled ? "current_and_future" : periodScope,
|
||||
@ -870,11 +883,13 @@ function PayrollComponentSettingsModal({
|
||||
const calculationPreview = componentCalculationHelp(
|
||||
previewComponent,
|
||||
components,
|
||||
balanceRulesVersion,
|
||||
);
|
||||
const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, {
|
||||
code: component?.code || "PREVIEW",
|
||||
kind,
|
||||
include_in_point_base: includeInPointBase,
|
||||
include_in_salary: !isScheduled && includeInSalary,
|
||||
});
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
@ -1068,6 +1083,28 @@ function PayrollComponentSettingsModal({
|
||||
автоматически не копируются.
|
||||
</span>
|
||||
</label>
|
||||
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isScheduled && includeInSalary}
|
||||
onChange={(event) => {
|
||||
setIncludeInSalary(event.target.checked);
|
||||
if (!event.target.checked) setIncludeInPointBase(false);
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 rounded"
|
||||
disabled={pending || isScheduled}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-[var(--foreground)]">
|
||||
Учитывать в расчёте зарплаты
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block text-xs">
|
||||
Без галочки сумма не меняет зарплату и «К выплате». Фактические
|
||||
выплаты остаются в учёте выданных денег. Переходящие остатки
|
||||
настраиваются отдельно.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -1089,10 +1126,10 @@ function PayrollComponentSettingsModal({
|
||||
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isScheduled ? false : includeInPointBase}
|
||||
checked={!isScheduled && includeInSalary && includeInPointBase}
|
||||
onChange={(event) => setIncludeInPointBase(event.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded"
|
||||
disabled={pending || isScheduled}
|
||||
disabled={pending || isScheduled || !includeInSalary}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-[var(--foreground)]">
|
||||
@ -1457,13 +1494,14 @@ function calculationHelps(
|
||||
formula:
|
||||
"Выплачено = Аванс + Фактическая выплата + Σ прочих операций типа «Уже выплачено»",
|
||||
manual:
|
||||
"Сам итог не редактируется. Введите сумму в ячейке «Аванс» или «Фактическая выплата» — она уменьшит остаток к выплате.",
|
||||
"Сам итог не редактируется. Все фактические выплаты учитываются здесь, в том числе исключённые из расчёта зарплаты.",
|
||||
},
|
||||
payable: {
|
||||
title: "К выплате",
|
||||
logic:
|
||||
"Остаток зарплаты только за выбранный период. Зарплата, рассчитанная по баллам, может быть ниже или выше оклада — это нормальный результат периода и в переходящий остаток не попадает.",
|
||||
formula: "К выплате = После удержаний − Выплачено",
|
||||
formula:
|
||||
"К выплате = После удержаний − Σ выплат с галочкой «Учитывать в расчёте зарплаты»",
|
||||
manual:
|
||||
"Нет. Начисления и корректировки меняют зарплату периода; удержания уменьшают её; «Аванс» и «Фактическая выплата» фиксируют уже выданные деньги.",
|
||||
},
|
||||
@ -1482,6 +1520,7 @@ function calculationHelps(
|
||||
function componentCalculationHelp(
|
||||
component: PayrollPeriodTableComponent,
|
||||
components: PayrollPeriodTableComponent[] = [],
|
||||
balanceRulesVersion = 2,
|
||||
): CalculationHelp {
|
||||
if (component.kind === "scheduled_change") {
|
||||
const target = components.find(
|
||||
@ -1497,7 +1536,7 @@ function componentCalculationHelp(
|
||||
"Да. В ячейке маркера задаётся итоговая знаковая сумма для сотрудника. После применения в целевой ячейке отображается отметка с источником изменения.",
|
||||
};
|
||||
}
|
||||
const flow = {
|
||||
const salaryFlow = {
|
||||
earning: {
|
||||
effect:
|
||||
"увеличивает «До корректировок», затем «Начислено», «После удержаний» и остаток «К выплате»",
|
||||
@ -1528,22 +1567,34 @@ function componentCalculationHelp(
|
||||
formula: `${component.label} → без влияния`,
|
||||
},
|
||||
}[component.kind];
|
||||
const decreasesCarryover = ["earning", "adjustment_credit"].includes(
|
||||
component.kind,
|
||||
);
|
||||
const flow =
|
||||
component.include_in_salary === false
|
||||
? {
|
||||
effect:
|
||||
component.kind === "payment"
|
||||
? "увеличивает «Выплачено», но не меняет зарплату и «К выплате»"
|
||||
: "не меняет зарплату, удержания и «К выплате»",
|
||||
formula: `${component.label} → вне расчёта зарплаты${component.kind === "payment" ? "; Выплачено (+)" : ""}`,
|
||||
}
|
||||
: salaryFlow;
|
||||
const decreasesCarryover =
|
||||
(component.kind === "payment" && balanceRulesVersion >= 2) ||
|
||||
["earning", "adjustment_credit"].includes(component.kind);
|
||||
const decreasesPointBase = [
|
||||
"adjustment_debit",
|
||||
"deduction",
|
||||
"payment",
|
||||
].includes(component.kind);
|
||||
const pointBaseEffect = component.include_in_point_base
|
||||
? decreasesPointBase
|
||||
? "В балльной базе вычитается: меняет цену балла и перерасчёт по фактическим баллам."
|
||||
: "В балльной базе прибавляется: меняет цену балла и перерасчёт по фактическим баллам."
|
||||
: "На балльную базу, цену балла и перерасчёт по баллам не влияет.";
|
||||
const pointBaseFormula = component.include_in_point_base
|
||||
? `Вклад в балльную базу: ${decreasesPointBase ? "−" : "+"} сумма «${component.label}»`
|
||||
: `Балльная база: «${component.label}» не участвует`;
|
||||
const pointBaseEffect =
|
||||
component.include_in_point_base && component.include_in_salary !== false
|
||||
? decreasesPointBase
|
||||
? "В балльной базе вычитается: меняет цену балла и перерасчёт по фактическим баллам."
|
||||
: "В балльной базе прибавляется: меняет цену балла и перерасчёт по фактическим баллам."
|
||||
: "На балльную базу, цену балла и перерасчёт по баллам не влияет.";
|
||||
const pointBaseFormula =
|
||||
component.include_in_point_base && component.include_in_salary !== false
|
||||
? `Вклад в балльную базу: ${decreasesPointBase ? "−" : "+"} сумма «${component.label}»`
|
||||
: `Балльная база: «${component.label}» не участвует`;
|
||||
const carryoverEffect = component.include_in_carryover
|
||||
? decreasesCarryover
|
||||
? "Уменьшает переходящий остаток следующего периода; значение ячейки не копируется."
|
||||
@ -2348,7 +2399,11 @@ export function PayrollPeriodTableView({
|
||||
>
|
||||
<ColumnHeader
|
||||
label={component.label}
|
||||
help={componentCalculationHelp(component, data.component_columns)}
|
||||
help={componentCalculationHelp(
|
||||
component,
|
||||
data.component_columns,
|
||||
data.balance_rules_version,
|
||||
)}
|
||||
onConfigure={
|
||||
onUpdateComponent
|
||||
? () => {
|
||||
@ -2520,8 +2575,7 @@ export function PayrollPeriodTableView({
|
||||
title: "Остаток к переносу",
|
||||
logic:
|
||||
"Накопленная разница из строк колонок с включённой настройкой «Учитывать в переходящих остатках» и действующих ручных корректировок остатка. Ручная корректировка учитывается только с выбранного открытого периода и далее, без изменения закрытых снимков.\n\nЗначения ячеек не копируются в колонки следующего периода. Плюс — накопленная недоплата, минус — накопленная переплата. Зарплату и рассылку ручная корректировка не меняет.",
|
||||
formula:
|
||||
"Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки, удержания и выплаты\n− учитываемые начисления и положительные корректировки\n\nОстаток на начало учитывает утверждённые строки предыдущих периодов и действующие ручные корректировки, начиная с указанного для них периода",
|
||||
formula: `Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки и удержания${data.balance_rules_version === 1 ? " и выплаты" : ""}\n− учитываемые начисления и положительные корректировки${data.balance_rules_version === 1 ? "" : " и выплаты"}\n\nОстаток на начало учитывает утверждённые строки предыдущих периодов по их сохранённым правилам и действующие ручные корректировки, начиная с указанного для них периода`,
|
||||
manual:
|
||||
"Карандаш в заголовке открывает реестр переходящих остатков. В нём можно добавить ручную корректировку с открытого периода без расчёта или аннулировать её до расчёта. Исходную строку открытого периода можно включить или исключить из остатка. Участие всей колонки настраивается её карандашом в блоке «Начисления и выплаты».",
|
||||
}}
|
||||
@ -3809,6 +3863,7 @@ export function PayrollPeriodTableView({
|
||||
rows={data.rows}
|
||||
components={data.component_columns}
|
||||
currency={data.currency}
|
||||
balanceRulesVersion={data.balance_rules_version}
|
||||
onSave={(payload) =>
|
||||
editingComponent && onUpdateComponent
|
||||
? onUpdateComponent(editingComponent, payload)
|
||||
|
||||
@ -48,6 +48,7 @@ export type PayrollStatementSummary = {
|
||||
};
|
||||
|
||||
export type PayrollStatementLine = {
|
||||
include_in_salary?: boolean;
|
||||
code: string;
|
||||
label: string;
|
||||
kind: PayrollLineKind;
|
||||
@ -59,6 +60,7 @@ export type PayrollStatementLine = {
|
||||
};
|
||||
|
||||
export type PayrollStatement = PayrollStatementSummary & {
|
||||
non_salary_payment_total?: string;
|
||||
point_delta: string | null;
|
||||
gross_before_adjustments: string;
|
||||
adjustment_total: string;
|
||||
@ -404,9 +406,11 @@ export type PayrollPeriodTableStatus =
|
||||
| "calculated"
|
||||
| "ready"
|
||||
| "draft"
|
||||
| "incomplete";
|
||||
| "incomplete"
|
||||
| "balance_only";
|
||||
|
||||
export type PayrollPeriodTableComponent = {
|
||||
include_in_salary?: boolean;
|
||||
code: string;
|
||||
label: string;
|
||||
kind: PayrollLineKind;
|
||||
@ -457,6 +461,7 @@ export type PayrollAttendanceAlert = {
|
||||
};
|
||||
|
||||
export type PayrollPeriodTableRow = {
|
||||
non_salary_payment_total?: string;
|
||||
off_cycle_settlement?: PayrollOffCycleBalance | null;
|
||||
employee: PayrollAdminEmployee & {
|
||||
first_name: string;
|
||||
@ -518,6 +523,7 @@ export type PayrollPeriodTableCarryoverSummary = {
|
||||
};
|
||||
|
||||
export type PayrollPeriodTable = {
|
||||
balance_rules_version?: number;
|
||||
recorded_payments?: {
|
||||
payroll_recorded: string;
|
||||
outside_period: string;
|
||||
@ -644,6 +650,7 @@ export type PayrollPointBreakdown = {
|
||||
};
|
||||
|
||||
export type PayrollComponent = {
|
||||
include_in_salary?: boolean;
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
@ -671,6 +678,7 @@ export type PayrollColumnHighlightColor =
|
||||
| "violet";
|
||||
|
||||
export type PayrollCustomComponentWrite = {
|
||||
include_in_salary?: boolean;
|
||||
name: string;
|
||||
kind: PayrollLineKind;
|
||||
requires_reason: boolean;
|
||||
|
||||
@ -20,6 +20,15 @@ const payment = {
|
||||
include_in_point_base: false,
|
||||
};
|
||||
|
||||
test("salary-excluded columns never affect the point base", () => {
|
||||
const result = assessPayrollPointBaseChange(
|
||||
[employee(1, "5000", { SALARY_PAYMENT: "10000" })],
|
||||
[bonus, payment],
|
||||
{ ...payment, include_in_point_base: true, include_in_salary: false },
|
||||
);
|
||||
assert.deepEqual(result, { negative: [], zero: [] });
|
||||
});
|
||||
|
||||
test("blocks a component setting that makes an affected point base negative", () => {
|
||||
const result = assessPayrollPointBaseChange(
|
||||
[employee(1, null, { SALARY_PAYMENT: "5000.00" })],
|
||||
|
||||
@ -2,6 +2,7 @@ type PointBaseComponent = {
|
||||
code: string;
|
||||
kind: string;
|
||||
include_in_point_base: boolean;
|
||||
include_in_salary?: boolean;
|
||||
};
|
||||
|
||||
type PointBaseRow = {
|
||||
@ -68,7 +69,11 @@ export function assessPayrollPointBaseChange(
|
||||
|
||||
let pointBase = decimalToScaled(row.rate_amount);
|
||||
for (const component of effectiveComponents) {
|
||||
if (!component.include_in_point_base) continue;
|
||||
if (
|
||||
!component.include_in_point_base ||
|
||||
component.include_in_salary === false
|
||||
)
|
||||
continue;
|
||||
const amount = decimalToScaled(row.component_amounts[component.code]);
|
||||
pointBase += NEGATIVE_KINDS.has(component.kind) ? -amount : amount;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user