feat: make payroll columns configurable
All checks were successful
Deploy production / deploy (push) Successful in 10m13s
All checks were successful
Deploy production / deploy (push) Successful in 10m13s
This commit is contained in:
parent
d2e7f1e08d
commit
158b71ac04
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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.
|
||||
|
||||
102
backend/finance/migrations/0035_payroll_component_settings.py
Normal file
102
backend/finance/migrations/0035_payroll_component_settings.py
Normal 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="Учитывать в переходящем остатке",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
),
|
||||
|
||||
@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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]) == {}
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -574,7 +574,7 @@ export function PayrollBulkPointRateModal({
|
||||
Как в пределах нормы
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block text-xs">
|
||||
Для каждого сотрудника: (оклад + премия) ÷ его норма баллов.
|
||||
Для каждого сотрудника: балльная база ÷ его норма баллов.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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/`),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user