feat(finance): expand payroll period workflows

This commit is contained in:
lizerk 2026-08-04 16:40:23 +03:00
parent 91ca6b55e8
commit 1295d24c86
35 changed files with 3914 additions and 217 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, # Каждый час

View File

@ -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", "Отменено"

View File

@ -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="Создал",
),
),
]

View 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')],
},
),
]

View 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')],
},
),
]

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
}

View 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

View File

@ -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="Открыть комментарии"
>

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "Ставки" },

View 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"],
);
});

View 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;
}