feat: add custom payroll columns
This commit is contained in:
parent
58034245cb
commit
c7a9fbe347
@ -23,9 +23,7 @@ from finance.models import (
|
||||
PayrollAuditEvent,
|
||||
PayrollPeriod,
|
||||
)
|
||||
from finance.payroll.components import (
|
||||
PAYROLL_COMPONENTS_BY_CODE,
|
||||
)
|
||||
from finance.payroll.components import get_payroll_component
|
||||
from finance.payroll.access import (
|
||||
can_access_payroll_management,
|
||||
has_payroll_permission,
|
||||
@ -146,15 +144,19 @@ def _payroll_history_action_label(action):
|
||||
return "Состояние записи изменено"
|
||||
|
||||
|
||||
def _validate_payroll_table_column(column_id):
|
||||
def _validate_payroll_table_column(column_id, *, period=None):
|
||||
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
|
||||
return column_id
|
||||
prefix, separator, component_code = column_id.partition(":")
|
||||
try:
|
||||
component = get_payroll_component(component_code, period=period)
|
||||
except ValueError:
|
||||
component = None
|
||||
if (
|
||||
prefix == "component"
|
||||
and separator
|
||||
and component_code not in SYSTEM_COMPONENT_CODES
|
||||
and component_code in PAYROLL_COMPONENTS_BY_CODE
|
||||
and component is not None
|
||||
):
|
||||
return column_id
|
||||
raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."})
|
||||
|
||||
@ -15,7 +15,7 @@ from finance.models import (
|
||||
PayrollInputCopyConfiguration,
|
||||
PayrollInputLine,
|
||||
)
|
||||
from finance.payroll.components import PAYROLL_COMPONENTS, PAYROLL_COMPONENTS_BY_CODE
|
||||
from finance.payroll.components import get_payroll_components
|
||||
|
||||
from ..admin_serializers import (
|
||||
PayrollComponentSerializer,
|
||||
@ -42,13 +42,20 @@ def _validate_input_copy_periods(target_period, source_period):
|
||||
)
|
||||
|
||||
|
||||
def _copyable_input_lines(source_period):
|
||||
def _copyable_input_lines(source_period, *, target_period=None):
|
||||
component_codes = {
|
||||
component.code for component in get_payroll_components(source_period)
|
||||
}
|
||||
if target_period is not None:
|
||||
component_codes &= {
|
||||
component.code for component in get_payroll_components(target_period)
|
||||
}
|
||||
return (
|
||||
PayrollInputLine.objects.filter(
|
||||
period=source_period,
|
||||
employee__is_hidden_in_ui=False,
|
||||
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
|
||||
component_code__in=PAYROLL_COMPONENTS_BY_CODE,
|
||||
component_code__in=component_codes,
|
||||
)
|
||||
.exclude(
|
||||
source=InputSource.API,
|
||||
@ -109,7 +116,9 @@ def _input_copy_configuration_payload(actor):
|
||||
|
||||
def _input_copy_preview(target_period, source_period, *, actor):
|
||||
_validate_input_copy_periods(target_period, source_period)
|
||||
lines = list(_copyable_input_lines(source_period))
|
||||
lines = list(
|
||||
_copyable_input_lines(source_period, target_period=target_period)
|
||||
)
|
||||
component_rows = {
|
||||
component.code: {
|
||||
"component": component,
|
||||
@ -117,7 +126,7 @@ def _input_copy_preview(target_period, source_period, *, actor):
|
||||
"employee_ids": set(),
|
||||
"amount_total": Decimal("0.00"),
|
||||
}
|
||||
for component in PAYROLL_COMPONENTS
|
||||
for component in get_payroll_components(target_period)
|
||||
}
|
||||
employee_rows = {}
|
||||
for line in lines:
|
||||
@ -228,7 +237,10 @@ def _apply_input_copy(*, target_period, source_period, actor, data):
|
||||
"idempotent_replay": True,
|
||||
}
|
||||
lines = list(
|
||||
_copyable_input_lines(source_period).filter(
|
||||
_copyable_input_lines(
|
||||
source_period,
|
||||
target_period=target_period,
|
||||
).filter(
|
||||
component_code__in=data["component_codes"]
|
||||
)
|
||||
)
|
||||
|
||||
@ -18,15 +18,17 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from finance.models import (
|
||||
PayrollCustomComponent,
|
||||
PayrollInputCopyConfiguration,
|
||||
PayrollPeriod,
|
||||
)
|
||||
from finance.payroll.components import (
|
||||
PAYROLL_COMPONENTS,
|
||||
get_payroll_components,
|
||||
)
|
||||
|
||||
from ...admin_serializers import (
|
||||
PayrollComponentSerializer,
|
||||
PayrollCustomComponentWriteSerializer,
|
||||
PayrollEmployeeMiniSerializer,
|
||||
PayrollPeriodSerializer,
|
||||
PayrollPeriodWriteSerializer,
|
||||
@ -39,6 +41,7 @@ from ..common import (
|
||||
PayrollAdminAPIView,
|
||||
_audit_api_mutation,
|
||||
_conflict,
|
||||
_optional_int,
|
||||
)
|
||||
from ..input_copy import _apply_input_copy
|
||||
|
||||
@ -61,7 +64,54 @@ class PayrollAdminComponentListView(PayrollAdminAPIView):
|
||||
allowed_permissions = INPUT_VIEW_PERMISSIONS
|
||||
|
||||
def get(self, request):
|
||||
return Response(PayrollComponentSerializer(PAYROLL_COMPONENTS, many=True).data)
|
||||
period_id = _optional_int(request, "period_id")
|
||||
period = (
|
||||
get_object_or_404(PayrollPeriod, pk=period_id)
|
||||
if period_id is not None
|
||||
else None
|
||||
)
|
||||
return Response(
|
||||
PayrollComponentSerializer(
|
||||
get_payroll_components(period),
|
||||
many=True,
|
||||
).data
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
|
||||
serializer = PayrollCustomComponentWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
next_order = (
|
||||
PayrollCustomComponent.objects.order_by("-display_order")
|
||||
.values_list("display_order", flat=True)
|
||||
.first()
|
||||
or 890
|
||||
) + 10
|
||||
component = PayrollCustomComponent(
|
||||
**serializer.validated_data,
|
||||
display_order=next_order,
|
||||
created_by=request.user,
|
||||
)
|
||||
try:
|
||||
component.full_clean()
|
||||
component.save(force_insert=True)
|
||||
except DjangoValidationError as exc:
|
||||
raise ValidationError(_validation_detail(exc)) from exc
|
||||
except IntegrityError as exc:
|
||||
raise ValidationError(
|
||||
{"non_field_errors": "Не удалось сохранить колонку."}
|
||||
) from exc
|
||||
_audit_api_mutation(
|
||||
actor=request.user,
|
||||
action="payroll.component_created",
|
||||
instance=component,
|
||||
metadata={"kind": component.kind},
|
||||
)
|
||||
return Response(
|
||||
PayrollComponentSerializer(component).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
|
||||
@ -43,7 +43,10 @@ class PayrollCellCommentsView(PayrollAdminAPIView):
|
||||
def get_context(self, pk, employee_id, column_id):
|
||||
period = get_object_or_404(PayrollPeriod, pk=pk)
|
||||
employee = get_object_or_404(Employee, pk=employee_id)
|
||||
return period, employee, _validate_payroll_table_column(column_id)
|
||||
return period, employee, _validate_payroll_table_column(
|
||||
column_id,
|
||||
period=period,
|
||||
)
|
||||
|
||||
def get(self, request, pk, employee_id, column_id):
|
||||
period, employee, column_id = self.get_context(
|
||||
@ -94,7 +97,7 @@ class PayrollCellCommentDetailView(PayrollAdminAPIView):
|
||||
def delete(self, request, pk, employee_id, column_id, comment_id):
|
||||
period = get_object_or_404(PayrollPeriod, pk=pk)
|
||||
employee = get_object_or_404(Employee, pk=employee_id)
|
||||
column_id = _validate_payroll_table_column(column_id)
|
||||
column_id = _validate_payroll_table_column(column_id, period=period)
|
||||
thread = get_object_or_404(
|
||||
PayrollCellCommentThread,
|
||||
period=period,
|
||||
|
||||
@ -13,7 +13,7 @@ from finance.payroll.access import (
|
||||
has_payroll_permission,
|
||||
has_simple_admin_access,
|
||||
)
|
||||
from finance.payroll.components import PAYROLL_COMPONENTS
|
||||
from finance.payroll.components import get_payroll_components
|
||||
|
||||
from ...employees.serializers import EmployeeBriefSerializer
|
||||
from ..admin_serializers import (
|
||||
@ -119,7 +119,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
many=True,
|
||||
).data,
|
||||
"components": PayrollComponentSerializer(
|
||||
PAYROLL_COMPONENTS,
|
||||
get_payroll_components(selected_period),
|
||||
many=True,
|
||||
).data,
|
||||
"periods": PayrollPeriodSerializer(periods, many=True).data,
|
||||
|
||||
@ -14,6 +14,7 @@ from .common import (
|
||||
PayrollCellCommentSerializer,
|
||||
PayrollCellCommentWriteSerializer,
|
||||
PayrollComponentSerializer,
|
||||
PayrollCustomComponentWriteSerializer,
|
||||
PayrollEmployeeMiniSerializer,
|
||||
PayrollUserRefSerializer,
|
||||
)
|
||||
@ -79,6 +80,7 @@ __all__ = [
|
||||
"PayrollCellCommentWriteSerializer",
|
||||
"PayrollCompleteWorkflowSerializer",
|
||||
"PayrollComponentSerializer",
|
||||
"PayrollCustomComponentWriteSerializer",
|
||||
"PayrollEmployeeMiniSerializer",
|
||||
"PayrollInputLineSerializer",
|
||||
"PayrollInputLineWriteSerializer",
|
||||
|
||||
@ -12,9 +12,7 @@ from decimal import Decimal
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
|
||||
from finance.payroll.components import (
|
||||
PAYROLL_COMPONENT_CODE_CHOICES,
|
||||
)
|
||||
from .common import PayrollComponentCodeField
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
@ -25,13 +23,11 @@ class ApprovalCommandSerializer(serializers.Serializer):
|
||||
|
||||
class ClearComponentCellCommandSerializer(serializers.Serializer):
|
||||
employee_id = serializers.IntegerField(min_value=1)
|
||||
component_code = serializers.ChoiceField(
|
||||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||||
component_code = PayrollComponentCodeField(
|
||||
required=False,
|
||||
)
|
||||
component_id = serializers.ChoiceField(
|
||||
component_id = PayrollComponentCodeField(
|
||||
source="component_code",
|
||||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
@ -57,13 +53,13 @@ class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer):
|
||||
class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
|
||||
source_period_id = serializers.IntegerField(min_value=1)
|
||||
component_codes = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
|
||||
child=PayrollComponentCodeField(),
|
||||
allow_empty=False,
|
||||
required=False,
|
||||
)
|
||||
component_ids = serializers.ListField(
|
||||
source="component_codes",
|
||||
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
|
||||
child=PayrollComponentCodeField(),
|
||||
allow_empty=False,
|
||||
required=False,
|
||||
write_only=True,
|
||||
|
||||
@ -15,7 +15,10 @@ from api.v1.employees.serializers import EmployeeBriefSerializer
|
||||
from finance.enums import (
|
||||
ApprovalStatus,
|
||||
InputSource,
|
||||
PayrollComponentPeriodScope,
|
||||
)
|
||||
from finance.models import PayrollCustomComponent, PayrollPeriod
|
||||
from finance.payroll.components import get_payroll_component
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
@ -93,7 +96,7 @@ class PayrollCellCommentWriteSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class PayrollComponentSerializer(serializers.Serializer):
|
||||
id = serializers.CharField(read_only=True)
|
||||
id = serializers.SerializerMethodField()
|
||||
code = serializers.CharField(read_only=True)
|
||||
name = serializers.CharField(read_only=True)
|
||||
kind = serializers.CharField(read_only=True)
|
||||
@ -101,6 +104,60 @@ class PayrollComponentSerializer(serializers.Serializer):
|
||||
requires_reason = serializers.BooleanField(read_only=True)
|
||||
is_active = serializers.BooleanField(read_only=True)
|
||||
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)
|
||||
|
||||
def get_id(self, obj) -> str:
|
||||
return obj.code
|
||||
|
||||
|
||||
class PayrollComponentCodeField(serializers.CharField):
|
||||
default_error_messages = {
|
||||
"unknown": "Неизвестный вид зарплатной операции.",
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
value = super().to_internal_value(data)
|
||||
try:
|
||||
get_payroll_component(value)
|
||||
except ValueError:
|
||||
self.fail("unknown")
|
||||
return value
|
||||
|
||||
|
||||
class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
|
||||
name = serializers.CharField(max_length=80, trim_whitespace=True)
|
||||
period_scope = serializers.ChoiceField(
|
||||
choices=PayrollComponentPeriodScope.choices,
|
||||
default=PayrollComponentPeriodScope.CURRENT_AND_FUTURE,
|
||||
)
|
||||
period_id = serializers.PrimaryKeyRelatedField(
|
||||
source="scope_period",
|
||||
queryset=PayrollPeriod.objects.all(),
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PayrollCustomComponent
|
||||
fields = [
|
||||
"name",
|
||||
"kind",
|
||||
"requires_reason",
|
||||
"period_scope",
|
||||
"period_id",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
scope = attrs["period_scope"]
|
||||
if scope == PayrollComponentPeriodScope.ALL:
|
||||
attrs.pop("scope_period", None)
|
||||
elif attrs.get("scope_period") is None:
|
||||
raise serializers.ValidationError(
|
||||
{"period_id": "Для выбранной области укажите расчётный период."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class DraftWriteSerializerMixin:
|
||||
|
||||
@ -17,16 +17,14 @@ from finance.models import (
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
)
|
||||
from finance.payroll.components import (
|
||||
PAYROLL_COMPONENT_CODE_CHOICES,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
DraftWriteSerializerMixin,
|
||||
PayrollComponentCodeField,
|
||||
PayrollComponentSerializer,
|
||||
PayrollEmployeeMiniSerializer,
|
||||
PayrollUserRefSerializer,
|
||||
)
|
||||
from finance.payroll.components import get_payroll_component
|
||||
from .periods import PayrollCarryoverPeriodSerializer
|
||||
|
||||
Employee = get_user_model()
|
||||
@ -106,13 +104,11 @@ class PayrollInputLineWriteSerializer(
|
||||
source="employee",
|
||||
queryset=Employee.objects.visible_in_ui(),
|
||||
)
|
||||
component_code = serializers.ChoiceField(
|
||||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||||
component_code = PayrollComponentCodeField(
|
||||
required=False,
|
||||
)
|
||||
component_id = serializers.ChoiceField(
|
||||
component_id = PayrollComponentCodeField(
|
||||
source="component_code",
|
||||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
@ -137,6 +133,14 @@ class PayrollInputLineWriteSerializer(
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
if not attrs.get("component_code"):
|
||||
component_code = attrs.get("component_code")
|
||||
if not component_code:
|
||||
raise serializers.ValidationError({"component_code": "Обязательное поле."})
|
||||
period = attrs.get("period") or getattr(self.instance, "period", None)
|
||||
try:
|
||||
get_payroll_component(component_code, period=period)
|
||||
except ValueError as exc:
|
||||
raise serializers.ValidationError(
|
||||
{"component_code": "Колонка недоступна в выбранном периоде."}
|
||||
) from exc
|
||||
return attrs
|
||||
|
||||
@ -31,6 +31,12 @@ class PayrollComponentKind(models.TextChoices):
|
||||
PAYMENT = "payment", "Выплата"
|
||||
|
||||
|
||||
class PayrollComponentPeriodScope(models.TextChoices):
|
||||
CURRENT_ONLY = "current_only", "Только выбранный период"
|
||||
CURRENT_AND_FUTURE = "current_and_future", "Выбранный и будущие периоды"
|
||||
ALL = "all", "Все периоды"
|
||||
|
||||
|
||||
class PayrollPeriodStatus(models.TextChoices):
|
||||
OPEN = "open", "Открыт"
|
||||
CALCULATED = "calculated", "Рассчитан"
|
||||
|
||||
37
backend/finance/migrations/0027_payrollcustomcomponent.py
Normal file
37
backend/finance/migrations/0027_payrollcustomcomponent.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-26 10:37
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import finance.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0026_alter_employeepayrate_source_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PayrollCustomComponent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(default=finance.models.generate_custom_payroll_component_code, editable=False, max_length=64, unique=True, validators=[django.core.validators.RegexValidator(message='Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-', regex='^[A-Z][A-Z0-9_.-]{0,63}$')], verbose_name='Код')),
|
||||
('name', models.CharField(max_length=80, unique=True, verbose_name='Название')),
|
||||
('kind', models.CharField(choices=[('earning', 'Начисление'), ('adjustment_credit', 'Коррекция +'), ('adjustment_debit', 'Коррекция −'), ('deduction', 'Удержание'), ('payment', 'Выплата')], max_length=24, verbose_name='Влияние на расчёт')),
|
||||
('requires_reason', models.BooleanField(default=False, verbose_name='Требовать основание')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
|
||||
('display_order', models.PositiveIntegerField(default=900, verbose_name='Порядок')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_components', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Пользовательская зарплатная колонка',
|
||||
'verbose_name_plural': 'Пользовательские зарплатные колонки',
|
||||
'ordering': ['display_order', 'name', 'id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-26 11:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0027_payrollcustomcomponent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payrollcustomcomponent',
|
||||
name='period_scope',
|
||||
field=models.CharField(choices=[('current_only', 'Только выбранный период'), ('current_and_future', 'Выбранный и будущие периоды'), ('all', 'Все периоды')], default='all', max_length=24, verbose_name='Периоды отображения'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payrollcustomcomponent',
|
||||
name='scope_period',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='scoped_custom_components', to='finance.payrollperiod', verbose_name='Начальный период'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-26 11:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0028_payrollcustomcomponent_period_scope_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='payrollcustomcomponent',
|
||||
name='name',
|
||||
field=models.CharField(max_length=80, verbose_name='Название'),
|
||||
),
|
||||
]
|
||||
@ -21,6 +21,7 @@ from .enums import (
|
||||
PayrollCarryoverDirection,
|
||||
PayrollCarryoverStatus,
|
||||
PayrollComponentKind,
|
||||
PayrollComponentPeriodScope,
|
||||
PayrollPeriodStatus,
|
||||
PayrollRunStatus,
|
||||
VacationPayInstructionStatus,
|
||||
@ -42,6 +43,10 @@ payroll_cell_column_validator = RegexValidator(
|
||||
DEFAULT_DAILY_TARGET_POINTS = Decimal("5")
|
||||
|
||||
|
||||
def generate_custom_payroll_component_code() -> str:
|
||||
return f"CUSTOM_{uuid.uuid4().hex.upper()}"
|
||||
|
||||
|
||||
class FinanceSettings(models.Model):
|
||||
"""Global access settings for the native finance management workspace."""
|
||||
|
||||
@ -920,6 +925,132 @@ class PayrollComponent:
|
||||
objects = _PayrollComponentRegistryManager()
|
||||
|
||||
|
||||
class PayrollCustomComponent(models.Model):
|
||||
"""User-created payroll column with immutable calculation semantics."""
|
||||
|
||||
code = models.CharField(
|
||||
"Код",
|
||||
max_length=64,
|
||||
unique=True,
|
||||
default=generate_custom_payroll_component_code,
|
||||
editable=False,
|
||||
validators=[component_code_validator],
|
||||
)
|
||||
name = models.CharField("Название", max_length=80)
|
||||
kind = models.CharField(
|
||||
"Влияние на расчёт",
|
||||
max_length=24,
|
||||
choices=PayrollComponentKind.choices,
|
||||
)
|
||||
requires_reason = models.BooleanField(
|
||||
"Требовать основание",
|
||||
default=False,
|
||||
)
|
||||
is_active = models.BooleanField("Активна", default=True)
|
||||
display_order = models.PositiveIntegerField("Порядок", default=900)
|
||||
period_scope = models.CharField(
|
||||
"Периоды отображения",
|
||||
max_length=24,
|
||||
choices=PayrollComponentPeriodScope.choices,
|
||||
default=PayrollComponentPeriodScope.ALL,
|
||||
)
|
||||
scope_period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="scoped_custom_components",
|
||||
verbose_name="Начальный период",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_payroll_components",
|
||||
verbose_name="Создал",
|
||||
)
|
||||
created_at = models.DateTimeField("Создана", auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Пользовательская зарплатная колонка"
|
||||
verbose_name_plural = "Пользовательские зарплатные колонки"
|
||||
ordering = ["display_order", "name", "id"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Укажите название колонки."})
|
||||
if self.period_scope == PayrollComponentPeriodScope.ALL:
|
||||
self.scope_period = None
|
||||
elif self.scope_period_id is None:
|
||||
raise ValidationError(
|
||||
{"scope_period": "Для выбранной области укажите расчётный период."}
|
||||
)
|
||||
|
||||
if not self.is_active:
|
||||
return
|
||||
|
||||
from finance.payroll.components import PAYROLL_COMPONENTS
|
||||
|
||||
normalized_name = self.name.casefold()
|
||||
has_builtin_conflict = any(
|
||||
component.name.casefold() == normalized_name
|
||||
for component in PAYROLL_COMPONENTS
|
||||
)
|
||||
custom_components = (
|
||||
PayrollCustomComponent.objects.filter(is_active=True)
|
||||
.exclude(pk=self.pk)
|
||||
.select_related("scope_period")
|
||||
)
|
||||
has_custom_conflict = any(
|
||||
component.name.casefold() == normalized_name
|
||||
and self.period_scope_overlaps(component)
|
||||
for component in custom_components
|
||||
)
|
||||
if has_builtin_conflict or has_custom_conflict:
|
||||
raise ValidationError(
|
||||
{
|
||||
"name": (
|
||||
"Колонка с таким названием уже существует "
|
||||
"в одном из выбранных периодов."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def period_scope_overlaps(self, other):
|
||||
"""Return whether two component columns can appear in the same period."""
|
||||
|
||||
if (
|
||||
self.period_scope == PayrollComponentPeriodScope.ALL
|
||||
or other.period_scope == PayrollComponentPeriodScope.ALL
|
||||
):
|
||||
return True
|
||||
if self.scope_period_id is None or other.scope_period_id is None:
|
||||
return False
|
||||
if (
|
||||
self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
|
||||
and other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
|
||||
):
|
||||
return self.scope_period_id == other.scope_period_id
|
||||
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
|
||||
return self.scope_period.date_from >= other.scope_period.date_from
|
||||
if other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
|
||||
return other.scope_period.date_from >= self.scope_period.date_from
|
||||
return True
|
||||
|
||||
def applies_to_period(self, period):
|
||||
if self.period_scope == PayrollComponentPeriodScope.ALL:
|
||||
return True
|
||||
if period is None or self.scope_period_id is None:
|
||||
return False
|
||||
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
|
||||
return period.pk == self.scope_period_id
|
||||
return period.date_from >= self.scope_period.date_from
|
||||
|
||||
|
||||
class PayrollInputLine(DraftVersionedModel):
|
||||
"""Append-only bonus, correction, deduction or recorded payment input."""
|
||||
|
||||
@ -1098,7 +1229,12 @@ class PayrollInputLine(DraftVersionedModel):
|
||||
}
|
||||
)
|
||||
try:
|
||||
component = self.component
|
||||
from finance.payroll.components import get_payroll_component
|
||||
|
||||
component = get_payroll_component(
|
||||
self.component_code,
|
||||
period=self.period if self.period_id else None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValidationError({"component_code": str(exc)}) from exc
|
||||
if component.requires_reason and not self.reason:
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from finance.enums import PayrollComponentKind
|
||||
from finance.enums import PayrollComponentPeriodScope
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -25,6 +26,14 @@ 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
|
||||
|
||||
@ -92,11 +101,54 @@ class _PayrollComponentCollection(tuple):
|
||||
class PayrollComponent:
|
||||
"""Compatibility facade over the code registry; this is not a database model."""
|
||||
|
||||
objects = _PayrollComponentCollection(PAYROLL_COMPONENTS)
|
||||
class _Manager:
|
||||
def all(self):
|
||||
return get_payroll_components()
|
||||
|
||||
def get(self, **criteria):
|
||||
return self.all().get(**criteria)
|
||||
|
||||
def filter(self, **criteria):
|
||||
return self.all().filter(**criteria)
|
||||
|
||||
def exclude(self, **criteria):
|
||||
return self.all().exclude(**criteria)
|
||||
|
||||
def order_by(self, *fields):
|
||||
return self.all().order_by(*fields)
|
||||
|
||||
objects = _Manager()
|
||||
|
||||
|
||||
def get_payroll_component(code: str) -> PayrollComponentDefinition:
|
||||
try:
|
||||
return PAYROLL_COMPONENTS_BY_CODE[code]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Неизвестный вид зарплатной операции: {code}") from exc
|
||||
def get_payroll_components(period=None) -> _PayrollComponentCollection:
|
||||
"""Return built-ins plus active user-created component columns."""
|
||||
|
||||
from finance.models import PayrollCustomComponent
|
||||
|
||||
custom = list(
|
||||
PayrollCustomComponent.objects.filter(is_active=True)
|
||||
.select_related("scope_period")
|
||||
.order_by("display_order", "name", "id")
|
||||
)
|
||||
if period is not None:
|
||||
custom = [component for component in custom if component.applies_to_period(period)]
|
||||
return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *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
|
||||
raise ValueError(f"Неизвестный вид зарплатной операции: {code}")
|
||||
|
||||
@ -29,7 +29,7 @@ from finance.models import (
|
||||
PayrollWorkRecord,
|
||||
PayrollWorkSettings,
|
||||
)
|
||||
from finance.payroll.components import PAYROLL_COMPONENTS
|
||||
from finance.payroll.components import get_payroll_components
|
||||
|
||||
from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request
|
||||
from .balances import opening_balances
|
||||
@ -371,7 +371,7 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
work_records = _latest_work_by_employee(period, employee_ids)
|
||||
input_lines = _input_lines_by_employee(period, employee_ids)
|
||||
balances = opening_balances(period, employee_ids)
|
||||
components = PAYROLL_COMPONENTS
|
||||
components = get_payroll_components(period)
|
||||
component_definitions = {
|
||||
component.code: _component_definition(
|
||||
component.code,
|
||||
|
||||
@ -55,6 +55,7 @@ from .config import (
|
||||
ruleset_not_effective_message,
|
||||
ruleset_period_details,
|
||||
)
|
||||
from .components import get_payroll_component
|
||||
from .exceptions import PayrollOperationError, PayrollPermissionDenied
|
||||
from .work_norm import calculate_period_target_points
|
||||
|
||||
@ -1994,13 +1995,21 @@ def set_component_input_line_value(
|
||||
) -> PayrollInputLine:
|
||||
"""Replace the aggregate value of one editable spreadsheet component cell."""
|
||||
|
||||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||||
try:
|
||||
get_payroll_component(component_code, period=period)
|
||||
except ValueError as exc:
|
||||
raise PayrollOperationError(
|
||||
"COMPONENT_NOT_AVAILABLE",
|
||||
"Колонка недоступна в выбранном расчётном периоде.",
|
||||
details={"component_code": component_code},
|
||||
) from exc
|
||||
clear_component_input_lines(
|
||||
period_id,
|
||||
employee_id=employee_id,
|
||||
component_code=component_code,
|
||||
actor=actor,
|
||||
)
|
||||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||||
line = PayrollInputLine(
|
||||
period=period,
|
||||
employee_id=employee_id,
|
||||
|
||||
@ -23,6 +23,7 @@ from finance.models import (
|
||||
PayrollAuditEvent,
|
||||
PayrollCellCommentThread,
|
||||
PayrollComponent,
|
||||
PayrollCustomComponent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
@ -98,6 +99,236 @@ def make_approved_period_inputs(
|
||||
)
|
||||
|
||||
|
||||
def test_manager_can_add_custom_component_column(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="component.manager@example.test")
|
||||
employee = user_factory(email="component.employee@example.test")
|
||||
grant(manager, "manage_payroll_inputs", "view_all_payroll")
|
||||
period = make_period(creator=manager)
|
||||
client = auth_client_factory(manager)
|
||||
|
||||
created = client.post(
|
||||
api_url("admin-component-list"),
|
||||
{
|
||||
"name": "Компенсация питания",
|
||||
"kind": "earning",
|
||||
"requires_reason": True,
|
||||
"period_scope": "current_and_future",
|
||||
"period_id": period.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert created.status_code == 201, created.content
|
||||
component = created.json()
|
||||
assert component["id"] == component["code"]
|
||||
assert component["code"].startswith("CUSTOM_")
|
||||
assert component["name"] == "Компенсация питания"
|
||||
assert component["kind"] == "earning"
|
||||
assert component["requires_reason"] is True
|
||||
assert component["period_scope"] == "current_and_future"
|
||||
assert component["scope_period_id"] == period.pk
|
||||
assert PayrollCustomComponent.objects.filter(code=component["code"]).exists()
|
||||
|
||||
saved = client.post(
|
||||
api_url("admin-component-cell-set", pk=period.pk),
|
||||
{
|
||||
"employee_id": employee.pk,
|
||||
"component_code": component["code"],
|
||||
"amount": "1250.00",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert saved.status_code == 201, saved.content
|
||||
line = PayrollInputLine.objects.get(pk=saved.json()["id"])
|
||||
assert line.component.name == "Компенсация питания"
|
||||
assert line.reason == "Перезаписано в итоговой таблице"
|
||||
|
||||
table = client.get(api_url("admin-period-table", pk=period.pk))
|
||||
assert table.status_code == 200, table.content
|
||||
assert component["code"] in {
|
||||
item["code"] for item in table.json()["component_columns"]
|
||||
}
|
||||
row = next(
|
||||
item for item in table.json()["rows"] if item["employee"]["id"] == employee.pk
|
||||
)
|
||||
assert row["component_amounts"] == {component["code"]: "1250.00"}
|
||||
assert row["gross_before_adjustments"] == "1250.00"
|
||||
assert row["gross_total"] == "1250.00"
|
||||
assert row["net_pay"] == "1250.00"
|
||||
assert row["payable"] == "1250.00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("period_scope", "expected_visibility"),
|
||||
[
|
||||
("current_only", (False, True, False)),
|
||||
("current_and_future", (False, True, True)),
|
||||
("all", (True, True, True)),
|
||||
],
|
||||
)
|
||||
def test_custom_component_period_scope_controls_table_visibility(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
period_scope,
|
||||
expected_visibility,
|
||||
):
|
||||
manager = user_factory(email=f"scope.{period_scope}@example.test")
|
||||
employee = user_factory(email=f"scope.employee.{period_scope}@example.test")
|
||||
grant(manager, "manage_payroll_inputs", "view_all_payroll")
|
||||
periods = [
|
||||
PayrollPeriod.objects.create(
|
||||
code="2026-05",
|
||||
name="Май 2026",
|
||||
date_from="2026-05-01",
|
||||
date_to="2026-05-31",
|
||||
pay_date="2026-06-05",
|
||||
currency="RUB",
|
||||
created_by=manager,
|
||||
),
|
||||
make_period(creator=manager),
|
||||
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": f"Колонка {period_scope}",
|
||||
"kind": "payment",
|
||||
"requires_reason": False,
|
||||
"period_scope": period_scope,
|
||||
"period_id": periods[1].pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert created.status_code == 201, created.content
|
||||
component_code = created.json()["code"]
|
||||
|
||||
visibility = []
|
||||
for period in periods:
|
||||
response = client.get(api_url("admin-period-table", pk=period.pk))
|
||||
assert response.status_code == 200, response.content
|
||||
visibility.append(
|
||||
component_code
|
||||
in {item["code"] for item in response.json()["component_columns"]}
|
||||
)
|
||||
assert tuple(visibility) == expected_visibility
|
||||
|
||||
unavailable_period = next(
|
||||
(
|
||||
period
|
||||
for period, visible in zip(periods, expected_visibility, strict=True)
|
||||
if not visible
|
||||
),
|
||||
None,
|
||||
)
|
||||
if unavailable_period is not None:
|
||||
rejected = client.post(
|
||||
api_url("admin-component-cell-set", pk=unavailable_period.pk),
|
||||
{
|
||||
"employee_id": employee.pk,
|
||||
"component_code": component_code,
|
||||
"amount": "500.00",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert rejected.status_code == 409, rejected.content
|
||||
assert rejected.json()["code"] == "COMPONENT_NOT_AVAILABLE"
|
||||
assert not PayrollInputLine.objects.filter(
|
||||
period=unavailable_period,
|
||||
employee=employee,
|
||||
component_code=component_code,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_custom_component_name_is_unique_only_in_overlapping_periods(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="scope.name.manager@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
periods = [
|
||||
PayrollPeriod.objects.create(
|
||||
code="2026-05",
|
||||
name="Май 2026",
|
||||
date_from="2026-05-01",
|
||||
date_to="2026-05-31",
|
||||
pay_date="2026-06-05",
|
||||
currency="RUB",
|
||||
created_by=manager,
|
||||
),
|
||||
make_period(creator=manager),
|
||||
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)
|
||||
|
||||
def create_component(*, period, period_scope, name="Компенсация"):
|
||||
return client.post(
|
||||
api_url("admin-component-list"),
|
||||
{
|
||||
"name": name,
|
||||
"kind": "earning",
|
||||
"requires_reason": False,
|
||||
"period_scope": period_scope,
|
||||
"period_id": period.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
may_only = create_component(
|
||||
period=periods[0],
|
||||
period_scope="current_only",
|
||||
)
|
||||
june_only = create_component(
|
||||
period=periods[1],
|
||||
period_scope="current_only",
|
||||
name="КОМПЕНСАЦИЯ",
|
||||
)
|
||||
from_july = create_component(
|
||||
period=periods[2],
|
||||
period_scope="current_and_future",
|
||||
)
|
||||
|
||||
assert may_only.status_code == 201, may_only.content
|
||||
assert june_only.status_code == 201, june_only.content
|
||||
assert from_july.status_code == 201, from_july.content
|
||||
|
||||
overlapping_requests = [
|
||||
create_component(period=periods[2], period_scope="current_only"),
|
||||
create_component(period=periods[1], period_scope="current_and_future"),
|
||||
create_component(period=periods[0], period_scope="all"),
|
||||
]
|
||||
for response in overlapping_requests:
|
||||
assert response.status_code == 400, response.content
|
||||
assert "выбранных периодов" in response.json()["name"][0]
|
||||
|
||||
builtin_conflict = create_component(
|
||||
period=periods[0],
|
||||
period_scope="current_only",
|
||||
name="ПРЕМИЯ",
|
||||
)
|
||||
assert builtin_conflict.status_code == 400, builtin_conflict.content
|
||||
assert "выбранных периодов" in builtin_conflict.json()["name"][0]
|
||||
|
||||
|
||||
def test_copy_input_lines_previews_scope_and_handles_existing_values(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
|
||||
@ -72,6 +72,7 @@ import type {
|
||||
PayrollBulkPointRateResult,
|
||||
PayrollBulkTargetPointsResult,
|
||||
PayrollCalculationBlocker,
|
||||
PayrollCustomComponentWrite,
|
||||
PayrollInputLineWrite,
|
||||
PayrollInputCopyResult,
|
||||
PayrollInputHistoryEvent,
|
||||
@ -902,6 +903,19 @@ export function PayrollAdminWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const addComponentColumn = async (payload: PayrollCustomComponentWrite) => {
|
||||
if (!canManageSourceData || !selectedPeriod) return;
|
||||
await apiClient.createPayrollAdminComponent({
|
||||
...payload,
|
||||
period_id: selectedPeriod.id,
|
||||
});
|
||||
await Promise.all([
|
||||
loadWorkspace(selectedPeriod.id, true),
|
||||
refreshInlineTable("inputs"),
|
||||
]);
|
||||
setNotice(`Колонка «${payload.name}» добавлена.`);
|
||||
};
|
||||
|
||||
const saveRateCell = async (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => {
|
||||
if (!canManageSourceData || !selectedPeriod) return;
|
||||
const rawValue = value.trim().replace(",", ".");
|
||||
@ -1254,7 +1268,7 @@ export function PayrollAdminWorkspace({
|
||||
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
|
||||
{tab === "summary" ? (
|
||||
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
|
||||
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
|
||||
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onAddComponent={canManageSourceData ? addComponentColumn : undefined} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
|
||||
</section>
|
||||
) : null}
|
||||
{tab === "approval" ? (
|
||||
|
||||
@ -16,8 +16,8 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react";
|
||||
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, Plus, RotateCcw, Search, Table2, X } from "lucide-react";
|
||||
import { Fragment, type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal";
|
||||
@ -27,6 +27,9 @@ import {
|
||||
} from "@/components/finance/admin/PayrollCellCommentsModal";
|
||||
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
|
||||
import type {
|
||||
PayrollComponentPeriodScope,
|
||||
PayrollCustomComponentWrite,
|
||||
PayrollLineKind,
|
||||
PayrollPeriodTable,
|
||||
PayrollPeriodTableComponent,
|
||||
PayrollPeriodTableRow,
|
||||
@ -52,6 +55,44 @@ const kindLabels = {
|
||||
payment: "Выплата",
|
||||
};
|
||||
|
||||
const kindHelpItems: Array<{
|
||||
kind: PayrollLineKind;
|
||||
description: string;
|
||||
formula: string;
|
||||
}> = [
|
||||
{
|
||||
kind: "earning",
|
||||
description: "Основная часть дохода: например, премия, отпускные или компенсация. Входит в сумму до корректировок.",
|
||||
formula: "Увеличивает «Начислено» и «К выплате».",
|
||||
},
|
||||
{
|
||||
kind: "adjustment_credit",
|
||||
description: "Доплата, которая исправляет или уточняет уже рассчитанные начисления.",
|
||||
formula: "Прибавляется к «Корректировкам», «Начислено» и «К выплате».",
|
||||
},
|
||||
{
|
||||
kind: "adjustment_debit",
|
||||
description: "Уменьшение, которое исправляет или уточняет уже рассчитанные начисления.",
|
||||
formula: "Вычитается из «Корректировок», «Начислено» и «К выплате».",
|
||||
},
|
||||
{
|
||||
kind: "deduction",
|
||||
description: "Сумма, которую удерживают из начисленного дохода. Само начисление при этом не меняется.",
|
||||
formula: "Уменьшает «После удержаний» и «К выплате».",
|
||||
},
|
||||
{
|
||||
kind: "payment",
|
||||
description: "Деньги, которые уже выданы сотруднику: например, аванс или фактическая выплата.",
|
||||
formula: "Уменьшает только остаток «К выплате», не меняя начисления и удержания.",
|
||||
},
|
||||
];
|
||||
|
||||
const periodScopeLabels: Record<PayrollComponentPeriodScope, string> = {
|
||||
current_only: "Только текущий период",
|
||||
current_and_future: "Текущий и будущие периоды",
|
||||
all: "Все периоды",
|
||||
};
|
||||
|
||||
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 = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums";
|
||||
const HIDDEN_COLUMNS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-columns";
|
||||
@ -415,6 +456,177 @@ function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp;
|
||||
);
|
||||
}
|
||||
|
||||
function AddPayrollComponentModal({
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
onCreate: (payload: PayrollCustomComponentWrite) => 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 [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const helpOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
helpOpenRef.current = helpOpen;
|
||||
}, [helpOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
nameRef.current?.focus();
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (helpOpenRef.current) {
|
||||
setHelpOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!pending) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose, pending]);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
setError("Укажите название колонки.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate({
|
||||
name: normalizedName,
|
||||
kind,
|
||||
requires_reason: requiresReason,
|
||||
period_scope: periodScope,
|
||||
});
|
||||
onClose();
|
||||
} catch (createError) {
|
||||
setError(getPayrollAdminError(createError, "Не удалось добавить колонку."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" role="presentation">
|
||||
<button type="button" className="absolute inset-0 bg-black/60" onClick={pending ? undefined : onClose} aria-label="Закрыть добавление колонки" />
|
||||
<section className="app-surface relative z-10 w-full max-w-md rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="payroll-add-component-title">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="app-text-muted text-xs uppercase tracking-wide">Начисления и выплаты</p>
|
||||
<h3 id="payroll-add-component-title" className="mt-1 inline-flex items-center gap-1.5 text-lg font-semibold text-[var(--foreground)]">
|
||||
<span>Добавить колонку</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="app-accent-text inline-flex h-5 w-5 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
|
||||
aria-label="Открыть справку о типах колонок"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={helpOpen}
|
||||
title="Как тип колонки влияет на расчёт"
|
||||
>
|
||||
<CircleHelp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</h3>
|
||||
</div>
|
||||
<button type="button" className="app-action-ghost inline-flex h-8 w-8 items-center justify-center rounded-lg" onClick={onClose} disabled={pending} aria-label="Закрыть"><X size={17} /></button>
|
||||
</div>
|
||||
<form className="mt-5 space-y-4" onSubmit={submit}>
|
||||
<label className="block">
|
||||
<span className="app-field-label">Название</span>
|
||||
<input ref={nameRef} value={name} onChange={(event) => setName(event.target.value)} maxLength={80} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" placeholder="Например, Компенсация питания" disabled={pending} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-field-label">Как влияет на расчёт</span>
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as PayrollLineKind)} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" disabled={pending}>
|
||||
{Object.entries(kindLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-field-label">В каких периодах показывать</span>
|
||||
<select value={periodScope} onChange={(event) => setPeriodScope(event.target.value as PayrollComponentPeriodScope)} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" disabled={pending}>
|
||||
{Object.entries(periodScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</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">
|
||||
<input type="checkbox" checked={requiresReason} onChange={(event) => setRequiresReason(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}</div> : null}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium" onClick={onClose} disabled={pending}>Отмена</button>
|
||||
<button type="submit" className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" size={15} /> : <Plus size={15} />}
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{helpOpen ? <PayrollComponentKindsHelpModal onClose={() => setHelpOpen(false)} /> : null}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
closeButtonRef.current?.focus();
|
||||
return () => previouslyFocused?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1010] flex items-center justify-center p-4" role="presentation">
|
||||
<button type="button" className="absolute inset-0 bg-black/60" onClick={onClose} 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" role="dialog" aria-modal="true" aria-labelledby="payroll-component-kinds-help-title">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="app-text-muted text-xs uppercase tracking-wide">Начисления и выплаты</p>
|
||||
<h3 id="payroll-component-kinds-help-title" className="mt-1 text-lg font-semibold text-[var(--foreground)]">Как выбрать тип колонки</h3>
|
||||
</div>
|
||||
<button ref={closeButtonRef} type="button" className="app-action-ghost inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" onClick={onClose} aria-label="Закрыть"><X size={17} /></button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{kindHelpItems.map((item) => (
|
||||
<section key={item.kind} className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<h4 className="text-sm font-semibold text-[var(--foreground)]">{kindLabels[item.kind]}</h4>
|
||||
<p className="app-text-muted mt-1 text-xs leading-relaxed">{item.description}</p>
|
||||
<p className="mt-1.5 text-xs font-medium text-[var(--foreground)]">{item.formula}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="app-selected mt-4 rounded-xl px-3 py-2.5 text-xs leading-relaxed">
|
||||
<span className="font-semibold">Итоговая формула: </span>
|
||||
К выплате = Начислено − Удержано − Выплачено.
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"]): Record<string, CalculationHelp> {
|
||||
const pointRecalculationLogic = pointPolicy === "proportional_with_excess"
|
||||
? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный."
|
||||
@ -834,6 +1046,7 @@ export function PayrollPeriodTableView({
|
||||
onSaveRate,
|
||||
onSaveWork,
|
||||
onSaveComponent,
|
||||
onAddComponent,
|
||||
onOpenCarryovers,
|
||||
onSetPaymentConfirmed,
|
||||
compact = false,
|
||||
@ -851,6 +1064,7 @@ export function PayrollPeriodTableView({
|
||||
onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise<void>;
|
||||
onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise<void>;
|
||||
onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise<void>;
|
||||
onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise<void>;
|
||||
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
|
||||
onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise<void>;
|
||||
compact?: boolean;
|
||||
@ -872,6 +1086,7 @@ export function PayrollPeriodTableView({
|
||||
displayName: string;
|
||||
} | null>(null);
|
||||
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
|
||||
const [addComponentOpen, setAddComponentOpen] = useState(false);
|
||||
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
|
||||
const [compactPanel, setCompactPanel] = useState<CompactPeriodPanel | null>("table");
|
||||
const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState<Set<number>>(new Set());
|
||||
@ -1580,7 +1795,20 @@ export function PayrollPeriodTableView({
|
||||
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-40 align-middle`}>Сотрудник</th>
|
||||
{groupSegments.map((segment, index) => (
|
||||
<th key={`${segment.group}-${index}`} colSpan={segment.columns.length} className={`${headerCellClass} text-center uppercase tracking-wide ${index > 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}>
|
||||
{segment.group}
|
||||
<span className="inline-flex items-center justify-center gap-1.5">
|
||||
<span>{segment.group}</span>
|
||||
{onAddComponent && segment.group === "Начисления и выплаты" && groupSegments.findIndex((item) => item.group === segment.group) === index ? (
|
||||
<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)}
|
||||
aria-label="Добавить колонку начисления или выплаты"
|
||||
title="Добавить колонку"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@ -1660,6 +1888,7 @@ export function PayrollPeriodTableView({
|
||||
</div>
|
||||
</DndContext> : null}
|
||||
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
|
||||
{addComponentOpen && onAddComponent ? <AddPayrollComponentModal onCreate={onAddComponent} onClose={() => setAddComponentOpen(false)} /> : null}
|
||||
{breakdownEmployee ? (
|
||||
<PayrollPointBreakdownModal
|
||||
periodId={data.period_id}
|
||||
|
||||
@ -7,6 +7,11 @@ export type PayrollLineKind =
|
||||
| "deduction"
|
||||
| "payment";
|
||||
|
||||
export type PayrollComponentPeriodScope =
|
||||
| "current_only"
|
||||
| "current_and_future"
|
||||
| "all";
|
||||
|
||||
export type PayrollPeriodSummary = {
|
||||
code: string;
|
||||
name: string;
|
||||
@ -581,6 +586,15 @@ export type PayrollComponent = {
|
||||
requires_reason: boolean;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
period_scope: PayrollComponentPeriodScope;
|
||||
scope_period_id: number | null;
|
||||
};
|
||||
|
||||
export type PayrollCustomComponentWrite = {
|
||||
name: string;
|
||||
kind: PayrollLineKind;
|
||||
requires_reason: boolean;
|
||||
period_scope: PayrollComponentPeriodScope;
|
||||
};
|
||||
|
||||
export type PayrollAdminPayRate = {
|
||||
@ -1029,6 +1043,10 @@ export function createFinanceApi(request: RequestFn) {
|
||||
),
|
||||
getPayrollAdminWorkspace: (periodId?: number): Promise<PayrollAdminWorkspace> =>
|
||||
request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`),
|
||||
createPayrollAdminComponent: (
|
||||
payload: PayrollCustomComponentWrite & { period_id: number },
|
||||
): Promise<PayrollComponent> =>
|
||||
request(`${adminPrefix}/components/`, jsonOptions("POST", payload)),
|
||||
getPayrollAdminPeriods: (): Promise<PayrollAdminListPayload<PayrollAdminPeriod>> =>
|
||||
request(`${adminPrefix}/periods/`),
|
||||
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>
|
||||
|
||||
@ -6,13 +6,37 @@ import {
|
||||
normalizePayrollColumnOrder,
|
||||
} from "./payroll-columns.ts";
|
||||
|
||||
test("payroll column order keeps saved columns and appends newly available ones", () => {
|
||||
test("payroll column order keeps saved columns and places newly available ones by default neighbors", () => {
|
||||
assert.deepEqual(
|
||||
normalizePayrollColumnOrder(
|
||||
["payable", "actual_points", "removed", "payable"],
|
||||
["rate_amount", "actual_points", "payable", "status"],
|
||||
),
|
||||
["payable", "actual_points", "rate_amount", "status"],
|
||||
["payable", "rate_amount", "actual_points", "status"],
|
||||
);
|
||||
});
|
||||
|
||||
test("new payroll component stays before the calculation group", () => {
|
||||
assert.deepEqual(
|
||||
normalizePayrollColumnOrder(
|
||||
["rate_amount", "component:BONUS", "gross_total", "payable", "status"],
|
||||
[
|
||||
"rate_amount",
|
||||
"component:BONUS",
|
||||
"component:CUSTOM_ADVANCE",
|
||||
"gross_total",
|
||||
"payable",
|
||||
"status",
|
||||
],
|
||||
),
|
||||
[
|
||||
"rate_amount",
|
||||
"component:BONUS",
|
||||
"component:CUSTOM_ADVANCE",
|
||||
"gross_total",
|
||||
"payable",
|
||||
"status",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -9,8 +9,18 @@ export function normalizePayrollColumnOrder(
|
||||
seen.add(columnId);
|
||||
return true;
|
||||
});
|
||||
for (const columnId of availableIds) {
|
||||
if (!seen.has(columnId)) normalized.push(columnId);
|
||||
for (const [availableIndex, columnId] of availableIds.entries()) {
|
||||
if (seen.has(columnId)) continue;
|
||||
|
||||
const nextKnownId = availableIds
|
||||
.slice(availableIndex + 1)
|
||||
.find((candidateId) => seen.has(candidateId));
|
||||
if (nextKnownId) {
|
||||
normalized.splice(normalized.indexOf(nextKnownId), 0, columnId);
|
||||
} else {
|
||||
normalized.push(columnId);
|
||||
}
|
||||
seen.add(columnId);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user