Merge branch 'develop'
This commit is contained in:
commit
4b9da34186
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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="Обновлена",
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
@ -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": "Строки начислений/удержаний",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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=(
|
||||
|
||||
102
backend/finance/payroll/components.py
Normal file
102
backend/finance/payroll/components.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
};
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user