Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-12 15:47:55 +03:00
commit 4b9da34186
23 changed files with 734 additions and 340 deletions

View File

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

View File

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

View File

@ -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/<int:pk>/component-cell/set/",
PayrollComponentCellSetView.as_view(),
name="admin-component-cell-set",
),
path(
"payroll/admin/runs/<int:pk>/",
PayrollRunDetailView.as_view(),

View File

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

View File

@ -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="Обновлена",
),
)
],
)
]

View File

@ -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": "Строки начислений/удержаний",
},
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string | null>(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({
</label>
<label className="sm:col-span-2">
<span className="app-field-label">Вид операции</span>
<select className={selectClass} required disabled={Boolean(line)} value={form.component_id} onChange={(event) => setForm({ ...form, component_id: event.target.value })}>
<select className={selectClass} required disabled={Boolean(line)} value={form.component_code} onChange={(event) => setForm({ ...form, component_code: event.target.value })}>
<option value="">Выберите вид</option>
{components.filter((item) => item.is_active).map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
{components.filter((item) => item.is_active).map((item) => <option value={item.code} key={item.code}>{item.name}</option>)}
</select>
</label>
<label>

View File

@ -936,7 +936,7 @@ export function PayrollAdminWorkspace({
if (matchingLines.length) {
await apiClient.clearPayrollAdminComponentCell(selectedPeriod.id, {
employee_id: row.employee.id,
component_id: component.id,
component_code: component.code,
});
await refreshInlineTable("inputs");
}
@ -946,31 +946,11 @@ export function PayrollAdminWorkspace({
if (!Number.isFinite(desiredAmount) || desiredAmount <= 0) {
throw new Error("Сумма операции должна быть больше нуля или оставлена пустой.");
}
const draft = matchingLines
.filter((item) => item.status === "draft")
.sort((left, right) => right.id - left.id)[0];
const currentAmount = Number(row.component_amounts[componentCode] || "0");
if (draft) {
const amountWithoutDraft = currentAmount - Number(draft.amount);
const replacementAmount = desiredAmount - amountWithoutDraft;
if (replacementAmount <= 0) {
throw new Error("Значение ниже уже утверждённой суммы. Исправьте её во вкладке «Начисления и выплаты».");
}
await apiClient.updatePayrollAdminInputLine(draft.id, { amount: replacementAmount.toFixed(2), expected_lock_version: draft.lock_version });
} else {
const addition = desiredAmount - currentAmount;
if (addition <= 0) {
throw new Error("Утверждённую сумму можно уменьшить через корректирующую операцию во вкладке «Начисления и выплаты».");
}
await apiClient.createPayrollAdminInputLine({
period_id: selectedPeriod.id,
employee_id: row.employee.id,
component_id: component.id,
amount: addition.toFixed(2),
relates_to_period_id: null,
reason: "Введено в итоговой таблице",
});
}
await apiClient.setPayrollAdminComponentCell(selectedPeriod.id, {
employee_id: row.employee.id,
component_code: component.code,
amount: desiredAmount.toFixed(2),
});
await refreshInlineTable("inputs");
};

View File

@ -141,7 +141,7 @@ export function PayrollCarryoversModal({
</div>
<div className="app-text-muted mt-3 space-y-1 text-xs leading-relaxed">
<p><b className="text-[var(--foreground)]">В реестре:</b> все положительные и отрицательные корректировки за выбранный и предыдущие периоды.</p>
<p><b className="text-[var(--foreground)]">В остатке:</b> утверждённые корректировки опубликованных периодов и активные корректировки текущего периода.</p>
<p><b className="text-[var(--foreground)]">В остатке:</b> сумма всех учитываемых утверждённых корректировок за всю историю до конца выбранного периода и активных корректировок самого выбранного периода.</p>
<p><b className="text-[var(--foreground)]">Не входят:</b> зарплата по баллам, обычные премии и начисления, аванс, прочие выплаты и удержания.</p>
<p><b className="text-[var(--foreground)]">Отключение записи</b> меняет только переходящий остаток. Сумма зарплаты в исходном периоде не изменяется.</p>
</div>
@ -166,7 +166,7 @@ export function PayrollCarryoversModal({
? "Исключена из остатка вручную."
: entry.status === "voided"
? "Аннулированная запись не участвует в остатке."
: "Запись начнёт участвовать после утверждения и публикации периода.";
: "Запись начнёт участвовать после утверждения.";
return (
<article key={entry.id} className={`app-surface-muted rounded-xl p-3 ${entry.include_in_carryover ? "" : "opacity-70"}`}>
<div className="flex items-start gap-3">

View File

@ -43,7 +43,7 @@ export function PayrollInputCopyModal({
? previousPeriod?.id || 0
: sourcePeriodId;
const [preview, setPreview] = useState<PayrollInputCopyPreview | null>(null);
const [selectedComponentIds, setSelectedComponentIds] = useState<Set<number>>(new Set());
const [selectedComponentIds, setSelectedComponentIds] = useState<Set<string>>(new Set());
const [employeeScope, setEmployeeScope] = useState<PayrollInputCopyEmployeeScope>("active");
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<number>>(new Set());
const [existingMode, setExistingMode] = useState<PayrollInputCopyExistingMode>("skip");
@ -63,7 +63,7 @@ export function PayrollInputCopyModal({
if (cancelled) return;
setPreview(payload);
const availableComponentIds = new Set(payload.components.map((component) => component.id));
const configuredComponentIds = payload.configuration.component_ids.filter((id) => availableComponentIds.has(id));
const configuredComponentIds = payload.configuration.component_codes.filter((id) => availableComponentIds.has(id));
setSelectedComponentIds(new Set(
payload.configuration.saved
? configuredComponentIds
@ -139,7 +139,7 @@ export function PayrollInputCopyModal({
&& hasValidEmployeeSelection
&& (projection.created > 0 || hasConfigurationUpdate);
const toggleComponent = (componentId: number) => {
const toggleComponent = (componentId: string) => {
setSelectedComponentIds((current) => {
const next = new Set(current);
if (next.has(componentId)) next.delete(componentId);
@ -163,7 +163,7 @@ export function PayrollInputCopyModal({
try {
const result = await apiClient.copyPayrollAdminInputLines(period.id, {
source_period_id: selectedSourcePeriodId,
component_ids: [...selectedComponentIds],
component_codes: [...selectedComponentIds],
employee_scope: employeeScope,
employee_ids: employeeScope === "selected" ? [...selectedEmployeeIds] : [],
existing_mode: existingMode,

View File

@ -560,7 +560,7 @@ export type PayrollPointBreakdown = {
};
export type PayrollComponent = {
id: number;
id: string;
code: string;
name: string;
kind: PayrollLineKind;
@ -779,7 +779,7 @@ export type PayrollInputCopyPreview = {
}>;
configuration: {
saved: boolean;
component_ids: number[];
component_codes: string[];
employee_scope: PayrollInputCopyEmployeeScope;
employee_ids: number[];
existing_mode: PayrollInputCopyExistingMode;
@ -789,7 +789,7 @@ export type PayrollInputCopyPreview = {
export type PayrollInputCopyWrite = {
source_period_id: number;
component_ids: number[];
component_codes: string[];
employee_scope: PayrollInputCopyEmployeeScope;
employee_ids: number[];
existing_mode: PayrollInputCopyExistingMode;
@ -819,7 +819,7 @@ export type PayrollAdminInputLine = {
employee: PayrollAdminEmployee;
employee_id?: number;
component: PayrollComponent;
component_id?: number;
component_code: string;
amount: string;
relates_to_period_id: number | null;
relates_to_period: PayrollCarryoverPeriod | null;
@ -950,7 +950,7 @@ export type PayrollWorkRecordWrite = {
export type PayrollInputLineWrite = {
period_id: number;
employee_id: number;
component_id: number;
component_code: string;
amount: string;
relates_to_period_id?: number | null;
reason: string;
@ -1196,14 +1196,19 @@ export function createFinanceApi(request: RequestFn) {
request(`${adminPrefix}/input-lines/`, jsonOptions("POST", payload)),
updatePayrollAdminInputLine: (
lineId: number,
payload: Partial<Omit<PayrollInputLineWrite, "period_id" | "employee_id" | "component_id">> & { expected_lock_version: number },
payload: Partial<Omit<PayrollInputLineWrite, "period_id" | "employee_id" | "component_code">> & { expected_lock_version: number },
): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/input-lines/${lineId}/`, jsonOptions("PATCH", payload)),
clearPayrollAdminComponentCell: (
periodId: number,
payload: { employee_id: number; component_id: number },
payload: { employee_id: number; component_code: string },
): Promise<{ draft: number; approved: number; total: number }> =>
request(`${adminPrefix}/periods/${periodId}/component-cell/clear/`, jsonOptions("POST", payload)),
setPayrollAdminComponentCell: (
periodId: number,
payload: { employee_id: number; component_code: string; amount: string },
): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/periods/${periodId}/component-cell/set/`, jsonOptions("POST", payload)),
approvePayrollAdminInputLine: (lineId: number, expectedLockVersion: number): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/input-lines/${lineId}/approve/`, jsonOptions("POST", { expected_lock_version: expectedLockVersion })),
calculatePayrollAdminPeriod: (