feat(finance): expand payroll period workflows
This commit is contained in:
parent
91ca6b55e8
commit
1295d24c86
@ -13,9 +13,16 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from finance.enums import ApprovalStatus, InputSource
|
||||
from api.v1.employees.serializers import EmployeeBriefSerializer
|
||||
from finance.enums import (
|
||||
ApprovalStatus,
|
||||
InputSource,
|
||||
PayrollCarryoverDirection,
|
||||
PayrollCarryoverStatus,
|
||||
)
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollCarryover,
|
||||
PayrollComponent,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
@ -88,6 +95,17 @@ class PayrollUserRefSerializer(serializers.ModelSerializer):
|
||||
return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}"
|
||||
|
||||
|
||||
class PayrollCellCommentSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
author = EmployeeBriefSerializer(read_only=True)
|
||||
text = serializers.CharField(read_only=True)
|
||||
created_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
|
||||
class PayrollCellCommentWriteSerializer(serializers.Serializer):
|
||||
text = serializers.CharField(max_length=10000, trim_whitespace=True)
|
||||
|
||||
|
||||
class PayrollComponentSerializer(serializers.ModelSerializer):
|
||||
kind_label = serializers.CharField(source="get_kind_display", read_only=True)
|
||||
|
||||
@ -132,6 +150,69 @@ class PayrollPeriodSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class PayrollCarryoverPeriodSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PayrollPeriod
|
||||
fields = ["id", "code", "name", "date_from", "date_to", "currency"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class PayrollCarryoverSerializer(serializers.ModelSerializer):
|
||||
employee = PayrollEmployeeMiniSerializer(read_only=True)
|
||||
source_period = PayrollCarryoverPeriodSerializer(read_only=True)
|
||||
target_period = PayrollCarryoverPeriodSerializer(read_only=True)
|
||||
direction_label = serializers.CharField(
|
||||
source="get_direction_display",
|
||||
read_only=True,
|
||||
)
|
||||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||||
created_by = PayrollUserRefSerializer(read_only=True)
|
||||
generated_input_line_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
can_cancel = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PayrollCarryover
|
||||
fields = [
|
||||
"id",
|
||||
"source_period",
|
||||
"target_period",
|
||||
"employee",
|
||||
"direction",
|
||||
"direction_label",
|
||||
"amount",
|
||||
"reason",
|
||||
"status",
|
||||
"status_label",
|
||||
"generated_input_line_id",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"transferred_at",
|
||||
"cancelled_at",
|
||||
"can_cancel",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_can_cancel(self, obj):
|
||||
return obj.status != PayrollCarryoverStatus.CANCELLED and (
|
||||
obj.generated_input_line_id is None
|
||||
or obj.generated_input_line.status == ApprovalStatus.DRAFT
|
||||
)
|
||||
|
||||
|
||||
class PayrollCarryoverWriteSerializer(serializers.Serializer):
|
||||
employee_id = serializers.PrimaryKeyRelatedField(
|
||||
source="employee",
|
||||
queryset=Employee.objects.all(),
|
||||
)
|
||||
direction = serializers.ChoiceField(choices=PayrollCarryoverDirection.choices)
|
||||
amount = serializers.DecimalField(
|
||||
max_digits=19,
|
||||
decimal_places=2,
|
||||
min_value=Decimal("0.01"),
|
||||
)
|
||||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||||
|
||||
|
||||
class PayrollPeriodWriteSerializer(serializers.ModelSerializer):
|
||||
expected_lock_version = serializers.IntegerField(
|
||||
min_value=0,
|
||||
@ -525,6 +606,8 @@ class PayrollInputLineSerializer(serializers.ModelSerializer):
|
||||
component = PayrollComponentSerializer(read_only=True)
|
||||
component_id = serializers.IntegerField(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()
|
||||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||||
created_by_id = serializers.IntegerField(read_only=True)
|
||||
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
@ -543,6 +626,8 @@ class PayrollInputLineSerializer(serializers.ModelSerializer):
|
||||
"component",
|
||||
"amount",
|
||||
"relates_to_period_id",
|
||||
"relates_to_period",
|
||||
"is_automatic_carryover",
|
||||
"reason",
|
||||
"status",
|
||||
"status_label",
|
||||
@ -559,6 +644,11 @@ class PayrollInputLineSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_is_automatic_carryover(self, obj):
|
||||
return obj.source == InputSource.API and obj.source_ref.startswith(
|
||||
"payroll-carryover:"
|
||||
)
|
||||
|
||||
|
||||
class PayrollInputLineWriteSerializer(
|
||||
DraftWriteSerializerMixin,
|
||||
|
||||
@ -7,10 +7,14 @@ access to payroll data.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models import Count, Max, Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, status
|
||||
@ -18,10 +22,19 @@ from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
|
||||
from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from finance.enums import (
|
||||
ApprovalStatus,
|
||||
PayrollCarryoverStatus,
|
||||
PayrollPeriodStatus,
|
||||
PayrollRunStatus,
|
||||
)
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollAuditEvent,
|
||||
PayrollCarryover,
|
||||
PayrollCellCommentThread,
|
||||
PayrollComponent,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
@ -35,6 +48,11 @@ from finance.payroll.config import (
|
||||
ruleset_not_effective_message,
|
||||
ruleset_period_details,
|
||||
)
|
||||
from finance.payroll.carryovers import (
|
||||
cancel_carryover,
|
||||
create_carryover,
|
||||
materialize_pending_carryovers,
|
||||
)
|
||||
from finance.payroll.access import has_payroll_permission, has_simple_admin_access
|
||||
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
|
||||
from finance.payroll.services import (
|
||||
@ -49,12 +67,14 @@ from finance.payroll.services import (
|
||||
calculate_period,
|
||||
clear_component_input_lines,
|
||||
close_period,
|
||||
complete_period_workflow,
|
||||
publish_run,
|
||||
pay_rates_relevant_to_period,
|
||||
return_run_for_correction,
|
||||
submit_run_for_review,
|
||||
)
|
||||
from finance.payroll.reporting import (
|
||||
SYSTEM_COMPONENT_CODES,
|
||||
build_payroll_employee_point_breakdown,
|
||||
build_payroll_period_table,
|
||||
)
|
||||
@ -80,6 +100,10 @@ from .admin_serializers import (
|
||||
EmployeePayRateSerializer,
|
||||
EmployeePayRateWriteSerializer,
|
||||
PayrollComponentSerializer,
|
||||
PayrollCellCommentSerializer,
|
||||
PayrollCellCommentWriteSerializer,
|
||||
PayrollCarryoverSerializer,
|
||||
PayrollCarryoverWriteSerializer,
|
||||
PayrollEmployeeMiniSerializer,
|
||||
PayrollInputLineSerializer,
|
||||
PayrollInputLineWriteSerializer,
|
||||
@ -125,6 +149,28 @@ RUN_VIEW_PERMISSIONS = (
|
||||
PAYROLL_PERMISSIONS["view_all"],
|
||||
)
|
||||
|
||||
PAYROLL_TABLE_FIXED_COLUMN_IDS = {
|
||||
"employee",
|
||||
"rate_amount",
|
||||
"in_norm_point_rate",
|
||||
"point_rate",
|
||||
"target_points",
|
||||
"attendance_points",
|
||||
"personnel_points",
|
||||
"actual_points",
|
||||
"point_delta",
|
||||
"point_amount",
|
||||
"gross_before_adjustments",
|
||||
"adjustment_total",
|
||||
"gross_total",
|
||||
"deduction_total",
|
||||
"net_pay",
|
||||
"payment_total",
|
||||
"payable",
|
||||
"carryovers",
|
||||
"status",
|
||||
}
|
||||
|
||||
|
||||
def _permission_error(permission):
|
||||
raise PermissionDenied(
|
||||
@ -164,6 +210,119 @@ def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None):
|
||||
)
|
||||
|
||||
|
||||
def _validate_payroll_table_column(column_id):
|
||||
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
|
||||
return column_id
|
||||
prefix, separator, component_code = column_id.partition(":")
|
||||
if (
|
||||
prefix == "component"
|
||||
and separator
|
||||
and component_code not in SYSTEM_COMPONENT_CODES
|
||||
and PayrollComponent.objects.filter(
|
||||
code=component_code,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return column_id
|
||||
raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."})
|
||||
|
||||
|
||||
def _cell_comment_summaries(period):
|
||||
threads = list(
|
||||
PayrollCellCommentThread.objects.filter(period=period).values(
|
||||
"id",
|
||||
"subject_employee_id",
|
||||
"column_id",
|
||||
)
|
||||
)
|
||||
if not threads:
|
||||
return []
|
||||
thread_by_id = {thread["id"]: thread for thread in threads}
|
||||
content_type = ContentType.objects.get_for_model(PayrollCellCommentThread)
|
||||
totals = (
|
||||
Message.objects.filter(
|
||||
chat__type="comments",
|
||||
chat__context_content_type=content_type,
|
||||
chat__context_object_id__in=thread_by_id,
|
||||
is_deleted=False,
|
||||
)
|
||||
.values("chat__context_object_id")
|
||||
.annotate(count=Count("id"), latest_at=Max("created_at"))
|
||||
)
|
||||
result = []
|
||||
for total in totals:
|
||||
thread = thread_by_id[int(total["chat__context_object_id"])]
|
||||
result.append(
|
||||
{
|
||||
"employee_id": thread["subject_employee_id"],
|
||||
"column_id": thread["column_id"],
|
||||
"count": total["count"],
|
||||
"latest_at": total["latest_at"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_cell_comment(message, request):
|
||||
return PayrollCellCommentSerializer(
|
||||
{
|
||||
"id": message.id,
|
||||
"author": message.author,
|
||||
"text": message.content,
|
||||
"created_at": message.created_at,
|
||||
},
|
||||
context={"request": request},
|
||||
).data
|
||||
|
||||
|
||||
def _carryover_summary(period):
|
||||
outgoing = PayrollCarryover.objects.filter(source_period=period).exclude(
|
||||
status=PayrollCarryoverStatus.CANCELLED
|
||||
)
|
||||
incoming = PayrollCarryover.objects.filter(target_period=period).exclude(
|
||||
status=PayrollCarryoverStatus.CANCELLED
|
||||
)
|
||||
return {
|
||||
"outgoing": outgoing.count(),
|
||||
"incoming": incoming.count(),
|
||||
"pending": outgoing.filter(status=PayrollCarryoverStatus.PENDING).count(),
|
||||
}
|
||||
|
||||
|
||||
def _carryover_employee_summaries(period):
|
||||
summaries = defaultdict(
|
||||
lambda: {
|
||||
"count": 0,
|
||||
"company_owes": Decimal("0.00"),
|
||||
"employee_owes": Decimal("0.00"),
|
||||
"pending": 0,
|
||||
}
|
||||
)
|
||||
records = (
|
||||
PayrollCarryover.objects.filter(
|
||||
Q(source_period=period) | Q(target_period=period)
|
||||
)
|
||||
.exclude(status=PayrollCarryoverStatus.CANCELLED)
|
||||
.values("employee_id", "direction", "amount", "status")
|
||||
)
|
||||
for record in records:
|
||||
summary = summaries[record["employee_id"]]
|
||||
summary["count"] += 1
|
||||
summary[record["direction"]] += record["amount"]
|
||||
if record["status"] == PayrollCarryoverStatus.PENDING:
|
||||
summary["pending"] += 1
|
||||
return [
|
||||
{
|
||||
"employee_id": employee_id,
|
||||
"count": summary["count"],
|
||||
"company_owes": str(summary["company_owes"]),
|
||||
"employee_owes": str(summary["employee_owes"]),
|
||||
"pending": summary["pending"],
|
||||
}
|
||||
for employee_id, summary in summaries.items()
|
||||
]
|
||||
|
||||
|
||||
def _lock_period_accepting_draft_edits(period_id):
|
||||
period = get_object_or_404(
|
||||
PayrollPeriod.objects.select_for_update(of=("self",)).select_related(
|
||||
@ -346,16 +505,33 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
period=selected_period,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
).count()
|
||||
draft_input_count = PayrollInputLine.objects.filter(
|
||||
draft_inputs = PayrollInputLine.objects.filter(
|
||||
period=selected_period,
|
||||
status=ApprovalStatus.DRAFT,
|
||||
).count()
|
||||
pending_rate_count = EmployeePayRate.objects.filter(
|
||||
)
|
||||
draft_input_count = draft_inputs.count()
|
||||
draft_input_employee_ids = list(
|
||||
draft_inputs.order_by()
|
||||
.values_list("employee_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
pending_rates = EmployeePayRate.objects.filter(
|
||||
employee_id__in=roster_employee_ids,
|
||||
rate_code=base_rate_code(),
|
||||
status=ApprovalStatus.DRAFT,
|
||||
effective_from__lte=selected_period.date_to,
|
||||
).count()
|
||||
)
|
||||
pending_rate_count = pending_rates.count()
|
||||
pending_rate_employee_ids = list(
|
||||
pending_rates.order_by()
|
||||
.values_list("employee_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
pending_rate_at_start_employee_id_set = set(
|
||||
pending_rates.filter(
|
||||
effective_from__lte=selected_period.date_from,
|
||||
).values_list("employee_id", flat=True)
|
||||
)
|
||||
blockers = []
|
||||
active_rules = build_rules()
|
||||
if not rules_cover_period(
|
||||
@ -374,40 +550,74 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
),
|
||||
}
|
||||
)
|
||||
if not approved_employee_ids:
|
||||
if not roster_employee_ids:
|
||||
blockers.append(
|
||||
{
|
||||
"code": "NO_APPROVED_WORK_RECORDS",
|
||||
"message": "Нет утверждённого состава сотрудников за период.",
|
||||
"message": (
|
||||
"В разделе «Выработка» нет ни одной записи за "
|
||||
f"{selected_period.date_from:%d.%m.%Y}–"
|
||||
f"{selected_period.date_to:%d.%m.%Y}; состав "
|
||||
"периода пуст."
|
||||
),
|
||||
}
|
||||
)
|
||||
blockers.extend(
|
||||
{
|
||||
"code": "MISSING_APPROVED_RATE",
|
||||
"message": "Нет утверждённой ставки на начало периода.",
|
||||
"employee_id": employee_id,
|
||||
}
|
||||
for employee_id in missing_rate_employee_ids[:20]
|
||||
)
|
||||
if draft_work.exists():
|
||||
missing_rate_without_draft = [
|
||||
employee_id
|
||||
for employee_id in missing_rate_employee_ids
|
||||
if employee_id not in pending_rate_at_start_employee_id_set
|
||||
]
|
||||
if missing_rate_without_draft:
|
||||
blockers.append(
|
||||
{
|
||||
"code": "MISSING_APPROVED_RATE",
|
||||
"message": (
|
||||
"Сотрудников без утверждённой ставки на "
|
||||
f"{selected_period.date_from:%d.%m.%Y}: "
|
||||
f"{len(missing_rate_without_draft)}."
|
||||
),
|
||||
"count": len(missing_rate_without_draft),
|
||||
"employee_ids": missing_rate_without_draft[:20],
|
||||
"employee_count": len(missing_rate_without_draft),
|
||||
}
|
||||
)
|
||||
if draft_work_employee_ids:
|
||||
blockers.append(
|
||||
{
|
||||
"code": "DRAFT_WORK_RECORDS",
|
||||
"message": "Остались неутверждённые показатели выработки.",
|
||||
"message": (
|
||||
"Записей выработки в статусе «Черновик»: "
|
||||
f"{len(draft_work_employee_ids)}."
|
||||
),
|
||||
"count": len(draft_work_employee_ids),
|
||||
"employee_ids": draft_work_employee_ids[:20],
|
||||
"employee_count": len(draft_work_employee_ids),
|
||||
}
|
||||
)
|
||||
if draft_input_count:
|
||||
blockers.append(
|
||||
{
|
||||
"code": "DRAFT_INPUT_LINES",
|
||||
"message": "Остались неутверждённые начисления или удержания.",
|
||||
"message": (
|
||||
"Начислений или удержаний в статусе «Черновик»: "
|
||||
f"{draft_input_count}."
|
||||
),
|
||||
"count": draft_input_count,
|
||||
"employee_ids": draft_input_employee_ids[:20],
|
||||
"employee_count": len(draft_input_employee_ids),
|
||||
}
|
||||
)
|
||||
if pending_rate_count:
|
||||
blockers.append(
|
||||
{
|
||||
"code": "DRAFT_PAY_RATES",
|
||||
"message": "Остались неутверждённые ставки сотрудников.",
|
||||
"message": (
|
||||
"Ставок сотрудников в статусе «Черновик»: "
|
||||
f"{pending_rate_count}."
|
||||
),
|
||||
"count": pending_rate_count,
|
||||
"employee_ids": pending_rate_employee_ids[:20],
|
||||
"employee_count": len(pending_rate_employee_ids),
|
||||
}
|
||||
)
|
||||
if selected_period.status == PayrollPeriodStatus.CLOSED:
|
||||
@ -486,6 +696,11 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
).data,
|
||||
"summary": summary,
|
||||
"pending_approvals": pending_approvals,
|
||||
"carryovers": (
|
||||
_carryover_summary(selected_period)
|
||||
if selected_period is not None
|
||||
else {"outgoing": 0, "incoming": 0, "pending": 0}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@ -543,6 +758,7 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView):
|
||||
instance=period,
|
||||
period=period,
|
||||
)
|
||||
materialize_pending_carryovers(period)
|
||||
return Response(
|
||||
PayrollPeriodSerializer(period).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
@ -611,7 +827,156 @@ class PayrollPeriodTableView(PayrollAdminAPIView):
|
||||
PayrollPeriod.objects.select_related("current_run"),
|
||||
pk=pk,
|
||||
)
|
||||
return Response(build_payroll_period_table(period))
|
||||
payload = build_payroll_period_table(period)
|
||||
payload["cell_comments"] = _cell_comment_summaries(period)
|
||||
payload["carryovers"] = _carryover_employee_summaries(period)
|
||||
return Response(payload)
|
||||
|
||||
|
||||
class PayrollPeriodCarryoverListCreateView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
|
||||
|
||||
def get(self, request, pk):
|
||||
period = get_object_or_404(PayrollPeriod, pk=pk)
|
||||
queryset = PayrollCarryover.objects.select_related(
|
||||
"source_period",
|
||||
"target_period",
|
||||
"employee",
|
||||
"employee__position",
|
||||
"created_by",
|
||||
"generated_input_line",
|
||||
)
|
||||
outgoing = queryset.filter(source_period=period)
|
||||
incoming = queryset.filter(target_period=period)
|
||||
return Response(
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"summary": _carryover_summary(period),
|
||||
"outgoing": PayrollCarryoverSerializer(outgoing, many=True).data,
|
||||
"incoming": PayrollCarryoverSerializer(incoming, many=True).data,
|
||||
}
|
||||
)
|
||||
|
||||
def post(self, request, pk):
|
||||
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
|
||||
period = get_object_or_404(PayrollPeriod, pk=pk)
|
||||
serializer = PayrollCarryoverWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
carryover = create_carryover(
|
||||
source_period=period,
|
||||
actor=request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
except DjangoValidationError as exc:
|
||||
raise ValidationError(_validation_detail(exc)) from exc
|
||||
carryover = PayrollCarryover.objects.select_related(
|
||||
"source_period",
|
||||
"target_period",
|
||||
"employee",
|
||||
"created_by",
|
||||
"generated_input_line",
|
||||
).get(pk=carryover.pk)
|
||||
return Response(
|
||||
PayrollCarryoverSerializer(carryover).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class PayrollCarryoverDetailView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
|
||||
|
||||
def delete(self, request, pk):
|
||||
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
|
||||
if not PayrollCarryover.objects.filter(pk=pk).exists():
|
||||
raise Http404
|
||||
carryover = cancel_carryover(pk, actor=request.user)
|
||||
carryover = PayrollCarryover.objects.select_related(
|
||||
"source_period",
|
||||
"target_period",
|
||||
"employee",
|
||||
"created_by",
|
||||
"generated_input_line",
|
||||
).get(pk=carryover.pk)
|
||||
return Response(PayrollCarryoverSerializer(carryover).data)
|
||||
|
||||
|
||||
class PayrollCellCommentsView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
|
||||
|
||||
def get_context(self, pk, employee_id, column_id):
|
||||
period = get_object_or_404(PayrollPeriod, pk=pk)
|
||||
employee = get_object_or_404(Employee, pk=employee_id)
|
||||
return period, employee, _validate_payroll_table_column(column_id)
|
||||
|
||||
def get(self, request, pk, employee_id, column_id):
|
||||
period, employee, column_id = self.get_context(
|
||||
pk,
|
||||
employee_id,
|
||||
column_id,
|
||||
)
|
||||
thread = PayrollCellCommentThread.objects.filter(
|
||||
period=period,
|
||||
subject_employee=employee,
|
||||
column_id=column_id,
|
||||
).first()
|
||||
if thread is None:
|
||||
return Response([])
|
||||
messages = comments_helpers.get_comments(thread)
|
||||
return Response(
|
||||
[_serialize_cell_comment(message, request) for message in messages]
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, pk, employee_id, column_id):
|
||||
period, employee, column_id = self.get_context(
|
||||
pk,
|
||||
employee_id,
|
||||
column_id,
|
||||
)
|
||||
serializer = PayrollCellCommentWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
thread, _ = PayrollCellCommentThread.objects.get_or_create(
|
||||
period=period,
|
||||
subject_employee=employee,
|
||||
column_id=column_id,
|
||||
)
|
||||
message = comments_helpers.create_comment(
|
||||
obj=thread,
|
||||
author=request.user,
|
||||
content=serializer.validated_data["text"],
|
||||
)
|
||||
return Response(
|
||||
_serialize_cell_comment(message, request),
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class PayrollCellCommentDetailView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
|
||||
|
||||
def delete(self, request, pk, employee_id, column_id, comment_id):
|
||||
period = get_object_or_404(PayrollPeriod, pk=pk)
|
||||
employee = get_object_or_404(Employee, pk=employee_id)
|
||||
column_id = _validate_payroll_table_column(column_id)
|
||||
thread = get_object_or_404(
|
||||
PayrollCellCommentThread,
|
||||
period=period,
|
||||
subject_employee=employee,
|
||||
column_id=column_id,
|
||||
)
|
||||
chat = comments_helpers.get_comments_chat_if_exists(thread)
|
||||
if chat is None:
|
||||
raise Http404
|
||||
message = get_object_or_404(Message, pk=comment_id, chat=chat, is_deleted=False)
|
||||
if message.author_id != request.user.id:
|
||||
raise PermissionDenied("Удалить комментарий может только его автор.")
|
||||
comments_helpers.delete_comment(
|
||||
message=message,
|
||||
deleted_by=request.user,
|
||||
soft_delete=True,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class PayrollPeriodPointBreakdownView(PayrollAdminAPIView):
|
||||
@ -1337,6 +1702,34 @@ class PayrollPeriodCalculateView(PayrollAdminAPIView):
|
||||
return Response(self.run_payload(request, run), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class PayrollPeriodCompleteWorkflowView(PayrollAdminAPIView):
|
||||
"""Complete calculation, review, approval, publication and closing at once."""
|
||||
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],)
|
||||
|
||||
def post(self, request, pk):
|
||||
if not has_simple_admin_access(request.user):
|
||||
_permission_error("finance.full_payroll_admin")
|
||||
command = CalculatePayrollCommandSerializer(data=request.data)
|
||||
command.is_valid(raise_exception=True)
|
||||
if not PayrollPeriod.objects.filter(pk=pk).exists():
|
||||
raise Http404
|
||||
result = complete_period_workflow(
|
||||
pk,
|
||||
actor=request.user,
|
||||
expected_lock_version=command.validated_data["expected_lock_version"],
|
||||
idempotency_key=command.validated_data.get("idempotency_key"),
|
||||
recalculation_reason=command.validated_data["recalculation_reason"],
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"period": PayrollPeriodSerializer(result["period"]).data,
|
||||
"run": self.run_payload(request, result["run"]),
|
||||
"completed_steps": result["completed_steps"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PayrollRunDetailView(PayrollAdminAPIView):
|
||||
allowed_permissions = RUN_VIEW_PERMISSIONS
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ from finance.models import (
|
||||
|
||||
|
||||
class PayrollStatementLineSerializer(serializers.ModelSerializer):
|
||||
is_automatic_carryover = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PayrollStatementLine
|
||||
fields = [
|
||||
@ -22,9 +24,15 @@ class PayrollStatementLineSerializer(serializers.ModelSerializer):
|
||||
"source_period_from",
|
||||
"source_period_to",
|
||||
"is_retro",
|
||||
"is_automatic_carryover",
|
||||
"calculated",
|
||||
]
|
||||
|
||||
def get_is_automatic_carryover(self, obj):
|
||||
return obj.source_ref.startswith("api:payroll-carryover:") or obj.source_ref.startswith(
|
||||
"payroll-carryover:"
|
||||
)
|
||||
|
||||
|
||||
class PayrollStatementAcknowledgementSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@ -14,7 +14,10 @@ from .admin_views import (
|
||||
PayrollBulkPayRateView,
|
||||
PayrollBulkPointRateView,
|
||||
PayrollBulkTargetPointsView,
|
||||
PayrollCarryoverDetailView,
|
||||
PayrollComponentCellClearView,
|
||||
PayrollCellCommentDetailView,
|
||||
PayrollCellCommentsView,
|
||||
PayrollAdminComponentListView,
|
||||
PayrollAdminEmployeeListView,
|
||||
PayrollAdminPeriodDetailView,
|
||||
@ -25,6 +28,8 @@ from .admin_views import (
|
||||
PayrollInputLineDetailView,
|
||||
PayrollInputLineListCreateView,
|
||||
PayrollPeriodCalculateView,
|
||||
PayrollPeriodCarryoverListCreateView,
|
||||
PayrollPeriodCompleteWorkflowView,
|
||||
PayrollPeriodApproveDraftsView,
|
||||
PayrollPeriodCloseView,
|
||||
PayrollPeriodPointBreakdownView,
|
||||
@ -75,6 +80,11 @@ urlpatterns = [
|
||||
PayrollPeriodCalculateView.as_view(),
|
||||
name="admin-period-calculate",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/complete-workflow/",
|
||||
PayrollPeriodCompleteWorkflowView.as_view(),
|
||||
name="admin-period-complete-workflow",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/approve-drafts/",
|
||||
PayrollPeriodApproveDraftsView.as_view(),
|
||||
@ -85,6 +95,26 @@ urlpatterns = [
|
||||
PayrollPeriodTableView.as_view(),
|
||||
name="admin-period-table",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/carryovers/",
|
||||
PayrollPeriodCarryoverListCreateView.as_view(),
|
||||
name="admin-period-carryovers",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/carryovers/<int:pk>/",
|
||||
PayrollCarryoverDetailView.as_view(),
|
||||
name="admin-carryover-detail",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/cells/<str:column_id>/comments/",
|
||||
PayrollCellCommentsView.as_view(),
|
||||
name="admin-cell-comments",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/cells/<str:column_id>/comments/<int:comment_id>/",
|
||||
PayrollCellCommentDetailView.as_view(),
|
||||
name="admin-cell-comment-detail",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/point-breakdown/",
|
||||
PayrollPeriodPointBreakdownView.as_view(),
|
||||
|
||||
@ -200,7 +200,7 @@ class CanManageProcurementRequest(permissions.BasePermission):
|
||||
dept_id = obj.department_id
|
||||
|
||||
# Submit дополнительно валидируется resolver-ом: только сотрудник
|
||||
# отдела-исполнителя, не автор заявки.
|
||||
# отдела-исполнителя. Автор допускается для внутренней заявки отдела.
|
||||
if action == "submit":
|
||||
return ProcurementApprovalResolver.user_can_submit_for_approval(
|
||||
user,
|
||||
|
||||
@ -703,6 +703,10 @@ CELERYD_PIDFILE = os.path.join(tempfile.gettempdir(), "celery-worker.pid")
|
||||
|
||||
# Расписание периодических задач
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"open-monthly-payroll-period": {
|
||||
"task": "finance.tasks.open_monthly_payroll_period",
|
||||
"schedule": crontab(day_of_month="1", hour=0, minute=5),
|
||||
},
|
||||
"cleanup-orphaned-attachments": {
|
||||
"task": "communications.tasks.cleanup_orphaned_attachments",
|
||||
"schedule": 3600.0, # Каждый час
|
||||
|
||||
@ -40,3 +40,14 @@ class PayrollRunStatus(models.TextChoices):
|
||||
PUBLISHED = "published", "Опубликован"
|
||||
RETURNED = "returned", "Возвращён на исправление"
|
||||
SUPERSEDED = "superseded", "Заменён новой ревизией"
|
||||
|
||||
|
||||
class PayrollCarryoverDirection(models.TextChoices):
|
||||
COMPANY_OWES = "company_owes", "Доплата сотруднику"
|
||||
EMPLOYEE_OWES = "employee_owes", "Долг сотрудника"
|
||||
|
||||
|
||||
class PayrollCarryoverStatus(models.TextChoices):
|
||||
PENDING = "pending", "Ожидает следующего периода"
|
||||
TRANSFERRED = "transferred", "Перенесено"
|
||||
CANCELLED = "cancelled", "Отменено"
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("finance", "0014_pay_rate_automatic_excess_price"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="payrollperiod",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Не заполнено для периодов, созданных автоматически.",
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_payroll_periods",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Создал",
|
||||
),
|
||||
),
|
||||
]
|
||||
33
backend/finance/migrations/0016_payrollcellcommentthread.py
Normal file
33
backend/finance/migrations/0016_payrollcellcommentthread.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-04 10:46
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0015_alter_payrollperiod_created_by'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PayrollCellCommentThread',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('column_id', models.CharField(max_length=80, validators=[django.core.validators.RegexValidator(message='Некорректный идентификатор колонки расчётной таблицы.', regex='^[a-z][a-z0-9_]*(?::[A-Z][A-Z0-9_.-]{0,63})?$')], verbose_name='Колонка')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
|
||||
('period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cell_comment_threads', to='finance.payrollperiod', verbose_name='Расчётный период')),
|
||||
('subject_employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_cell_comment_threads', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник в строке')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Обсуждение ячейки расчёта',
|
||||
'verbose_name_plural': 'Обсуждения ячеек расчёта',
|
||||
'ordering': ['period_id', 'subject_employee_id', 'column_id'],
|
||||
'constraints': [models.UniqueConstraint(fields=('period', 'subject_employee', 'column_id'), name='pay_cell_comment_thread_unique')],
|
||||
},
|
||||
),
|
||||
]
|
||||
45
backend/finance/migrations/0017_payrollcarryover.py
Normal file
45
backend/finance/migrations/0017_payrollcarryover.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-04 11:09
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0016_payrollcellcommentthread'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PayrollCarryover',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('direction', models.CharField(choices=[('company_owes', 'Доплата сотруднику'), ('employee_owes', 'Долг сотрудника')], max_length=20, verbose_name='Вид остатка')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Сумма')),
|
||||
('reason', models.TextField(verbose_name='Основание')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает следующего периода'), ('transferred', 'Перенесено'), ('cancelled', 'Отменено')], default='pending', max_length=16, verbose_name='Статус')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
|
||||
('transferred_at', models.DateTimeField(blank=True, null=True, verbose_name='Перенесён')),
|
||||
('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Отменён')),
|
||||
('cancelled_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cancelled_payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Отменил')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
|
||||
('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
|
||||
('generated_input_line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_from_carryover', to='finance.payrollinputline', verbose_name='Созданная операция')),
|
||||
('source_period', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_carryovers', to='finance.payrollperiod', verbose_name='Исходный период')),
|
||||
('target_period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='incoming_carryovers', to='finance.payrollperiod', verbose_name='Период переноса')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Долг или доплата',
|
||||
'verbose_name_plural': 'Долги и доплаты',
|
||||
'ordering': ['-created_at', '-id'],
|
||||
'indexes': [models.Index(fields=['status', 'source_period'], name='pay_carry_status_source_idx'), models.Index(fields=['target_period', 'employee'], name='pay_carry_target_employee_idx')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('amount__gt', 0)), name='pay_carry_amount_positive')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -18,6 +18,8 @@ from django.db.models import F, Q
|
||||
from .enums import (
|
||||
ApprovalStatus,
|
||||
InputSource,
|
||||
PayrollCarryoverDirection,
|
||||
PayrollCarryoverStatus,
|
||||
PayrollComponentKind,
|
||||
PayrollPeriodStatus,
|
||||
PayrollRunStatus,
|
||||
@ -31,6 +33,10 @@ component_code_validator = RegexValidator(
|
||||
regex=r"^[A-Z][A-Z0-9_.-]{0,63}$",
|
||||
message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-",
|
||||
)
|
||||
payroll_cell_column_validator = RegexValidator(
|
||||
regex=r"^[a-z][a-z0-9_]*(?::[A-Z][A-Z0-9_.-]{0,63})?$",
|
||||
message="Некорректный идентификатор колонки расчётной таблицы.",
|
||||
)
|
||||
|
||||
DEFAULT_DAILY_TARGET_POINTS = Decimal("5")
|
||||
|
||||
@ -67,8 +73,11 @@ class PayrollPeriod(models.Model):
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="created_payroll_periods",
|
||||
verbose_name="Создал",
|
||||
help_text="Не заполнено для периодов, созданных автоматически.",
|
||||
)
|
||||
created_at = models.DateTimeField("Создан", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлён", auto_now=True)
|
||||
@ -170,6 +179,156 @@ class PayrollPeriod(models.Model):
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PayrollCellCommentThread(models.Model):
|
||||
"""Stable address of one employee cell used by generic comments."""
|
||||
|
||||
period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cell_comment_threads",
|
||||
verbose_name="Расчётный период",
|
||||
)
|
||||
subject_employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="payroll_cell_comment_threads",
|
||||
verbose_name="Сотрудник в строке",
|
||||
)
|
||||
column_id = models.CharField(
|
||||
"Колонка",
|
||||
max_length=80,
|
||||
validators=[payroll_cell_column_validator],
|
||||
)
|
||||
created_at = models.DateTimeField("Создан", auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Обсуждение ячейки расчёта"
|
||||
verbose_name_plural = "Обсуждения ячеек расчёта"
|
||||
ordering = ["period_id", "subject_employee_id", "column_id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["period", "subject_employee", "column_id"],
|
||||
name="pay_cell_comment_thread_unique",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.period.code}: сотрудник #{self.subject_employee_id}, "
|
||||
f"колонка {self.column_id}"
|
||||
)
|
||||
|
||||
|
||||
class PayrollCarryover(models.Model):
|
||||
"""Explicit debt or additional payment transferred to the next period."""
|
||||
|
||||
source_period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="outgoing_carryovers",
|
||||
verbose_name="Исходный период",
|
||||
)
|
||||
target_period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="incoming_carryovers",
|
||||
verbose_name="Период переноса",
|
||||
)
|
||||
employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="payroll_carryovers",
|
||||
verbose_name="Сотрудник",
|
||||
)
|
||||
direction = models.CharField(
|
||||
"Вид остатка",
|
||||
max_length=20,
|
||||
choices=PayrollCarryoverDirection.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
"Сумма",
|
||||
max_digits=19,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal("0.01"))],
|
||||
)
|
||||
reason = models.TextField("Основание")
|
||||
status = models.CharField(
|
||||
"Статус",
|
||||
max_length=16,
|
||||
choices=PayrollCarryoverStatus.choices,
|
||||
default=PayrollCarryoverStatus.PENDING,
|
||||
)
|
||||
generated_input_line = models.OneToOneField(
|
||||
"PayrollInputLine",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="generated_from_carryover",
|
||||
verbose_name="Созданная операция",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_payroll_carryovers",
|
||||
verbose_name="Создал",
|
||||
)
|
||||
created_at = models.DateTimeField("Создан", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлён", auto_now=True)
|
||||
transferred_at = models.DateTimeField("Перенесён", null=True, blank=True)
|
||||
cancelled_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="cancelled_payroll_carryovers",
|
||||
verbose_name="Отменил",
|
||||
)
|
||||
cancelled_at = models.DateTimeField("Отменён", null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Долг или доплата"
|
||||
verbose_name_plural = "Долги и доплаты"
|
||||
ordering = ["-created_at", "-id"]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["status", "source_period"],
|
||||
name="pay_carry_status_source_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["target_period", "employee"],
|
||||
name="pay_carry_target_employee_idx",
|
||||
),
|
||||
]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(amount__gt=0),
|
||||
name="pay_carry_amount_positive",
|
||||
)
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if not self.reason.strip():
|
||||
raise ValidationError({"reason": "Укажите основание переноса."})
|
||||
if self.target_period_id:
|
||||
if self.target_period.date_from <= self.source_period.date_to:
|
||||
raise ValidationError(
|
||||
{"target_period": "Перенос возможен только в следующий период."}
|
||||
)
|
||||
if self.target_period.currency != self.source_period.currency:
|
||||
raise ValidationError(
|
||||
{"target_period": "Валюты исходного и целевого периодов различаются."}
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.get_direction_display()}: {self.employee_id}, "
|
||||
f"{self.amount} {self.source_period.currency}"
|
||||
)
|
||||
|
||||
|
||||
class DraftVersionedModel(models.Model):
|
||||
"""Optimistic version for maker-checker records edited as drafts."""
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ from .services import (
|
||||
clear_component_input_lines,
|
||||
bulk_set_target_points,
|
||||
close_period,
|
||||
complete_period_workflow,
|
||||
publish_run,
|
||||
return_run_for_correction,
|
||||
submit_run_for_review,
|
||||
@ -25,6 +26,7 @@ __all__ = [
|
||||
"clear_component_input_lines",
|
||||
"bulk_set_target_points",
|
||||
"close_period",
|
||||
"complete_period_workflow",
|
||||
"publish_run",
|
||||
"return_run_for_correction",
|
||||
"submit_run_for_review",
|
||||
|
||||
253
backend/finance/payroll/carryovers.py
Normal file
253
backend/finance/payroll/carryovers.py
Normal file
@ -0,0 +1,253 @@
|
||||
"""Explicit payroll debts and additional payments carried to the next period."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from finance.enums import (
|
||||
ApprovalStatus,
|
||||
InputSource,
|
||||
PayrollCarryoverDirection,
|
||||
PayrollCarryoverStatus,
|
||||
PayrollPeriodStatus,
|
||||
)
|
||||
from finance.models import (
|
||||
PayrollAuditEvent,
|
||||
PayrollCarryover,
|
||||
PayrollComponent,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
)
|
||||
from finance.payroll.exceptions import PayrollOperationError
|
||||
|
||||
CARRYOVER_COMPONENT_CODES = {
|
||||
PayrollCarryoverDirection.COMPANY_OWES: "CORRECTION_CREDIT",
|
||||
PayrollCarryoverDirection.EMPLOYEE_OWES: "DEDUCTION",
|
||||
}
|
||||
CARRYOVER_SOURCE_PREFIX = "payroll-carryover:"
|
||||
|
||||
|
||||
def _operation_error(code: str, message: str, **details):
|
||||
raise PayrollOperationError(code, message, details=details)
|
||||
|
||||
|
||||
def _audit(*, actor, action, instance, period, metadata=None):
|
||||
PayrollAuditEvent.objects.create(
|
||||
actor=actor,
|
||||
action=action,
|
||||
object_type=instance._meta.label_lower,
|
||||
object_id=str(instance.pk),
|
||||
period=period,
|
||||
metadata={"channel": "carryover", **(metadata or {})},
|
||||
)
|
||||
|
||||
|
||||
def _next_period(source_period: PayrollPeriod):
|
||||
return (
|
||||
PayrollPeriod.objects.filter(date_from__gt=source_period.date_to)
|
||||
.order_by("date_from", "pk")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _target_accepts_transfer(target_period: PayrollPeriod) -> bool:
|
||||
return (
|
||||
target_period.status == PayrollPeriodStatus.OPEN
|
||||
and target_period.current_run_id is None
|
||||
)
|
||||
|
||||
|
||||
def _materialize_one(carryover: PayrollCarryover, target_period: PayrollPeriod):
|
||||
if carryover.status != PayrollCarryoverStatus.PENDING:
|
||||
return carryover
|
||||
if not _target_accepts_transfer(target_period):
|
||||
return carryover
|
||||
if target_period.currency != carryover.source_period.currency:
|
||||
_operation_error(
|
||||
"CARRYOVER_CURRENCY_MISMATCH",
|
||||
"Валюта следующего периода не совпадает с исходным периодом.",
|
||||
carryover_id=carryover.pk,
|
||||
target_period_id=target_period.pk,
|
||||
)
|
||||
|
||||
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,
|
||||
source_ref=source_ref,
|
||||
).first()
|
||||
if line is None:
|
||||
line = PayrollInputLine(
|
||||
period=target_period,
|
||||
employee=carryover.employee,
|
||||
component=component,
|
||||
amount=carryover.amount,
|
||||
relates_to_period=carryover.source_period,
|
||||
reason=(
|
||||
f"Автоперенос из периода {carryover.source_period.name or carryover.source_period.code}. "
|
||||
f"{carryover.reason.strip()}"
|
||||
),
|
||||
status=ApprovalStatus.DRAFT,
|
||||
source=InputSource.API,
|
||||
source_ref=source_ref,
|
||||
created_by=carryover.created_by,
|
||||
)
|
||||
line.full_clean()
|
||||
try:
|
||||
line.save(force_insert=True)
|
||||
except IntegrityError:
|
||||
line = PayrollInputLine.objects.get(
|
||||
source=InputSource.API,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
|
||||
carryover.target_period = target_period
|
||||
carryover.generated_input_line = line
|
||||
carryover.status = PayrollCarryoverStatus.TRANSFERRED
|
||||
carryover.transferred_at = timezone.now()
|
||||
carryover.full_clean()
|
||||
carryover.save(
|
||||
update_fields=[
|
||||
"target_period",
|
||||
"generated_input_line",
|
||||
"status",
|
||||
"transferred_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
_audit(
|
||||
actor=None,
|
||||
action="payroll.carryover_transferred",
|
||||
instance=carryover,
|
||||
period=target_period,
|
||||
metadata={
|
||||
"source_period_id": carryover.source_period_id,
|
||||
"input_line_id": line.pk,
|
||||
"direction": carryover.direction,
|
||||
},
|
||||
)
|
||||
return carryover
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def materialize_pending_carryovers(target_period: PayrollPeriod):
|
||||
"""Create draft input lines whose immediate next period is target_period."""
|
||||
|
||||
target_period = PayrollPeriod.objects.select_for_update().get(pk=target_period.pk)
|
||||
if not _target_accepts_transfer(target_period):
|
||||
return []
|
||||
pending = list(
|
||||
PayrollCarryover.objects.select_for_update()
|
||||
.select_related("source_period", "employee", "created_by")
|
||||
.filter(
|
||||
status=PayrollCarryoverStatus.PENDING,
|
||||
source_period__date_to__lt=target_period.date_from,
|
||||
)
|
||||
.order_by("source_period__date_to", "pk")
|
||||
)
|
||||
transferred = []
|
||||
for carryover in pending:
|
||||
next_period = _next_period(carryover.source_period)
|
||||
if next_period is None or next_period.pk != target_period.pk:
|
||||
continue
|
||||
transferred.append(_materialize_one(carryover, target_period))
|
||||
return transferred
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_carryover(
|
||||
*,
|
||||
source_period: PayrollPeriod,
|
||||
employee,
|
||||
direction: str,
|
||||
amount: Decimal,
|
||||
reason: str,
|
||||
actor,
|
||||
):
|
||||
source_period = PayrollPeriod.objects.select_for_update().get(pk=source_period.pk)
|
||||
if source_period.status != PayrollPeriodStatus.CLOSED:
|
||||
_operation_error(
|
||||
"CARRYOVER_SOURCE_NOT_CLOSED",
|
||||
"Долг или доплату можно зафиксировать только для закрытого периода.",
|
||||
source_period_id=source_period.pk,
|
||||
)
|
||||
carryover = PayrollCarryover(
|
||||
source_period=source_period,
|
||||
employee=employee,
|
||||
direction=direction,
|
||||
amount=amount,
|
||||
reason=reason.strip(),
|
||||
created_by=actor,
|
||||
)
|
||||
carryover.full_clean()
|
||||
carryover.save(force_insert=True)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action="payroll.carryover_created",
|
||||
instance=carryover,
|
||||
period=source_period,
|
||||
metadata={"direction": direction, "amount": str(amount)},
|
||||
)
|
||||
|
||||
target_period = _next_period(source_period)
|
||||
if target_period is not None:
|
||||
target_period = PayrollPeriod.objects.select_for_update().get(
|
||||
pk=target_period.pk
|
||||
)
|
||||
carryover = _materialize_one(carryover, target_period)
|
||||
return carryover
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_carryover(carryover_id: int, *, actor):
|
||||
carryover = (
|
||||
PayrollCarryover.objects.select_for_update()
|
||||
.select_related("generated_input_line", "source_period", "target_period")
|
||||
.get(pk=carryover_id)
|
||||
)
|
||||
if carryover.status == PayrollCarryoverStatus.CANCELLED:
|
||||
return carryover
|
||||
line = carryover.generated_input_line
|
||||
if line is not None:
|
||||
if line.status != ApprovalStatus.DRAFT:
|
||||
_operation_error(
|
||||
"CARRYOVER_ALREADY_APPROVED",
|
||||
"Утверждённый перенос нельзя отменить; создайте обратную корректировку.",
|
||||
carryover_id=carryover.pk,
|
||||
input_line_id=line.pk,
|
||||
)
|
||||
line.status = ApprovalStatus.VOIDED
|
||||
line.voided_by = actor
|
||||
line.voided_at = timezone.now()
|
||||
line.void_reason = "Отменена запись долга или доплаты"
|
||||
line.save(
|
||||
update_fields=["status", "voided_by", "voided_at", "void_reason"]
|
||||
)
|
||||
carryover.status = PayrollCarryoverStatus.CANCELLED
|
||||
carryover.cancelled_by = actor
|
||||
carryover.cancelled_at = timezone.now()
|
||||
carryover.save(
|
||||
update_fields=["status", "cancelled_by", "cancelled_at", "updated_at"]
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action="payroll.carryover_cancelled",
|
||||
instance=carryover,
|
||||
period=carryover.source_period,
|
||||
metadata={"input_line_id": line.pk if line else None},
|
||||
)
|
||||
return carryover
|
||||
@ -108,6 +108,9 @@ def _employee_payload(employee):
|
||||
return {
|
||||
"id": employee.pk,
|
||||
"display_name": employee.get_full_name().strip() or f"Сотрудник #{employee.pk}",
|
||||
"last_name": employee.last_name,
|
||||
"first_name": employee.first_name,
|
||||
"patronymic": employee.patronymic,
|
||||
"position": employee.position.name if employee.position_id else None,
|
||||
"department": _employee_department(employee),
|
||||
"is_active": employee.is_active,
|
||||
|
||||
@ -768,6 +768,82 @@ def close_period(period_id: int, *, actor) -> PayrollPeriod:
|
||||
return period
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def complete_period_workflow(
|
||||
period_id: int,
|
||||
*,
|
||||
actor,
|
||||
expected_lock_version: int,
|
||||
idempotency_key: uuid.UUID | None = None,
|
||||
recalculation_reason: str = "",
|
||||
) -> dict[str, object]:
|
||||
"""Run every remaining payroll workflow stage for a portal administrator."""
|
||||
|
||||
if not has_simple_admin_access(actor):
|
||||
raise PayrollPermissionDenied("finance.full_payroll_admin")
|
||||
|
||||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||||
run = (
|
||||
PayrollRun.objects.select_for_update().get(pk=period.current_run_id)
|
||||
if period.current_run_id is not None
|
||||
else None
|
||||
)
|
||||
if period.status == PayrollPeriodStatus.CLOSED:
|
||||
if run is None:
|
||||
_operation_error(
|
||||
"CLOSED_PERIOD_RUN_MISSING",
|
||||
"У закрытого периода отсутствует опубликованный расчёт.",
|
||||
period_id=period.pk,
|
||||
)
|
||||
return {"period": period, "run": run, "completed_steps": []}
|
||||
if period.lock_version != expected_lock_version:
|
||||
_operation_error(
|
||||
"STALE_PERIOD",
|
||||
"Период уже изменён; обновите данные перед завершением расчёта.",
|
||||
expected_lock_version=expected_lock_version,
|
||||
actual_lock_version=period.lock_version,
|
||||
)
|
||||
|
||||
completed_steps = []
|
||||
if run is None or run.status == PayrollRunStatus.RETURNED:
|
||||
run = calculate_period(
|
||||
period.pk,
|
||||
actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
recalculation_reason=recalculation_reason,
|
||||
)
|
||||
completed_steps.append("calculated")
|
||||
if run.status == PayrollRunStatus.CALCULATED:
|
||||
run = submit_run_for_review(run.pk, actor=actor)
|
||||
completed_steps.append("review_requested")
|
||||
if run.status == PayrollRunStatus.REVIEW:
|
||||
run = approve_run(run.pk, actor=actor)
|
||||
completed_steps.append("approved")
|
||||
if run.status == PayrollRunStatus.APPROVED:
|
||||
run = publish_run(run.pk, actor=actor)
|
||||
completed_steps.append("published")
|
||||
if run.status == PayrollRunStatus.PUBLISHED:
|
||||
period = close_period(period.pk, actor=actor)
|
||||
completed_steps.append("period_closed")
|
||||
|
||||
if period.status != PayrollPeriodStatus.CLOSED:
|
||||
_operation_error(
|
||||
"ADMIN_WORKFLOW_INCOMPLETE",
|
||||
"Не удалось завершить все этапы расчёта.",
|
||||
period_id=period.pk,
|
||||
run_status=run.status,
|
||||
)
|
||||
|
||||
_audit(
|
||||
actor=actor,
|
||||
action="payroll.admin_workflow_completed",
|
||||
instance=period,
|
||||
period=period,
|
||||
metadata={"steps": completed_steps, "run_id": run.pk},
|
||||
)
|
||||
return {"period": period, "run": run, "completed_steps": completed_steps}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_set_point_rate(
|
||||
period_id: int,
|
||||
|
||||
98
backend/finance/tasks.py
Normal file
98
backend/finance/tasks.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Periodic Finance tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
from datetime import date
|
||||
|
||||
from celery import shared_task
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import PayrollAuditEvent, PayrollPeriod
|
||||
from .payroll.carryovers import materialize_pending_carryovers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MONTH_NAMES_RU = (
|
||||
"",
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
)
|
||||
|
||||
|
||||
def ensure_monthly_payroll_period(reference_date: date | None = None):
|
||||
"""Create the open payroll period for a month unless it already exists."""
|
||||
|
||||
reference_date = reference_date or timezone.localdate()
|
||||
date_from = reference_date.replace(day=1)
|
||||
date_to = reference_date.replace(
|
||||
day=monthrange(reference_date.year, reference_date.month)[1]
|
||||
)
|
||||
|
||||
existing = PayrollPeriod.objects.filter(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
).first()
|
||||
if existing is not None:
|
||||
materialize_pending_carryovers(existing)
|
||||
return existing, False
|
||||
|
||||
period = PayrollPeriod(
|
||||
code=date_from.strftime("%Y-%m"),
|
||||
name=f"{MONTH_NAMES_RU[date_from.month]} {date_from.year}",
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
currency="RUB",
|
||||
created_by=None,
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
period.full_clean()
|
||||
period.save(force_insert=True)
|
||||
PayrollAuditEvent.objects.create(
|
||||
actor=None,
|
||||
action="payroll.period_created",
|
||||
object_type=period._meta.label_lower,
|
||||
object_id=str(period.pk),
|
||||
period=period,
|
||||
metadata={"channel": "automation", "schedule": "monthly"},
|
||||
)
|
||||
except IntegrityError:
|
||||
# Celery retries or two workers can race. The database constraint makes
|
||||
# creation safe; return the winner when it covers the same month.
|
||||
existing = PayrollPeriod.objects.filter(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
).first()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
raise
|
||||
except ValidationError:
|
||||
logger.exception("Could not open payroll period %s", period.code)
|
||||
raise
|
||||
|
||||
logger.info("Opened payroll period %s", period.code)
|
||||
materialize_pending_carryovers(period)
|
||||
return period, True
|
||||
|
||||
|
||||
@shared_task
|
||||
def open_monthly_payroll_period():
|
||||
"""Open the current calendar month's payroll period."""
|
||||
|
||||
period, created = ensure_monthly_payroll_period()
|
||||
return {"period_id": period.pk, "code": period.code, "created": created}
|
||||
@ -248,7 +248,13 @@ class ProcurementApprovalResolver:
|
||||
}:
|
||||
return False
|
||||
|
||||
if procurement_request.requestor_id == user.id:
|
||||
requestor_is_submitting = procurement_request.requestor_id == user.id
|
||||
same_customer_and_processing_department = (
|
||||
procurement_request.department_id
|
||||
and procurement_request.department_id
|
||||
== procurement_request.processing_department_id
|
||||
)
|
||||
if requestor_is_submitting and not same_customer_and_processing_department:
|
||||
return False
|
||||
|
||||
return cls.user_is_processing_department_member(
|
||||
|
||||
@ -2383,8 +2383,8 @@ class TestProcessingDepartmentWorkflow:
|
||||
assert detail_response.status_code == status.HTTP_200_OK
|
||||
assert detail_response.data["can_current_user_submit_for_approval"] is False
|
||||
|
||||
def test_customer_department_user_can_submit_when_processing_department_same(
|
||||
self, api_client, supply_department, supply_user,
|
||||
def test_requestor_can_submit_when_customer_and_processing_departments_match(
|
||||
self, api_client, supply_department,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
supply_head = Employee.objects.create_user(
|
||||
@ -2440,18 +2440,11 @@ class TestProcessingDepartmentWorkflow:
|
||||
|
||||
api_client.force_authenticate(user=requestor)
|
||||
requestor_detail = api_client.get(detail_url)
|
||||
requestor_submit = api_client.post(submit_url)
|
||||
assert requestor_detail.status_code == status.HTTP_200_OK
|
||||
assert requestor_detail.data["can_current_user_submit_for_approval"] is False
|
||||
assert requestor_submit.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
submitter_detail = api_client.get(detail_url)
|
||||
assert requestor_detail.data["can_current_user_submit_for_approval"] is True
|
||||
options_response = api_client.get(options_url)
|
||||
submit_response = api_client.post(submit_url)
|
||||
|
||||
assert submitter_detail.status_code == status.HTTP_200_OK
|
||||
assert submitter_detail.data["can_current_user_submit_for_approval"] is True
|
||||
assert options_response.status_code == status.HTTP_200_OK
|
||||
assert submit_response.status_code == status.HTTP_200_OK
|
||||
procurement_request.refresh_from_db()
|
||||
|
||||
@ -7,6 +7,7 @@ from django.contrib.auth.models import Permission
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from employees.constants import ACTION_DISMISSED, ACTION_HIRED, ACTION_REHIRED
|
||||
@ -14,6 +15,7 @@ from employees.models import EmployeeAction
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollAuditEvent,
|
||||
PayrollCellCommentThread,
|
||||
PayrollComponent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollInputLine,
|
||||
@ -59,6 +61,37 @@ def make_period(*, creator, code="2026-06"):
|
||||
)
|
||||
|
||||
|
||||
def make_approved_period_inputs(
|
||||
*,
|
||||
period,
|
||||
employee,
|
||||
creator,
|
||||
approver,
|
||||
expected_payable=None,
|
||||
):
|
||||
approved_at = timezone.now()
|
||||
EmployeePayRate.objects.create(
|
||||
employee=employee,
|
||||
amount="80000",
|
||||
effective_from="2026-01-01",
|
||||
status="approved",
|
||||
created_by=creator,
|
||||
approved_by=approver,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="110",
|
||||
actual_points="110",
|
||||
expected_payable=expected_payable,
|
||||
status="approved",
|
||||
created_by=creator,
|
||||
approved_by=approver,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
|
||||
|
||||
def test_work_record_target_is_optional_and_can_return_to_automatic(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
@ -192,6 +225,98 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all(
|
||||
assert inactive_employee.pk in {item["id"] for item in body["rate_employees"]}
|
||||
|
||||
|
||||
def test_workspace_readiness_identifies_affected_employees_and_draft_counts(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="readiness.manager@example.test")
|
||||
checker = user_factory(email="readiness.checker@example.test")
|
||||
draft_employee = user_factory(
|
||||
email="readiness.draft@example.test",
|
||||
first_name="Анна",
|
||||
last_name="Черновикова",
|
||||
)
|
||||
missing_rate_employee = user_factory(
|
||||
email="readiness.missing@example.test",
|
||||
first_name="Иван",
|
||||
last_name="Безставочный",
|
||||
)
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
approved_at = timezone.now()
|
||||
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=draft_employee,
|
||||
target_points="110",
|
||||
actual_points="100",
|
||||
created_by=manager,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=missing_rate_employee,
|
||||
target_points="110",
|
||||
actual_points="110",
|
||||
status="approved",
|
||||
created_by=manager,
|
||||
approved_by=checker,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
EmployeePayRate.objects.create(
|
||||
employee=draft_employee,
|
||||
amount="80000",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
PayrollInputLine.objects.create(
|
||||
period=period,
|
||||
employee=draft_employee,
|
||||
component=PayrollComponent.objects.get(code="BONUS"),
|
||||
amount="1000",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
response = auth_client_factory(manager).get(
|
||||
api_url("admin-workspace"),
|
||||
{"period_id": period.pk},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
blockers = {
|
||||
item["code"]: item
|
||||
for item in response.json()["readiness"]["calculation"]["blockers"]
|
||||
}
|
||||
assert "NO_APPROVED_WORK_RECORDS" not in blockers
|
||||
assert blockers["MISSING_APPROVED_RATE"] == {
|
||||
"code": "MISSING_APPROVED_RATE",
|
||||
"message": "Сотрудников без утверждённой ставки на 01.06.2026: 1.",
|
||||
"count": 1,
|
||||
"employee_ids": [missing_rate_employee.pk],
|
||||
"employee_count": 1,
|
||||
}
|
||||
assert blockers["DRAFT_WORK_RECORDS"] == {
|
||||
"code": "DRAFT_WORK_RECORDS",
|
||||
"message": "Записей выработки в статусе «Черновик»: 1.",
|
||||
"count": 1,
|
||||
"employee_ids": [draft_employee.pk],
|
||||
"employee_count": 1,
|
||||
}
|
||||
assert blockers["DRAFT_INPUT_LINES"] == {
|
||||
"code": "DRAFT_INPUT_LINES",
|
||||
"message": "Начислений или удержаний в статусе «Черновик»: 1.",
|
||||
"count": 1,
|
||||
"employee_ids": [draft_employee.pk],
|
||||
"employee_count": 1,
|
||||
}
|
||||
assert blockers["DRAFT_PAY_RATES"] == {
|
||||
"code": "DRAFT_PAY_RATES",
|
||||
"message": "Ставок сотрудников в статусе «Черновик»: 1.",
|
||||
"count": 1,
|
||||
"employee_ids": [draft_employee.pk],
|
||||
"employee_count": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_period_table_lists_all_employees_and_requires_view_all(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
@ -254,6 +379,8 @@ def test_period_table_lists_all_employees_and_requires_view_all(
|
||||
)
|
||||
row = next(item for item in body["rows"] if item["employee"]["id"] == employee.pk)
|
||||
assert row["employee"]["display_name"] == "Анна Итогова"
|
||||
assert row["employee"]["last_name"] == "Итогова"
|
||||
assert row["employee"]["first_name"] == "Анна"
|
||||
assert row["status"] == "draft"
|
||||
assert row["rate_amount"] == "80000.0000"
|
||||
assert row["in_norm_point_rate"] == "736.3636"
|
||||
@ -279,6 +406,132 @@ def test_period_table_lists_all_employees_and_requires_view_all(
|
||||
assert missing_row["payable"] == "0.00"
|
||||
|
||||
|
||||
def test_period_table_cell_comments_use_shared_discussion_flow(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
author = user_factory(
|
||||
email="cell.comment.author@example.test",
|
||||
first_name="Ирина",
|
||||
last_name="Авторова",
|
||||
)
|
||||
colleague = user_factory(email="cell.comment.colleague@example.test")
|
||||
employee = user_factory(
|
||||
email="cell.comment.employee@example.test",
|
||||
first_name="Анна",
|
||||
last_name="Расчётова",
|
||||
)
|
||||
input_manager = user_factory(email="cell.comment.input@example.test")
|
||||
grant(author, "view_all_payroll")
|
||||
grant(colleague, "view_all_payroll")
|
||||
grant(input_manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=input_manager)
|
||||
comments_url = api_url(
|
||||
"admin-cell-comments",
|
||||
pk=period.pk,
|
||||
employee_id=employee.pk,
|
||||
column_id="gross_total",
|
||||
)
|
||||
|
||||
denied = auth_client_factory(input_manager).post(
|
||||
comments_url,
|
||||
{"text": "Не должен сохраниться"},
|
||||
format="json",
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
|
||||
author_client = auth_client_factory(author)
|
||||
created = author_client.post(
|
||||
comments_url,
|
||||
{"text": "Проверить итоговое начисление"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert created.status_code == 201, created.content
|
||||
assert created.json()["text"] == "Проверить итоговое начисление"
|
||||
assert created.json()["author"]["id"] == author.pk
|
||||
assert PayrollCellCommentThread.objects.filter(
|
||||
period=period,
|
||||
subject_employee=employee,
|
||||
column_id="gross_total",
|
||||
).count() == 1
|
||||
|
||||
listed = auth_client_factory(colleague).get(comments_url)
|
||||
assert listed.status_code == 200
|
||||
assert [item["id"] for item in listed.json()] == [created.json()["id"]]
|
||||
|
||||
table = auth_client_factory(colleague).get(
|
||||
api_url("admin-period-table", pk=period.pk)
|
||||
)
|
||||
assert table.status_code == 200
|
||||
cell_summary = table.json()["cell_comments"]
|
||||
assert len(cell_summary) == 1
|
||||
assert cell_summary[0]["employee_id"] == employee.pk
|
||||
assert cell_summary[0]["column_id"] == "gross_total"
|
||||
assert cell_summary[0]["count"] == 1
|
||||
assert parse_datetime(cell_summary[0]["latest_at"]) == parse_datetime(
|
||||
created.json()["created_at"]
|
||||
)
|
||||
|
||||
detail_url = api_url(
|
||||
"admin-cell-comment-detail",
|
||||
pk=period.pk,
|
||||
employee_id=employee.pk,
|
||||
column_id="gross_total",
|
||||
comment_id=created.json()["id"],
|
||||
)
|
||||
forbidden_delete = auth_client_factory(colleague).delete(detail_url)
|
||||
assert forbidden_delete.status_code == 403
|
||||
|
||||
deleted = author_client.delete(detail_url)
|
||||
assert deleted.status_code == 204
|
||||
assert author_client.get(comments_url).json() == []
|
||||
assert auth_client_factory(colleague).get(
|
||||
api_url("admin-period-table", pk=period.pk)
|
||||
).json()["cell_comments"] == []
|
||||
|
||||
|
||||
def test_cell_comments_reject_columns_not_present_in_table(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
viewer = user_factory(email="cell.comment.invalid@example.test")
|
||||
employee = user_factory(email="cell.comment.invalid.employee@example.test")
|
||||
grant(viewer, "view_all_payroll")
|
||||
period = make_period(creator=viewer)
|
||||
|
||||
response = auth_client_factory(viewer).post(
|
||||
api_url(
|
||||
"admin-cell-comments",
|
||||
pk=period.pk,
|
||||
employee_id=employee.pk,
|
||||
column_id="unknown_column",
|
||||
),
|
||||
{"text": "Невидимый комментарий"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert PayrollCellCommentThread.objects.count() == 0
|
||||
|
||||
carryover_comment = auth_client_factory(viewer).post(
|
||||
api_url(
|
||||
"admin-cell-comments",
|
||||
pk=period.pk,
|
||||
employee_id=employee.pk,
|
||||
column_id="carryovers",
|
||||
),
|
||||
{"text": "Уточнить перенос остатка"},
|
||||
format="json",
|
||||
)
|
||||
assert carryover_comment.status_code == 201
|
||||
assert PayrollCellCommentThread.objects.filter(
|
||||
period=period,
|
||||
subject_employee=employee,
|
||||
column_id="carryovers",
|
||||
).exists()
|
||||
|
||||
|
||||
def test_period_table_projects_attendance_and_official_personnel_points(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
@ -2008,6 +2261,147 @@ def test_native_api_runs_complete_service_workflow_and_redacts_aggregate_money(
|
||||
)
|
||||
|
||||
|
||||
def test_staff_admin_completes_entire_payroll_workflow_with_one_command(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
administrator = user_factory(
|
||||
email="workflow.complete.admin@example.test",
|
||||
staff=True,
|
||||
)
|
||||
creator = user_factory(email="workflow.complete.creator@example.test")
|
||||
employee = user_factory(email="workflow.complete.employee@example.test")
|
||||
period = make_period(creator=administrator)
|
||||
make_approved_period_inputs(
|
||||
period=period,
|
||||
employee=employee,
|
||||
creator=creator,
|
||||
approver=administrator,
|
||||
)
|
||||
|
||||
response = auth_client_factory(administrator).post(
|
||||
api_url("admin-period-complete-workflow", pk=period.pk),
|
||||
{
|
||||
"expected_lock_version": period.lock_version,
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"recalculation_reason": "",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.json()["period"]["status"] == "closed"
|
||||
assert response.json()["run"]["status"] == "published"
|
||||
assert response.json()["completed_steps"] == [
|
||||
"calculated",
|
||||
"review_requested",
|
||||
"approved",
|
||||
"published",
|
||||
"period_closed",
|
||||
]
|
||||
period.refresh_from_db()
|
||||
run = PayrollRun.objects.get(pk=period.current_run_id)
|
||||
assert period.status == "closed"
|
||||
assert run.status == "published"
|
||||
assert run.requested_by_id == administrator.pk
|
||||
assert run.approved_by_id == administrator.pk
|
||||
assert run.published_by_id == administrator.pk
|
||||
assert run.self_approval_overridden is True
|
||||
assert set(
|
||||
PayrollAuditEvent.objects.filter(period=period).values_list(
|
||||
"action",
|
||||
flat=True,
|
||||
)
|
||||
) >= {
|
||||
"payroll.calculated",
|
||||
"payroll.review_requested",
|
||||
"payroll.approved",
|
||||
"payroll.published",
|
||||
"payroll.period_closed",
|
||||
"payroll.admin_workflow_completed",
|
||||
}
|
||||
|
||||
|
||||
def test_complete_workflow_rolls_back_every_stage_when_review_fails(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
administrator = user_factory(
|
||||
email="workflow.rollback.admin@example.test",
|
||||
staff=True,
|
||||
)
|
||||
creator = user_factory(email="workflow.rollback.creator@example.test")
|
||||
employee = user_factory(email="workflow.rollback.employee@example.test")
|
||||
period = make_period(creator=administrator)
|
||||
make_approved_period_inputs(
|
||||
period=period,
|
||||
employee=employee,
|
||||
creator=creator,
|
||||
approver=administrator,
|
||||
expected_payable="1.00",
|
||||
)
|
||||
|
||||
response = auth_client_factory(administrator).post(
|
||||
api_url("admin-period-complete-workflow", pk=period.pk),
|
||||
{
|
||||
"expected_lock_version": period.lock_version,
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"recalculation_reason": "",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == "RECONCILIATION_MISMATCH"
|
||||
period.refresh_from_db()
|
||||
assert period.status == "open"
|
||||
assert period.current_run_id is None
|
||||
assert period.lock_version == 0
|
||||
assert not PayrollRun.objects.filter(period=period).exists()
|
||||
assert not PayrollAuditEvent.objects.filter(
|
||||
period=period,
|
||||
action__in={
|
||||
"payroll.calculated",
|
||||
"payroll.review_requested",
|
||||
"payroll.approved",
|
||||
"payroll.published",
|
||||
"payroll.period_closed",
|
||||
"payroll.admin_workflow_completed",
|
||||
},
|
||||
).exists()
|
||||
|
||||
|
||||
def test_complete_workflow_is_available_only_to_full_portal_admins(
|
||||
settings,
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": False}
|
||||
operator = user_factory(email="workflow.granular@example.test")
|
||||
grant(
|
||||
operator,
|
||||
"calculate_payroll",
|
||||
"approve_payroll",
|
||||
"override_payroll_approval",
|
||||
"publish_payroll",
|
||||
)
|
||||
period = make_period(creator=operator)
|
||||
|
||||
response = auth_client_factory(operator).post(
|
||||
api_url("admin-period-complete-workflow", pk=period.pk),
|
||||
{
|
||||
"expected_lock_version": period.lock_version,
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"recalculation_reason": "",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "PERMISSION_DENIED"
|
||||
assert response.json()["details"]["permission"] == "finance.full_payroll_admin"
|
||||
|
||||
|
||||
def test_override_holder_can_self_approve_every_serialized_payroll_object(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
|
||||
242
backend/tests/finance/test_payroll_carryovers.py
Normal file
242
backend/tests/finance/test_payroll_carryovers.py
Normal file
@ -0,0 +1,242 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
|
||||
from finance.enums import ApprovalStatus, PayrollCarryoverStatus, PayrollPeriodStatus
|
||||
from finance.models import PayrollInputLine, PayrollPeriod
|
||||
from finance.payroll.carryovers import (
|
||||
cancel_carryover,
|
||||
create_carryover,
|
||||
materialize_pending_carryovers,
|
||||
)
|
||||
from finance.payroll.exceptions import PayrollOperationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def grant(user, *codenames):
|
||||
permissions = Permission.objects.filter(
|
||||
content_type__app_label="finance",
|
||||
codename__in=codenames,
|
||||
)
|
||||
assert permissions.count() == len(codenames)
|
||||
user.user_permissions.add(*permissions)
|
||||
for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
|
||||
if hasattr(user, cache_name):
|
||||
delattr(user, cache_name)
|
||||
|
||||
|
||||
def api_url(name, **kwargs):
|
||||
return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None)
|
||||
|
||||
|
||||
def make_period(*, creator, month, status=PayrollPeriodStatus.OPEN):
|
||||
starts = {6: date(2026, 6, 1), 7: date(2026, 7, 1), 8: date(2026, 8, 1)}
|
||||
ends = {6: date(2026, 6, 30), 7: date(2026, 7, 31), 8: date(2026, 8, 31)}
|
||||
return PayrollPeriod.objects.create(
|
||||
code=f"2026-{month:02d}",
|
||||
name=f"Месяц {month}",
|
||||
date_from=starts[month],
|
||||
date_to=ends[month],
|
||||
currency="RUB",
|
||||
status=status,
|
||||
created_by=creator,
|
||||
)
|
||||
|
||||
|
||||
def test_pending_carryover_materializes_as_next_period_draft(user_factory):
|
||||
manager = user_factory(email="carryover.manager@example.test")
|
||||
employee = user_factory(email="carryover.employee@example.test")
|
||||
source = make_period(
|
||||
creator=manager,
|
||||
month=6,
|
||||
status=PayrollPeriodStatus.CLOSED,
|
||||
)
|
||||
|
||||
carryover = create_carryover(
|
||||
source_period=source,
|
||||
employee=employee,
|
||||
direction="company_owes",
|
||||
amount=Decimal("5000.00"),
|
||||
reason="Недоплата за июнь",
|
||||
actor=manager,
|
||||
)
|
||||
|
||||
assert carryover.status == PayrollCarryoverStatus.PENDING
|
||||
assert carryover.target_period_id is None
|
||||
target = make_period(creator=manager, month=7)
|
||||
|
||||
transferred = materialize_pending_carryovers(target)
|
||||
|
||||
assert [item.pk for item in transferred] == [carryover.pk]
|
||||
carryover.refresh_from_db()
|
||||
assert carryover.status == PayrollCarryoverStatus.TRANSFERRED
|
||||
assert carryover.target_period == target
|
||||
line = carryover.generated_input_line
|
||||
assert line.period == target
|
||||
assert line.employee == employee
|
||||
assert line.component.code == "CORRECTION_CREDIT"
|
||||
assert line.amount == Decimal("5000.00")
|
||||
assert line.relates_to_period == source
|
||||
assert line.status == ApprovalStatus.DRAFT
|
||||
assert line.source == "api"
|
||||
assert line.source_ref == f"payroll-carryover:{carryover.pk}"
|
||||
assert "Недоплата за июнь" in line.reason
|
||||
|
||||
assert materialize_pending_carryovers(target) == []
|
||||
assert PayrollInputLine.objects.filter(source_ref=line.source_ref).count() == 1
|
||||
|
||||
|
||||
def test_employee_debt_uses_deduction_and_cancel_voids_draft(user_factory):
|
||||
manager = user_factory(email="carryover.debt.manager@example.test")
|
||||
employee = user_factory(email="carryover.debt.employee@example.test")
|
||||
source = make_period(
|
||||
creator=manager,
|
||||
month=6,
|
||||
status=PayrollPeriodStatus.CLOSED,
|
||||
)
|
||||
make_period(creator=manager, month=7)
|
||||
|
||||
carryover = create_carryover(
|
||||
source_period=source,
|
||||
employee=employee,
|
||||
direction="employee_owes",
|
||||
amount=Decimal("1200.00"),
|
||||
reason="Возврат переплаты",
|
||||
actor=manager,
|
||||
)
|
||||
|
||||
assert carryover.status == PayrollCarryoverStatus.TRANSFERRED
|
||||
assert carryover.generated_input_line.component.code == "DEDUCTION"
|
||||
cancelled = cancel_carryover(carryover.pk, actor=manager)
|
||||
assert cancelled.status == PayrollCarryoverStatus.CANCELLED
|
||||
cancelled.generated_input_line.refresh_from_db()
|
||||
assert cancelled.generated_input_line.status == ApprovalStatus.VOIDED
|
||||
|
||||
|
||||
def test_carryover_requires_closed_source_and_approved_transfer_cannot_cancel(
|
||||
user_factory,
|
||||
):
|
||||
manager = user_factory(email="carryover.lock.manager@example.test")
|
||||
employee = user_factory(email="carryover.lock.employee@example.test")
|
||||
source = make_period(creator=manager, month=6)
|
||||
|
||||
with pytest.raises(PayrollOperationError) as open_error:
|
||||
create_carryover(
|
||||
source_period=source,
|
||||
employee=employee,
|
||||
direction="company_owes",
|
||||
amount=Decimal("10.00"),
|
||||
reason="Ошибка",
|
||||
actor=manager,
|
||||
)
|
||||
assert open_error.value.code == "CARRYOVER_SOURCE_NOT_CLOSED"
|
||||
|
||||
source.status = PayrollPeriodStatus.CLOSED
|
||||
source.save(update_fields=["status", "updated_at"])
|
||||
make_period(creator=manager, month=7)
|
||||
carryover = create_carryover(
|
||||
source_period=source,
|
||||
employee=employee,
|
||||
direction="company_owes",
|
||||
amount=Decimal("10.00"),
|
||||
reason="Подтверждённая доплата",
|
||||
actor=manager,
|
||||
)
|
||||
line = carryover.generated_input_line
|
||||
line.status = ApprovalStatus.APPROVED
|
||||
line.approved_by = manager
|
||||
line.approved_at = carryover.transferred_at
|
||||
line.self_approval_overridden = True
|
||||
line.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"approved_by",
|
||||
"approved_at",
|
||||
"self_approval_overridden",
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(PayrollOperationError) as approved_error:
|
||||
cancel_carryover(carryover.pk, actor=manager)
|
||||
assert approved_error.value.code == "CARRYOVER_ALREADY_APPROVED"
|
||||
|
||||
|
||||
def test_carryover_admin_api_lists_incoming_and_marks_generated_line(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="carryover.api.manager@example.test")
|
||||
employee = user_factory(
|
||||
email="carryover.api.employee@example.test",
|
||||
first_name="Анна",
|
||||
last_name="Балансова",
|
||||
)
|
||||
grant(manager, "manage_payroll_inputs", "view_all_payroll")
|
||||
source = make_period(
|
||||
creator=manager,
|
||||
month=6,
|
||||
status=PayrollPeriodStatus.CLOSED,
|
||||
)
|
||||
target = make_period(creator=manager, month=7)
|
||||
client = auth_client_factory(manager)
|
||||
|
||||
created = client.post(
|
||||
api_url("admin-period-carryovers", pk=source.pk),
|
||||
{
|
||||
"employee_id": employee.pk,
|
||||
"direction": "company_owes",
|
||||
"amount": "3500.00",
|
||||
"reason": "Исправление после закрытия",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert created.status_code == 201, created.content
|
||||
assert created.json()["status"] == "transferred"
|
||||
outgoing = client.get(api_url("admin-period-carryovers", pk=source.pk))
|
||||
assert outgoing.status_code == 200
|
||||
assert outgoing.json()["summary"] == {
|
||||
"outgoing": 1,
|
||||
"incoming": 0,
|
||||
"pending": 0,
|
||||
}
|
||||
incoming = client.get(api_url("admin-period-carryovers", pk=target.pk))
|
||||
assert incoming.json()["summary"] == {
|
||||
"outgoing": 0,
|
||||
"incoming": 1,
|
||||
"pending": 0,
|
||||
}
|
||||
assert incoming.json()["incoming"][0]["source_period"]["id"] == source.pk
|
||||
|
||||
lines = client.get(
|
||||
api_url("admin-input-line-list"),
|
||||
{"period_id": target.pk},
|
||||
)
|
||||
assert lines.status_code == 200
|
||||
line = lines.json()[0]
|
||||
assert line["is_automatic_carryover"] is True
|
||||
assert line["relates_to_period"]["id"] == source.pk
|
||||
|
||||
period_table = client.get(api_url("admin-period-table", pk=target.pk))
|
||||
assert period_table.status_code == 200
|
||||
assert period_table.json()["carryovers"] == [
|
||||
{
|
||||
"employee_id": employee.pk,
|
||||
"count": 1,
|
||||
"company_owes": "3500.00",
|
||||
"employee_owes": "0.00",
|
||||
"pending": 0,
|
||||
}
|
||||
]
|
||||
|
||||
workspace = client.get(api_url("admin-workspace"), {"period_id": target.pk})
|
||||
assert workspace.status_code == 200
|
||||
assert workspace.json()["carryovers"] == {
|
||||
"outgoing": 0,
|
||||
"incoming": 1,
|
||||
"pending": 0,
|
||||
}
|
||||
98
backend/tests/finance/test_payroll_tasks.py
Normal file
98
backend/tests/finance/test_payroll_tasks.py
Normal file
@ -0,0 +1,98 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
|
||||
from finance.enums import PayrollPeriodStatus
|
||||
from finance.models import PayrollAuditEvent, PayrollPeriod
|
||||
from finance.payroll.carryovers import create_carryover
|
||||
from finance.tasks import ensure_monthly_payroll_period, open_monthly_payroll_period
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_monthly_period_task_is_scheduled_for_first_day():
|
||||
task = settings.CELERY_BEAT_SCHEDULE["open-monthly-payroll-period"]
|
||||
|
||||
assert task["task"] == "finance.tasks.open_monthly_payroll_period"
|
||||
assert task["schedule"].day_of_month == {1}
|
||||
assert task["schedule"].hour == {0}
|
||||
assert task["schedule"].minute == {5}
|
||||
|
||||
|
||||
def test_ensure_monthly_payroll_period_opens_full_calendar_month():
|
||||
period, created = ensure_monthly_payroll_period(date(2026, 8, 1))
|
||||
|
||||
assert created is True
|
||||
assert period.code == "2026-08"
|
||||
assert period.name == "Август 2026"
|
||||
assert period.date_from == date(2026, 8, 1)
|
||||
assert period.date_to == date(2026, 8, 31)
|
||||
assert period.pay_date is None
|
||||
assert period.currency == "RUB"
|
||||
assert period.status == PayrollPeriodStatus.OPEN
|
||||
assert period.created_by is None
|
||||
|
||||
event = PayrollAuditEvent.objects.get(
|
||||
action="payroll.period_created",
|
||||
period=period,
|
||||
)
|
||||
assert event.actor is None
|
||||
assert event.metadata == {"channel": "automation", "schedule": "monthly"}
|
||||
|
||||
|
||||
def test_ensure_monthly_payroll_period_is_idempotent():
|
||||
first, first_created = ensure_monthly_payroll_period(date(2028, 2, 1))
|
||||
second, second_created = ensure_monthly_payroll_period(date(2028, 2, 15))
|
||||
|
||||
assert first_created is True
|
||||
assert second_created is False
|
||||
assert second.pk == first.pk
|
||||
assert first.date_to == date(2028, 2, 29)
|
||||
assert PayrollPeriod.objects.count() == 1
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.period_created",
|
||||
period=first,
|
||||
).count() == 1
|
||||
|
||||
|
||||
def test_monthly_task_uses_local_current_month(monkeypatch):
|
||||
monkeypatch.setattr("finance.tasks.timezone.localdate", lambda: date(2026, 9, 1))
|
||||
|
||||
result = open_monthly_payroll_period()
|
||||
|
||||
period = PayrollPeriod.objects.get(pk=result["period_id"])
|
||||
assert result == {"period_id": period.pk, "code": "2026-09", "created": True}
|
||||
assert period.date_from == date(2026, 9, 1)
|
||||
assert period.date_to == date(2026, 9, 30)
|
||||
|
||||
|
||||
def test_monthly_period_opening_materializes_pending_carryover(user_factory):
|
||||
manager = user_factory(email="period.carryover.manager@example.test")
|
||||
employee = user_factory(email="period.carryover.employee@example.test")
|
||||
source = PayrollPeriod.objects.create(
|
||||
code="2026-07",
|
||||
name="Июль 2026",
|
||||
date_from=date(2026, 7, 1),
|
||||
date_to=date(2026, 7, 31),
|
||||
currency="RUB",
|
||||
status=PayrollPeriodStatus.CLOSED,
|
||||
created_by=manager,
|
||||
)
|
||||
carryover = create_carryover(
|
||||
source_period=source,
|
||||
employee=employee,
|
||||
direction="company_owes",
|
||||
amount=Decimal("700.00"),
|
||||
reason="Остаток июля",
|
||||
actor=manager,
|
||||
)
|
||||
assert carryover.target_period_id is None
|
||||
|
||||
target, created = ensure_monthly_payroll_period(date(2026, 8, 1))
|
||||
|
||||
assert created is True
|
||||
carryover.refresh_from_db()
|
||||
assert carryover.target_period == target
|
||||
assert carryover.generated_input_line.period == target
|
||||
@ -2150,7 +2150,7 @@ export default function AttendancePage() {
|
||||
setSelectedCell({ row, employee, cell });
|
||||
openCellComments(row, employee, cell);
|
||||
}}
|
||||
className="absolute right-1 top-1 rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[9px] leading-3 text-[var(--muted-foreground)] transition hover:border-[var(--accent-primary)] hover:text-[var(--foreground)]"
|
||||
className="absolute right-1 top-1 rounded border border-sky-500/60 bg-sky-500/15 px-1 text-[9px] font-semibold leading-3 text-sky-400 transition hover:bg-sky-500/25 hover:text-sky-300"
|
||||
aria-label={`Открыть комментарии: ${cell.comments_count}`}
|
||||
title="Открыть комментарии"
|
||||
>
|
||||
|
||||
@ -473,7 +473,6 @@ export default function ProcurementPage() {
|
||||
const canCurrentUserSubmitRequest = useCallback(
|
||||
(request: ProcurementRequest) => {
|
||||
const processingDepartmentId = Number(request.processing_department || 0);
|
||||
const requestorId = resolveUserId(request.requestor);
|
||||
const isProcessingDepartmentMember = (
|
||||
!userDepartmentsKnown ||
|
||||
userDepartmentIds.has(processingDepartmentId)
|
||||
@ -483,12 +482,10 @@ export default function ProcurementPage() {
|
||||
request.can_current_user_submit_for_approval &&
|
||||
user?.id &&
|
||||
processingDepartmentId &&
|
||||
requestorId !== user.id &&
|
||||
isProcessingDepartmentMember
|
||||
);
|
||||
},
|
||||
[
|
||||
resolveUserId,
|
||||
user?.id,
|
||||
userDepartmentIds,
|
||||
userDepartmentsKnown,
|
||||
|
||||
@ -283,7 +283,7 @@ export function PayrollStatementDetailModal({
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">{line.label}</p>
|
||||
{line.is_retro ? (
|
||||
<span className="app-badge app-badge-accent px-2 py-0.5 text-[0.65rem]">Перерасчёт</span>
|
||||
<span className="app-badge app-badge-accent px-2 py-0.5 text-[0.65rem]">{line.is_automatic_carryover ? "Автоперенос" : "Перерасчёт"}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{sourcePeriod ? (
|
||||
|
||||
@ -247,7 +247,18 @@ export function PayrollInputLinesTable({
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] md:items-center">
|
||||
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
|
||||
<div className="min-w-0"><p className="truncate text-sm text-[var(--foreground)]">{record.component.name}</p>{record.reason ? <p className="app-text-muted mt-0.5 truncate text-xs" title={record.reason}>{record.reason}</p> : null}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-[var(--foreground)]">{record.component.name}</p>
|
||||
{record.relates_to_period ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span className={`app-badge px-2 py-0.5 text-[10px] ${record.is_automatic_carryover ? "app-badge-accent" : "app-selected"}`}>
|
||||
{record.is_automatic_carryover ? "Автоперенос" : "Перерасчёт"}
|
||||
</span>
|
||||
<span className="app-text-muted text-[10px]">за {record.relates_to_period.name || record.relates_to_period.code}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{record.reason ? <p className="app-text-muted mt-1 truncate text-xs" title={record.reason}>{record.reason}</p> : null}
|
||||
</div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Сумма</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount)}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
Send,
|
||||
ShieldCheck,
|
||||
UsersRound,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -37,6 +38,7 @@ import {
|
||||
PayrollWorkRecordFormModal,
|
||||
} from "@/components/finance/admin/PayrollAdminModals";
|
||||
import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal";
|
||||
import { PayrollCarryoversModal } from "@/components/finance/admin/PayrollCarryoversModal";
|
||||
import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal";
|
||||
import {
|
||||
PayrollApprovalQueue,
|
||||
@ -59,6 +61,7 @@ import type {
|
||||
PayrollBulkPayRateResult,
|
||||
PayrollBulkPointRateResult,
|
||||
PayrollBulkTargetPointsResult,
|
||||
PayrollCalculationBlocker,
|
||||
PayrollInputLineWrite,
|
||||
PayrollPayRateWrite,
|
||||
PayrollPeriodTable,
|
||||
@ -69,6 +72,7 @@ import type {
|
||||
} from "@/lib/api/finance";
|
||||
import {
|
||||
getPayrollAdminError,
|
||||
getPayrollBlockerTarget,
|
||||
getPrimaryPayrollRunAction,
|
||||
normalizePayrollAdminList,
|
||||
PAYROLL_ADMIN_TABS,
|
||||
@ -118,6 +122,97 @@ function SummaryCards({ workspace }: { workspace: WorkspacePayload }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessBlockers({
|
||||
blockers,
|
||||
employees,
|
||||
canOpenInputTabs,
|
||||
canEditPeriod,
|
||||
onOpen,
|
||||
onEditPeriod,
|
||||
}: {
|
||||
blockers: PayrollCalculationBlocker[];
|
||||
employees: WorkspacePayload["rate_employees"];
|
||||
canOpenInputTabs: boolean;
|
||||
canEditPeriod: boolean;
|
||||
onOpen: (blocker: PayrollCalculationBlocker, employeeName?: string) => void;
|
||||
onEditPeriod: () => void;
|
||||
}) {
|
||||
if (!blockers.length) return null;
|
||||
|
||||
const employeesById = new Map(employees.map((employee) => [employee.id, employee]));
|
||||
|
||||
return (
|
||||
<div className="app-feedback-warning mt-4 rounded-xl p-3">
|
||||
<p className="text-sm font-semibold">Что мешает расчёту</p>
|
||||
<ul className="mt-2 divide-y divide-current/10 text-xs">
|
||||
{blockers.map((blocker, index) => {
|
||||
const employeeIds = blocker.employee_ids
|
||||
|| (blocker.employee_id ? [blocker.employee_id] : []);
|
||||
const affectedEmployees = employeeIds
|
||||
.map((employeeId) => employeesById.get(employeeId))
|
||||
.filter((employee): employee is WorkspacePayload["rate_employees"][number] => Boolean(employee));
|
||||
const target = getPayrollBlockerTarget(blocker.code);
|
||||
const canOpenTarget = Boolean(target && canOpenInputTabs);
|
||||
const canOpenPeriod = blocker.code === "RULESET_NOT_EFFECTIVE" && canEditPeriod;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${blocker.code}-${employeeIds.join("-") || index}`}
|
||||
className="flex items-start gap-2 py-2.5 first:pt-0 last:pb-0"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 shrink-0" size={14} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="leading-relaxed">{blocker.message}</p>
|
||||
{affectedEmployees.length ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-2 gap-y-1">
|
||||
{affectedEmployees.slice(0, 5).map((employee) => (
|
||||
canOpenTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
key={employee.id}
|
||||
className="font-medium underline decoration-current/40 underline-offset-2 hover:decoration-current"
|
||||
onClick={() => onOpen(blocker, employee.display_name)}
|
||||
>
|
||||
{employee.display_name}
|
||||
</button>
|
||||
) : (
|
||||
<span key={employee.id} className="font-medium">{employee.display_name}</span>
|
||||
)
|
||||
))}
|
||||
{(blocker.employee_count || affectedEmployees.length) > 5 ? (
|
||||
<span>
|
||||
и ещё {(blocker.employee_count || affectedEmployees.length) - Math.min(affectedEmployees.length, 5)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canOpenTarget && target ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex items-center gap-1 font-semibold underline decoration-current/40 underline-offset-2 hover:decoration-current"
|
||||
onClick={() => onOpen(blocker)}
|
||||
>
|
||||
{target.label} <ChevronRight size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
{canOpenPeriod ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex items-center gap-1 font-semibold underline decoration-current/40 underline-offset-2 hover:decoration-current"
|
||||
onClick={onEditPeriod}
|
||||
>
|
||||
Проверить параметры периода <ChevronRight size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableToolbar({
|
||||
search,
|
||||
status,
|
||||
@ -322,6 +417,8 @@ export function PayrollAdminWorkspace({
|
||||
const [editingWork, setEditingWork] = useState<PayrollAdminWorkRecord | null>(null);
|
||||
const [attendanceWorkModalOpen, setAttendanceWorkModalOpen] = useState(false);
|
||||
const [workbookImportModalOpen, setWorkbookImportModalOpen] = useState(false);
|
||||
const [carryoversModalOpen, setCarryoversModalOpen] = useState(false);
|
||||
const [carryoversEmployeeId, setCarryoversEmployeeId] = useState<number | null>(null);
|
||||
const [inputModalOpen, setInputModalOpen] = useState(false);
|
||||
const [editingInput, setEditingInput] = useState<PayrollAdminInputLine | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
@ -534,6 +631,46 @@ export function PayrollAdminWorkspace({
|
||||
setPendingAction(configs[action]);
|
||||
};
|
||||
|
||||
const openCompleteWorkflow = () => {
|
||||
if (!selectedPeriod || !workspace?.permissions.full_access) return;
|
||||
const runStatus = workspace.current_run?.status;
|
||||
const firstRemainingStep = runStatus === "published"
|
||||
? 4
|
||||
: runStatus === "approved"
|
||||
? 3
|
||||
: runStatus === "review"
|
||||
? 2
|
||||
: runStatus === "calculated"
|
||||
? 1
|
||||
: 0;
|
||||
const remainingSteps = [
|
||||
"рассчитать зарплату",
|
||||
"передать расчёт на проверку",
|
||||
"утвердить расчёт",
|
||||
"опубликовать расчётные листки",
|
||||
"закрыть период",
|
||||
].slice(firstRemainingStep);
|
||||
const requiresRecalculationReason = runStatus === "returned";
|
||||
|
||||
setPendingAction({
|
||||
title: "Завершить период одним действием",
|
||||
description: `Система последовательно выполнит: ${remainingSteps.join(" → ")}.`,
|
||||
confirmLabel: "Выполнить и закрыть",
|
||||
reasonLabel: requiresRecalculationReason ? "Основание перерасчёта" : undefined,
|
||||
reasonRequired: requiresRecalculationReason,
|
||||
warning: "Расчётные листки станут доступны сотрудникам, а период будет закрыт. Если любой этап не пройдёт проверку, ни одно изменение не сохранится.",
|
||||
successMessage: "Расчёт опубликован, период закрыт.",
|
||||
execute: (reason) => apiClient.completePayrollAdminPeriodWorkflow(
|
||||
selectedPeriod.id,
|
||||
{
|
||||
idempotency_key: globalThis.crypto.randomUUID(),
|
||||
recalculation_reason: reason,
|
||||
expected_lock_version: selectedPeriod.lock_version,
|
||||
},
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const openReturn = () => {
|
||||
const run = workspace?.current_run;
|
||||
if (!run) return;
|
||||
@ -563,6 +700,16 @@ export function PayrollAdminWorkspace({
|
||||
);
|
||||
const canManageSourceData = permissions.manage_inputs && !sourceDataLocked;
|
||||
const canApproveSourceData = permissions.approve_inputs && !sourceDataLocked;
|
||||
const openBlockerTarget = (
|
||||
blocker: PayrollCalculationBlocker,
|
||||
employeeName?: string,
|
||||
) => {
|
||||
const target = getPayrollBlockerTarget(blocker.code);
|
||||
if (!target || !canOpenInputTabs) return;
|
||||
setSearch(employeeName || "");
|
||||
setStatus(target.status);
|
||||
setTab(target.tab);
|
||||
};
|
||||
const draftApprovalRecords = [
|
||||
...rates.filter((record) => record.status === "draft"),
|
||||
...workRecords.filter((record) => record.status === "draft"),
|
||||
@ -753,9 +900,25 @@ export function PayrollAdminWorkspace({
|
||||
</button>
|
||||
) : null}
|
||||
{workspace.periods.length ? (
|
||||
<select className="app-select min-w-52 rounded-lg px-3 py-2.5 text-sm" value={selectedPeriod?.id || ""} onChange={(event) => void loadWorkspace(Number(event.target.value), true)} aria-label="Расчётный период">
|
||||
{workspace.periods.map((period) => <option value={period.id} key={period.id}>{period.name || period.code}</option>)}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="app-select min-w-52 rounded-lg px-3 py-2.5 text-sm" value={selectedPeriod?.id || ""} onChange={(event) => void loadWorkspace(Number(event.target.value), true)} aria-label="Расчётный период">
|
||||
{workspace.periods.map((period) => <option value={period.id} key={period.id}>{period.name || period.code}</option>)}
|
||||
</select>
|
||||
{selectedPeriod?.status === "open" && permissions.manage_inputs && !workspace.current_run ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
onClick={() => {
|
||||
setEditingPeriod(selectedPeriod);
|
||||
setPeriodModalOpen(true);
|
||||
}}
|
||||
title="Параметры выбранного периода"
|
||||
aria-label="Параметры выбранного периода"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{permissions.manage_inputs ? <button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold" onClick={() => { setEditingPeriod(null); setPeriodModalOpen(true); }}><Plus size={16} /> Новый период</button> : null}
|
||||
</div>
|
||||
@ -808,17 +971,84 @@ export function PayrollAdminWorkspace({
|
||||
<SectionIntro title="Готовность к расчёту" description="Система запускает расчёт только по утверждённым данным и показывает, что ещё нужно подготовить." />
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: "Ставки сотрудников", value: `${workspace.readiness.rates.approved} из ${workspace.readiness.rates.total}`, ready: workspace.readiness.rates.ready, tab: "rates" as const },
|
||||
{ label: "Выработка и состав", value: `${workspace.readiness.work_records.approved} из ${workspace.readiness.work_records.total}`, ready: workspace.readiness.work_records.ready, tab: "work" as const },
|
||||
{ label: "Начисления и выплаты", value: workspace.readiness.input_lines.draft ? `${workspace.readiness.input_lines.draft} черн.` : `${workspace.readiness.input_lines.approved} утвержд.`, ready: workspace.readiness.input_lines.draft === 0, tab: "inputs" as const },
|
||||
].map((item) => <button type="button" key={item.label} disabled={!canOpenInputTabs} className="app-surface-muted flex w-full items-center gap-3 rounded-xl p-3 text-left disabled:cursor-default" onClick={() => { if (canOpenInputTabs) setTab(item.tab); }}><span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${item.ready ? "app-feedback-success" : "app-feedback-warning"}`}>{item.ready ? <Check size={15} /> : <Clock3 size={15} />}</span><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium text-[var(--foreground)]">{item.label}</span><span className="app-text-muted mt-0.5 block text-xs">{item.value}</span></span>{canOpenInputTabs ? <ChevronRight className="app-text-muted" size={17} /> : null}</button>)}
|
||||
{
|
||||
label: "Ставки сотрудников",
|
||||
value: workspace.readiness.rates.total
|
||||
? `${workspace.readiness.rates.approved} из ${workspace.readiness.rates.total} с утверждённой ставкой${workspace.readiness.rates.draft ? ` · черновиков: ${workspace.readiness.rates.draft}` : ""}`
|
||||
: "Состав сотрудников ещё не заполнен",
|
||||
ready: workspace.readiness.rates.ready,
|
||||
tab: "rates" as const,
|
||||
},
|
||||
{
|
||||
label: "Выработка и состав",
|
||||
value: workspace.readiness.work_records.total
|
||||
? `${workspace.readiness.work_records.approved} из ${workspace.readiness.work_records.total} утверждено${workspace.readiness.work_records.draft ? ` · черновиков: ${workspace.readiness.work_records.draft}` : ""}`
|
||||
: "Нет записей выработки — состав периода пуст",
|
||||
ready: workspace.readiness.work_records.ready,
|
||||
tab: "work" as const,
|
||||
},
|
||||
{
|
||||
label: "Начисления и выплаты",
|
||||
value: `${workspace.readiness.input_lines.approved} утверждено${workspace.readiness.input_lines.draft ? ` · черновиков: ${workspace.readiness.input_lines.draft}` : " · черновиков нет"}`,
|
||||
ready: workspace.readiness.input_lines.draft === 0,
|
||||
tab: "inputs" as const,
|
||||
},
|
||||
].map((item) => <button type="button" key={item.label} disabled={!canOpenInputTabs} className="app-surface-muted flex w-full items-center gap-3 rounded-xl p-3 text-left disabled:cursor-default" onClick={() => { if (canOpenInputTabs) { setSearch(""); setStatus(""); setTab(item.tab); } }}><span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${item.ready ? "app-feedback-success" : "app-feedback-warning"}`}>{item.ready ? <Check size={15} /> : <Clock3 size={15} />}</span><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium text-[var(--foreground)]">{item.label}</span><span className="app-text-muted mt-0.5 block text-xs">{item.value}</span></span>{canOpenInputTabs ? <ChevronRight className="app-text-muted" size={17} /> : null}</button>)}
|
||||
</div>
|
||||
{workspace.readiness.calculation.blockers.length ? <div className="app-feedback-warning mt-4 rounded-xl p-3"><p className="text-sm font-semibold">Что мешает расчёту</p><ul className="mt-2 space-y-1.5 text-xs">{workspace.readiness.calculation.blockers.map((blocker) => { const employee = blocker.employee_id ? workspace.employees.find((item) => item.id === blocker.employee_id) : null; return <li key={`${blocker.code}-${blocker.employee_id || "all"}`} className="flex items-start gap-2"><AlertCircle className="mt-0.5 shrink-0" size={14} /><span>{blocker.message}{employee ? ` — ${employee.display_name}` : ""}</span></li>; })}</ul></div> : null}
|
||||
<ReadinessBlockers
|
||||
blockers={workspace.readiness.calculation.blockers}
|
||||
employees={workspace.rate_employees}
|
||||
canOpenInputTabs={canOpenInputTabs}
|
||||
canEditPeriod={Boolean(
|
||||
selectedPeriod.status === "open"
|
||||
&& permissions.manage_inputs
|
||||
&& !workspace.current_run
|
||||
)}
|
||||
onOpen={openBlockerTarget}
|
||||
onEditPeriod={() => {
|
||||
setEditingPeriod(selectedPeriod);
|
||||
setPeriodModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<aside className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex items-center gap-2"><ShieldCheck className="app-accent-text" size={18} /><h3 className="text-sm font-semibold text-[var(--foreground)]">Следующий шаг</h3></div>
|
||||
{primaryAction ? <><p className="app-text-muted mt-3 text-sm leading-relaxed">Все необходимые условия для этого этапа выполнены.</p><button type="button" className="app-action-primary mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold" onClick={() => openRunAction(primaryAction)}>{primaryAction === "calculate" ? <Calculator size={16} /> : primaryAction === "submit_review" ? <Send size={16} /> : <CheckCircle2 size={16} />}{payrollRunActionLabels[primaryAction]}</button></> : <p className="app-text-muted mt-3 text-sm leading-relaxed">Завершите отмеченные пункты.</p>}
|
||||
{selectedPeriod.status === "open" && permissions.manage_inputs && !workspace.current_run ? <button type="button" className="app-action-secondary mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium" onClick={() => { setEditingPeriod(selectedPeriod); setPeriodModalOpen(true); }}><Pencil size={15} /> Параметры периода</button> : null}
|
||||
{primaryAction ? (
|
||||
<>
|
||||
<p className="app-text-muted mt-3 text-sm leading-relaxed">
|
||||
{permissions.full_access
|
||||
? "Все условия выполнены. Весь оставшийся цикл можно провести одним подтверждением."
|
||||
: "Все необходимые условия для этого этапа выполнены."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-primary mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold"
|
||||
onClick={() => {
|
||||
if (permissions.full_access) openCompleteWorkflow();
|
||||
else openRunAction(primaryAction);
|
||||
}}
|
||||
>
|
||||
{permissions.full_access || primaryAction === "calculate" ? <Calculator size={16} /> : primaryAction === "submit_review" ? <Send size={16} /> : <CheckCircle2 size={16} />}
|
||||
{permissions.full_access ? "Выполнить весь цикл" : payrollRunActionLabels[primaryAction]}
|
||||
</button>
|
||||
{permissions.full_access && primaryAction !== "close" ? (
|
||||
<>
|
||||
<div className="app-text-muted my-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide">
|
||||
<span className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||
<span>или</span>
|
||||
<span className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-primary inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
onClick={() => openRunAction(primaryAction)}
|
||||
>
|
||||
Только текущий этап: {payrollRunActionLabels[primaryAction]}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : <p className="app-text-muted mt-3 text-sm leading-relaxed">Завершите отмеченные пункты.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@ -846,7 +1076,25 @@ export function PayrollAdminWorkspace({
|
||||
{tab === "summary" ? (
|
||||
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Итоговая таблица" description="Сводные исходные данные, начисления, удержания и выплаты по всем сотрудникам за выбранный период." />
|
||||
<PayrollPeriodTableView data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} />
|
||||
<div className="mb-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold"
|
||||
onClick={() => {
|
||||
setCarryoversEmployeeId(null);
|
||||
setCarryoversModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<WalletCards size={16} />
|
||||
Долги и доплаты
|
||||
{(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0) ? (
|
||||
<span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">
|
||||
{(workspace.carryovers?.incoming || 0) + (workspace.carryovers?.outgoing || 0)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
<PayrollPeriodTableView data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} />
|
||||
</section>
|
||||
) : null}
|
||||
{tab === "approval" ? (
|
||||
@ -890,7 +1138,7 @@ export function PayrollAdminWorkspace({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{workspace.current_run ? <div className="mt-5"><div className="mb-3 flex flex-wrap items-center justify-between gap-2"><h3 className="text-sm font-semibold text-[var(--foreground)]">Текущий расчёт</h3><div className="flex gap-2">{canReturn ? <button type="button" className="app-action-warning rounded-lg px-3 py-2 text-xs font-medium" onClick={openReturn}>Вернуть</button> : null}{primaryAction && primaryAction !== "calculate" ? <button type="button" className="app-action-primary rounded-lg px-3 py-2 text-xs font-semibold" onClick={() => openRunAction(primaryAction)}>{payrollRunActionLabels[primaryAction]}</button> : null}</div></div><RunCard run={workspace.current_run} current /></div> : null}
|
||||
{workspace.current_run ? <div className="mt-5"><div className="mb-3 flex flex-wrap items-center justify-between gap-2"><h3 className="text-sm font-semibold text-[var(--foreground)]">Текущий расчёт</h3><div className="flex flex-wrap gap-2">{canReturn ? <button type="button" className="app-action-warning rounded-lg px-3 py-2 text-xs font-medium" onClick={openReturn}>Вернуть</button> : null}{primaryAction && primaryAction !== "calculate" ? <button type="button" className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium" onClick={() => openRunAction(primaryAction)}>{payrollRunActionLabels[primaryAction]}</button> : null}{permissions.full_access && primaryAction && primaryAction !== "calculate" && primaryAction !== "close" ? <button type="button" className="app-action-primary rounded-lg px-3 py-2 text-xs font-semibold" onClick={openCompleteWorkflow}>Выполнить весь цикл</button> : null}</div></div><RunCard run={workspace.current_run} current /></div> : null}
|
||||
{workspace.runs.length > 1 ? <details className="mt-4"><summary className="cursor-pointer text-sm font-medium text-[var(--foreground)]">История ревизий ({workspace.runs.length})</summary><div className="mt-3 grid gap-2">{workspace.runs.map((run) => <RunCard run={run} key={run.id} current={run.id === workspace.current_run?.id} />)}</div></details> : null}
|
||||
</section>
|
||||
) : null}
|
||||
@ -902,6 +1150,7 @@ export function PayrollAdminWorkspace({
|
||||
{selectedPeriod && bulkPayRateModalOpen ? <PayrollBulkPayRateModal isOpen period={selectedPeriod} employees={workspace.rate_employees} rates={rates} onClose={() => setBulkPayRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkCreatePayrollAdminPayRates(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPayRateResult) => { setBulkPayRateModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Ставка сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && bulkPointRateModalOpen ? <PayrollBulkPointRateModal isOpen period={selectedPeriod} rates={rates} onClose={() => setBulkPointRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminPointRate(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPointRateResult) => { setBulkPointRateModalOpen(false); const changed = result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Цена балла сверх нормы сохранена для ${changed} ставок${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && bulkTargetPointsModalOpen ? <PayrollBulkTargetPointsModal isOpen period={selectedPeriod} employees={workspace.employees} records={workRecords} onClose={() => setBulkTargetPointsModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminTargetPoints(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkTargetPointsResult) => { setBulkTargetPointsModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Норма баллов сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && carryoversModalOpen ? <PayrollCarryoversModal isOpen selectedPeriod={selectedPeriod} periods={workspace.periods} employees={workspace.rate_employees} initialEmployeeId={carryoversEmployeeId} canManage={permissions.manage_inputs} onClose={() => setCarryoversModalOpen(false)} onChanged={() => refreshView("Реестр долгов и доплат обновлён.")} /> : null}
|
||||
{selectedPeriod && workModalOpen ? <PayrollWorkRecordFormModal isOpen period={selectedPeriod} employees={workspace.employees} record={editingWork} onClose={() => setWorkModalOpen(false)} onSubmit={saveWork} onSaved={async () => { setWorkModalOpen(false); await refreshView("Черновик выработки сохранён."); }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && attendanceWorkModalOpen ? (
|
||||
<PayrollAttendanceWorkModal
|
||||
|
||||
315
frontend/src/components/finance/admin/PayrollCarryoversModal.tsx
Normal file
315
frontend/src/components/finance/admin/PayrollCarryoversModal.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowDownLeft, ArrowUpRight, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
PayrollAdminEmployee,
|
||||
PayrollAdminPeriod,
|
||||
PayrollCarryover,
|
||||
PayrollCarryoverDirection,
|
||||
PayrollCarryoverWorkspace,
|
||||
} from "@/lib/api/finance";
|
||||
import { formatPayrollDateTime, formatPayrollMoney } from "@/lib/payroll";
|
||||
import { getPayrollAdminError, periodStatusMeta } from "@/lib/payroll-admin";
|
||||
|
||||
type PayrollCarryoversModalProps = {
|
||||
canManage: boolean;
|
||||
employees: PayrollAdminEmployee[];
|
||||
initialEmployeeId?: number | null;
|
||||
isOpen: boolean;
|
||||
onChanged: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
periods: PayrollAdminPeriod[];
|
||||
selectedPeriod: PayrollAdminPeriod;
|
||||
};
|
||||
|
||||
const inputClass = "app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm";
|
||||
|
||||
const carryoverStatusClasses = {
|
||||
pending: "app-feedback-warning",
|
||||
transferred: "app-selected",
|
||||
cancelled: "app-surface-muted app-text-muted",
|
||||
};
|
||||
|
||||
function CarryoverCard({
|
||||
carryover,
|
||||
incoming,
|
||||
cancelling,
|
||||
confirmCancel,
|
||||
onCancel,
|
||||
onConfirmCancel,
|
||||
onDismissCancel,
|
||||
}: {
|
||||
carryover: PayrollCarryover;
|
||||
incoming: boolean;
|
||||
cancelling: boolean;
|
||||
confirmCancel: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirmCancel: () => void;
|
||||
onDismissCancel: () => void;
|
||||
}) {
|
||||
const Icon = carryover.direction === "company_owes" ? ArrowUpRight : ArrowDownLeft;
|
||||
const period = incoming ? carryover.source_period : carryover.target_period;
|
||||
return (
|
||||
<article className="app-surface-muted rounded-xl p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${carryover.direction === "company_owes" ? "app-feedback-success" : "app-feedback-warning"}`}>
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{carryover.employee.display_name}</p>
|
||||
<p className="app-text-muted mt-0.5 text-xs">{carryover.direction_label}</p>
|
||||
</div>
|
||||
<p className={`shrink-0 text-sm font-bold ${carryover.direction === "company_owes" ? "text-emerald-400" : "text-amber-400"}`}>
|
||||
{carryover.direction === "company_owes" ? "+" : "−"}{formatPayrollMoney(carryover.amount, carryover.source_period.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className={`app-status-pill px-2 text-[10px] ${carryoverStatusClasses[carryover.status]}`}>{carryover.status_label}</span>
|
||||
{period ? <span className="app-text-muted text-xs">{incoming ? "Из периода" : "В период"}: {period.name || period.code}</span> : null}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-[var(--foreground)]">{carryover.reason}</p>
|
||||
<p className="app-text-muted mt-2 text-[10px]">Создал: {carryover.created_by.display_name} · {formatPayrollDateTime(carryover.created_at)}</p>
|
||||
{confirmCancel ? (
|
||||
<div className="app-feedback-warning mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg px-3 py-2 text-xs">
|
||||
<span>Отменить перенос и связанный черновик?</span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" className="app-action-ghost rounded px-2 py-1" onClick={onDismissCancel}>Нет</button>
|
||||
<button type="button" className="app-action-danger rounded px-2 py-1" onClick={onConfirmCancel} disabled={cancelling}>{cancelling ? "Отменяем..." : "Да, отменить"}</button>
|
||||
</span>
|
||||
</div>
|
||||
) : carryover.can_cancel ? (
|
||||
<button type="button" className="app-action-ghost app-text-muted mt-2 inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs" onClick={onCancel}>
|
||||
<Trash2 size={12} /> Отменить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function PayrollCarryoversModal({
|
||||
canManage,
|
||||
employees,
|
||||
initialEmployeeId = null,
|
||||
isOpen,
|
||||
onChanged,
|
||||
onClose,
|
||||
periods,
|
||||
selectedPeriod,
|
||||
}: PayrollCarryoversModalProps) {
|
||||
const [periodId, setPeriodId] = useState(selectedPeriod.id);
|
||||
const [workspace, setWorkspace] = useState<PayrollCarryoverWorkspace | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
|
||||
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
||||
const [employeeFilterId, setEmployeeFilterId] = useState("");
|
||||
const [form, setForm] = useState({
|
||||
employee_id: "",
|
||||
direction: "company_owes" as PayrollCarryoverDirection,
|
||||
amount: "",
|
||||
reason: "",
|
||||
});
|
||||
const contextPeriod = useMemo(
|
||||
() => periods.find((period) => period.id === periodId) || selectedPeriod,
|
||||
[periodId, periods, selectedPeriod],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setPeriodId(selectedPeriod.id);
|
||||
setWorkspace(null);
|
||||
setError(null);
|
||||
setConfirmCancelId(null);
|
||||
const employeeId = initialEmployeeId ? String(initialEmployeeId) : "";
|
||||
setEmployeeFilterId(employeeId);
|
||||
setForm((current) => ({ ...current, employee_id: employeeId }));
|
||||
}, [initialEmployeeId, isOpen, selectedPeriod.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiClient.getPayrollAdminCarryovers(periodId)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setWorkspace(payload);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!cancelled) setError(getPayrollAdminError(loadError, "Не удалось загрузить долги и доплаты."));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, periodId]);
|
||||
|
||||
async function reload() {
|
||||
const payload = await apiClient.getPayrollAdminCarryovers(periodId);
|
||||
setWorkspace(payload);
|
||||
}
|
||||
|
||||
async function createEntry(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
await apiClient.createPayrollAdminCarryover(periodId, {
|
||||
employee_id: Number(form.employee_id),
|
||||
direction: form.direction,
|
||||
amount: form.amount,
|
||||
reason: form.reason,
|
||||
});
|
||||
setForm({ employee_id: employeeFilterId, direction: "company_owes", amount: "", reason: "" });
|
||||
await reload();
|
||||
await onChanged();
|
||||
} catch (createError) {
|
||||
setError(getPayrollAdminError(createError, "Не удалось зарегистрировать долг или доплату."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelEntry(carryoverId: number) {
|
||||
try {
|
||||
setCancellingId(carryoverId);
|
||||
setError(null);
|
||||
await apiClient.cancelPayrollAdminCarryover(carryoverId);
|
||||
setConfirmCancelId(null);
|
||||
await reload();
|
||||
await onChanged();
|
||||
} catch (cancelError) {
|
||||
setError(getPayrollAdminError(cancelError, "Не удалось отменить перенос."));
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [
|
||||
...(workspace?.outgoing.map((item) => ({ item, incoming: false })) || []),
|
||||
...(workspace?.incoming.map((item) => ({ item, incoming: true })) || []),
|
||||
].filter(({ item }) => !employeeFilterId || item.employee.id === Number(employeeFilterId));
|
||||
const visibleSummary = {
|
||||
outgoing: entries.filter((entry) => !entry.incoming).length,
|
||||
incoming: entries.filter((entry) => entry.incoming).length,
|
||||
pending: entries.filter((entry) => entry.item.status === "pending").length,
|
||||
};
|
||||
const statusMeta = periodStatusMeta[contextPeriod.status];
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Долги и доплаты" size="xl">
|
||||
<div className="space-y-5">
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<label>
|
||||
<span className="app-field-label">Период реестра</span>
|
||||
<select className={inputClass} value={periodId} onChange={(event) => setPeriodId(Number(event.target.value))}>
|
||||
{periods.map((period) => <option key={period.id} value={period.id}>{period.name || period.code}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="app-field-label">Сотрудник</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={employeeFilterId}
|
||||
onChange={(event) => {
|
||||
const employeeId = event.target.value;
|
||||
setEmployeeFilterId(employeeId);
|
||||
if (employeeId) setForm((current) => ({ ...current, employee_id: employeeId }));
|
||||
}}
|
||||
>
|
||||
<option value="">Все сотрудники</option>
|
||||
{employees.map((employee) => <option key={employee.id} value={employee.id}>{employee.display_name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`app-status-pill ${statusMeta.className}`}>{statusMeta.label}</span>
|
||||
<span className="app-text-muted">Исходящих: {visibleSummary.outgoing}</span>
|
||||
<span className="app-text-muted">Входящих: {visibleSummary.incoming}</span>
|
||||
{visibleSummary.pending ? <span className="app-feedback-warning rounded-full px-2 py-1">Ожидают периода: {visibleSummary.pending}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="app-feedback-danger rounded-xl p-3 text-sm">{error}</div> : null}
|
||||
|
||||
{canManage && contextPeriod.status === "closed" ? (
|
||||
<form onSubmit={createEntry} className="app-surface rounded-xl border border-[var(--border-subtle)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Plus size={15} className="app-accent-text" />
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">Зарегистрировать остаток</h3>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="sm:col-span-2">
|
||||
<span className="app-field-label">Сотрудник</span>
|
||||
<select className={inputClass} required value={form.employee_id} onChange={(event) => setForm({ ...form, employee_id: event.target.value })}>
|
||||
<option value="">Выберите сотрудника</option>
|
||||
{employees.map((employee) => <option key={employee.id} value={employee.id}>{employee.display_name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Вид остатка</span>
|
||||
<select className={inputClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value as PayrollCarryoverDirection })}>
|
||||
<option value="company_owes">Доплата сотруднику</option>
|
||||
<option value="employee_owes">Долг сотрудника</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Сумма</span>
|
||||
<input className={inputClass} type="number" inputMode="decimal" min="0.01" step="0.01" required value={form.amount} onChange={(event) => setForm({ ...form, amount: event.target.value })} />
|
||||
</label>
|
||||
<label className="sm:col-span-2">
|
||||
<span className="app-field-label">Основание</span>
|
||||
<textarea className={`${inputClass} min-h-20 resize-y`} required value={form.reason} placeholder="Причина возникновения долга или доплаты" onChange={(event) => setForm({ ...form, reason: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="submit" className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="animate-spin" /> : <Plus size={15} />}
|
||||
Зарегистрировать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="app-feedback-info rounded-xl px-4 py-3 text-sm">
|
||||
Новые долги и доплаты фиксируются только для закрытого периода. Выберите закрытый месяц в списке выше.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-[var(--foreground)]">Записи периода</h3>
|
||||
{loading ? (
|
||||
<div className="flex min-h-32 items-center justify-center"><Loader2 className="app-accent-text animate-spin" size={22} /></div>
|
||||
) : entries.length ? (
|
||||
<div className="space-y-3">
|
||||
{entries.map(({ item, incoming }) => (
|
||||
<CarryoverCard
|
||||
key={`${incoming ? "incoming" : "outgoing"}-${item.id}`}
|
||||
carryover={item}
|
||||
incoming={incoming}
|
||||
cancelling={cancellingId === item.id}
|
||||
confirmCancel={confirmCancelId === item.id}
|
||||
onCancel={() => setConfirmCancelId(item.id)}
|
||||
onConfirmCancel={() => void cancelEntry(item.id)}
|
||||
onDismissCancel={() => setConfirmCancelId(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
|
||||
{employeeFilterId ? "У этого сотрудника в выбранном периоде переносов нет." : "В этом периоде переносов нет."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Loader2, MessageSquare, Send } from "lucide-react";
|
||||
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { PayrollCellComment } from "@/lib/api/finance";
|
||||
import { formatDateTime } from "@/lib/shared";
|
||||
import { getPayrollAdminError } from "@/lib/payroll-admin";
|
||||
|
||||
export type PayrollCommentCell = {
|
||||
periodId: number;
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
columnId: string;
|
||||
columnLabel: string;
|
||||
commentsCount: number;
|
||||
};
|
||||
|
||||
type PayrollCellCommentsModalProps = {
|
||||
cell: PayrollCommentCell | null;
|
||||
currentUserId?: number | null;
|
||||
onClose: () => void;
|
||||
onCommentCountChange: (cell: PayrollCommentCell, count: number) => void;
|
||||
periodId: number;
|
||||
};
|
||||
|
||||
function authorFallback(comment: PayrollCellComment) {
|
||||
return (
|
||||
comment.author.last_name?.[0] ||
|
||||
comment.author.first_name?.[0] ||
|
||||
comment.author.email?.[0] ||
|
||||
"К"
|
||||
).toUpperCase();
|
||||
}
|
||||
|
||||
export function PayrollCellCommentsModal({
|
||||
cell,
|
||||
currentUserId,
|
||||
onClose,
|
||||
onCommentCountChange,
|
||||
periodId,
|
||||
}: PayrollCellCommentsModalProps) {
|
||||
const [comments, setComments] = useState<PayrollCellComment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [composerExpanded, setComposerExpanded] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadComments = useCallback(async (activeCell: PayrollCommentCell) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const next = await apiClient.getPayrollAdminCellComments(
|
||||
periodId,
|
||||
activeCell.employeeId,
|
||||
activeCell.columnId,
|
||||
);
|
||||
setComments(next);
|
||||
onCommentCountChange(activeCell, next.length);
|
||||
} catch (loadError) {
|
||||
setComments([]);
|
||||
setError(getPayrollAdminError(loadError, "Не удалось загрузить комментарии."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onCommentCountChange, periodId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cell) return;
|
||||
setComments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setDeletingId(null);
|
||||
void loadComments(cell);
|
||||
}, [cell, loadComments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cell || loading) return;
|
||||
requestAnimationFrame(() => bottomRef.current?.scrollIntoView({ block: "end" }));
|
||||
}, [cell, comments.length, loading]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
const minHeight = 38;
|
||||
const maxHeight = 128;
|
||||
input.style.height = "0px";
|
||||
const resolvedHeight = Math.max(Math.min(input.scrollHeight, maxHeight), minHeight);
|
||||
input.style.height = `${resolvedHeight}px`;
|
||||
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
setComposerExpanded(resolvedHeight > minHeight + 2);
|
||||
}, [draft]);
|
||||
|
||||
const sendComment = useCallback(async () => {
|
||||
if (!cell || !draft.trim()) return;
|
||||
try {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const saved = await apiClient.addPayrollAdminCellComment(
|
||||
periodId,
|
||||
cell.employeeId,
|
||||
cell.columnId,
|
||||
draft.trim(),
|
||||
);
|
||||
const next = [...comments, saved];
|
||||
setComments(next);
|
||||
setDraft("");
|
||||
onCommentCountChange(cell, next.length);
|
||||
} catch (sendError) {
|
||||
setError(getPayrollAdminError(sendError, "Не удалось отправить комментарий."));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [cell, comments, draft, onCommentCountChange, periodId]);
|
||||
|
||||
const deleteComment = useCallback(async (comment: PayrollCellComment) => {
|
||||
if (!cell) return;
|
||||
try {
|
||||
setDeletingId(comment.id);
|
||||
setError(null);
|
||||
await apiClient.deletePayrollAdminCellComment(
|
||||
periodId,
|
||||
cell.employeeId,
|
||||
cell.columnId,
|
||||
comment.id,
|
||||
);
|
||||
const next = comments.filter((item) => item.id !== comment.id);
|
||||
setComments(next);
|
||||
onCommentCountChange(cell, next.length);
|
||||
} catch (deleteError) {
|
||||
setError(getPayrollAdminError(deleteError, "Не удалось удалить комментарий."));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}, [cell, comments, onCommentCountChange, periodId]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={Boolean(cell)}
|
||||
onClose={onClose}
|
||||
title="Комментарии к ячейке"
|
||||
size="lg"
|
||||
noPadding
|
||||
>
|
||||
{cell ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 px-4 pb-4 sm:px-6">
|
||||
<div className="app-surface-muted rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{cell.employeeName}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">Колонка: {cell.columnLabel}</p>
|
||||
</div>
|
||||
{error ? <div className="app-feedback-danger mt-3 rounded-xl p-3 text-sm">{error}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 sm:px-6">
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<MessageSquare size={16} className="app-text-muted" />
|
||||
<p className="app-card-caption">Обсуждение · {comments.length}</p>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="py-8 text-center">
|
||||
<Loader2 className="app-text-muted mx-auto animate-spin" size={22} />
|
||||
<p className="app-text-muted mt-3 text-sm">Загрузка комментариев...</p>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">Комментариев пока нет</p>
|
||||
<p className="app-text-muted mt-2 text-sm">Добавьте пояснение к значению в этой ячейке.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<article key={comment.id} className="app-surface-muted rounded-xl p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<RequestAvatar
|
||||
alt={comment.author.display_name}
|
||||
fallback={authorFallback(comment)}
|
||||
size="lg"
|
||||
src={comment.author.avatar}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{comment.author.display_name}</p>
|
||||
<p className="app-text-muted mt-0.5 text-xs">{formatDateTime(comment.created_at)}</p>
|
||||
</div>
|
||||
{comment.author.id === currentUserId ? (
|
||||
<CommentDeleteButton
|
||||
disabled={deletingId === comment.id}
|
||||
onClick={() => deleteComment(comment)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="app-text-wrap mt-2 text-sm leading-6 text-[var(--foreground)]">{comment.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-elevated shrink-0 border-t border-[var(--border-primary)] px-4 py-4 sm:px-6">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className={`app-input min-w-0 flex-1 overflow-hidden ${composerExpanded ? "rounded-[1.5rem]" : "h-10 rounded-full"}`}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void sendComment();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
maxLength={10000}
|
||||
placeholder="Введите комментарий..."
|
||||
aria-label="Поле ввода комментария"
|
||||
className="w-full resize-none bg-transparent px-4 py-2.5 text-sm leading-5 text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendComment()}
|
||||
disabled={sending || !draft.trim()}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white shadow-sm transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:border disabled:border-[var(--border-subtle)] disabled:bg-[var(--surface-secondary)] disabled:text-[var(--muted-foreground)]"
|
||||
title="Отправить комментарий"
|
||||
aria-label="Отправить комментарий"
|
||||
>
|
||||
{sending ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -67,6 +67,11 @@ test("finance API keeps payroll management inside the native admin REST namespac
|
||||
recalculation_reason: "Исправлена премия",
|
||||
expected_lock_version: 7,
|
||||
});
|
||||
await finance.completePayrollAdminPeriodWorkflow(17, {
|
||||
idempotency_key: "6404b890-f462-4205-a7ac-f69bc40b63a7",
|
||||
recalculation_reason: "",
|
||||
expected_lock_version: 7,
|
||||
});
|
||||
await finance.publishPayrollAdminRun(33);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
@ -114,6 +119,17 @@ test("finance API keeps payroll management inside the native admin REST namespac
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoint: "/api/v1/finance/payroll/admin/periods/17/complete-workflow/",
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
idempotency_key: "6404b890-f462-4205-a7ac-f69bc40b63a7",
|
||||
recalculation_reason: "",
|
||||
expected_lock_version: 7,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoint: "/api/v1/finance/payroll/admin/runs/33/publish/",
|
||||
options: { method: "POST", body: "{}" },
|
||||
@ -155,3 +171,43 @@ test("finance API previews and applies attendance work records through one perio
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("finance API manages payroll carryovers through the selected period", async () => {
|
||||
const calls = [];
|
||||
const finance = createFinanceApi(async (endpoint, options) => {
|
||||
calls.push({ endpoint, options });
|
||||
return {};
|
||||
});
|
||||
|
||||
await finance.getPayrollAdminCarryovers(17);
|
||||
await finance.createPayrollAdminCarryover(17, {
|
||||
employee_id: 9,
|
||||
direction: "company_owes",
|
||||
amount: "3500.00",
|
||||
reason: "Недоплата",
|
||||
});
|
||||
await finance.cancelPayrollAdminCarryover(41);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
endpoint: "/api/v1/finance/payroll/admin/periods/17/carryovers/",
|
||||
options: undefined,
|
||||
},
|
||||
{
|
||||
endpoint: "/api/v1/finance/payroll/admin/periods/17/carryovers/",
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
employee_id: 9,
|
||||
direction: "company_owes",
|
||||
amount: "3500.00",
|
||||
reason: "Недоплата",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoint: "/api/v1/finance/payroll/admin/carryovers/41/",
|
||||
options: { method: "DELETE" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@ -39,6 +39,7 @@ export type PayrollStatementLine = {
|
||||
source_period_from: string | null;
|
||||
source_period_to: string | null;
|
||||
is_retro: boolean;
|
||||
is_automatic_carryover: boolean;
|
||||
calculated: boolean;
|
||||
};
|
||||
|
||||
@ -189,6 +190,10 @@ export type PayrollCalculationBlocker = {
|
||||
code: string;
|
||||
message: string;
|
||||
employee_id?: number;
|
||||
employee_ids?: number[];
|
||||
employee_count?: number;
|
||||
count?: number;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PayrollAdminWorkspace = {
|
||||
@ -220,6 +225,64 @@ export type PayrollAdminWorkspace = {
|
||||
work_records: number;
|
||||
input_lines: number;
|
||||
};
|
||||
carryovers: PayrollCarryoverSummary;
|
||||
};
|
||||
|
||||
export type PayrollCarryoverDirection = "company_owes" | "employee_owes";
|
||||
export type PayrollCarryoverStatus = "pending" | "transferred" | "cancelled";
|
||||
|
||||
export type PayrollCarryoverPeriod = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type PayrollCarryoverSummary = {
|
||||
outgoing: number;
|
||||
incoming: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export type PayrollCarryover = {
|
||||
id: number;
|
||||
source_period: PayrollCarryoverPeriod;
|
||||
target_period: PayrollCarryoverPeriod | null;
|
||||
employee: PayrollAdminEmployee;
|
||||
direction: PayrollCarryoverDirection;
|
||||
direction_label: string;
|
||||
amount: string;
|
||||
reason: string;
|
||||
status: PayrollCarryoverStatus;
|
||||
status_label: string;
|
||||
generated_input_line_id: number | null;
|
||||
created_by: PayrollAdminUserRef;
|
||||
created_at: string;
|
||||
transferred_at: string | null;
|
||||
cancelled_at: string | null;
|
||||
can_cancel: boolean;
|
||||
};
|
||||
|
||||
export type PayrollCarryoverWorkspace = {
|
||||
period_id: number;
|
||||
summary: PayrollCarryoverSummary;
|
||||
outgoing: PayrollCarryover[];
|
||||
incoming: PayrollCarryover[];
|
||||
};
|
||||
|
||||
export type PayrollCarryoverWrite = {
|
||||
employee_id: number;
|
||||
direction: PayrollCarryoverDirection;
|
||||
amount: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PayrollCompleteWorkflowResult = {
|
||||
period: PayrollAdminPeriod;
|
||||
run: PayrollAdminRun;
|
||||
completed_steps: string[];
|
||||
};
|
||||
|
||||
export type PayrollPeriodTableStatus = "calculated" | "ready" | "draft" | "incomplete";
|
||||
@ -231,8 +294,39 @@ export type PayrollPeriodTableComponent = {
|
||||
display_order: number;
|
||||
};
|
||||
|
||||
export type PayrollCellCommentAuthor = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
patronymic: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
display_name: string;
|
||||
full_name: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
export type PayrollCellComment = {
|
||||
id: number;
|
||||
author: PayrollCellCommentAuthor;
|
||||
text: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type PayrollCellCommentSummary = {
|
||||
employee_id: number;
|
||||
column_id: string;
|
||||
count: number;
|
||||
latest_at: string;
|
||||
};
|
||||
|
||||
export type PayrollPeriodTableRow = {
|
||||
employee: PayrollAdminEmployee & { is_active: boolean };
|
||||
employee: PayrollAdminEmployee & {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
patronymic: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
status: PayrollPeriodTableStatus;
|
||||
rate_status: PayrollApprovalStatus | null;
|
||||
work_status: PayrollApprovalStatus | null;
|
||||
@ -258,6 +352,14 @@ export type PayrollPeriodTableRow = {
|
||||
payable: string | null;
|
||||
};
|
||||
|
||||
export type PayrollPeriodTableCarryoverSummary = {
|
||||
employee_id: number;
|
||||
count: number;
|
||||
company_owes: string;
|
||||
employee_owes: string;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export type PayrollPeriodTable = {
|
||||
period_id: number;
|
||||
currency: string;
|
||||
@ -273,6 +375,8 @@ export type PayrollPeriodTable = {
|
||||
};
|
||||
run: Pick<PayrollAdminRun, "id" | "revision" | "status"> | null;
|
||||
component_columns: PayrollPeriodTableComponent[];
|
||||
cell_comments: PayrollCellCommentSummary[];
|
||||
carryovers: PayrollPeriodTableCarryoverSummary[];
|
||||
rows: PayrollPeriodTableRow[];
|
||||
summary: {
|
||||
employee_count: number;
|
||||
@ -566,6 +670,8 @@ export type PayrollAdminInputLine = {
|
||||
component_id?: number;
|
||||
amount: string;
|
||||
relates_to_period_id: number | null;
|
||||
relates_to_period: PayrollCarryoverPeriod | null;
|
||||
is_automatic_carryover: boolean;
|
||||
reason: string;
|
||||
status: PayrollApprovalStatus;
|
||||
lock_version: number;
|
||||
@ -744,6 +850,47 @@ export function createFinanceApi(request: RequestFn) {
|
||||
request(`${adminPrefix}/periods/`),
|
||||
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/table/`),
|
||||
getPayrollAdminCarryovers: (periodId: number): Promise<PayrollCarryoverWorkspace> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/carryovers/`),
|
||||
createPayrollAdminCarryover: (
|
||||
periodId: number,
|
||||
payload: PayrollCarryoverWrite,
|
||||
): Promise<PayrollCarryover> =>
|
||||
request(
|
||||
`${adminPrefix}/periods/${periodId}/carryovers/`,
|
||||
jsonOptions("POST", payload),
|
||||
),
|
||||
cancelPayrollAdminCarryover: (carryoverId: number): Promise<PayrollCarryover> =>
|
||||
request(
|
||||
`${adminPrefix}/carryovers/${carryoverId}/`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
getPayrollAdminCellComments: (
|
||||
periodId: number,
|
||||
employeeId: number,
|
||||
columnId: string,
|
||||
): Promise<PayrollCellComment[]> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/employees/${employeeId}/cells/${encodeURIComponent(columnId)}/comments/`),
|
||||
addPayrollAdminCellComment: (
|
||||
periodId: number,
|
||||
employeeId: number,
|
||||
columnId: string,
|
||||
text: string,
|
||||
): Promise<PayrollCellComment> =>
|
||||
request(
|
||||
`${adminPrefix}/periods/${periodId}/employees/${employeeId}/cells/${encodeURIComponent(columnId)}/comments/`,
|
||||
jsonOptions("POST", { text }),
|
||||
),
|
||||
deletePayrollAdminCellComment: (
|
||||
periodId: number,
|
||||
employeeId: number,
|
||||
columnId: string,
|
||||
commentId: number,
|
||||
): Promise<void> =>
|
||||
request(
|
||||
`${adminPrefix}/periods/${periodId}/employees/${employeeId}/cells/${encodeURIComponent(columnId)}/comments/${commentId}/`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
getPayrollAdminPointBreakdown: (
|
||||
periodId: number,
|
||||
employeeId: number,
|
||||
@ -862,6 +1009,11 @@ export function createFinanceApi(request: RequestFn) {
|
||||
payload: { idempotency_key: string; recalculation_reason: string; expected_lock_version: number },
|
||||
): Promise<PayrollAdminRun> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/calculate/`, jsonOptions("POST", payload)),
|
||||
completePayrollAdminPeriodWorkflow: (
|
||||
periodId: number,
|
||||
payload: { idempotency_key: string; recalculation_reason: string; expected_lock_version: number },
|
||||
): Promise<PayrollCompleteWorkflowResult> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/complete-workflow/`, jsonOptions("POST", payload)),
|
||||
submitPayrollAdminRunForReview: (runId: number): Promise<PayrollAdminRun> =>
|
||||
request(`${adminPrefix}/runs/${runId}/submit-review/`, jsonOptions("POST")),
|
||||
returnPayrollAdminRun: (runId: number, reason: string): Promise<PayrollAdminRun> =>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
canApprovePayrollDraft,
|
||||
getDefaultPayrollPeriodForm,
|
||||
getPayrollAdminError,
|
||||
getPayrollBlockerTarget,
|
||||
getPrimaryPayrollRunAction,
|
||||
isPayrollAdminStaleConflict,
|
||||
normalizePayrollAdminList,
|
||||
@ -30,6 +31,20 @@ test("payroll admin normalizes DRF and plain list payloads", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("payroll readiness blockers link to the relevant filtered source data", () => {
|
||||
assert.deepEqual(getPayrollBlockerTarget("DRAFT_WORK_RECORDS"), {
|
||||
tab: "work",
|
||||
status: "draft",
|
||||
label: "Показать черновики выработки",
|
||||
});
|
||||
assert.deepEqual(getPayrollBlockerTarget("MISSING_APPROVED_RATE"), {
|
||||
tab: "rates",
|
||||
status: "",
|
||||
label: "Открыть ставки",
|
||||
});
|
||||
assert.equal(getPayrollBlockerTarget("PERIOD_CLOSED"), null);
|
||||
});
|
||||
|
||||
test("new payroll period defaults cover the current month and next payment date", () => {
|
||||
assert.deepEqual(
|
||||
getDefaultPayrollPeriodForm([], new Date(2026, 6, 20, 12)),
|
||||
|
||||
@ -14,6 +14,44 @@ export const PAYROLL_SELECTED_PERIOD_STORAGE_PREFIX = "finances.payroll.selected
|
||||
|
||||
export type PayrollAdminTab = "readiness" | "rates" | "work" | "inputs" | "summary" | "approval";
|
||||
|
||||
export type PayrollBlockerTarget = {
|
||||
tab: Extract<PayrollAdminTab, "rates" | "work" | "inputs">;
|
||||
status: "" | PayrollApprovalStatus;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const PAYROLL_BLOCKER_TARGETS: Record<string, PayrollBlockerTarget> = {
|
||||
NO_APPROVED_WORK_RECORDS: {
|
||||
tab: "work",
|
||||
status: "",
|
||||
label: "Открыть выработку",
|
||||
},
|
||||
MISSING_APPROVED_RATE: {
|
||||
tab: "rates",
|
||||
status: "",
|
||||
label: "Открыть ставки",
|
||||
},
|
||||
DRAFT_WORK_RECORDS: {
|
||||
tab: "work",
|
||||
status: "draft",
|
||||
label: "Показать черновики выработки",
|
||||
},
|
||||
DRAFT_INPUT_LINES: {
|
||||
tab: "inputs",
|
||||
status: "draft",
|
||||
label: "Показать черновики операций",
|
||||
},
|
||||
DRAFT_PAY_RATES: {
|
||||
tab: "rates",
|
||||
status: "draft",
|
||||
label: "Показать черновики ставок",
|
||||
},
|
||||
};
|
||||
|
||||
export function getPayrollBlockerTarget(code: string): PayrollBlockerTarget | null {
|
||||
return PAYROLL_BLOCKER_TARGETS[code] || null;
|
||||
}
|
||||
|
||||
export const PAYROLL_ADMIN_TABS: Array<{ value: PayrollAdminTab; label: string }> = [
|
||||
{ value: "readiness", label: "Готовность" },
|
||||
{ value: "rates", label: "Ставки" },
|
||||
|
||||
28
frontend/src/lib/payroll-columns.test.js
Normal file
28
frontend/src/lib/payroll-columns.test.js
Normal file
@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
movePayrollColumn,
|
||||
normalizePayrollColumnOrder,
|
||||
} from "./payroll-columns.ts";
|
||||
|
||||
test("payroll column order keeps saved columns and appends newly available ones", () => {
|
||||
assert.deepEqual(
|
||||
normalizePayrollColumnOrder(
|
||||
["payable", "actual_points", "removed", "payable"],
|
||||
["rate_amount", "actual_points", "payable", "status"],
|
||||
),
|
||||
["payable", "actual_points", "rate_amount", "status"],
|
||||
);
|
||||
});
|
||||
|
||||
test("payroll columns can move across calculation groups", () => {
|
||||
assert.deepEqual(
|
||||
movePayrollColumn(
|
||||
["rate_amount", "actual_points", "bonus", "payable"],
|
||||
"payable",
|
||||
"actual_points",
|
||||
),
|
||||
["rate_amount", "payable", "actual_points", "bonus"],
|
||||
);
|
||||
});
|
||||
31
frontend/src/lib/payroll-columns.ts
Normal file
31
frontend/src/lib/payroll-columns.ts
Normal file
@ -0,0 +1,31 @@
|
||||
export function normalizePayrollColumnOrder(
|
||||
storedOrder: string[],
|
||||
availableIds: string[],
|
||||
): string[] {
|
||||
const available = new Set(availableIds);
|
||||
const seen = new Set<string>();
|
||||
const normalized = storedOrder.filter((columnId) => {
|
||||
if (!available.has(columnId) || seen.has(columnId)) return false;
|
||||
seen.add(columnId);
|
||||
return true;
|
||||
});
|
||||
for (const columnId of availableIds) {
|
||||
if (!seen.has(columnId)) normalized.push(columnId);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function movePayrollColumn(
|
||||
columnOrder: string[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
): string[] {
|
||||
const oldIndex = columnOrder.indexOf(activeId);
|
||||
const newIndex = columnOrder.indexOf(overId);
|
||||
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return columnOrder;
|
||||
|
||||
const next = [...columnOrder];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
return next;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user