Separate salary participation and preserve historical carryover rules
All checks were successful
Deploy production / deploy (push) Successful in 11m10s

This commit is contained in:
admin i 2026-10-15 19:37:51 +03:00
parent af26bfc6de
commit 3da3100328
26 changed files with 932 additions and 69 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -45,6 +45,7 @@ class PayrollPeriodSerializer(serializers.ModelSerializer):
"status",
"status_label",
"current_run_id",
"balance_rules_version",
"lock_version",
"created_by_id",
"created_at",

View File

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

View File

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

View File

@ -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="Версия правил переходящего остатка",
),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@ -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 {}
),
}

View 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

View File

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

View File

@ -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 == "Историческая колонка"

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

View File

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

View File

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

View File

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

View File

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

View File

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