feat: make payroll columns configurable
All checks were successful
Deploy production / deploy (push) Successful in 10m13s

This commit is contained in:
lizerk 2026-09-11 02:14:27 +03:00
parent d2e7f1e08d
commit 158b71ac04
26 changed files with 1417 additions and 226 deletions

View File

@ -27,6 +27,7 @@ from .inputs import (
PayrollWorkRecordReviseView,
)
from .periods import (
PayrollAdminComponentDetailView,
PayrollAdminComponentListView,
PayrollAdminEmployeeListView,
PayrollAdminPeriodDetailView,
@ -65,6 +66,7 @@ __all__ = [
"EmployeePayRateDetailView",
"EmployeePayRateListCreateView",
"EmployeePayRateReviseView",
"PayrollAdminComponentDetailView",
"PayrollAdminComponentListView",
"PayrollAdminEmployeeListView",
"PayrollAdminPeriodDetailView",

View File

@ -8,6 +8,7 @@ from .carryovers import (
PayrollPeriodCarryoverListCreateView,
)
from .catalog import (
PayrollAdminComponentDetailView,
PayrollAdminComponentListView,
PayrollAdminEmployeeListView,
PayrollAdminPeriodDetailView,
@ -21,6 +22,7 @@ from .statements import (
)
__all__ = [
"PayrollAdminComponentDetailView",
"PayrollAdminComponentListView",
"PayrollAdminEmployeeListView",
"PayrollAdminPeriodDetailView",

View File

@ -19,6 +19,7 @@ from finance.models import (
PayrollPeriod,
)
from finance.payroll.carryovers import cancel_carryover
from finance.payroll.components import get_payroll_components
from ...admin_serializers import (
PayrollCarryoverInclusionSerializer,
@ -50,7 +51,7 @@ class PayrollPeriodCarryoverListCreateView(PayrollAdminAPIView):
"code": "MANUAL_CARRYOVER_DISABLED",
"message": (
"Отдельные переносы не создаются. Баланс складывается из "
"положительных и отрицательных корректировок."
"значений колонок, включённых в переходящие остатки."
),
},
status=status.HTTP_410_GONE,
@ -83,12 +84,17 @@ class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView):
def patch(self, request, pk):
command = PayrollCarryoverInclusionSerializer(data=request.data)
command.is_valid(raise_exception=True)
carryover_component_codes = {
component.code
for component in get_payroll_components()
if component.include_in_carryover
}
line = get_object_or_404(
PayrollInputLine.objects.select_for_update(of=("self",)).select_related(
"period"
),
pk=pk,
component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"),
component_code__in=carryover_component_codes,
)
included = command.validated_data["included"]
previous = line.include_in_carryover

View File

@ -17,9 +17,12 @@ from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from finance.enums import ApprovalStatus
from finance.models import (
FinanceSettings,
PayrollCustomComponent,
PayrollInputCopyConfiguration,
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.components import (
@ -115,6 +118,127 @@ class PayrollAdminComponentListView(PayrollAdminAPIView):
)
class PayrollAdminComponentDetailView(PayrollAdminAPIView):
serializer_class = PayrollComponentSerializer
allowed_permissions = INPUT_VIEW_PERMISSIONS
@transaction.atomic
def patch(self, request, code):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
try:
current = get_payroll_components().get(code=code)
except LookupError as exc:
raise ValidationError({"code": "Зарплатная колонка не найдена."}) from exc
serializer = PayrollCustomComponentWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
scope_period = values.pop("scope_period", None)
existing_lines = PayrollInputLine.objects.filter(component_code=code).exclude(
status=ApprovalStatus.VOIDED
)
period_scope = values["period_scope"]
if period_scope == "current_only":
existing_lines = existing_lines.exclude(period=scope_period)
elif period_scope == "current_and_future":
existing_lines = existing_lines.filter(
period__date_from__lt=scope_period.date_from
)
else:
existing_lines = existing_lines.none()
if existing_lines.exists():
_conflict(
"COMPONENT_SCOPE_HAS_INPUTS",
(
"Новая область периодов скрывает сохранённые значения колонки. "
"Сначала очистите эти значения или оставьте колонку доступной."
),
)
normalized_name = values["name"].casefold()
if any(
component.code != code and component.name.casefold() == normalized_name
for component in get_payroll_components()
):
raise ValidationError({"name": "Колонка с таким названием уже существует."})
custom = (
PayrollCustomComponent.objects.select_for_update().filter(code=code).first()
)
if custom is not None:
for field_name, value in values.items():
setattr(custom, field_name, value)
custom.scope_period = scope_period
try:
custom.full_clean()
custom.save(update_fields=[*values, "scope_period"])
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
audit_instance = custom
else:
settings = FinanceSettings.get_solo()
settings = FinanceSettings.objects.select_for_update().get(pk=settings.pk)
overrides = dict(settings.payroll_component_overrides)
overrides[code] = {
**values,
"scope_period_id": scope_period.pk if scope_period else None,
}
settings.payroll_component_overrides = overrides
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
audit_instance = settings
_audit_api_mutation(
actor=request.user,
action="payroll.component_updated",
instance=audit_instance,
metadata={"component_code": code, "previous_kind": current.kind},
)
updated = get_payroll_components().get(code=code)
return Response(PayrollComponentSerializer(updated).data)
@transaction.atomic
def delete(self, request, code):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
component = get_object_or_404(
PayrollCustomComponent.objects.select_for_update(),
code=code,
)
period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id"))
mode = request.data.get("mode")
affected_lines = PayrollInputLine.objects.filter(component_code=code).exclude(
status=ApprovalStatus.VOIDED
)
if mode == "current":
affected_lines = affected_lines.filter(period=period)
elif mode == "current_and_future":
affected_lines = affected_lines.filter(
period__date_from__gte=period.date_from
)
else:
raise ValidationError(
{"mode": "Выберите текущий период или текущий и будущие."}
)
if affected_lines.exists():
_conflict(
"COMPONENT_HAS_INPUTS",
(
"В выбранных периодах есть значения этой колонки. "
"Сначала очистите их, затем удалите колонку."
),
)
if mode == "current":
component.excluded_periods.add(period)
else:
component.hidden_from_period = period
component.save(update_fields=["hidden_from_period"])
_audit_api_mutation(
actor=request.user,
action="payroll.component_hidden",
instance=component,
period=period,
metadata={"mode": mode},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema_view(
get=extend_schema(operation_id="finance_payroll_admin_periods_list")
)

View File

@ -9,7 +9,6 @@ from communications.models import Message
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollComponentKind,
PayrollPeriodStatus,
)
from finance.models import (
@ -17,6 +16,8 @@ from finance.models import (
PayrollInputLine,
PayrollStatement,
)
from finance.payroll.balances import CARRYOVER_DEBIT_KINDS
from finance.payroll.components import get_payroll_components
from ..admin_serializers import (
PayrollCellCommentSerializer,
@ -112,12 +113,17 @@ def _automatic_balance_summaries(rows):
def _carryover_ledger(period):
carryover_components = {
component.code: component
for component in get_payroll_components()
if component.include_in_carryover
}
lines = (
PayrollInputLine.objects.filter(
period__currency=period.currency,
period__date_to__lte=period.date_to,
employee__is_hidden_in_ui=False,
component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"),
component_code__in=carryover_components,
)
.exclude(
source=InputSource.API,
@ -144,10 +150,9 @@ def _carryover_ledger(period):
and line.status == ApprovalStatus.DRAFT
)
counts_in_balance = bool(line.include_in_carryover and active_status)
component = carryover_components[line.component_code]
raw_contribution = (
line.amount
if line.component.kind == PayrollComponentKind.ADJUSTMENT_DEBIT
else -line.amount
line.amount if component.kind in CARRYOVER_DEBIT_KINDS else -line.amount
)
contribution = raw_contribution if counts_in_balance else Decimal("0.00")
balances_by_employee[line.employee_id] = (
@ -169,7 +174,7 @@ def _carryover_ledger(period):
"currency": line.period.currency,
},
"employee": PayrollEmployeeMiniSerializer(line.employee).data,
"component": PayrollComponentSerializer(line.component).data,
"component": PayrollComponentSerializer(component).data,
"amount": str(line.amount),
"raw_contribution": str(raw_contribution),
"contribution": str(contribution),

View File

@ -106,6 +106,11 @@ class PayrollComponentSerializer(serializers.Serializer):
display_order = serializers.IntegerField(read_only=True)
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_carryover = serializers.BooleanField(read_only=True)
visible_to_employee = serializers.BooleanField(read_only=True)
is_custom = serializers.BooleanField(read_only=True)
highlight_color = serializers.CharField(read_only=True, allow_blank=True)
def get_id(self, obj) -> str:
return obj.code
@ -144,6 +149,10 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
"name",
"kind",
"requires_reason",
"include_in_point_base",
"include_in_carryover",
"visible_to_employee",
"highlight_color",
"period_scope",
"period_id",
]

View File

@ -146,7 +146,7 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
source="run.published_at",
read_only=True,
)
lines = PayrollStatementLineSerializer(many=True, read_only=True)
lines = serializers.SerializerMethodField()
acknowledgement = serializers.SerializerMethodField()
payment_confirmation = serializers.SerializerMethodField()
can_confirm_payment = serializers.SerializerMethodField()
@ -189,6 +189,11 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
def get_can_confirm_payment(self, obj) -> bool:
return obj.run.period.status == "closed"
@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
class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer):
status_label = serializers.CharField(source="get_status_display", read_only=True)

View File

@ -5,6 +5,7 @@ from .admin import (
EmployeePayRateDetailView,
EmployeePayRateListCreateView,
EmployeePayRateReviseView,
PayrollAdminComponentDetailView,
PayrollAdminComponentListView,
PayrollAdminEmployeeListView,
PayrollAdminPeriodDetailView,
@ -102,6 +103,11 @@ urlpatterns = [
PayrollAdminComponentListView.as_view(),
name="admin-component-list",
),
path(
"payroll/admin/components/<str:code>/",
PayrollAdminComponentDetailView.as_view(),
name="admin-component-detail",
),
path(
"payroll/admin/periods/",
PayrollAdminPeriodListCreateView.as_view(),

View File

@ -49,13 +49,14 @@ employees above or below the point target.
| Аванс | payment input line, reducing payable |
| Итого / выработка / перерасчёт / к выплате | reconciliation checkpoints |
Published statements persist a signed correction balance. Only explicit manual
adjustment lines change it: adjustment debits increase the balance and adjustment
credits decrease it. Point-based recalculation, ordinary earnings, deductions and
recorded payments never create a carryover. The next period uses the last published
closing balance as its opening balance until opposite corrections net it to zero.
Historical statements from before this feature start with a zero closing balance
and are not backfilled.
Published statements persist a signed carryover balance. Each input column has an
independent setting that decides whether its approved manual lines participate.
Debit-like lines (negative adjustments, deductions and payments) increase the
balance; credit-like lines (earnings and positive adjustments) decrease it.
Point-based recalculation never creates a carryover. The next period uses the last
published closing balance as its opening balance; input cell values themselves are
not copied into the next period. Historical statements from before this feature
start with a zero closing balance and are not backfilled.
Checkpoint columns can block review on mismatch but never feed money back
into the calculation.

View File

@ -0,0 +1,102 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("finance", "0034_reportingentry_local_project")]
operations = [
migrations.AddField(
model_name="financesettings",
name="payroll_component_overrides",
field=models.JSONField(
blank=True,
default=dict,
verbose_name="Настройки зарплатных колонок",
),
),
migrations.AddField(
model_name="payrollcustomcomponent",
name="include_in_point_base",
field=models.BooleanField(
default=False,
verbose_name="Учитывать в балльной базе",
),
),
migrations.AddField(
model_name="payrollcustomcomponent",
name="include_in_carryover",
field=models.BooleanField(
default=False,
verbose_name="Учитывать в переходящих остатках",
),
),
migrations.AddField(
model_name="payrollcustomcomponent",
name="hidden_from_period",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.PROTECT,
related_name="hidden_custom_components_from_period",
to="finance.payrollperiod",
verbose_name="Скрыта начиная с периода",
),
),
migrations.AddField(
model_name="payrollcustomcomponent",
name="highlight_color",
field=models.CharField(
blank=True,
choices=[
("", "Без выделения"),
("green", "Зелёный"),
("red", "Красный"),
("amber", "Янтарный"),
("blue", "Синий"),
("violet", "Фиолетовый"),
],
default="",
max_length=16,
verbose_name="Цвет выделения",
),
),
migrations.AddField(
model_name="payrollcustomcomponent",
name="excluded_periods",
field=models.ManyToManyField(
blank=True,
related_name="excluded_custom_components",
to="finance.payrollperiod",
verbose_name="Скрыта в отдельных периодах",
),
),
migrations.AddField(
model_name="payrollcustomcomponent",
name="visible_to_employee",
field=models.BooleanField(
default=True,
verbose_name="Показывать сотруднику в расчётке",
),
),
migrations.AddField(
model_name="payrollstatementline",
name="visible_to_employee",
field=models.BooleanField(
default=True,
verbose_name="Показывать сотруднику",
),
),
migrations.AlterField(
model_name="payrollinputline",
name="include_in_carryover",
field=models.BooleanField(
default=True,
help_text=(
"Учитывается, когда для колонки включены переходящие остатки; "
"на расчёт зарплаты самой строки не влияет и не копирует её "
"значение."
),
verbose_name="Учитывать в переходящем остатке",
),
),
]

View File

@ -66,6 +66,11 @@ class FinanceSettings(models.Model):
"с финансовыми правами. Заполните список, чтобы ограничить доступ."
),
)
payroll_component_overrides = models.JSONField(
"Настройки зарплатных колонок",
default=dict,
blank=True,
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
@ -973,7 +978,7 @@ class PayrollComponent:
class PayrollCustomComponent(models.Model):
"""User-created payroll column with immutable calculation semantics."""
"""User-created payroll column with configurable calculation semantics."""
code = models.CharField(
"Код",
@ -993,6 +998,32 @@ class PayrollCustomComponent(models.Model):
"Требовать основание",
default=False,
)
include_in_point_base = models.BooleanField(
"Учитывать в балльной базе",
default=False,
)
include_in_carryover = models.BooleanField(
"Учитывать в переходящих остатках",
default=False,
)
visible_to_employee = models.BooleanField(
"Показывать сотруднику в расчётке",
default=True,
)
highlight_color = models.CharField(
"Цвет выделения",
max_length=16,
blank=True,
choices=(
("", "Без выделения"),
("green", "Зелёный"),
("red", "Красный"),
("amber", "Янтарный"),
("blue", "Синий"),
("violet", "Фиолетовый"),
),
default="",
)
is_active = models.BooleanField("Активна", default=True)
display_order = models.PositiveIntegerField("Порядок", default=900)
period_scope = models.CharField(
@ -1009,6 +1040,20 @@ class PayrollCustomComponent(models.Model):
related_name="scoped_custom_components",
verbose_name="Начальный период",
)
hidden_from_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="hidden_custom_components_from_period",
verbose_name="Скрыта начиная с периода",
)
excluded_periods = models.ManyToManyField(
PayrollPeriod,
blank=True,
related_name="excluded_custom_components",
verbose_name="Скрыта в отдельных периодах",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
@ -1025,6 +1070,10 @@ class PayrollCustomComponent(models.Model):
def __str__(self):
return self.name
@property
def is_custom(self):
return True
def clean(self):
super().clean()
self.name = self.name.strip()
@ -1089,6 +1138,14 @@ class PayrollCustomComponent(models.Model):
return True
def applies_to_period(self, period):
if self.hidden_from_period_id and (
period.date_from >= self.hidden_from_period.date_from
):
return False
if self.pk and any(
excluded.pk == period.pk for excluded in self.excluded_periods.all()
):
return False
if self.period_scope == PayrollComponentPeriodScope.ALL:
return True
if period is None or self.scope_period_id is None:
@ -1169,8 +1226,8 @@ class PayrollInputLine(DraftVersionedModel):
"Учитывать в переходящем остатке",
default=True,
help_text=(
"Используется только для положительных и отрицательных корректировок; "
"на расчёт зарплаты самой строки не влияет."
"Учитывается, когда для колонки включены переходящие остатки; "
"на расчёт зарплаты самой строки не влияет и не копирует её значение."
),
)
created_by = models.ForeignKey(
@ -1254,6 +1311,9 @@ class PayrollInputLine(DraftVersionedModel):
def component(self):
from finance.payroll.components import get_payroll_component
# Historical and voided rows must remain serializable after a custom
# component is hidden from their period. Write validation below still
# resolves the component with the target period and rejects new rows.
return get_payroll_component(self.component_code)
@component.setter
@ -1741,6 +1801,10 @@ class PayrollStatementLine(models.Model):
)
source_ref = models.CharField("Источник", max_length=255)
reason = models.TextField("Основание", blank=True)
visible_to_employee = models.BooleanField(
"Показывать сотруднику",
default=True,
)
source_period_from = models.DateField(
"Период-источник: начало",
null=True,

View File

@ -20,10 +20,13 @@ from payroll_core import (
PayrollPeriod as CorePayrollPeriod,
)
POINT_BASE_COMPONENT_CODES = frozenset(
{"BONUS", "CORRECTION_CREDIT", "CORRECTION_DEBIT"}
POINT_BASE_NEGATIVE_KINDS = frozenset(
{
"adjustment_debit",
"deduction",
"payment",
}
)
POINT_BASE_DEBIT_COMPONENT_CODES = frozenset({"CORRECTION_DEBIT"})
def _core_period(period: PayrollPeriod) -> CorePayrollPeriod:
@ -47,18 +50,30 @@ def _expected_totals(work_record: PayrollWorkRecord) -> ExpectedTotals | None:
)
def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine, ...]:
def _component_map(period, component_definitions=None):
if component_definitions is not None:
return component_definitions
from .components import get_payroll_components
return {component.code: component for component in get_payroll_components(period)}
def _mapped_input_lines(
input_lines: list[PayrollInputLine],
component_definitions,
) -> tuple[InputLine, ...]:
mapped_lines = []
for record in input_lines:
component = component_definitions[record.component_code]
source_period = None
if record.relates_to_period_id:
source_period = _core_period(record.relates_to_period)
mapped_lines.append(
InputLine(
line_id=f"input:{record.pk}",
code=record.component.code,
label=record.component.name,
kind=LineKind(record.component.kind),
code=component.code,
label=component.name,
kind=LineKind(component.kind),
amount=record.amount,
source_ref=(
record.source_ref or f"{record.source}:payroll-input:{record.pk}"
@ -66,7 +81,9 @@ def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine,
reason=record.reason,
source_period=source_period,
is_retro=source_period is not None,
include_in_carryover=record.include_in_carryover,
include_in_carryover=(
component.include_in_carryover and record.include_in_carryover
),
)
)
return tuple(mapped_lines)
@ -95,25 +112,24 @@ def _effective_point_rate(
def _point_base_accrual(
rate: EmployeePayRate | None,
input_lines: list[PayrollInputLine],
component_definitions,
) -> Decimal:
"""Return the amount whose payout is proportional to actual points.
Vacation pay, one-time accruals, deductions and payments are intentionally
excluded. Manual corrections belong to the point basis and keep their
accounting sign.
Component settings explicitly decide which columns participate. By
default only the regular bonus is included; both correction columns remain
fixed changes of the final amount.
"""
base_amount = rate.amount if rate is not None else Decimal("0")
return base_amount + sum(
(
-record.amount
if record.component_code in POINT_BASE_DEBIT_COMPONENT_CODES
else record.amount
for record in input_lines
if record.component_code in POINT_BASE_COMPONENT_CODES
),
Decimal("0"),
)
point_base_lines = Decimal("0")
for record in input_lines:
component = component_definitions[record.component_code]
if not component.include_in_point_base:
continue
direction = -1 if component.kind in POINT_BASE_NEGATIVE_KINDS else 1
point_base_lines += record.amount * direction
return base_amount + point_base_lines
def build_core_request(
@ -124,9 +140,15 @@ def build_core_request(
input_lines: list[PayrollInputLine],
opening_balance: Decimal = Decimal("0"),
rounding: str = ROUND_HALF_UP,
component_definitions=None,
) -> PayrollRequest:
components_by_code = _component_map(period, component_definitions)
target_points = work_record.target_points if work_record else None
point_base_accrual = _point_base_accrual(rate, input_lines)
point_base_accrual = _point_base_accrual(
rate,
input_lines,
components_by_code,
)
return PayrollRequest(
employee_ref=f"employee:{rate.employee_id}",
period=_core_period(period),
@ -144,7 +166,7 @@ def build_core_request(
rounding=rounding,
),
point_rate_overridden=rate.point_rate is not None,
lines=_mapped_input_lines(input_lines),
lines=_mapped_input_lines(input_lines, components_by_code),
expected_totals=(
_expected_totals(work_record) if work_record is not None else None
),
@ -161,10 +183,16 @@ def build_core_preview_request(
input_lines: list[PayrollInputLine],
opening_balance: Decimal = Decimal("0"),
rounding: str = ROUND_HALF_UP,
component_definitions=None,
) -> PayrollRequest:
"""Build a non-persisted calculation request from draft or partial inputs."""
point_base_accrual = _point_base_accrual(rate, input_lines)
components_by_code = _component_map(period, component_definitions)
point_base_accrual = _point_base_accrual(
rate,
input_lines,
components_by_code,
)
return PayrollRequest(
employee_ref=f"employee:{employee_id}",
period=_core_period(period),
@ -186,7 +214,7 @@ def build_core_preview_request(
rounding=rounding,
),
point_rate_overridden=bool(rate is not None and rate.point_rate is not None),
lines=_mapped_input_lines(input_lines),
lines=_mapped_input_lines(input_lines, components_by_code),
expected_totals=None,
)

View File

@ -1,52 +1,53 @@
"""Payroll balances reconstructed from explicit correction input lines."""
"""Payroll balances reconstructed from configured source input lines."""
from __future__ import annotations
from collections.abc import Iterable
from decimal import Decimal
from django.db.models import Q, Sum
from finance.enums import ApprovalStatus, InputSource
from finance.models import PayrollInputLine, PayrollPeriod
from finance.payroll.components import get_payroll_components
CARRYOVER_DEBIT_KINDS = frozenset({"adjustment_debit", "deduction", "payment"})
def opening_balances(
period: PayrollPeriod,
employee_ids: Iterable[int],
) -> dict[int, Decimal]:
"""Return the cumulative balance from approved earlier corrections."""
"""Return the cumulative balance from approved configured earlier lines."""
requested_ids = set(employee_ids)
if not requested_ids:
return {}
rows = (
components = {
component.code: component
for component in get_payroll_components()
if component.include_in_carryover
}
if not components:
return {}
lines = (
PayrollInputLine.objects.filter(
employee_id__in=requested_ids,
period__currency=period.currency,
period__date_to__lt=period.date_from,
status=ApprovalStatus.APPROVED,
include_in_carryover=True,
component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"),
component_code__in=components,
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.values("employee_id")
.annotate(
credit_total=Sum(
"amount",
filter=Q(component_code="CORRECTION_CREDIT"),
default=Decimal("0.00"),
),
debit_total=Sum(
"amount",
filter=Q(component_code="CORRECTION_DEBIT"),
default=Decimal("0.00"),
),
)
.values("employee_id", "component_code", "amount")
)
return {
row["employee_id"]: row["debit_total"] - row["credit_total"] for row in rows
}
balances = {}
for line in lines:
component = components[line["component_code"]]
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)
return balances

View File

@ -1,6 +1,6 @@
"""Code-owned payroll input component definitions."""
from dataclasses import dataclass
from dataclasses import dataclass, replace
from finance.enums import PayrollComponentKind, PayrollComponentPeriodScope
@ -12,6 +12,13 @@ class PayrollComponentDefinition:
kind: str
requires_reason: bool
display_order: int
include_in_point_base: bool = False
include_in_carryover: bool = False
visible_to_employee: bool = True
period_scope: str = PayrollComponentPeriodScope.ALL
scope_period_id: int | None = None
is_custom: bool = False
highlight_color: str = ""
@property
def id(self) -> str:
@ -25,14 +32,6 @@ class PayrollComponentDefinition:
def is_active(self) -> bool:
return True
@property
def period_scope(self) -> str:
return PayrollComponentPeriodScope.ALL
@property
def scope_period_id(self) -> None:
return None
def get_kind_display(self) -> str:
return PayrollComponentKind(self.kind).label
@ -42,7 +41,12 @@ PAYROLL_COMPONENTS = (
"VACATION_PAY", "Отпускные", PayrollComponentKind.EARNING, False, 20
),
PayrollComponentDefinition(
"BONUS", "Премия", PayrollComponentKind.EARNING, False, 30
"BONUS",
"Премия",
PayrollComponentKind.EARNING,
False,
30,
include_in_point_base=True,
),
PayrollComponentDefinition(
"CORRECTION_CREDIT",
@ -50,6 +54,8 @@ PAYROLL_COMPONENTS = (
PayrollComponentKind.ADJUSTMENT_CREDIT,
True,
40,
include_in_carryover=True,
highlight_color="green",
),
PayrollComponentDefinition(
"CORRECTION_DEBIT",
@ -57,6 +63,8 @@ PAYROLL_COMPONENTS = (
PayrollComponentKind.ADJUSTMENT_DEBIT,
True,
41,
include_in_carryover=True,
highlight_color="red",
),
PayrollComponentDefinition(
"ONE_TIME_PAYMENT", "Разовое начисление", PayrollComponentKind.EARNING, True, 50
@ -150,34 +158,86 @@ class PayrollComponent:
def get_payroll_components(period=None) -> _PayrollComponentCollection:
"""Return built-ins plus active user-created component columns."""
from finance.models import PayrollCustomComponent
from finance.models import FinanceSettings, PayrollCustomComponent, PayrollPeriod
finance_settings = FinanceSettings.objects.first()
overrides = finance_settings.payroll_component_overrides if finance_settings else {}
builtins = []
for component in PAYROLL_COMPONENTS:
override = overrides.get(component.code, {})
builtins.append(
replace(
component,
name=override.get("name", component.name),
kind=override.get("kind", component.kind),
requires_reason=override.get(
"requires_reason", component.requires_reason
),
include_in_point_base=override.get(
"include_in_point_base", component.include_in_point_base
),
include_in_carryover=override.get(
"include_in_carryover", component.include_in_carryover
),
visible_to_employee=override.get(
"visible_to_employee", component.visible_to_employee
),
highlight_color=override.get(
"highlight_color", component.highlight_color
),
period_scope=override.get("period_scope", component.period_scope),
scope_period_id=override.get(
"scope_period_id", component.scope_period_id
),
)
)
custom = list(
PayrollCustomComponent.objects.filter(is_active=True)
.select_related("scope_period")
.select_related("scope_period", "hidden_from_period")
.prefetch_related("excluded_periods")
.order_by("display_order", "name", "id")
)
if period is not None:
scope_period_ids = {
component.scope_period_id
for component in builtins
if component.scope_period_id is not None
}
scope_dates = dict(
PayrollPeriod.objects.filter(pk__in=scope_period_ids).values_list(
"pk", "date_from"
)
)
builtins = [
component
for component in builtins
if component.period_scope == PayrollComponentPeriodScope.ALL
or (
component.scope_period_id is not None
and (
(
component.period_scope
== PayrollComponentPeriodScope.CURRENT_ONLY
and component.scope_period_id == period.pk
)
or (
component.period_scope
== PayrollComponentPeriodScope.CURRENT_AND_FUTURE
and scope_dates.get(component.scope_period_id, period.date_from)
<= period.date_from
)
)
)
]
custom = [
component for component in custom if component.applies_to_period(period)
]
return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *custom))
return _PayrollComponentCollection((*builtins, *custom))
def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition:
component = PAYROLL_COMPONENTS_BY_CODE.get(code)
if component is not None:
return component
from finance.models import PayrollCustomComponent
component = (
PayrollCustomComponent.objects.filter(code=code, is_active=True)
.select_related("scope_period")
.first()
)
if component is not None and (
period is None or component.applies_to_period(period)
):
return component
component = get_payroll_components(period).filter(code=code)
if component:
return component[0]
raise ValueError(f"Неизвестный вид зарплатной операции: {code}")

View File

@ -31,8 +31,7 @@ from requests_app.enums import RequestStatus
from requests_app.models import Request
from .adapter import (
POINT_BASE_COMPONENT_CODES,
POINT_BASE_DEBIT_COMPONENT_CODES,
POINT_BASE_NEGATIVE_KINDS,
build_core_preview_request,
)
from .attendance import (
@ -74,18 +73,17 @@ def _in_norm_point_rate(amount, target_points, *, rounding):
)
def _point_base_amount(rate_amount, component_amounts):
def _point_base_amount(rate_amount, component_amounts, component_definitions):
if rate_amount in (None, ""):
return None
return Decimal(str(rate_amount)) + sum(
(
-Decimal(str(component_amounts.get(code, "0")))
if code in POINT_BASE_DEBIT_COMPONENT_CODES
else Decimal(str(component_amounts.get(code, "0")))
for code in POINT_BASE_COMPONENT_CODES
),
Decimal("0"),
)
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"]:
continue
direction = -1 if component["kind"] in POINT_BASE_NEGATIVE_KINDS else 1
amount += Decimal(str(component_amount)) * direction
return amount
def _signed_point_amount(lines):
@ -176,12 +174,34 @@ def _input_lines_by_employee(period, employee_ids):
return grouped
def _component_definition(code, label, kind, order):
def _component_definition(
code,
label,
kind,
order,
*,
requires_reason=False,
include_in_point_base=False,
include_in_carryover=False,
visible_to_employee=True,
period_scope="all",
scope_period_id=None,
is_custom=False,
highlight_color="",
):
return {
"code": code,
"label": label,
"kind": kind,
"display_order": order,
"requires_reason": requires_reason,
"include_in_point_base": include_in_point_base,
"include_in_carryover": include_in_carryover,
"visible_to_employee": visible_to_employee,
"period_scope": period_scope,
"scope_period_id": scope_period_id,
"is_custom": is_custom,
"highlight_color": highlight_color,
}
@ -372,12 +392,21 @@ def build_payroll_period_table(period: PayrollPeriod):
input_lines = _input_lines_by_employee(period, employee_ids)
balances = opening_balances(period, employee_ids)
components = get_payroll_components(period)
components_by_code = {component.code: component for component in components}
component_definitions = {
component.code: _component_definition(
component.code,
component.name,
component.kind,
component.display_order,
requires_reason=component.requires_reason,
include_in_point_base=component.include_in_point_base,
include_in_carryover=component.include_in_carryover,
visible_to_employee=component.visible_to_employee,
period_scope=component.period_scope,
scope_period_id=component.scope_period_id,
is_custom=component.is_custom,
highlight_color=component.highlight_color,
)
for component in components
}
@ -482,6 +511,7 @@ def build_payroll_period_table(period: PayrollPeriod):
input_lines=source_lines,
opening_balance=balances.get(employee.pk, Decimal("0")),
rounding=preview_rules.rounding.value,
component_definitions=components_by_code,
)
preview_result = calculator.calculate(preview_request, preview_rules)
point_amount = _signed_point_amount(preview_result.lines)
@ -545,7 +575,11 @@ def build_payroll_period_table(period: PayrollPeriod):
actual_points=actual_points,
)
point_base_amount = _point_base_amount(rate_amount, component_amounts)
point_base_amount = _point_base_amount(
rate_amount,
component_amounts,
component_definitions,
)
in_norm_point_rate = _in_norm_point_rate(
point_base_amount,
target_points,

View File

@ -48,7 +48,7 @@ from .access import (
)
from .adapter import build_core_request, employee_snapshot
from .balances import opening_balances
from .components import get_payroll_component
from .components import get_payroll_component, get_payroll_components
from .config import (
base_rate_code,
build_rules,
@ -285,6 +285,9 @@ 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 = []
@ -310,6 +313,7 @@ def _calculate_current_period_inputs(
input_lines=input_lines[rate.employee_id],
opening_balance=balances.get(rate.employee_id, Decimal("0")),
rounding=active_rules.rounding.value,
component_definitions=component_definitions,
)
try:
result = active_calculator.calculate(request, active_rules)
@ -515,6 +519,10 @@ def calculate_period(
employee_id__in=employee_ids,
)
}
employee_line_visibility = {
component.code: component.visible_to_employee
for component in get_payroll_components(period)
}
for request, (rate, result) in zip(requests, results, strict=True):
totals = result.totals
@ -553,6 +561,10 @@ def calculate_period(
amount=line.amount,
source_ref=line.source_ref,
reason=line.reason,
visible_to_employee=employee_line_visibility.get(
line.code,
True,
),
source_period_from=(
line.source_period.start if line.source_period else None
),

View File

@ -290,21 +290,22 @@ class DeterministicPayrollCalculator:
(line.amount for line in lines if line.kind is LineKind.PAYMENT),
Decimal("0"),
)
manual_adjustment_credits = sum(
manual_carryover_credits = sum(
(
line.amount
for line in lines
if line.kind is LineKind.ADJUSTMENT_CREDIT
if line.kind in (LineKind.EARNING, LineKind.ADJUSTMENT_CREDIT)
and not line.calculated
and line.include_in_carryover
),
Decimal("0"),
)
manual_adjustment_debits = sum(
manual_carryover_debits = sum(
(
line.amount
for line in lines
if line.kind is LineKind.ADJUSTMENT_DEBIT
if line.kind
in (LineKind.ADJUSTMENT_DEBIT, LineKind.DEDUCTION, LineKind.PAYMENT)
and not line.calculated
and line.include_in_carryover
),
@ -322,7 +323,7 @@ class DeterministicPayrollCalculator:
payment_total=payments,
payable=net_pay - payments,
closing_balance=(
opening_balance + manual_adjustment_debits - manual_adjustment_credits
opening_balance + manual_carryover_debits - manual_carryover_credits
),
)

View File

@ -656,6 +656,7 @@ def test_balance_tracks_only_manual_corrections(period, rules):
"SALARY_PAYMENT",
LineKind.PAYMENT,
"104000",
include_in_carryover=False,
),
),
)
@ -669,6 +670,28 @@ def test_balance_tracks_only_manual_corrections(period, rules):
assert result.totals.closing_balance == Decimal("6000.00")
def test_balance_can_include_configured_earning_and_deduction(period, rules):
balance_rules = replace(rules, allow_negative_payable=True)
request = make_request(
period,
base_accrual=Decimal("0"),
lines=(
input_line("earning:1", "CUSTOM_EARNING", LineKind.EARNING, "2500"),
input_line(
"deduction:1",
"CUSTOM_DEDUCTION",
LineKind.DEDUCTION,
"700",
),
),
)
result = DeterministicPayrollCalculator().calculate(request, balance_rules)
assert result.totals.payable == Decimal("1800.00")
assert result.totals.closing_balance == Decimal("-1800.00")
def test_excluded_correction_changes_salary_but_not_carrying_balance(period, rules):
request = make_request(
period,

View File

@ -20,6 +20,7 @@ from employees.constants import (
from employees.models import EmployeeAction
from finance.models import (
EmployeePayRate,
FinanceSettings,
PayrollAuditEvent,
PayrollCellCommentThread,
PayrollComponent,
@ -117,6 +118,10 @@ def test_manager_can_add_custom_component_column(
"name": "Компенсация питания",
"kind": "earning",
"requires_reason": True,
"include_in_point_base": False,
"include_in_carryover": True,
"visible_to_employee": False,
"highlight_color": "blue",
"period_scope": "current_and_future",
"period_id": period.pk,
},
@ -130,6 +135,10 @@ def test_manager_can_add_custom_component_column(
assert component["name"] == "Компенсация питания"
assert component["kind"] == "earning"
assert component["requires_reason"] is True
assert component["include_in_point_base"] is False
assert component["include_in_carryover"] is True
assert component["visible_to_employee"] is False
assert component["highlight_color"] == "blue"
assert component["period_scope"] == "current_and_future"
assert component["scope_period_id"] == period.pk
assert PayrollCustomComponent.objects.filter(code=component["code"]).exists()
@ -163,6 +172,189 @@ def test_manager_can_add_custom_component_column(
assert row["payable"] == "1250.00"
def test_manager_can_customize_system_component_behavior(
user_factory,
auth_client_factory,
):
manager = user_factory(email="system.component.manager@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
response = auth_client_factory(manager).patch(
api_url("admin-component-detail", code="BONUS"),
{
"name": "Премия по результату",
"kind": "earning",
"requires_reason": True,
"include_in_point_base": False,
"include_in_carryover": True,
"visible_to_employee": False,
"highlight_color": "violet",
"period_scope": "current_and_future",
"period_id": period.pk,
},
format="json",
)
assert response.status_code == 200, response.content
assert response.json() == {
"id": "BONUS",
"code": "BONUS",
"name": "Премия по результату",
"kind": "earning",
"kind_label": "Начислить сотруднику",
"requires_reason": True,
"is_active": True,
"display_order": 30,
"period_scope": "current_and_future",
"scope_period_id": period.pk,
"include_in_point_base": False,
"include_in_carryover": True,
"visible_to_employee": False,
"is_custom": False,
"highlight_color": "violet",
}
override = FinanceSettings.get_solo().payroll_component_overrides["BONUS"]
assert override["include_in_carryover"] is True
assert override["highlight_color"] == "violet"
def test_custom_component_can_be_hidden_by_period_but_not_with_saved_values(
user_factory,
auth_client_factory,
):
manager = user_factory(email="delete.component.manager@example.test")
employee = user_factory(email="delete.component.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
june = make_period(creator=manager)
july = PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from="2026-07-01",
date_to="2026-07-31",
pay_date="2026-08-05",
currency="RUB",
created_by=manager,
)
client = auth_client_factory(manager)
created = client.post(
api_url("admin-component-list"),
{
"name": "Временная колонка",
"kind": "earning",
"requires_reason": False,
"include_in_point_base": False,
"include_in_carryover": False,
"visible_to_employee": True,
"highlight_color": "",
"period_scope": "all",
},
format="json",
)
assert created.status_code == 201, created.content
code = created.json()["code"]
saved = client.post(
api_url("admin-component-cell-set", pk=june.pk),
{"employee_id": employee.pk, "component_code": code, "amount": "100"},
format="json",
)
assert saved.status_code == 201, saved.content
blocked = client.delete(
api_url("admin-component-detail", code=code),
{"period_id": june.pk, "mode": "current_and_future"},
format="json",
)
assert blocked.status_code == 409, blocked.content
assert blocked.json()["code"] == "COMPONENT_HAS_INPUTS"
cleared = client.post(
api_url("admin-component-cell-clear", pk=june.pk),
{"employee_id": employee.pk, "component_code": code},
format="json",
)
assert cleared.status_code == 200, cleared.content
assert PayrollInputLine.objects.get(pk=saved.json()["id"]).status == "voided"
hidden = client.delete(
api_url("admin-component-detail", code=code),
{"period_id": june.pk, "mode": "current_and_future"},
format="json",
)
assert hidden.status_code == 204, hidden.content
for period in (june, july):
table = client.get(api_url("admin-period-table", pk=period.pk))
assert code not in {item["code"] for item in table.json()["component_columns"]}
history = client.get(
api_url("admin-input-line-list"),
{"period_id": june.pk, "component_code": code},
)
assert history.status_code == 200, history.content
assert history.json()[0]["component"]["code"] == code
assert history.json()[0]["status"] == "voided"
def test_component_scope_change_cannot_orphan_saved_values(
user_factory,
auth_client_factory,
):
manager = user_factory(email="scope.guard.manager@example.test")
employee = user_factory(email="scope.guard.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
june = make_period(creator=manager)
july = PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from="2026-07-01",
date_to="2026-07-31",
pay_date="2026-08-05",
currency="RUB",
created_by=manager,
)
client = auth_client_factory(manager)
created = client.post(
api_url("admin-component-list"),
{
"name": "Колонка с историей",
"kind": "earning",
"requires_reason": False,
"period_scope": "all",
},
format="json",
)
assert created.status_code == 201, created.content
code = created.json()["code"]
PayrollInputLine.objects.create(
period=june,
employee=employee,
component_code=code,
amount="100.00",
created_by=manager,
)
response = client.patch(
api_url("admin-component-detail", code=code),
{
"name": "Колонка с историей",
"kind": "earning",
"requires_reason": False,
"include_in_point_base": False,
"include_in_carryover": False,
"visible_to_employee": True,
"highlight_color": "",
"period_scope": "current_and_future",
"period_id": july.pk,
},
format="json",
)
assert response.status_code == 409, response.content
assert response.json()["code"] == "COMPONENT_SCOPE_HAS_INPUTS"
component = PayrollCustomComponent.objects.get(code=code)
assert component.period_scope == "all"
assert component.scope_period_id is None
@pytest.mark.parametrize(
("period_scope", "expected_visibility"),
[
@ -1866,17 +2058,17 @@ def test_period_table_includes_all_active_and_only_inactive_employees_with_work(
assert inactive_with_zero_work.pk not in employee_ids
def test_period_table_preview_matches_excel_point_formula_with_bonus(
def test_period_table_preview_keeps_both_corrections_out_of_point_basis(
user_factory,
auth_client_factory,
):
manager = user_factory(email="table.excel.formula.manager@example.test")
employee = user_factory(email="table.excel.formula.employee@example.test")
grant(manager, "view_all_payroll")
grant(manager, "view_all_payroll", "manage_payroll_inputs")
period = make_period(creator=manager)
EmployeePayRate.objects.create(
employee=employee,
amount="30000",
amount="80000",
point_rate=None,
effective_from=period.date_from,
created_by=manager,
@ -1884,8 +2076,8 @@ def test_period_table_preview_matches_excel_point_formula_with_bonus(
PayrollWorkRecord.objects.create(
period=period,
employee=employee,
target_points="110",
actual_points="90",
target_points="105",
actual_points="95",
created_by=manager,
)
PayrollInputLine.objects.create(
@ -1895,8 +2087,25 @@ def test_period_table_preview_matches_excel_point_formula_with_bonus(
amount="10000",
created_by=manager,
)
PayrollInputLine.objects.create(
period=period,
employee=employee,
component=PayrollComponent.objects.get(code="CORRECTION_CREDIT"),
amount="5000",
reason="Временное увеличение премиальной базы",
created_by=manager,
)
PayrollInputLine.objects.create(
period=period,
employee=employee,
component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"),
amount="20000",
reason="Фиксированное уменьшение итога",
created_by=manager,
)
response = auth_client_factory(manager).get(
client = auth_client_factory(manager)
response = client.get(
api_url("admin-period-table", pk=period.pk),
)
@ -1906,13 +2115,40 @@ def test_period_table_preview_matches_excel_point_formula_with_bonus(
for item in response.json()["rows"]
if item["employee"]["id"] == employee.pk
)
assert row["in_norm_point_rate"] == "363.6364"
assert row["in_norm_point_rate"] == "857.1429"
assert row["point_rate"] is None
assert row["point_amount"] == "-7272.73"
assert row["gross_before_adjustments"] == "40000.00"
assert row["adjustment_total"] == "-7272.73"
assert row["gross_total"] == "32727.27"
assert row["payable"] == "32727.27"
assert row["point_amount"] == "-8571.43"
assert row["gross_before_adjustments"] == "90000.00"
assert row["adjustment_total"] == "-23571.43"
assert row["gross_total"] == "66428.57"
assert row["payable"] == "66428.57"
configured = client.patch(
api_url("admin-component-detail", code="CORRECTION_CREDIT"),
{
"name": "Положительная корректировка",
"kind": "adjustment_credit",
"requires_reason": True,
"include_in_point_base": True,
"include_in_carryover": True,
"visible_to_employee": True,
"highlight_color": "green",
"period_scope": "all",
},
format="json",
)
assert configured.status_code == 200, configured.content
configured_table = client.get(api_url("admin-period-table", pk=period.pk)).json()
configured_row = next(
item
for item in configured_table["rows"]
if item["employee"]["id"] == employee.pk
)
assert configured_row["in_norm_point_rate"] == "904.7619"
assert configured_row["point_amount"] == "-9047.62"
assert configured_row["adjustment_total"] == "-24047.62"
assert configured_row["gross_total"] == "65952.38"
def test_staff_admin_gets_full_pilot_access_and_can_manage_any_draft(

View File

@ -13,7 +13,12 @@ from finance.enums import (
PayrollCarryoverStatus,
PayrollPeriodStatus,
)
from finance.models import PayrollComponent, PayrollInputLine, PayrollPeriod
from finance.models import (
FinanceSettings,
PayrollComponent,
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.balances import opening_balances
from finance.payroll.carryovers import (
cancel_carryover,
@ -242,7 +247,7 @@ def test_manual_carryover_api_is_disabled(
assert created.status_code == 410, created.content
assert created.json()["code"] == "MANUAL_CARRYOVER_DISABLED"
assert "положительных и отрицательных корректировок" in created.json()["message"]
assert "колонок, включённых в переходящие остатки" in created.json()["message"]
def test_carryover_ledger_lists_history_and_can_exclude_a_record(
@ -300,3 +305,50 @@ def test_carryover_ledger_lists_history_and_can_exclude_a_record(
assert refreshed["summary"]["balance"] == "0.00"
assert refreshed["entries"][0]["contribution"] == "0.00"
assert refreshed["entries"][0]["raw_contribution"] == "3500.00"
def test_component_setting_adds_existing_values_to_carryover_balance(
user_factory,
auth_client_factory,
):
manager = user_factory(email="carryover.component.manager@example.test")
employee = user_factory(email="carryover.component.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
june = make_period(creator=manager, month=6)
july = make_period(creator=manager, month=7)
line = PayrollInputLine.objects.create(
period=june,
employee=employee,
component=PayrollComponent.objects.get(code="ONE_TIME_PAYMENT"),
amount=Decimal("2400.00"),
reason="Разовая выплата",
status=ApprovalStatus.APPROVED,
created_by=manager,
approved_by=manager,
approved_at=timezone.now(),
self_approval_overridden=True,
)
assert line.include_in_carryover is True
assert opening_balances(july, [employee.pk]) == {}
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {
"ONE_TIME_PAYMENT": {"include_in_carryover": True}
}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
assert opening_balances(july, [employee.pk]) == {employee.pk: Decimal("-2400.00")}
ledger = auth_client_factory(manager).get(
api_url("admin-period-carryovers", pk=july.pk)
)
assert ledger.status_code == 200, ledger.content
assert [entry["id"] for entry in ledger.json()["entries"]] == [line.pk]
assert ledger.json()["summary"]["balance"] == "-2400.00"
excluded = auth_client_factory(manager).patch(
api_url("admin-input-line-carryover-inclusion", pk=line.pk),
{"included": False},
format="json",
)
assert excluded.status_code == 200, excluded.content
assert opening_balances(july, [employee.pk]) == {}

View File

@ -22,6 +22,7 @@ from finance.enums import (
)
from finance.models import (
EmployeePayRate,
FinanceSettings,
PayrollAuditEvent,
PayrollComponent,
PayrollInputLine,
@ -443,9 +444,20 @@ def test_calculation_uses_excel_point_basis_with_bonus(
assert statement.result_snapshot["warnings"] == []
def test_calculation_applies_corrections_before_point_recalculation(
@pytest.mark.parametrize(
("actual_points", "expected_adjustment", "expected_gross"),
(
(Decimal("95"), Decimal("-23571.43"), Decimal("66428.57")),
(Decimal("105"), Decimal("-15000.00"), Decimal("75000.00")),
(Decimal("115"), Decimal("-6428.57"), Decimal("83571.43")),
),
)
def test_calculation_keeps_both_manual_corrections_out_of_point_basis(
june_period,
payroll_users,
actual_points,
expected_adjustment,
expected_gross,
):
operator = payroll_users["operator"]
approver = payroll_users["input_approver"]
@ -462,7 +474,7 @@ def test_calculation_applies_corrections_before_point_recalculation(
period=june_period,
employee=employee,
target_points=Decimal("105"),
actual_points=Decimal("95"),
actual_points=actual_points,
created_by=operator,
)
bonus = PayrollInputLine.objects.create(
@ -472,19 +484,28 @@ def test_calculation_applies_corrections_before_point_recalculation(
amount=Decimal("10000"),
created_by=operator,
)
correction = PayrollInputLine.objects.create(
positive_correction = PayrollInputLine.objects.create(
period=june_period,
employee=employee,
component=PayrollComponent.objects.get(code="CORRECTION_CREDIT"),
amount=Decimal("5000"),
reason="Временное увеличение премиальной базы",
created_by=operator,
)
negative_correction = PayrollInputLine.objects.create(
period=june_period,
employee=employee,
component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"),
amount=Decimal("20000"),
reason="Корректировка базы выработки",
reason="Фиксированное уменьшение итога",
created_by=operator,
)
for instance, approve in (
(rate, approve_pay_rate),
(work, approve_work_record),
(bonus, approve_input_line),
(correction, approve_input_line),
(positive_correction, approve_input_line),
(negative_correction, approve_input_line),
):
approve(
instance.pk,
@ -495,12 +516,12 @@ def test_calculation_applies_corrections_before_point_recalculation(
run = calculate_period(june_period.pk, actor=operator)
statement = run.statements.get(employee=employee)
assert statement.input_snapshot["point_base_accrual"] == "70000"
assert statement.input_snapshot["point_rate"] == "666.6667"
assert statement.input_snapshot["point_base_accrual"] == "90000"
assert statement.input_snapshot["point_rate"] == "857.1429"
assert statement.gross_before_adjustments == Decimal("90000.00")
assert statement.adjustment_total == Decimal("-26666.67")
assert statement.gross_total == Decimal("63333.33")
assert statement.payable == Decimal("63333.33")
assert statement.adjustment_total == expected_adjustment
assert statement.gross_total == expected_gross
assert statement.payable == expected_gross
def test_full_workflow_requires_a_second_person_and_employee_can_acknowledge(
@ -921,6 +942,9 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
auth_client_factory,
extract_results,
):
settings = FinanceSettings.get_solo()
settings.payroll_component_overrides = {"BONUS": {"visible_to_employee": False}}
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
run = calculate_excel_period(june_period, payroll_users)
publish_calculation(run, payroll_users)
statement = run.statements.get()
@ -968,7 +992,9 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
detail_response = employee_client.get(detail_url)
assert detail_response.status_code == 200
detail = detail_response.json()
assert len(detail["lines"]) == 4
assert len(detail["lines"]) == 3
assert all(line["code"] != "BONUS" for line in detail["lines"])
assert statement.lines.get(code="BONUS").visible_to_employee is False
assert "opening_balance" not in detail
assert "closing_balance" not in detail
assert all("is_automatic_carryover" not in line for line in detail["lines"])

View File

@ -574,7 +574,7 @@ export function PayrollBulkPointRateModal({
Как в пределах нормы
</span>
<span className="app-text-muted mt-1 block text-xs">
Для каждого сотрудника: (оклад + премия) ÷ его норма баллов.
Для каждого сотрудника: балльная база ÷ его норма баллов.
</span>
</span>
</span>

View File

@ -82,6 +82,7 @@ import type {
PayrollInputHistoryKind,
PayrollPayRateWrite,
PayrollPeriodTable,
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
PayrollPeriodWrite,
PayrollWorkRecordWrite,
@ -1369,6 +1370,43 @@ export function PayrollAdminWorkspace({
setNotice(`Колонка «${payload.name}» добавлена.`);
};
const updateComponentColumn = async (
component: PayrollPeriodTableComponent,
payload: PayrollCustomComponentWrite,
) => {
if (!canManageSourceData || !selectedPeriod) return;
await apiClient.updatePayrollAdminComponent(component.code, {
...payload,
period_id: selectedPeriod.id,
});
await Promise.all([
loadWorkspace(selectedPeriod.id, true),
refreshInlineTable("inputs"),
]);
setNotice(`Настройки колонки «${payload.name}» сохранены.`);
};
const deleteComponentColumn = async (
component: PayrollPeriodTableComponent,
mode: "current" | "current_and_future",
) => {
if (!canManageSourceData || !selectedPeriod) return;
await apiClient.hidePayrollAdminComponent(
component.code,
selectedPeriod.id,
mode,
);
await Promise.all([
loadWorkspace(selectedPeriod.id, true),
refreshInlineTable("inputs"),
]);
setNotice(
mode === "current"
? `Колонка «${component.label}» удалена из текущего периода.`
: `Колонка «${component.label}» удалена из текущего и будущих периодов.`,
);
};
const saveRateCell = async (
row: PayrollPeriodTableRow,
field: "amount" | "point_rate",
@ -1978,7 +2016,7 @@ export function PayrollAdminWorkspace({
<section className="app-surface rounded-2xl p-4 sm:p-5">
<SectionIntro
title="Ставки"
description="Оклад и цена балла сверх нормы действуют с указанной даты. Цена балла в норме рассчитывается по Excel: оклад плюс премия, разделённые на норму."
description="Оклад и цена балла сверх нормы действуют с указанной даты. По умолчанию цена балла в норме рассчитывается из оклада и премии; дополнительные колонки можно включить в базу через их настройки."
/>
{sourceDataLocked ? (
<div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">
@ -2188,8 +2226,14 @@ export function PayrollAdminWorkspace({
onAddComponent={
canManageSourceData ? addComponentColumn : undefined
}
onUpdateComponent={
canManageSourceData ? updateComponentColumn : undefined
}
onDeleteComponent={
canManageSourceData ? deleteComponentColumn : undefined
}
onOpenCarryovers={(employee) => {
setCarryoversEmployeeId(employee.id);
setCarryoversEmployeeId(employee?.id || null);
setCarryoversModalOpen(true);
}}
onSetPaymentConfirmed={

View File

@ -195,20 +195,19 @@ export function PayrollCarryoversModal({
</div>
<div className="app-text-muted mt-3 space-y-1 text-xs leading-relaxed">
<p>
<b className="text-[var(--foreground)]">В реестре:</b> все
положительные и отрицательные корректировки за выбранный и
предыдущие периоды.
<b className="text-[var(--foreground)]">В реестре:</b> все строки
колонок, для которых включён параметр «Учитывать в переходящих
остатках», за выбранный и предыдущие периоды.
</p>
<p>
<b className="text-[var(--foreground)]">В остатке:</b> сумма всех
учитываемых утверждённых корректировок за всю историю до конца
выбранного периода и активных корректировок самого выбранного
периода.
учитываемых утверждённых строк за всю историю до конца выбранного
периода и активных строк самого выбранного периода.
</p>
<p>
<b className="text-[var(--foreground)]">Не входят:</b> зарплата по
баллам, обычные премии и начисления, аванс, прочие выплаты и
удержания.
<b className="text-[var(--foreground)]">Это не копирование:</b>{" "}
значение ячейки не появляется в колонке следующего периода;
меняется только переходящий остаток.
</p>
<p>
<b className="text-[var(--foreground)]">Отключение записи</b>{" "}
@ -332,7 +331,7 @@ export function PayrollCarryoversModal({
}
/>
<span className="text-[var(--foreground)]">
Учитывать эту корректировку в переходящем остатке
Учитывать эту строку в переходящем остатке
</span>
{savingId === entry.id ? (
<Loader2 className="animate-spin" size={13} />
@ -346,7 +345,7 @@ export function PayrollCarryoversModal({
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
Корректировок за выбранный и предыдущие периоды нет.
Учитываемых строк за выбранный и предыдущие периоды нет.
</div>
)}
</div>

View File

@ -52,6 +52,7 @@ import {
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
import type {
PayrollComponentPeriodScope,
PayrollColumnHighlightColor,
PayrollCustomComponentWrite,
PayrollLineKind,
PayrollPeriodTable,
@ -132,6 +133,33 @@ const periodScopeLabels: Record<PayrollComponentPeriodScope, string> = {
all: "Все периоды",
};
const highlightClasses: Record<
PayrollColumnHighlightColor,
{ column: string; text: string }
> = {
"": { column: "", text: "" },
green: {
column: "!border-x-2 !border-emerald-500/80 bg-emerald-500/[0.035]",
text: "font-bold text-emerald-400",
},
red: {
column: "!border-x-2 !border-red-500/80 bg-red-500/[0.035]",
text: "font-bold text-red-400",
},
amber: {
column: "!border-x-2 !border-amber-500/80 bg-amber-500/[0.035]",
text: "font-bold text-amber-400",
},
blue: {
column: "!border-x-2 !border-blue-500/80 bg-blue-500/[0.035]",
text: "font-bold text-blue-400",
},
violet: {
column: "!border-x-2 !border-violet-500/80 bg-violet-500/[0.035]",
text: "font-bold text-violet-400",
},
};
const headerCellClass =
"app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]";
const bodyCellClass =
@ -468,6 +496,7 @@ function ColumnHeader({
help,
onOpen,
onBulkEdit,
onConfigure,
onHide,
dragHandle,
}: {
@ -476,6 +505,7 @@ function ColumnHeader({
help?: CalculationHelp;
onOpen: (help: CalculationHelp) => void;
onBulkEdit?: () => void;
onConfigure?: () => void;
onHide: () => void;
dragHandle?: ReactNode;
}) {
@ -499,8 +529,19 @@ function ColumnHeader({
type="button"
className="app-text-muted inline-flex h-4 w-4 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={onBulkEdit}
aria-label={`Массово заполнить колонку «${label}»`}
title="Массовое заполнение"
aria-label={`Заполнить колонку «${label}»`}
title="Заполнить колонку"
>
<Plus size={12} strokeWidth={2} />
</button>
) : null}
{onConfigure ? (
<button
type="button"
className="app-text-muted inline-flex h-4 w-4 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={onConfigure}
aria-label={`Настроить колонку «${label}»`}
title="Настроить колонку"
>
<Pencil size={12} strokeWidth={2} />
</button>
@ -668,18 +709,38 @@ function CalculationHelpModal({
);
}
function AddPayrollComponentModal({
onCreate,
function PayrollComponentSettingsModal({
component,
onSave,
onDelete,
onClose,
}: {
onCreate: (payload: PayrollCustomComponentWrite) => Promise<void>;
component?: PayrollPeriodTableComponent | null;
onSave: (payload: PayrollCustomComponentWrite) => Promise<void>;
onDelete?: (mode: "current" | "current_and_future") => Promise<void>;
onClose: () => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<PayrollLineKind>("earning");
const [requiresReason, setRequiresReason] = useState(false);
const [periodScope, setPeriodScope] =
useState<PayrollComponentPeriodScope>("current_and_future");
const [name, setName] = useState(component?.label || "");
const [kind, setKind] = useState<PayrollLineKind>(
component?.kind || "earning",
);
const [requiresReason, setRequiresReason] = useState(
component?.requires_reason || false,
);
const [includeInPointBase, setIncludeInPointBase] = useState(
component?.include_in_point_base || false,
);
const [includeInCarryover, setIncludeInCarryover] = useState(
component?.include_in_carryover || false,
);
const [visibleToEmployee, setVisibleToEmployee] = useState(
component?.visible_to_employee ?? true,
);
const [highlightColor, setHighlightColor] =
useState<PayrollColumnHighlightColor>(component?.highlight_color || "");
const [periodScope, setPeriodScope] = useState<PayrollComponentPeriodScope>(
component?.period_scope || "current_and_future",
);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
@ -718,22 +779,63 @@ function AddPayrollComponentModal({
setPending(true);
setError(null);
try {
await onCreate({
await onSave({
name: normalizedName,
kind,
requires_reason: requiresReason,
include_in_point_base: includeInPointBase,
include_in_carryover: includeInCarryover,
visible_to_employee: visibleToEmployee,
highlight_color: highlightColor,
period_scope: periodScope,
});
onClose();
} catch (createError) {
setError(
getPayrollAdminError(createError, "Не удалось добавить колонку."),
getPayrollAdminError(
createError,
component
? "Не удалось сохранить колонку."
: "Не удалось добавить колонку.",
),
);
} finally {
setPending(false);
}
};
const remove = async (mode: "current" | "current_and_future") => {
if (!onDelete) return;
setPending(true);
setError(null);
try {
await onDelete(mode);
onClose();
} catch (deleteError) {
setError(
getPayrollAdminError(deleteError, "Не удалось удалить колонку."),
);
} finally {
setPending(false);
}
};
const previewComponent: PayrollPeriodTableComponent = {
code: component?.code || "PREVIEW",
label: name.trim() || "Новая колонка",
kind,
display_order: component?.display_order || 0,
requires_reason: requiresReason,
include_in_point_base: includeInPointBase,
include_in_carryover: includeInCarryover,
visible_to_employee: visibleToEmployee,
period_scope: periodScope,
scope_period_id: component?.scope_period_id ?? null,
is_custom: component?.is_custom ?? true,
highlight_color: highlightColor,
};
const calculationPreview = componentCalculationHelp(previewComponent);
if (typeof document === "undefined") return null;
return createPortal(
<div
@ -744,10 +846,12 @@ function AddPayrollComponentModal({
type="button"
className="absolute inset-0 bg-black/60"
onClick={pending ? undefined : onClose}
aria-label="Закрыть добавление колонки"
aria-label={
component ? "Закрыть настройку колонки" : "Закрыть добавление колонки"
}
/>
<section
className="app-surface relative z-10 w-full max-w-md rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl"
className="app-surface relative z-10 max-h-[calc(100vh-2rem)] w-full max-w-md overflow-y-auto rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="payroll-add-component-title"
@ -761,7 +865,9 @@ function AddPayrollComponentModal({
id="payroll-add-component-title"
className="mt-1 inline-flex items-center gap-1.5 text-lg font-semibold text-[var(--foreground)]"
>
<span>Добавить колонку</span>
<span>
{component ? "Настроить колонку" : "Добавить колонку"}
</span>
<button
type="button"
onClick={() => setHelpOpen(true)}
@ -815,6 +921,52 @@ function AddPayrollComponentModal({
))}
</select>
</label>
<section
className="app-selected rounded-xl border border-[var(--border-subtle)] px-3 py-3"
aria-live="polite"
aria-label="Предпросмотр формулы колонки"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground)]">
Как работает сейчас
</p>
<button
type="button"
onClick={() => setHelpOpen(true)}
className="app-accent-text inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
aria-label="Подробно показать текущую формулу"
>
<CircleHelp size={13} strokeWidth={2} />
Подробнее
</button>
</div>
<p className="app-text-muted mt-1.5 text-xs leading-relaxed">
{calculationPreview.logic}
</p>
<div className="app-surface mt-2 whitespace-pre-wrap rounded-lg px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--foreground)]">
{calculationPreview.formula}
</div>
</section>
<label className="block">
<span className="app-field-label">Цвет выделения колонки</span>
<select
value={highlightColor}
onChange={(event) =>
setHighlightColor(
event.target.value as PayrollColumnHighlightColor,
)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
disabled={pending}
>
<option value="">Без выделения</option>
<option value="green">Зелёный</option>
<option value="red">Красный</option>
<option value="amber">Янтарный</option>
<option value="blue">Синий</option>
<option value="violet">Фиолетовый</option>
</select>
</label>
<label className="block">
<span className="app-field-label">В каких периодах показывать</span>
<select
@ -834,8 +986,8 @@ function AddPayrollComponentModal({
))}
</select>
<span className="app-text-muted mt-1.5 block text-xs leading-relaxed">
Новая колонка появится согласно выбранной области; суммы между
периодами автоматически не копируются.
Колонка появится согласно выбранной области; суммы между периодами
автоматически не копируются.
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
@ -855,6 +1007,58 @@ function AddPayrollComponentModal({
</span>
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={includeInCarryover}
onChange={(event) => setIncludeInCarryover(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
/>
<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"
checked={includeInPointBase}
onChange={(event) => setIncludeInPointBase(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
/>
<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"
checked={visibleToEmployee}
onChange={(event) => setVisibleToEmployee(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
/>
<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>
{error ? (
<div className="app-feedback-danger rounded-lg px-3 py-2.5 text-sm">
{error}
@ -876,23 +1080,59 @@ function AddPayrollComponentModal({
>
{pending ? (
<Loader2 className="animate-spin" size={15} />
) : component ? (
<Pencil size={15} />
) : (
<Plus size={15} />
)}
Добавить
{component ? "Сохранить" : "Добавить"}
</button>
</div>
{component?.is_custom && onDelete ? (
<div className="border-t border-[var(--border-subtle)] pt-4">
<p className="app-text-muted mb-2 text-xs">
Удаление безопасно скрывает колонку, сохраняя прошлые расчёты.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
disabled={pending}
onClick={() => void remove("current")}
>
Удалить в текущем
</button>
<button
type="button"
className="app-feedback-danger rounded-lg px-3 py-2 text-xs font-medium"
disabled={pending}
onClick={() => void remove("current_and_future")}
>
Удалить в текущем и будущих
</button>
</div>
</div>
) : null}
</form>
</section>
{helpOpen ? (
<PayrollComponentKindsHelpModal onClose={() => setHelpOpen(false)} />
<PayrollComponentKindsHelpModal
preview={calculationPreview}
onClose={() => setHelpOpen(false)}
/>
) : null}
</div>,
document.body,
);
}
function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
function PayrollComponentKindsHelpModal({
preview,
onClose,
}: {
preview: CalculationHelp;
onClose: () => void;
}) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@ -913,7 +1153,7 @@ function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
aria-label="Закрыть справку о типах колонок"
/>
<section
className="app-surface relative z-10 w-full max-w-lg rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl"
className="app-surface relative z-10 max-h-[calc(100vh-2rem)] w-full max-w-lg overflow-y-auto rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="payroll-component-kinds-help-title"
@ -927,7 +1167,7 @@ function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
id="payroll-component-kinds-help-title"
className="mt-1 text-lg font-semibold text-[var(--foreground)]"
>
Как выбрать тип колонки
Текущая формула «{preview.title}»
</h3>
</div>
<button
@ -941,7 +1181,25 @@ function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
</button>
</div>
<div className="mt-5 space-y-2.5">
<section
className="app-selected mt-5 rounded-xl border border-[var(--border-subtle)] p-3"
aria-live="polite"
>
<h4 className="text-sm font-semibold text-[var(--foreground)]">
Что изменится в расчёте
</h4>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
{preview.logic}
</p>
<div className="app-surface mt-2 whitespace-pre-wrap rounded-lg px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--foreground)]">
{preview.formula}
</div>
</section>
<p className="app-text-muted mt-5 text-xs font-semibold uppercase tracking-wide">
Справочник типов
</p>
<div className="mt-2.5 space-y-2.5">
{kindHelpItems.map((item) => (
<section
key={item.kind}
@ -960,7 +1218,7 @@ function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
))}
</div>
<div className="app-selected mt-4 rounded-xl px-3 py-2.5 text-xs leading-relaxed">
<div className="app-surface-muted mt-4 rounded-xl px-3 py-2.5 text-xs leading-relaxed">
<span className="font-semibold">Итоговая формула: </span>К выплате =
Начислено − Удержано − Выплачено.
</div>
@ -974,13 +1232,13 @@ function calculationHelps(
): Record<string, CalculationHelp> {
const pointRecalculationLogic =
pointPolicy === "proportional_with_excess"
? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный."
? "Баллы изменяют настроенную балльную базу. По умолчанию в неё входят оклад и премия; обе ручные корректировки изменяют итог фиксированной суммой. Участие любой колонки можно изменить в её настройках."
: pointPolicy === "excess_only"
? "Недовыполнение нормы не уменьшает начисление; оплачивается только превышение."
: "Перерасчёт по баллам отключён текущими правилами.";
const pointRecalculationFormula =
pointPolicy === "proportional_with_excess"
? "Если цена сверх нормы пустая:\nПо баллам = ((Оклад + Премия) ÷ Норма × Баллы) − (Оклад + Премия)\n\nЕсли цена задана и Баллы > Норма:\nПо баллам = (Баллы − Норма) × Цена сверх нормы"
? "База = Оклад + Σ колонок с настройкой «Учитывать в балльной базе»\n\nЕсли цена сверх нормы пустая:\nПо баллам = (База ÷ Норма × Баллы) − База\n\nЕсли цена задана и Баллы > Норма:\nПо баллам = (Баллы − Норма) × Цена сверх нормы"
: pointPolicy === "excess_only"
? "По баллам = max(Баллы − Норма, 0) × Цена балла сверх нормы"
: "По баллам = 0";
@ -989,9 +1247,10 @@ function calculationHelps(
inNormPointRate: {
title: "Цена балла в норме",
logic:
"Расчётная стоимость одного балла в исходной Excel-формуле. В балльную базу входят оклад и премия.",
formula: "Цена балла в норме = (Оклад + Премия) ÷ Норма",
manual: "Нет. Значение меняется через оклад, премию или норму баллов.",
"Расчётная стоимость одного балла. По умолчанию в балльную базу входят оклад и премия; состав базы изменяется в настройках колонок.",
formula: "Цена балла в норме = Балльная база ÷ Норма",
manual:
"Нет. Значение меняется через оклад, участвующие в базе колонки или норму баллов.",
},
excessPointRate: {
title: "Цена балла сверх нормы",
@ -1000,7 +1259,7 @@ function calculationHelps(
? "При текущем правиле балльная доплата отключена, поэтому цена не участвует в расчёте."
: "Если отдельная цена не заполнена, система автоматически использует цену балла в норме. Можно задать индивидуальную цену.",
formula:
"Цена сверх нормы = указанная цена\nесли пусто: (Оклад + Премия) ÷ Норма",
"Цена сверх нормы = указанная цена\nесли пусто: Балльная база ÷ Норма",
manual:
"Да. Пустая ячейка возвращает автоматический расчёт по цене балла в норме.",
},
@ -1050,7 +1309,7 @@ function calculationHelps(
logic: pointRecalculationLogic,
formula: pointRecalculationFormula,
manual:
"Нет. Меняется через оклад, премию, норму и баллы; при перевыполнении также через цену сверх нормы.",
"Нет. Меняется через балльную базу, норму и баллы; при перевыполнении также через цену сверх нормы.",
},
grossBeforeAdjustments: {
title: "До корректировок",
@ -1126,39 +1385,65 @@ function componentCalculationHelp(
earning: {
effect:
"увеличивает «До корректировок», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → До корректировок (+) → Начислено → После удержаний → К выплате\nПереходящий остаток: не изменяется`,
balance: "В переходящий остаток не входит.",
formula: `${component.label} → До корректировок (+) → Начислено → После удержаний → К выплате`,
},
adjustment_credit: {
effect:
"увеличивает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Корректировки (+) → Начислено → После удержаний → К выплате\nПереходящий остаток: уменьшается`,
balance:
"Уменьшает накопленный остаток недоплаты; превышение создаёт отрицательный остаток.",
formula: `${component.label} → Корректировки (+) → Начислено → После удержаний → К выплате`,
},
adjustment_debit: {
effect:
"уменьшает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Корректировки (−) → Начислено → После удержаний → К выплате\nПереходящий остаток: увеличивается`,
balance: "Создаёт или увеличивает накопленный остаток недоплаты.",
formula: `${component.label} → Корректировки (−) → Начислено → После удержаний → К выплате`,
},
deduction: {
effect:
"увеличивает «Удержано» и тем самым уменьшает «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Удержано (+) → После удержаний (−) → К выплате\nПереходящий остаток: не изменяется`,
balance: "В переходящий остаток не входит.",
formula: `${component.label} → Удержано (+) → После удержаний (−) → К выплате`,
},
payment: {
effect:
"увеличивает «Выплачено» и тем самым уменьшает остаток «К выплате»; на начисленную зарплату не влияет",
formula: `${component.label} → Выплачено (+) → К выплате (−)\nПереходящий остаток: не изменяется`,
balance: "В переходящий остаток не входит.",
formula: `${component.label} → Выплачено (+) → К выплате (−)`,
},
}[component.kind];
const decreasesCarryover = ["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 carryoverEffect = component.include_in_carryover
? decreasesCarryover
? "Уменьшает переходящий остаток следующего периода; значение ячейки не копируется."
: "Увеличивает переходящий остаток следующего периода; значение ячейки не копируется."
: "Переходящий остаток следующего периода не меняет.";
const carryoverFormula = component.include_in_carryover
? decreasesCarryover
? `Остаток следующего периода = остаток без этой колонки − сумма «${component.label}»`
: `Остаток следующего периода = остаток без этой колонки + сумма «${component.label}»`
: `Остаток следующего периода: «${component.label}» не участвует`;
const employeeEffect = component.visible_to_employee
? "В расчётке сотрудника колонка видна."
: "Колонка внутренняя и в расчётке сотрудника скрыта; итоговые суммы сохраняются.";
const reasonEffect = component.requires_reason
? "При вводе обязательно основание."
: "Основание при вводе необязательно.";
return {
title: component.label,
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Она ${flow.effect}. ${flow.balance}`,
formula: `${component.label} = Σ операций этого вида за период\n\nКуда идёт:\n${flow.formula}`,
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Она ${flow.effect}. ${pointBaseEffect} ${carryoverEffect} ${employeeEffect} ${reasonEffect}`,
formula: `${component.label} = Σ операций этого вида за период\n\nИтоги текущего периода:\n${flow.formula}\n\nБалльный расчёт:\n${pointBaseFormula}\n\nПереходящие остатки:\n${carryoverFormula}`,
manual:
"Да. Введите итоговую сумму в ячейку: система создаст новый черновик или изменит существующий на нужную разницу. Утверждённая операция не переписывается — для исправления создаётся новая операция.",
};
@ -1503,6 +1788,8 @@ export function PayrollPeriodTableView({
onSaveWork,
onSaveComponent,
onAddComponent,
onUpdateComponent,
onDeleteComponent,
onOpenCarryovers,
onSetPaymentConfirmed,
onSetReviewMark,
@ -1535,7 +1822,15 @@ export function PayrollPeriodTableView({
value: string,
) => Promise<void>;
onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise<void>;
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
onUpdateComponent?: (
component: PayrollPeriodTableComponent,
payload: PayrollCustomComponentWrite,
) => Promise<void>;
onDeleteComponent?: (
component: PayrollPeriodTableComponent,
mode: "current" | "current_and_future",
) => Promise<void>;
onOpenCarryovers?: (employee?: PayrollPeriodTableRow["employee"]) => void;
onSetPaymentConfirmed?: (
row: PayrollPeriodTableRow,
confirmed: boolean,
@ -1565,6 +1860,8 @@ export function PayrollPeriodTableView({
} | null>(null);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const [addComponentOpen, setAddComponentOpen] = useState(false);
const [editingComponent, setEditingComponent] =
useState<PayrollPeriodTableComponent | null>(null);
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
const [compactPanel, setCompactPanel] = useState<CompactPeriodPanel | null>(
"table",
@ -1858,12 +2155,8 @@ export function PayrollPeriodTableView({
: section === "footer"
? "!border-b-2"
: "";
if (component?.kind === "adjustment_credit") {
return `!border-x-2 !border-emerald-500/80 bg-emerald-500/[0.035] ${sectionEdge}`;
}
if (component?.kind === "adjustment_debit") {
return `!border-x-2 !border-red-500/80 bg-red-500/[0.035] ${sectionEdge}`;
}
if (component?.highlight_color)
return `${highlightClasses[component.highlight_color].column} ${sectionEdge}`;
return groupBoundaryColumnIds.has(column.id)
? "!border-l-2 !border-l-[var(--border-strong)]"
: "";
@ -1912,12 +2205,7 @@ export function PayrollPeriodTableView({
};
const component = componentsByColumnId.get(column.id);
if (component) {
const accentClass =
component.kind === "adjustment_credit"
? "font-bold text-emerald-400"
: component.kind === "adjustment_debit"
? "font-bold text-red-400"
: "";
const accentClass = highlightClasses[component.highlight_color].text;
return (
<span
className={accentClass}
@ -1926,6 +2214,14 @@ export function PayrollPeriodTableView({
<ColumnHeader
label={component.label}
help={componentCalculationHelp(component)}
onConfigure={
onUpdateComponent
? () => {
setEditingComponent(component);
setAddComponentOpen(true);
}
: undefined
}
{...common}
>
{component.label}
@ -2082,14 +2378,17 @@ export function PayrollPeriodTableView({
return (
<ColumnHeader
label="Остаток к переносу"
onConfigure={
onOpenCarryovers ? () => onOpenCarryovers(undefined) : undefined
}
help={{
title: "Остаток к переносу",
logic:
"Накопленная разница, созданная только явными положительными и отрицательными корректировками. После публикации она переходит в следующий период той же валюты.\n\nЗарплата по баллам, оклад, премии, отпускные, разовые начисления, удержания и выплаты этот остаток не создают.\n\nПлюс — накопленная недоплата. Минус — накопленная переплата. Ноль — корректировки полностью скомпенсированы.",
"Накопленная разница из строк колонок с включённой настройкой «Учитывать в переходящих остатках». После публикации итог становится начальным остатком следующего периода той же валюты.\n\nЗначения ячеек при этом не копируются в колонки следующего периода. Плюс — накопленная недоплата, минус — накопленная переплата.",
formula:
"Остаток на конец = Остаток на начало + Отрицательная корректировка − Положительная корректировка\n\nОстаток на начало следующего периода = Остаток на конец опубликованного периода",
"Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки, удержания и выплаты\n− учитываемые начисления и положительные корректировки\n\nОстаток следующего периода учитывает утверждённые строки предыдущих периодов",
manual:
"Отдельно не редактируется.\n• Отрицательная корректировка создаёт или увеличивает недоплату.\n• Положительная корректировка погашает недоплату; превышение создаёт переплату.\n• Противоположные корректировки на одинаковую сумму сводят остаток к нулю.",
"Сам итог отдельно не редактируется. Карандаш в заголовке открывает реестр, где отдельную строку можно включить или исключить. Участие всей колонки настраивается её карандашом в блоке «Начисления и выплаты».",
}}
{...common}
>
@ -2992,14 +3291,7 @@ export function PayrollPeriodTableView({
className="h-3 w-3 rounded-sm border-2 border-emerald-500"
aria-hidden="true"
/>
Зелёная рамка — положительная корректировка
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-3 w-3 rounded-sm border-2 border-red-500"
aria-hidden="true"
/>
Красная рамка — отрицательная корректировка
Цветная рамка — выделение, выбранное в настройках колонки
</span>
<span className="inline-flex items-center gap-1.5">
<span
@ -3090,7 +3382,10 @@ export function PayrollPeriodTableView({
<button
type="button"
className="app-action-ghost inline-flex h-5 w-5 items-center justify-center rounded-md text-[var(--accent-primary)]"
onClick={() => setAddComponentOpen(true)}
onClick={() => {
setEditingComponent(null);
setAddComponentOpen(true);
}}
aria-label="Добавить колонку начисления или выплаты"
title="Добавить колонку"
>
@ -3270,9 +3565,22 @@ export function PayrollPeriodTableView({
/>
) : null}
{addComponentOpen && onAddComponent ? (
<AddPayrollComponentModal
onCreate={onAddComponent}
onClose={() => setAddComponentOpen(false)}
<PayrollComponentSettingsModal
component={editingComponent}
onSave={(payload) =>
editingComponent && onUpdateComponent
? onUpdateComponent(editingComponent, payload)
: onAddComponent(payload)
}
onDelete={
editingComponent?.is_custom && onDeleteComponent
? (mode) => onDeleteComponent(editingComponent, mode)
: undefined
}
onClose={() => {
setAddComponentOpen(false);
setEditingComponent(null);
}}
/>
) : null}
{breakdownEmployee ? (

View File

@ -392,6 +392,14 @@ export type PayrollPeriodTableComponent = {
label: string;
kind: PayrollLineKind;
display_order: number;
requires_reason: boolean;
include_in_point_base: boolean;
include_in_carryover: boolean;
visible_to_employee: boolean;
period_scope: PayrollComponentPeriodScope;
scope_period_id: number | null;
is_custom: boolean;
highlight_color: PayrollColumnHighlightColor;
};
export type PayrollCellCommentAuthor = {
@ -606,12 +614,29 @@ export type PayrollComponent = {
display_order: number;
period_scope: PayrollComponentPeriodScope;
scope_period_id: number | null;
include_in_point_base: boolean;
include_in_carryover: boolean;
visible_to_employee: boolean;
is_custom: boolean;
highlight_color: PayrollColumnHighlightColor;
};
export type PayrollColumnHighlightColor =
| ""
| "green"
| "red"
| "amber"
| "blue"
| "violet";
export type PayrollCustomComponentWrite = {
name: string;
kind: PayrollLineKind;
requires_reason: boolean;
include_in_point_base: boolean;
include_in_carryover: boolean;
visible_to_employee: boolean;
highlight_color: PayrollColumnHighlightColor;
period_scope: PayrollComponentPeriodScope;
};
@ -1026,7 +1051,7 @@ function buildQuery(
}
function jsonOptions(
method: "POST" | "PATCH",
method: "POST" | "PATCH" | "DELETE",
payload: object = {},
): RequestInit {
return { method, body: JSON.stringify(payload) };
@ -1089,6 +1114,22 @@ export function createFinanceApi(request: RequestFn) {
payload: PayrollCustomComponentWrite & { period_id: number },
): Promise<PayrollComponent> =>
request(`${adminPrefix}/components/`, jsonOptions("POST", payload)),
updatePayrollAdminComponent: (
code: string,
payload: PayrollCustomComponentWrite & { period_id: number },
): Promise<PayrollComponent> =>
request(
`${adminPrefix}/components/${encodeURIComponent(code)}/`,
jsonOptions("PATCH", payload),
),
hidePayrollAdminComponent: (
code: string,
periodId: number,
mode: "current" | "current_and_future",
): Promise<void> =>
request(`${adminPrefix}/components/${encodeURIComponent(code)}/`, {
...jsonOptions("DELETE", { period_id: periodId, mode }),
}),
getPayrollAdminPeriods: (): Promise<
PayrollAdminListPayload<PayrollAdminPeriod>
> => request(`${adminPrefix}/periods/`),