diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py index 35b17be2..6c7d268d 100644 --- a/backend/api/v1/finance/admin_serializers.py +++ b/backend/api/v1/finance/admin_serializers.py @@ -23,12 +23,14 @@ from finance.enums import ( from finance.models import ( EmployeePayRate, PayrollCarryover, - PayrollComponent, PayrollInputLine, PayrollPeriod, PayrollRun, PayrollWorkRecord, ) +from finance.payroll.components import ( + PAYROLL_COMPONENT_CODE_CHOICES, +) from finance.payroll.work_norm import calculate_period_target_points Employee = get_user_model() @@ -106,22 +108,15 @@ class PayrollCellCommentWriteSerializer(serializers.Serializer): text = serializers.CharField(max_length=10000, trim_whitespace=True) -class PayrollComponentSerializer(serializers.ModelSerializer): +class PayrollComponentSerializer(serializers.Serializer): + id = serializers.CharField(read_only=True) + code = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + kind = serializers.CharField(read_only=True) kind_label = serializers.CharField(source="get_kind_display", read_only=True) - - class Meta: - model = PayrollComponent - fields = [ - "id", - "code", - "name", - "kind", - "kind_label", - "requires_reason", - "is_active", - "display_order", - ] - read_only_fields = fields + requires_reason = serializers.BooleanField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + display_order = serializers.IntegerField(read_only=True) class PayrollPeriodSerializer(serializers.ModelSerializer): @@ -317,6 +312,7 @@ class EmployeePayRateSerializer(serializers.ModelSerializer): "created_by_id", "created_by", "created_at", + "updated_at", "approved_by_id", "approved_by", "approved_at", @@ -603,8 +599,9 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): employee = PayrollEmployeeMiniSerializer(read_only=True) employee_id = serializers.IntegerField(read_only=True) period_id = serializers.IntegerField(read_only=True) - component = PayrollComponentSerializer(read_only=True) - component_id = serializers.IntegerField(read_only=True) + component = serializers.SerializerMethodField() + component_code = serializers.CharField(read_only=True) + component_id = serializers.CharField(source="component_code", read_only=True) relates_to_period_id = serializers.IntegerField(read_only=True, allow_null=True) relates_to_period = PayrollCarryoverPeriodSerializer(read_only=True) is_automatic_carryover = serializers.SerializerMethodField() @@ -622,6 +619,7 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): "period_id", "employee_id", "employee", + "component_code", "component_id", "component", "amount", @@ -650,6 +648,9 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): "payroll-carryover:" ) + def get_component(self, obj): + return PayrollComponentSerializer(obj.component).data + class PayrollCarryoverInclusionSerializer(serializers.Serializer): included = serializers.BooleanField() @@ -668,9 +669,15 @@ class PayrollInputLineWriteSerializer( source="employee", queryset=Employee.objects.all(), ) - component_id = serializers.PrimaryKeyRelatedField( - source="component", - queryset=PayrollComponent.objects.all(), + component_code = serializers.ChoiceField( + choices=PAYROLL_COMPONENT_CODE_CHOICES, + required=False, + ) + component_id = serializers.ChoiceField( + source="component_code", + choices=PAYROLL_COMPONENT_CODE_CHOICES, + required=False, + write_only=True, ) relates_to_period_id = serializers.PrimaryKeyRelatedField( source="relates_to_period", @@ -684,6 +691,7 @@ class PayrollInputLineWriteSerializer( fields = [ "period_id", "employee_id", + "component_code", "component_id", "amount", "relates_to_period_id", @@ -691,6 +699,13 @@ class PayrollInputLineWriteSerializer( "expected_lock_version", ] + def validate(self, attrs): + if not attrs.get("component_code"): + raise serializers.ValidationError( + {"component_code": "Обязательное поле."} + ) + return attrs + class ApprovalCommandSerializer(serializers.Serializer): expected_lock_version = serializers.IntegerField(min_value=0) @@ -698,7 +713,29 @@ class ApprovalCommandSerializer(serializers.Serializer): class ClearComponentCellCommandSerializer(serializers.Serializer): employee_id = serializers.IntegerField(min_value=1) - component_id = serializers.IntegerField(min_value=1) + component_code = serializers.ChoiceField( + choices=PAYROLL_COMPONENT_CODE_CHOICES, + required=False, + ) + component_id = serializers.ChoiceField( + source="component_code", + choices=PAYROLL_COMPONENT_CODE_CHOICES, + required=False, + write_only=True, + ) + + def validate(self, attrs): + if not attrs.get("component_code"): + raise serializers.ValidationError({"component_code": "Обязательное поле."}) + return attrs + + +class SetComponentCellCommandSerializer(ClearComponentCellCommandSerializer): + amount = serializers.DecimalField( + max_digits=19, + decimal_places=2, + min_value=Decimal("0.01"), + ) class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer): @@ -707,9 +744,17 @@ class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer): class CopyPayrollInputLinesCommandSerializer(serializers.Serializer): source_period_id = serializers.IntegerField(min_value=1) - component_ids = serializers.ListField( - child=serializers.IntegerField(min_value=1), + component_codes = serializers.ListField( + child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES), allow_empty=False, + required=False, + ) + component_ids = serializers.ListField( + source="component_codes", + child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES), + allow_empty=False, + required=False, + write_only=True, ) employee_scope = serializers.ChoiceField( choices=("all", "active", "selected"), @@ -726,7 +771,11 @@ class CopyPayrollInputLinesCommandSerializer(serializers.Serializer): auto_apply_on_period_creation = serializers.BooleanField(default=False) def validate(self, attrs): - attrs["component_ids"] = list(dict.fromkeys(attrs["component_ids"])) + if not attrs.get("component_codes"): + raise serializers.ValidationError( + {"component_codes": "Выберите хотя бы один вид операции."} + ) + attrs["component_codes"] = list(dict.fromkeys(attrs["component_codes"])) attrs["employee_ids"] = list(dict.fromkeys(attrs["employee_ids"])) if attrs["employee_scope"] == "selected" and not attrs["employee_ids"]: raise serializers.ValidationError( diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 05ba159f..1e79dd27 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -41,7 +41,6 @@ from finance.models import ( PayrollAuditEvent, PayrollCarryover, PayrollCellCommentThread, - PayrollComponent, PayrollInputLine, PayrollInputCopyConfiguration, PayrollPeriod, @@ -50,6 +49,10 @@ from finance.models import ( PayrollStatementPaymentConfirmation, PayrollWorkRecord, ) +from finance.payroll.components import ( + PAYROLL_COMPONENTS, + PAYROLL_COMPONENTS_BY_CODE, +) from finance.payroll.config import ( base_rate_code, build_rules, @@ -80,6 +83,7 @@ from finance.payroll.services import ( publish_run, pay_rates_relevant_to_period, return_run_for_correction, + set_component_input_line_value, submit_run_for_review, ) from finance.payroll.reporting import ( @@ -121,6 +125,7 @@ from .admin_serializers import ( PayrollPeriodWriteSerializer, PayrollPaymentConfirmationCommandSerializer, PayrollRunSerializer, + SetComponentCellCommandSerializer, PayrollUserRefSerializer, PayrollWorkRecordRevisionSerializer, PayrollWorkRecordSerializer, @@ -245,10 +250,7 @@ def _validate_payroll_table_column(column_id): prefix == "component" and separator and component_code not in SYSTEM_COMPONENT_CODES - and PayrollComponent.objects.filter( - code=component_code, - is_active=True, - ).exists() + and component_code in PAYROLL_COMPONENTS_BY_CODE ): return column_id raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."}) @@ -351,7 +353,7 @@ def _copyable_input_lines(source_period): PayrollInputLine.objects.filter( period=source_period, status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), - component__is_active=True, + component_code__in=PAYROLL_COMPONENTS_BY_CODE, ) .exclude( source=InputSource.API, @@ -360,14 +362,13 @@ def _copyable_input_lines(source_period): .select_related( "employee", "employee__position", - "component", "relates_to_period", ) .prefetch_related( "employee__departments_links__department", "employee__actions__source_request", ) - .order_by("employee_id", "component__display_order", "id") + .order_by("employee_id", "component_code", "id") ) @@ -388,6 +389,7 @@ def _input_copy_configuration_payload(actor): if configuration is None: return { "saved": False, + "component_codes": [], "component_ids": [], "employee_scope": "active", "employee_ids": [], @@ -396,7 +398,8 @@ def _input_copy_configuration_payload(actor): } return { "saved": True, - "component_ids": configuration.component_ids, + "component_codes": configuration.component_codes, + "component_ids": configuration.component_codes, "employee_scope": configuration.employee_scope, "employee_ids": configuration.employee_ids, "existing_mode": configuration.existing_mode, @@ -410,20 +413,17 @@ def _input_copy_preview(target_period, source_period, *, actor): _validate_input_copy_periods(target_period, source_period) lines = list(_copyable_input_lines(source_period)) component_rows = { - component.pk: { + component.code: { "component": component, "line_count": 0, "employee_ids": set(), "amount_total": Decimal("0.00"), } - for component in PayrollComponent.objects.filter(is_active=True).order_by( - "display_order", - "code", - ) + for component in PAYROLL_COMPONENTS } employee_rows = {} for line in lines: - component_row = component_rows[line.component_id] + component_row = component_rows[line.component_code] component_row["line_count"] += 1 component_row["employee_ids"].add(line.employee_id) component_row["amount_total"] += line.amount @@ -440,8 +440,8 @@ def _input_copy_preview(target_period, source_period, *, actor): } employee_rows[line.employee_id] = employee_row employee_row["line_count"] += 1 - employee_row["component_counts"][str(line.component_id)] = ( - employee_row["component_counts"].get(str(line.component_id), 0) + 1 + employee_row["component_counts"][line.component_code] = ( + employee_row["component_counts"].get(line.component_code, 0) + 1 ) existing_counts = {} if employee_rows and component_rows: @@ -449,18 +449,18 @@ def _input_copy_preview(target_period, source_period, *, actor): PayrollInputLine.objects.filter( period=target_period, employee_id__in=employee_rows, - component_id__in=component_rows, + component_code__in=component_rows, status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), ) .exclude( source=InputSource.API, source_ref__startswith="payroll-carryover:", ) - .values("employee_id", "component_id") + .values("employee_id", "component_code") .annotate(count=Count("id")) ): existing_counts.setdefault(row["employee_id"], {})[ - str(row["component_id"]) + row["component_code"] ] = row["count"] components = [ { @@ -530,7 +530,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): } lines = list( _copyable_input_lines(source_period).filter( - component_id__in=data["component_ids"] + component_code__in=data["component_codes"] ) ) if data["employee_scope"] == "selected": @@ -543,7 +543,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): if _employee_active_for_period(line.employee, target_period) ] selected_cells = { - (line.employee_id, line.component_id) + (line.employee_id, line.component_code) for line in lines } existing_lines = [] @@ -553,7 +553,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): .filter( period=target_period, employee_id__in={employee_id for employee_id, _ in selected_cells}, - component_id__in={component_id for _, component_id in selected_cells}, + component_code__in={component_code for _, component_code in selected_cells}, status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), ) .order_by("pk") @@ -561,14 +561,14 @@ def _apply_input_copy(*, target_period, source_period, actor, data): existing_lines = [ line for line in possible_existing_lines - if (line.employee_id, line.component_id) in selected_cells + if (line.employee_id, line.component_code) in selected_cells and not ( line.source == InputSource.API and line.source_ref.startswith("payroll-carryover:") ) ] occupied_cells = { - (line.employee_id, line.component_id) + (line.employee_id, line.component_code) for line in existing_lines } skipped = 0 @@ -577,7 +577,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): if data["existing_mode"] == "skip" and occupied_cells: kept_lines = [] for line in lines: - if (line.employee_id, line.component_id) in occupied_cells: + if (line.employee_id, line.component_code) in occupied_cells: skipped += 1 else: kept_lines.append(line) @@ -617,7 +617,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): PayrollInputLine( period=target_period, employee=line.employee, - component=line.component, + component_code=line.component_code, amount=line.amount, relates_to_period=line.relates_to_period, reason=line.reason, @@ -663,22 +663,11 @@ def _apply_input_copy(*, target_period, source_period, actor, data): def _carryover_ledger(period): - published_period_ids = set( - PayrollRun.objects.filter( - status=PayrollRunStatus.PUBLISHED, - period__currency=period.currency, - period__date_to__lte=period.date_to, - ).values_list("period_id", flat=True) - ) - target_is_published = period.pk in published_period_ids lines = ( PayrollInputLine.objects.filter( period__currency=period.currency, period__date_to__lte=period.date_to, - component__kind__in=( - PayrollComponentKind.ADJUSTMENT_CREDIT, - PayrollComponentKind.ADJUSTMENT_DEBIT, - ), + component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"), ) .exclude( source=InputSource.API, @@ -688,7 +677,6 @@ def _carryover_ledger(period): "period", "employee", "employee__position", - "component", "created_by", ) .prefetch_related("employee__departments_links__department") @@ -702,13 +690,10 @@ def _carryover_ledger(period): current_period_line = line.period_id == period.pk active_status = line.status == ApprovalStatus.APPROVED or ( current_period_line - and not target_is_published + and period.status != PayrollPeriodStatus.CLOSED and line.status == ApprovalStatus.DRAFT ) - active_period = current_period_line or line.period_id in published_period_ids - counts_in_balance = bool( - line.include_in_carryover and active_status and active_period - ) + counts_in_balance = bool(line.include_in_carryover and active_status) raw_contribution = ( line.amount if line.component.kind == PayrollComponentKind.ADJUSTMENT_DEBIT @@ -883,7 +868,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): .prefetch_related("departments_links__department") .order_by("last_name", "first_name", "id") ) - components = PayrollComponent.objects.order_by("display_order", "code") + components = PAYROLL_COMPONENTS current_run = None runs = [] @@ -1247,8 +1232,7 @@ class PayrollAdminComponentListView(PayrollAdminAPIView): allowed_permissions = INPUT_VIEW_PERMISSIONS def get(self, request): - components = PayrollComponent.objects.order_by("display_order", "code") - return Response(PayrollComponentSerializer(components, many=True).data) + return Response(PayrollComponentSerializer(PAYROLL_COMPONENTS, many=True).data) class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): @@ -1310,7 +1294,7 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): source_period=source_period, actor=request.user, data={ - "component_ids": configuration.component_ids, + "component_codes": configuration.component_codes, "employee_scope": configuration.employee_scope, "employee_ids": configuration.employee_ids, "existing_mode": configuration.existing_mode, @@ -1515,14 +1499,9 @@ class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView): command = PayrollCarryoverInclusionSerializer(data=request.data) command.is_valid(raise_exception=True) line = get_object_or_404( - PayrollInputLine.objects.select_for_update(of=("self",)).select_related( - "period", "component" - ), + PayrollInputLine.objects.select_for_update(of=("self",)).select_related("period"), pk=pk, - component__kind__in=( - PayrollComponentKind.ADJUSTMENT_CREDIT, - PayrollComponentKind.ADJUSTMENT_DEBIT, - ), + component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"), ) included = command.validated_data["included"] previous = line.include_in_carryover @@ -1774,7 +1753,6 @@ class PayrollInputHistoryView(PayrollAdminAPIView): .select_related( "employee", "employee__position", - "component", ) .prefetch_related("employee__departments_links__department") ) @@ -2099,7 +2077,6 @@ class PayrollInputLineListCreateView(DraftCollectionView): "period", "employee", "employee__position", - "component", "relates_to_period", "created_by", "approved_by", @@ -2111,11 +2088,11 @@ class PayrollInputLineListCreateView(DraftCollectionView): def filter_queryset(self, request, queryset): queryset = super().filter_queryset(request, queryset) period_id = _optional_int(request, "period_id") - component_id = _optional_int(request, "component_id") + component_code = request.query_params.get("component_code", "").strip() if period_id: queryset = queryset.filter(period_id=period_id) - if component_id: - queryset = queryset.filter(component_id=component_id) + if component_code: + queryset = queryset.filter(component_code=component_code) return queryset @@ -2125,14 +2102,13 @@ class PayrollInputLineDetailView(DraftDetailView): write_serializer_class = PayrollInputLineWriteSerializer audit_label = "input_line" period_scoped = True - immutable_patch_fields = ("period_id", "employee_id", "component_id") + immutable_patch_fields = ("period_id", "employee_id", "component_code") def get_queryset(self): return PayrollInputLine.objects.select_related( "period", "employee", "employee__position", - "component", "relates_to_period", "created_by", "approved_by", @@ -2148,12 +2124,31 @@ class PayrollComponentCellClearView(PayrollAdminAPIView): summary = clear_component_input_lines( pk, employee_id=command.validated_data["employee_id"], - component_id=command.validated_data["component_id"], + component_code=command.validated_data["component_code"], actor=request.user, ) return Response(summary) +class PayrollComponentCellSetView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + def post(self, request, pk): + command = SetComponentCellCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + line = set_component_input_line_value( + pk, + employee_id=command.validated_data["employee_id"], + component_code=command.validated_data["component_code"], + amount=command.validated_data["amount"], + actor=request.user, + ) + return Response( + PayrollInputLineSerializer(line).data, + status=status.HTTP_201_CREATED, + ) + + class PayrollInputLinePeriodCopyView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) @@ -2195,7 +2190,7 @@ class PayrollInputLinePeriodCopyView(PayrollAdminAPIView): PayrollInputCopyConfiguration.objects.update_or_create( owner=request.user, defaults={ - "component_ids": data["component_ids"], + "component_codes": data["component_codes"], "employee_scope": data["employee_scope"], "employee_ids": data["employee_ids"], "existing_mode": data["existing_mode"], @@ -2262,7 +2257,6 @@ class PayrollInputLineApproveView(DraftApprovalView): "period", "employee", "employee__position", - "component", "created_by", "approved_by", ).prefetch_related("employee__departments_links__department") diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 29fada06..5c036649 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -17,6 +17,7 @@ from .admin_views import ( PayrollBulkTargetPointsView, PayrollCarryoverDetailView, PayrollComponentCellClearView, + PayrollComponentCellSetView, PayrollCellCommentDetailView, PayrollCellCommentsView, PayrollAdminComponentListView, @@ -246,6 +247,11 @@ urlpatterns = [ PayrollComponentCellClearView.as_view(), name="admin-component-cell-clear", ), + path( + "payroll/admin/periods//component-cell/set/", + PayrollComponentCellSetView.as_view(), + name="admin-component-cell-set", + ), path( "payroll/admin/runs//", PayrollRunDetailView.as_view(), diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 0cdf812e..92e80e11 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -5,6 +5,7 @@ from django.contrib import admin, messages from django.contrib.admin import helpers from django.core.exceptions import PermissionDenied from django.db import transaction +from django.db.models import Q from django.template.response import TemplateResponse from .enums import ApprovalStatus @@ -12,7 +13,6 @@ from .models import ( EmployeePayRate, FinanceSettings, PayrollAuditEvent, - PayrollComponent, PayrollDailyWorkEntry, PayrollInputLine, PayrollPeriod, @@ -227,41 +227,6 @@ class ImmutableApprovedAdminMixin(PayrollSensitiveAdminMixin): super().save_model(request, obj, form, change) -@admin.register(PayrollComponent) -class PayrollComponentAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): - view_permissions = ( - "finance.manage_payroll_inputs", - "finance.approve_payroll_inputs", - "finance.view_all_payroll", - ) - list_display = ["code", "name", "kind", "is_active", "display_order"] - list_filter = ["kind", "is_active"] - search_fields = ["code", "name"] - ordering = ["display_order", "code"] - - def has_add_permission(self, request): - return has_payroll_permission( - request.user, - "finance.manage_payroll_inputs", - ) - - def has_change_permission(self, request, obj=None): - if obj is None: - return self._has_payroll_access(request) - return has_payroll_permission( - request.user, - "finance.manage_payroll_inputs", - ) - - def has_delete_permission(self, request, obj=None): - return request.user.is_superuser - - def get_readonly_fields(self, request, obj=None): - if obj is None: - return [] - return list(PayrollComponent.semantic_fields) - - @admin.register(PayrollPeriod) class PayrollPeriodAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin): view_permissions = ( @@ -391,6 +356,7 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): readonly_fields = [ "created_by", "created_at", + "updated_at", "approved_by", "approved_at", "voided_by", @@ -398,6 +364,62 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): ] actions = ["approve_selected"] + @staticmethod + def _revision_chain_ids(records): + chain_ids = {record.pk for record in records if record.pk is not None} + frontier = set(chain_ids) + while frontier: + linked_rows = EmployeePayRate.objects.filter( + Q(pk__in=frontier) | Q(replaces_id__in=frontier) + ).values_list("pk", "replaces_id") + linked_ids = { + linked_id + for row in linked_rows + for linked_id in row + if linked_id is not None + } + frontier = linked_ids - chain_ids + chain_ids.update(frontier) + return chain_ids + + def get_deleted_objects(self, objs, request): + chain_ids = self._revision_chain_ids(objs) + records = EmployeePayRate.objects.filter(pk__in=chain_ids).order_by( + "employee_id", + "effective_from", + "revision", + "pk", + ) + deleted_objects = [ + ( + f"{self.model._meta.verbose_name}: {record} " + f"(ID {record.pk}, ревизия {record.revision}, " + f"статус: {record.get_status_display()})" + ) + for record in records + ] + return ( + deleted_objects, + {self.model._meta.verbose_name_plural: len(deleted_objects)}, + set(), + [], + ) + + @transaction.atomic + def _delete_revision_chains(self, records): + chain_ids = self._revision_chain_ids(records) + revisions = EmployeePayRate.objects.select_for_update().filter( + pk__in=chain_ids + ).order_by("-revision", "-pk") + for revision in revisions: + revision.delete() + + def delete_model(self, request, obj): + self._delete_revision_chains([obj]) + + def delete_queryset(self, request, queryset): + self._delete_revision_chains(queryset) + @admin.action(description="Утвердить выбранные ставки") def approve_selected(self, request, queryset): return self._approve_records( @@ -539,7 +561,7 @@ class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): list_display = [ "period", "employee", - "component", + "component_code", "amount", "relates_to_period", "status", @@ -547,7 +569,7 @@ class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): list_filter = [ "period", "status", - "component", + "component_code", "source", ] search_fields = [ diff --git a/backend/finance/migrations/0024_employeepayrate_updated_at.py b/backend/finance/migrations/0024_employeepayrate_updated_at.py new file mode 100644 index 00000000..33303fba --- /dev/null +++ b/backend/finance/migrations/0024_employeepayrate_updated_at.py @@ -0,0 +1,39 @@ +from django.db import migrations, models +from django.utils import timezone + + +def ensure_updated_at_column(apps, schema_editor): + employee_pay_rate = apps.get_model("finance", "EmployeePayRate") + table_name = employee_pay_rate._meta.db_table + with schema_editor.connection.cursor() as cursor: + columns = schema_editor.connection.introspection.get_table_description( + cursor, + table_name, + ) + if any(column.name == "updated_at" for column in columns): + return + + database_field = models.DateTimeField(default=timezone.now) + database_field.set_attributes_from_name("updated_at") + database_field.model = employee_pay_rate + schema_editor.add_field(employee_pay_rate, database_field) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0023_payrollstatementpaymentconfirmation")] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[migrations.RunPython(ensure_updated_at_column)], + state_operations=[ + migrations.AddField( + model_name="employeepayrate", + name="updated_at", + field=models.DateTimeField( + auto_now=True, + verbose_name="Обновлена", + ), + ) + ], + ) + ] diff --git a/backend/finance/migrations/0025_payroll_components_in_code.py b/backend/finance/migrations/0025_payroll_components_in_code.py new file mode 100644 index 00000000..14db8694 --- /dev/null +++ b/backend/finance/migrations/0025_payroll_components_in_code.py @@ -0,0 +1,82 @@ +import django.core.validators +from django.db import migrations, models + + +def copy_component_codes(apps, schema_editor): + component_model = apps.get_model("finance", "PayrollComponent") + input_line_model = apps.get_model("finance", "PayrollInputLine") + configuration_model = apps.get_model("finance", "PayrollInputCopyConfiguration") + codes_by_id = dict(component_model.objects.values_list("id", "code")) + + lines = list(input_line_model.objects.select_related("component")) + for line in lines: + line.component_code = line.component.code + if lines: + input_line_model.objects.bulk_update(lines, ["component_code"]) + + configurations = list(configuration_model.objects.all()) + for configuration in configurations: + configuration.component_ids = [ + codes_by_id[component_id] + for component_id in configuration.component_ids + if component_id in codes_by_id + ] + if configurations: + configuration_model.objects.bulk_update(configurations, ["component_ids"]) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0024_employeepayrate_updated_at")] + + operations = [ + migrations.AddField( + model_name="payrollinputline", + name="component_code", + field=models.CharField( + max_length=64, + null=True, + verbose_name="Вид операции", + ), + ), + migrations.RunPython(copy_component_codes, migrations.RunPython.noop), + migrations.RemoveField( + model_name="payrollinputline", + name="component", + ), + migrations.AlterField( + model_name="payrollinputline", + name="component_code", + field=models.CharField( + max_length=64, + validators=[ + django.core.validators.RegexValidator( + message=( + "Код должен начинаться с A-Z и содержать только " + "A-Z, 0-9, _.-" + ), + regex="^[A-Z][A-Z0-9_.-]{0,63}$", + ) + ], + verbose_name="Вид операции", + ), + ), + migrations.RenameField( + model_name="payrollinputcopyconfiguration", + old_name="component_ids", + new_name="component_codes", + ), + migrations.DeleteModel(name="PayrollComponent"), + migrations.AlterModelOptions( + name="payrollinputline", + options={ + "ordering": [ + "period_id", + "employee_id", + "component_code", + "id", + ], + "verbose_name": "Строка начисления/удержания", + "verbose_name_plural": "Строки начислений/удержаний", + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 8489ed35..96fa5326 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -478,6 +478,7 @@ class EmployeePayRate(DraftVersionedModel): verbose_name="Создал", ) created_at = models.DateTimeField("Создана", auto_now_add=True) + updated_at = models.DateTimeField("Обновлена", auto_now=True) approved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, @@ -890,63 +891,32 @@ class PayrollWorkRecord(DraftVersionedModel): ) -class PayrollComponent(models.Model): - """Reusable catalog entry for an explicit payroll input line.""" +class _PayrollComponentRegistryManager: + def get(self, **criteria): + from finance.payroll.components import PayrollComponent - code = models.CharField( - "Код", - max_length=64, - unique=True, - validators=[component_code_validator], - ) - name = models.CharField("Название", max_length=160) - 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=100) + return PayrollComponent.objects.get(**criteria) - class Meta: - verbose_name = "Компонент зарплаты" - verbose_name_plural = "Компоненты зарплаты" - ordering = ["display_order", "code"] + def filter(self, **criteria): + from finance.payroll.components import PayrollComponent - def __str__(self): - return f"{self.code} — {self.name}" + return PayrollComponent.objects.filter(**criteria) - semantic_fields = ( - "code", - "name", - "kind", - "requires_reason", - "display_order", - ) + def all(self): + from finance.payroll.components import PayrollComponent - def _validate_semantics_immutable(self, original=None): - if not self.pk: - return - original = original or PayrollComponent.objects.get(pk=self.pk) - if any( - getattr(self, field_name) != getattr(original, field_name) - for field_name in self.semantic_fields - ): - raise ValidationError( - "Семантику компонента нельзя менять; создайте новый код." - ) + return PayrollComponent.objects.all() - def clean(self): - super().clean() - self._validate_semantics_immutable() + def order_by(self, *fields): + from finance.payroll.components import PayrollComponent - @transaction.atomic - def save(self, *args, **kwargs): - if self.pk and not self._state.adding: - original = PayrollComponent.objects.select_for_update().get(pk=self.pk) - self._validate_semantics_immutable(original) - return super().save(*args, **kwargs) + return PayrollComponent.objects.order_by(*fields) + + +class PayrollComponent: + """Compatibility facade; payroll component definitions are stored in code.""" + + objects = _PayrollComponentRegistryManager() class PayrollInputLine(DraftVersionedModel): @@ -964,11 +934,10 @@ class PayrollInputLine(DraftVersionedModel): related_name="payroll_input_lines", verbose_name="Сотрудник", ) - component = models.ForeignKey( - PayrollComponent, - on_delete=models.PROTECT, - related_name="input_lines", - verbose_name="Компонент", + component_code = models.CharField( + "Вид операции", + max_length=64, + validators=[component_code_validator], ) amount = models.DecimalField( "Сумма", @@ -1061,7 +1030,7 @@ class PayrollInputLine(DraftVersionedModel): class Meta: verbose_name = "Строка начисления/удержания" verbose_name_plural = "Строки начислений/удержаний" - ordering = ["period_id", "employee_id", "component__display_order", "id"] + ordering = ["period_id", "employee_id", "component_code", "id"] indexes = [ models.Index( fields=["period", "employee", "status"], @@ -1100,7 +1069,21 @@ class PayrollInputLine(DraftVersionedModel): ] def __str__(self): - return f"{self.employee}: {self.component.code} {self.amount}" + return f"{self.employee}: {self.component_code} {self.amount}" + + @property + def component(self): + from finance.payroll.components import get_payroll_component + + return get_payroll_component(self.component_code) + + @component.setter + def component(self, value): + self.component_code = value.code + + @property + def component_id(self): + return self.component_code def clean(self): super().clean() @@ -1113,7 +1096,11 @@ class PayrollInputLine(DraftVersionedModel): ) } ) - if self.component_id and self.component.requires_reason and not self.reason: + try: + component = self.component + except ValueError as exc: + raise ValidationError({"component_code": str(exc)}) from exc + if component.requires_reason and not self.reason: raise ValidationError({"reason": "Для компонента требуется основание."}) if self.relates_to_period_id == self.period_id: raise ValidationError( @@ -1134,7 +1121,7 @@ class PayrollInputCopyConfiguration(models.Model): related_name="payroll_input_copy_configuration", verbose_name="Пользователь", ) - component_ids = models.JSONField("Виды операций", default=list) + component_codes = models.JSONField("Виды операций", default=list) employee_scope = models.CharField( "Охват сотрудников", max_length=16, diff --git a/backend/finance/payroll/__init__.py b/backend/finance/payroll/__init__.py index d4c0625b..33cf4fa5 100644 --- a/backend/finance/payroll/__init__.py +++ b/backend/finance/payroll/__init__.py @@ -13,6 +13,7 @@ from .services import ( complete_period_workflow, publish_run, return_run_for_correction, + set_component_input_line_value, submit_run_for_review, ) @@ -29,5 +30,6 @@ __all__ = [ "complete_period_workflow", "publish_run", "return_run_for_correction", + "set_component_input_line_value", "submit_run_for_review", ] diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index 6f1e80c2..70312bcf 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -99,7 +99,7 @@ def _point_base_accrual( ( record.amount for record in input_lines - if record.component.code in POINT_BASE_COMPONENT_CODES + if record.component_code in POINT_BASE_COMPONENT_CODES ), Decimal("0"), ) diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py index 04158215..45d1f4a1 100644 --- a/backend/finance/payroll/balances.py +++ b/backend/finance/payroll/balances.py @@ -7,12 +7,7 @@ from decimal import Decimal from django.db.models import Q, Sum -from finance.enums import ( - ApprovalStatus, - InputSource, - PayrollComponentKind, - PayrollRunStatus, -) +from finance.enums import ApprovalStatus, InputSource from finance.models import PayrollInputLine, PayrollPeriod @@ -20,7 +15,7 @@ def opening_balances( period: PayrollPeriod, employee_ids: Iterable[int], ) -> dict[int, Decimal]: - """Return balances produced by included corrections in published periods.""" + """Return the cumulative balance from approved earlier corrections.""" requested_ids = set(employee_ids) if not requested_ids: @@ -30,13 +25,9 @@ def opening_balances( employee_id__in=requested_ids, period__currency=period.currency, period__date_to__lt=period.date_from, - period__runs__status=PayrollRunStatus.PUBLISHED, status=ApprovalStatus.APPROVED, include_in_carryover=True, - component__kind__in=( - PayrollComponentKind.ADJUSTMENT_CREDIT, - PayrollComponentKind.ADJUSTMENT_DEBIT, - ), + component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"), ) .exclude( source=InputSource.API, @@ -46,12 +37,12 @@ def opening_balances( .annotate( credit_total=Sum( "amount", - filter=Q(component__kind=PayrollComponentKind.ADJUSTMENT_CREDIT), + filter=Q(component_code="CORRECTION_CREDIT"), default=Decimal("0.00"), ), debit_total=Sum( "amount", - filter=Q(component__kind=PayrollComponentKind.ADJUSTMENT_DEBIT), + filter=Q(component_code="CORRECTION_DEBIT"), default=Decimal("0.00"), ), ) diff --git a/backend/finance/payroll/carryovers.py b/backend/finance/payroll/carryovers.py index 96465218..8138cd09 100644 --- a/backend/finance/payroll/carryovers.py +++ b/backend/finance/payroll/carryovers.py @@ -17,7 +17,6 @@ from finance.enums import ( from finance.models import ( PayrollAuditEvent, PayrollCarryover, - PayrollComponent, PayrollInputLine, PayrollPeriod, ) @@ -74,17 +73,6 @@ def _materialize_one(carryover: PayrollCarryover, target_period: PayrollPeriod): ) component_code = CARRYOVER_COMPONENT_CODES[carryover.direction] - component = PayrollComponent.objects.select_for_update().filter( - code=component_code, - is_active=True, - ).first() - if component is None: - _operation_error( - "CARRYOVER_COMPONENT_MISSING", - "Не найден активный вид операции для автоматического переноса.", - component_code=component_code, - ) - source_ref = f"{CARRYOVER_SOURCE_PREFIX}{carryover.pk}" line = PayrollInputLine.objects.filter( source=InputSource.API, @@ -94,7 +82,7 @@ def _materialize_one(carryover: PayrollCarryover, target_period: PayrollPeriod): line = PayrollInputLine( period=target_period, employee=carryover.employee, - component=component, + component_code=component_code, amount=carryover.amount, relates_to_period=carryover.source_period, reason=( diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py new file mode 100644 index 00000000..79859525 --- /dev/null +++ b/backend/finance/payroll/components.py @@ -0,0 +1,102 @@ +"""Code-owned payroll input component definitions.""" + +from dataclasses import dataclass + +from finance.enums import PayrollComponentKind + + +@dataclass(frozen=True, slots=True) +class PayrollComponentDefinition: + code: str + name: str + kind: str + requires_reason: bool + display_order: int + + @property + def id(self) -> str: + return self.code + + @property + def pk(self) -> str: + return self.code + + @property + def is_active(self) -> bool: + return True + + def get_kind_display(self) -> str: + return PayrollComponentKind(self.kind).label + + +PAYROLL_COMPONENTS = ( + PayrollComponentDefinition("VACATION_PAY", "Отпускные", PayrollComponentKind.EARNING, False, 20), + PayrollComponentDefinition("BONUS", "Премия", PayrollComponentKind.EARNING, False, 30), + PayrollComponentDefinition("CORRECTION_CREDIT", "Положительная корректировка", PayrollComponentKind.ADJUSTMENT_CREDIT, True, 40), + PayrollComponentDefinition("CORRECTION_DEBIT", "Отрицательная корректировка", PayrollComponentKind.ADJUSTMENT_DEBIT, True, 41), + PayrollComponentDefinition("ONE_TIME_PAYMENT", "Разовое начисление", PayrollComponentKind.EARNING, True, 50), + PayrollComponentDefinition("DEDUCTION", "Удержание", PayrollComponentKind.DEDUCTION, True, 60), + PayrollComponentDefinition("ADVANCE", "Аванс", PayrollComponentKind.PAYMENT, False, 70), + PayrollComponentDefinition("SALARY_PAYMENT", "Фактическая выплата", PayrollComponentKind.PAYMENT, True, 80), +) +PAYROLL_COMPONENTS_BY_CODE = {component.code: component for component in PAYROLL_COMPONENTS} +PAYROLL_COMPONENT_CODE_CHOICES = tuple( + (component.code, component.name) for component in PAYROLL_COMPONENTS +) + + +class _PayrollComponentCollection(tuple): + def get(self, **criteria): + matches = self.filter(**criteria) + if len(matches) != 1: + raise LookupError(f"Ожидался один вид операции, найдено: {len(matches)}") + return matches[0] + + def filter(self, **criteria): + return _PayrollComponentCollection( + component + for component in self + if all(getattr(component, key) == value for key, value in criteria.items()) + ) + + def exclude(self, **criteria): + normalized = { + key.removesuffix("__in"): ("in" if key.endswith("__in") else "exact", value) + for key, value in criteria.items() + } + return _PayrollComponentCollection( + component + for component in self + if not all( + getattr(component, key) in value if lookup == "in" else getattr(component, key) == value + for key, (lookup, value) in normalized.items() + ) + ) + + def all(self): + return self + + def order_by(self, *fields): + result = list(self) + for field in reversed(fields): + reverse = field.startswith("-") + name = field.removeprefix("-") + result.sort(key=lambda component: getattr(component, name), reverse=reverse) + return _PayrollComponentCollection(result) + + def values_list(self, field, *, flat=False): + values = [getattr(component, field) for component in self] + return values if flat else [(value,) for value in values] + + +class PayrollComponent: + """Compatibility facade over the code registry; this is not a database model.""" + + objects = _PayrollComponentCollection(PAYROLL_COMPONENTS) + + +def get_payroll_component(code: str) -> PayrollComponentDefinition: + try: + return PAYROLL_COMPONENTS_BY_CODE[code] + except KeyError as exc: + raise ValueError(f"Неизвестный вид зарплатной операции: {code}") from exc diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 258a1d12..30329b0c 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -22,13 +22,13 @@ from requests_app.models import Request from finance.enums import ApprovalStatus, InputSource from finance.models import ( EmployeePayRate, - PayrollComponent, PayrollDailyWorkEntry, PayrollInputLine, PayrollPeriod, PayrollWorkRecord, PayrollWorkSettings, ) +from finance.payroll.components import PAYROLL_COMPONENTS from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request from .balances import opening_balances @@ -162,8 +162,8 @@ def _input_lines_by_employee(period, employee_ids): status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED], ) .exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:") - .select_related("component", "relates_to_period") - .order_by("employee_id", "component__display_order", "id") + .select_related("relates_to_period") + .order_by("employee_id", "component_code", "id") ) for record in records: grouped[record.employee_id].append(record) @@ -276,7 +276,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 = list(PayrollComponent.objects.all()) + components = PAYROLL_COMPONENTS component_definitions = { component.code: _component_definition( component.code, @@ -289,7 +289,7 @@ def build_payroll_period_table(period: PayrollPeriod): component_order = { component.code: component_definitions[component.code] for component in components - if component.is_active and component.code not in SYSTEM_COMPONENT_CODES + if component.code not in SYSTEM_COMPONENT_CODES } calculator = DeterministicPayrollCalculator() active_rules = build_rules() diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index d54a9cd2..a66aa19d 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -28,7 +28,6 @@ from finance.enums import ( from finance.models import ( EmployeePayRate, PayrollAuditEvent, - PayrollComponent, PayrollDailyWorkEntry, PayrollInputLine, PayrollPeriod, @@ -225,8 +224,8 @@ def _input_lines_by_employee(period, employee_ids): status=ApprovalStatus.APPROVED, ) .exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:") - .select_related("component", "relates_to_period") - .order_by("employee_id", "component__display_order", "id") + .select_related("relates_to_period") + .order_by("employee_id", "component_code", "id") ) for record in records: grouped[record.employee_id].append(record) @@ -1917,7 +1916,7 @@ def clear_component_input_lines( period_id: int, *, employee_id: int, - component_id: int, + component_code: str, actor, ) -> dict[str, int]: """Clear one spreadsheet-like component cell without losing its audit trail.""" @@ -1929,15 +1928,12 @@ def clear_component_input_lines( _ensure_period_accepts_approvals(period) if not Employee.objects.filter(pk=employee_id).exists(): _operation_error("EMPLOYEE_NOT_FOUND", "Сотрудник не найден.") - if not PayrollComponent.objects.filter(pk=component_id).exists(): - _operation_error("COMPONENT_NOT_FOUND", "Вид операции не найден.") - lines = list( PayrollInputLine.objects.select_for_update() .filter( period=period, employee_id=employee_id, - component_id=component_id, + component_code=component_code, status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), ) .order_by("pk") @@ -1969,6 +1965,57 @@ def clear_component_input_lines( return summary +@transaction.atomic +def set_component_input_line_value( + period_id: int, + *, + employee_id: int, + component_code: str, + amount: Decimal, + actor, +) -> PayrollInputLine: + """Replace the aggregate value of one editable spreadsheet component cell.""" + + 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, + component_code=component_code, + amount=amount, + reason="Перезаписано в итоговой таблице", + status=ApprovalStatus.DRAFT, + source=InputSource.MANUAL, + created_by=actor, + ) + try: + line.full_clean() + line.save(force_insert=True) + except ValidationError as exc: + raise PayrollOperationError( + "INPUT_VALIDATION_FAILED", + "Новое значение ячейки не прошло проверку.", + details={ + "errors": ( + exc.message_dict if hasattr(exc, "message_dict") else exc.messages + ) + }, + ) from exc + _audit( + actor=actor, + action="payroll.input_line_draft_created_from_table", + instance=line, + period=period, + metadata={"component_code": component_code, "amount": str(amount)}, + ) + return line + + @transaction.atomic def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: """Approve every draft shown in a period queue as one transaction.""" @@ -1976,7 +2023,7 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: _require_permission(actor, "finance.approve_payroll_inputs") # Keep the global lock order used by single-record rate approval: periods, - # active runs, rates, work records, components, then input lines. + # active runs, rates, work records, then input lines. periods = list(PayrollPeriod.objects.select_for_update().order_by("pk")) period = next((item for item in periods if item.pk == period_id), None) if period is None: @@ -2015,17 +2062,11 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: status=ApprovalStatus.DRAFT, ) .order_by("pk") - .values_list("pk", "component_id") + .values_list("pk", "component_code") ) - components = { - component.pk: component - for component in PayrollComponent.objects.select_for_update() - .filter(pk__in={component_id for _, component_id in input_refs}) - .order_by("pk") - } input_lines = list( PayrollInputLine.objects.select_for_update() - .select_related("period", "component") + .select_related("period") .filter(pk__in=[line_id for line_id, _ in input_refs]) .order_by("pk") ) @@ -2054,16 +2095,6 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: "После публикации показатели меняются только новой ревизией.", ) - for line in input_lines: - component = components[line.component_id] - if not component.is_active: - _operation_error( - "PAYROLL_COMPONENT_INACTIVE", - "Нельзя утвердить строку с неактивным компонентом.", - component_id=component.pk, - ) - line._state.fields_cache["component"] = component - for rate in rates: _approve_record( rate, @@ -2182,22 +2213,12 @@ def approve_input_line( expected_lock_version: int, ) -> PayrollInputLine: _require_permission(actor, "finance.approve_payroll_inputs") - reference = PayrollInputLine.objects.only("period_id", "component_id").get( + reference = PayrollInputLine.objects.only("period_id").get( pk=line_id ) period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id) _ensure_period_accepts_approvals(period) - component = PayrollComponent.objects.select_for_update().get( - pk=reference.component_id - ) - if not component.is_active: - _operation_error( - "PAYROLL_COMPONENT_INACTIVE", - "Нельзя утвердить строку с неактивным компонентом.", - component_id=component.pk, - ) record = PayrollInputLine.objects.select_for_update().get(pk=line_id) - record._state.fields_cache["component"] = component return _approve_record( record, actor=actor, diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index e6fc5df7..c41aff8f 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -225,7 +225,7 @@ def test_copy_input_lines_previews_scope_and_handles_existing_values( status="draft", ) assert active_target_lines.count() == 2 - assert set(active_target_lines.values_list("component_id", flat=True)) == { + assert set(active_target_lines.values_list("component_code", flat=True)) == { bonus.pk, advance.pk, } @@ -277,7 +277,7 @@ def test_copy_input_lines_previews_scope_and_handles_existing_values( status="draft", ) assert august_lines.count() == 3 - assert set(august_lines.values_list("component_id", flat=True)) == { + assert set(august_lines.values_list("component_code", flat=True)) == { bonus.pk, advance.pk, correction_credit.pk, @@ -1336,6 +1336,63 @@ def test_component_cell_can_be_cleared_and_voids_all_active_lines( assert repeated.json() == {"draft": 0, "approved": 0, "total": 0} +def test_component_cell_value_can_replace_approved_and_draft_lines_atomically( + user_factory, + auth_client_factory, +): + manager = user_factory(email="table.replace.manager@example.test") + employee = user_factory(email="table.replace.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + period = make_period(creator=manager) + approved = PayrollInputLine.objects.create( + period=period, + employee=employee, + component_code="ADVANCE", + amount="5000.00", + reason="Первоначальный аванс", + status="approved", + created_by=manager, + approved_by=manager, + approved_at=timezone.now(), + self_approval_overridden=True, + ) + draft = PayrollInputLine.objects.create( + period=period, + employee=employee, + component_code="ADVANCE", + amount="1000.00", + reason="Дополнение", + created_by=manager, + ) + + response = auth_client_factory(manager).post( + api_url("admin-component-cell-set", pk=period.pk), + { + "employee_id": employee.pk, + "component_code": "ADVANCE", + "amount": "2500.00", + }, + format="json", + ) + + assert response.status_code == 201, response.content + approved.refresh_from_db() + draft.refresh_from_db() + assert approved.status == draft.status == "voided" + replacement = PayrollInputLine.objects.get(pk=response.json()["id"]) + assert replacement.status == "draft" + assert replacement.amount == Decimal("2500.00") + assert replacement.component_code == "ADVANCE" + assert replacement.reason == "Перезаписано в итоговой таблице" + + active_lines = PayrollInputLine.objects.filter( + period=period, + employee=employee, + component_code="ADVANCE", + ).exclude(status="voided") + assert list(active_lines) == [replacement] + + def test_period_table_includes_all_active_and_only_inactive_employees_with_work( user_factory, auth_client_factory, @@ -1605,8 +1662,11 @@ def test_approve_all_period_drafts_rolls_back_when_one_draft_is_invalid( amount="5000.00", created_by=administrator, ) - bonus.is_active = False - bonus.save(update_fields=["is_active"]) + PayrollInputLine.objects.filter( + period=period, + employee=employee, + component_code=bonus.code, + ).update(component_code="UNKNOWN") response = auth_client_factory(administrator).post( api_url("admin-period-approve-drafts", pk=period.pk), @@ -1615,7 +1675,7 @@ def test_approve_all_period_drafts_rolls_back_when_one_draft_is_invalid( ) assert response.status_code == 409 - assert response.json()["code"] == "PAYROLL_COMPONENT_INACTIVE" + assert response.json()["code"] == "INPUT_VALIDATION_FAILED" rate.refresh_from_db() assert rate.status == "draft" diff --git a/backend/tests/finance/test_payroll_admin_permissions.py b/backend/tests/finance/test_payroll_admin_permissions.py index 0b1fa4f9..fb86c567 100644 --- a/backend/tests/finance/test_payroll_admin_permissions.py +++ b/backend/tests/finance/test_payroll_admin_permissions.py @@ -86,6 +86,97 @@ def test_superuser_has_unrestricted_finance_admin_permissions(user_factory): ).has_delete_permission(superuser_request) is True +def test_pay_rate_admin_deletes_entire_revision_chain(user_factory): + superuser = user_factory( + email="payroll.rate.delete@example.test", + staff=True, + superuser=True, + ) + employee = user_factory(email="payroll.rate.delete.employee@example.test") + first = EmployeePayRate.objects.create( + employee=employee, + amount="80000", + effective_from="2026-01-01", + revision=1, + created_by=superuser, + ) + second = EmployeePayRate.objects.create( + employee=employee, + amount="85000", + effective_from="2026-01-01", + revision=2, + replaces=first, + created_by=superuser, + ) + third = EmployeePayRate.objects.create( + employee=employee, + amount="90000", + effective_from="2026-01-01", + revision=3, + replaces=second, + created_by=superuser, + ) + model_admin = EmployeePayRateAdmin(EmployeePayRate, admin.site) + request = admin_request(superuser) + + deleted_objects, model_count, perms_needed, protected = ( + model_admin.get_deleted_objects([second], request) + ) + + assert len(deleted_objects) == 3 + assert model_count[EmployeePayRate._meta.verbose_name_plural] == 3 + assert perms_needed == set() + assert protected == [] + + model_admin.delete_model(request, second) + + assert not EmployeePayRate.objects.filter( + pk__in=[first.pk, second.pk, third.pk] + ).exists() + + +def test_pay_rate_admin_bulk_delete_expands_selected_revision_chains(user_factory): + superuser = user_factory( + email="payroll.rate.bulk-delete@example.test", + staff=True, + superuser=True, + ) + employee = user_factory( + email="payroll.rate.bulk-delete.employee@example.test" + ) + first = EmployeePayRate.objects.create( + employee=employee, + amount="80000", + effective_from="2026-01-01", + revision=1, + created_by=superuser, + ) + second = EmployeePayRate.objects.create( + employee=employee, + amount="90000", + effective_from="2026-01-01", + revision=2, + replaces=first, + created_by=superuser, + ) + unrelated = EmployeePayRate.objects.create( + employee=employee, + amount="95000", + effective_from="2026-02-01", + revision=1, + created_by=superuser, + ) + model_admin = EmployeePayRateAdmin(EmployeePayRate, admin.site) + + model_admin.delete_queryset( + admin_request(superuser), + EmployeePayRate.objects.filter(pk=first.pk), + ) + + assert not EmployeePayRate.objects.filter(pk__in=[first.pk, second.pk]).exists() + assert EmployeePayRate.objects.filter(pk=unrelated.pk).exists() + + def test_period_admin_confirms_and_cascades_related_payroll_objects(user_factory): superuser = user_factory( email="payroll.delete@example.test", diff --git a/backend/tests/finance/test_payroll_carryovers.py b/backend/tests/finance/test_payroll_carryovers.py index e714ef57..a5a68681 100644 --- a/backend/tests/finance/test_payroll_carryovers.py +++ b/backend/tests/finance/test_payroll_carryovers.py @@ -10,9 +10,8 @@ from finance.enums import ( ApprovalStatus, PayrollCarryoverStatus, PayrollPeriodStatus, - PayrollRunStatus, ) -from finance.models import PayrollComponent, PayrollInputLine, PayrollPeriod, PayrollRun +from finance.models import PayrollComponent, PayrollInputLine, PayrollPeriod from finance.payroll.balances import opening_balances from finance.payroll.carryovers import ( cancel_carryover, @@ -231,23 +230,6 @@ def test_carryover_ledger_lists_history_and_can_exclude_a_record( approved_at=timezone.now(), self_approval_overridden=True, ) - PayrollRun.objects.create( - period=june, - revision=1, - status=PayrollRunStatus.PUBLISHED, - ruleset_id="test", - ruleset_version="1", - ruleset_hash="rules", - calculator_version="test", - input_hash="input", - result_hash="result", - requested_by=manager, - approved_by=manager, - approved_at=timezone.now(), - self_approval_overridden=True, - published_by=manager, - published_at=timezone.now(), - ) client = auth_client_factory(manager) ledger = client.get(api_url("admin-period-carryovers", pk=july.pk)) diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index 3c3bfa4d..2315e510 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from dataclasses import FrozenInstanceError from datetime import date from decimal import Decimal @@ -1100,24 +1101,15 @@ def test_stale_draft_save_is_rejected(payroll_users): def test_component_semantics_freeze_after_creation(payroll_users): component = PayrollComponent.objects.get(code="BONUS") - component.kind = "deduction" - - with pytest.raises(ValidationError): - component.save() - - component.refresh_from_db() - component.is_active = False - component.save() - assert component.is_active is False + with pytest.raises(FrozenInstanceError): + component.kind = "deduction" -def test_inactive_component_cannot_be_approved( +def test_unknown_component_code_cannot_be_approved( june_period, payroll_users, ): component = PayrollComponent.objects.get(code="BONUS") - component.is_active = False - component.save() line = PayrollInputLine.objects.create( period=june_period, employee=payroll_users["employee"], @@ -1125,6 +1117,7 @@ def test_inactive_component_cannot_be_approved( amount=Decimal("100"), created_by=payroll_users["operator"], ) + PayrollInputLine.objects.filter(pk=line.pk).update(component_code="UNKNOWN") with pytest.raises(PayrollOperationError) as error: approve_input_line( @@ -1133,7 +1126,7 @@ def test_inactive_component_cannot_be_approved( expected_lock_version=line.lock_version, ) - assert error.value.code == "PAYROLL_COMPONENT_INACTIVE" + assert error.value.code == "INPUT_VALIDATION_FAILED" def test_admin_recalculation_collects_and_persists_reason( diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx index a921631e..7d99db74 100644 --- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -1046,7 +1046,7 @@ export function PayrollInputLineFormModal({ }) { const [form, setForm] = useState(() => ({ employee_id: line ? String(line.employee.id) : "", - component_id: line ? String(line.component.id) : "", + component_code: line?.component_code || "", amount: line?.amount || "", relates_to_period_id: line?.relates_to_period_id ? String(line.relates_to_period_id) : "", reason: line?.reason || "", @@ -1054,13 +1054,13 @@ export function PayrollInputLineFormModal({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const component = useMemo(() => components.find((item) => item.id === Number(form.component_id)), [components, form.component_id]); + const component = useMemo(() => components.find((item) => item.code === form.component_code), [components, form.component_code]); const submit = (event: FormEvent) => { event.preventDefault(); void submitModalForm(() => onSubmit({ period_id: period.id, employee_id: Number(form.employee_id), - component_id: Number(form.component_id), + component_code: form.component_code, amount: form.amount, relates_to_period_id: form.relates_to_period_id ? Number(form.relates_to_period_id) : null, reason: form.reason, @@ -1086,9 +1086,9 @@ export function PayrollInputLineFormModal({