feat(payroll): refine carryovers and period workflows

This commit is contained in:
lizerk 2026-08-12 13:03:15 +03:00
parent 4f45e567b1
commit 37fd5efc33
37 changed files with 3237 additions and 527 deletions

View File

@ -514,7 +514,7 @@ class PayrollWorkRecordWriteSerializer(
)
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.filter(is_active=True),
queryset=Employee.objects.all(),
)
target_points = serializers.DecimalField(
max_digits=19,
@ -628,6 +628,7 @@ class PayrollInputLineSerializer(serializers.ModelSerializer):
"relates_to_period_id",
"relates_to_period",
"is_automatic_carryover",
"include_in_carryover",
"reason",
"status",
"status_label",
@ -650,6 +651,10 @@ class PayrollInputLineSerializer(serializers.ModelSerializer):
)
class PayrollCarryoverInclusionSerializer(serializers.Serializer):
included = serializers.BooleanField()
class PayrollInputLineWriteSerializer(
DraftWriteSerializerMixin,
serializers.ModelSerializer,
@ -661,7 +666,7 @@ class PayrollInputLineWriteSerializer(
)
employee_id = serializers.PrimaryKeyRelatedField(
source="employee",
queryset=Employee.objects.filter(is_active=True),
queryset=Employee.objects.all(),
)
component_id = serializers.PrimaryKeyRelatedField(
source="component",
@ -696,6 +701,50 @@ class ClearComponentCellCommandSerializer(serializers.Serializer):
component_id = serializers.IntegerField(min_value=1)
class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer):
confirmed = serializers.BooleanField()
class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
source_period_id = serializers.IntegerField(min_value=1)
component_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=False,
)
employee_scope = serializers.ChoiceField(
choices=("all", "active", "selected"),
)
existing_mode = serializers.ChoiceField(choices=("skip", "replace"))
employee_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
required=False,
default=list,
)
expected_period_lock_version = serializers.IntegerField(min_value=0)
idempotency_key = serializers.UUIDField()
remember_configuration = serializers.BooleanField(default=False)
auto_apply_on_period_creation = serializers.BooleanField(default=False)
def validate(self, attrs):
attrs["component_ids"] = list(dict.fromkeys(attrs["component_ids"]))
attrs["employee_ids"] = list(dict.fromkeys(attrs["employee_ids"]))
if attrs["employee_scope"] == "selected" and not attrs["employee_ids"]:
raise serializers.ValidationError(
{"employee_ids": "Выберите хотя бы одного сотрудника."}
)
if attrs["employee_scope"] != "selected":
attrs["employee_ids"] = []
if attrs["auto_apply_on_period_creation"] and not attrs["remember_configuration"]:
raise serializers.ValidationError(
{
"auto_apply_on_period_creation": (
"Для автоприменения сначала сохраните конфигурацию."
)
}
)
return attrs
class CalculatePayrollCommandSerializer(serializers.Serializer):
expected_lock_version = serializers.IntegerField(min_value=0)
recalculation_reason = serializers.CharField(

View File

@ -7,7 +7,8 @@ access to payroll data.
from __future__ import annotations
from collections import defaultdict
import logging
import uuid
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType
@ -17,6 +18,7 @@ from django.db import IntegrityError, transaction
from django.db.models import Count, Max, Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
@ -24,9 +26,12 @@ from rest_framework.views import APIView
from communications import comments_helpers
from communications.models import Message
from employees.constants import ACTION_DISMISSED
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import (
ApprovalStatus,
PayrollCarryoverStatus,
InputSource,
PayrollComponentKind,
PayrollPeriodStatus,
PayrollRunStatus,
)
@ -38,8 +43,11 @@ from finance.models import (
PayrollCellCommentThread,
PayrollComponent,
PayrollInputLine,
PayrollInputCopyConfiguration,
PayrollPeriod,
PayrollRun,
PayrollStatement,
PayrollStatementPaymentConfirmation,
PayrollWorkRecord,
)
from finance.payroll.config import (
@ -49,11 +57,7 @@ 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.carryovers import cancel_carryover
from finance.payroll.access import (
can_access_payroll_management,
has_payroll_permission,
@ -101,6 +105,7 @@ from .admin_serializers import (
BulkTargetPointsCommandSerializer,
CalculatePayrollCommandSerializer,
ClearComponentCellCommandSerializer,
CopyPayrollInputLinesCommandSerializer,
EmployeePayRateRevisionSerializer,
EmployeePayRateSerializer,
EmployeePayRateWriteSerializer,
@ -108,12 +113,13 @@ from .admin_serializers import (
PayrollCellCommentSerializer,
PayrollCellCommentWriteSerializer,
PayrollCarryoverSerializer,
PayrollCarryoverWriteSerializer,
PayrollCarryoverInclusionSerializer,
PayrollEmployeeMiniSerializer,
PayrollInputLineSerializer,
PayrollInputLineWriteSerializer,
PayrollPeriodSerializer,
PayrollPeriodWriteSerializer,
PayrollPaymentConfirmationCommandSerializer,
PayrollRunSerializer,
PayrollUserRefSerializer,
PayrollWorkRecordRevisionSerializer,
@ -127,6 +133,7 @@ from .admin_serializers import (
from .views import NoStorePayrollResponseMixin, PayrollConflict
Employee = get_user_model()
logger = logging.getLogger(__name__)
PAYROLL_PERMISSIONS = {
"manage_inputs": "finance.manage_payroll_inputs",
@ -296,51 +303,471 @@ def _serialize_cell_comment(message, request):
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
)
statements = PayrollStatement.objects.filter(
run=period.current_run,
) if period.current_run_id else PayrollStatement.objects.none()
return {
"outgoing": outgoing.count(),
"incoming": incoming.count(),
"pending": outgoing.filter(status=PayrollCarryoverStatus.PENDING).count(),
"outgoing": statements.exclude(closing_balance=0).count(),
"incoming": statements.exclude(opening_balance=0).count(),
"pending": 0,
}
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)
def _automatic_balance_summaries(rows):
summaries = []
for row in rows:
balance = Decimal(row["closing_balance"])
if balance == 0:
continue
summaries.append(
{
"employee_id": row["employee"]["id"],
"count": 1,
"company_owes": str(max(balance, Decimal("0.00"))),
"employee_owes": str(max(-balance, Decimal("0.00"))),
"pending": 0,
}
)
.exclude(status=PayrollCarryoverStatus.CANCELLED)
.values("employee_id", "direction", "amount", "status")
return summaries
def _validate_input_copy_periods(target_period, source_period):
if source_period.pk == target_period.pk:
raise ValidationError(
{"source_period_id": "Период-источник должен отличаться от текущего."}
)
if source_period.date_to >= target_period.date_from:
raise ValidationError(
{"source_period_id": "Переносить записи можно только из более раннего периода."}
)
if source_period.currency != target_period.currency:
raise ValidationError(
{"source_period_id": "Валюты исходного и текущего периодов различаются."}
)
def _copyable_input_lines(source_period):
return (
PayrollInputLine.objects.filter(
period=source_period,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
component__is_active=True,
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.select_related(
"employee",
"employee__position",
"component",
"relates_to_period",
)
.prefetch_related(
"employee__departments_links__department",
"employee__actions__source_request",
)
.order_by("employee_id", "component__display_order", "id")
)
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"],
def _employee_active_for_period(employee, period):
if not employee.is_active:
return False
actions = employee._prefetched_objects_cache.get("actions")
state = resolve_employee_personnel_state(
employee,
period.date_to,
actions=actions,
)
return state.status != ACTION_DISMISSED
def _input_copy_configuration_payload(actor):
configuration = PayrollInputCopyConfiguration.objects.filter(owner=actor).first()
if configuration is None:
return {
"saved": False,
"component_ids": [],
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "skip",
"auto_apply_on_period_creation": False,
}
for employee_id, summary in summaries.items()
return {
"saved": True,
"component_ids": configuration.component_ids,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"existing_mode": configuration.existing_mode,
"auto_apply_on_period_creation": (
configuration.auto_apply_on_period_creation
),
}
def _input_copy_preview(target_period, source_period, *, actor):
_validate_input_copy_periods(target_period, source_period)
lines = list(_copyable_input_lines(source_period))
component_rows = {
component.pk: {
"component": component,
"line_count": 0,
"employee_ids": set(),
"amount_total": Decimal("0.00"),
}
for component in PayrollComponent.objects.filter(is_active=True).order_by(
"display_order",
"code",
)
}
employee_rows = {}
for line in lines:
component_row = component_rows[line.component_id]
component_row["line_count"] += 1
component_row["employee_ids"].add(line.employee_id)
component_row["amount_total"] += line.amount
employee_row = employee_rows.get(line.employee_id)
if employee_row is None:
employee_row = {
"employee": line.employee,
"is_active": _employee_active_for_period(
line.employee,
target_period,
),
"line_count": 0,
"component_counts": {},
}
employee_rows[line.employee_id] = employee_row
employee_row["line_count"] += 1
employee_row["component_counts"][str(line.component_id)] = (
employee_row["component_counts"].get(str(line.component_id), 0) + 1
)
existing_counts = {}
if employee_rows and component_rows:
for row in (
PayrollInputLine.objects.filter(
period=target_period,
employee_id__in=employee_rows,
component_id__in=component_rows,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.values("employee_id", "component_id")
.annotate(count=Count("id"))
):
existing_counts.setdefault(row["employee_id"], {})[
str(row["component_id"])
] = row["count"]
components = [
{
**PayrollComponentSerializer(row["component"]).data,
"line_count": row["line_count"],
"employee_count": len(row["employee_ids"]),
"amount_total": str(row["amount_total"]),
}
for row in component_rows.values()
]
employees = [
{
**PayrollEmployeeMiniSerializer(row["employee"]).data,
"is_active": row["is_active"],
"line_count": row["line_count"],
"component_counts": row["component_counts"],
"existing_component_counts": existing_counts.get(
row["employee"].pk,
{},
),
}
for row in employee_rows.values()
]
employees.sort(key=lambda item: item["display_name"].casefold())
return {
"source_period": PayrollPeriodSerializer(source_period).data,
"target_period": PayrollPeriodSerializer(target_period).data,
"summary": {
"eligible_lines": len(lines),
"employees": len(employees),
"active_employees": sum(item["is_active"] for item in employees),
},
"components": components,
"employees": employees,
"configuration": _input_copy_configuration_payload(actor),
}
def _apply_input_copy(*, target_period, source_period, actor, data):
copy_prefix = (
f"payroll-period-copy:{target_period.pk}:{source_period.pk}:"
f"{data['idempotency_key']}:"
)
existing = list(
PayrollInputLine.objects.filter(
period=target_period,
source_ref__startswith=copy_prefix,
)
.order_by("id")
.values_list("id", flat=True)
)
if existing:
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(existing),
"employees": PayrollInputLine.objects.filter(
pk__in=existing
).values("employee_id").distinct().count(),
"skipped": 0,
"replaced_cells": 0,
"voided": 0,
},
"record_ids": existing,
"idempotent_replay": True,
}
lines = list(
_copyable_input_lines(source_period).filter(
component_id__in=data["component_ids"]
)
)
if data["employee_scope"] == "selected":
selected_ids = set(data["employee_ids"])
lines = [line for line in lines if line.employee_id in selected_ids]
elif data["employee_scope"] == "active":
lines = [
line
for line in lines
if _employee_active_for_period(line.employee, target_period)
]
selected_cells = {
(line.employee_id, line.component_id)
for line in lines
}
existing_lines = []
if selected_cells:
possible_existing_lines = list(
PayrollInputLine.objects.select_for_update()
.filter(
period=target_period,
employee_id__in={employee_id for employee_id, _ in selected_cells},
component_id__in={component_id for _, component_id in selected_cells},
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.order_by("pk")
)
existing_lines = [
line
for line in possible_existing_lines
if (line.employee_id, line.component_id) in selected_cells
and not (
line.source == InputSource.API
and line.source_ref.startswith("payroll-carryover:")
)
]
occupied_cells = {
(line.employee_id, line.component_id)
for line in existing_lines
}
skipped = 0
replaced_cells = 0
voided = 0
if data["existing_mode"] == "skip" and occupied_cells:
kept_lines = []
for line in lines:
if (line.employee_id, line.component_id) in occupied_cells:
skipped += 1
else:
kept_lines.append(line)
lines = kept_lines
elif data["existing_mode"] == "replace" and existing_lines:
replaced_cells = len(occupied_cells)
voided = len(existing_lines)
now = timezone.now()
for line in existing_lines:
previous_status = line.status
line.status = ApprovalStatus.VOIDED
line.voided_by = actor
line.voided_at = now
line.void_reason = (
f"Заменено переносом записей из периода {source_period.code}"
)
line.save(
update_fields=[
"status",
"voided_by",
"voided_at",
"void_reason",
]
)
_audit_api_mutation(
actor=actor,
action="payroll.input_line_voided_from_period_copy",
instance=line,
period=target_period,
metadata={
"previous_status": previous_status,
"source_period_id": source_period.pk,
},
)
records = PayrollInputLine.objects.bulk_create(
[
PayrollInputLine(
period=target_period,
employee=line.employee,
component=line.component,
amount=line.amount,
relates_to_period=line.relates_to_period,
reason=line.reason,
status=ApprovalStatus.DRAFT,
source=InputSource.API,
source_ref=f"{copy_prefix}{line.pk}",
include_in_carryover=line.include_in_carryover,
created_by=actor,
)
for line in lines
]
)
PayrollAuditEvent.objects.bulk_create(
[
PayrollAuditEvent(
actor=actor,
action="payroll.input_line_draft_created",
object_type=PayrollInputLine._meta.label_lower,
object_id=str(record.pk),
period=target_period,
metadata={
"channel": "admin_api",
"copied_from_period_id": source_period.pk,
"copied_from_input_line_id": source.pk,
},
)
for record, source in zip(records, lines, strict=True)
]
)
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(records),
"employees": len({record.employee_id for record in records}),
"skipped": skipped,
"replaced_cells": replaced_cells,
"voided": voided,
},
"record_ids": [record.pk for record in records],
"idempotent_replay": False,
}
def _carryover_ledger(period):
published_period_ids = set(
PayrollRun.objects.filter(
status=PayrollRunStatus.PUBLISHED,
period__currency=period.currency,
period__date_to__lte=period.date_to,
).values_list("period_id", flat=True)
)
target_is_published = period.pk in published_period_ids
lines = (
PayrollInputLine.objects.filter(
period__currency=period.currency,
period__date_to__lte=period.date_to,
component__kind__in=(
PayrollComponentKind.ADJUSTMENT_CREDIT,
PayrollComponentKind.ADJUSTMENT_DEBIT,
),
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.select_related(
"period",
"employee",
"employee__position",
"component",
"created_by",
)
.prefetch_related("employee__departments_links__department")
.order_by("-period__date_to", "-created_at", "-id")
)
entries = []
balances_by_employee = {}
included_count = 0
excluded_count = 0
for line in lines:
current_period_line = line.period_id == period.pk
active_status = line.status == ApprovalStatus.APPROVED or (
current_period_line
and not target_is_published
and line.status == ApprovalStatus.DRAFT
)
active_period = current_period_line or line.period_id in published_period_ids
counts_in_balance = bool(
line.include_in_carryover and active_status and active_period
)
raw_contribution = (
line.amount
if line.component.kind == PayrollComponentKind.ADJUSTMENT_DEBIT
else -line.amount
)
contribution = raw_contribution if counts_in_balance else Decimal("0.00")
balances_by_employee[line.employee_id] = (
balances_by_employee.get(line.employee_id, Decimal("0.00"))
+ contribution
)
if line.include_in_carryover:
included_count += 1
else:
excluded_count += 1
entries.append(
{
"id": line.pk,
"period": {
"id": line.period_id,
"code": line.period.code,
"name": line.period.name,
"date_from": line.period.date_from,
"date_to": line.period.date_to,
"currency": line.period.currency,
},
"employee": PayrollEmployeeMiniSerializer(line.employee).data,
"component": PayrollComponentSerializer(line.component).data,
"amount": str(line.amount),
"raw_contribution": str(raw_contribution),
"contribution": str(contribution),
"reason": line.reason,
"status": line.status,
"status_label": line.get_status_display(),
"include_in_carryover": line.include_in_carryover,
"counts_in_balance": counts_in_balance,
"created_by": PayrollUserRefSerializer(line.created_by).data,
"created_at": line.created_at,
}
)
company_owes = sum(
(max(balance, Decimal("0.00")) for balance in balances_by_employee.values()),
Decimal("0.00"),
)
employee_owes = sum(
(max(-balance, Decimal("0.00")) for balance in balances_by_employee.values()),
Decimal("0.00"),
)
return {
"period_id": period.pk,
"currency": period.currency,
"summary": {
"included_count": included_count,
"excluded_count": excluded_count,
"company_owes": str(company_owes),
"employee_owes": str(employee_owes),
"balance": str(company_owes - employee_owes),
},
"entries": entries,
}
def _lock_period_accepting_draft_edits(period_id):
@ -850,7 +1277,10 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView):
except IntegrityError:
_conflict(
"PERIOD_CONFLICT",
"Параллельная операция уже создала пересекающийся период.",
(
"Не удалось создать расчётный период: сохранённые периоды "
"пересекаются с указанными датами. Обновите список периодов."
),
)
_audit_api_mutation(
actor=request.user,
@ -858,9 +1288,49 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView):
instance=period,
period=period,
)
materialize_pending_carryovers(period)
automatic_copy = None
configuration = PayrollInputCopyConfiguration.objects.filter(
owner=request.user,
auto_apply_on_period_creation=True,
).first()
if configuration is not None:
source_period = (
PayrollPeriod.objects.select_for_update(of=("self",))
.filter(
currency=period.currency,
date_to__lt=period.date_from,
)
.exclude(pk=period.pk)
.order_by("-date_to", "-id")
.first()
)
if source_period is not None:
automatic_copy = _apply_input_copy(
target_period=period,
source_period=source_period,
actor=request.user,
data={
"component_ids": configuration.component_ids,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"existing_mode": configuration.existing_mode,
"idempotency_key": uuid.uuid4(),
},
)
_audit_api_mutation(
actor=request.user,
action="payroll.period_input_copy_auto_applied",
instance=period,
period=period,
metadata={
"source_period_id": source_period.pk,
**automatic_copy["summary"],
},
)
payload = PayrollPeriodSerializer(period).data
payload["automatic_input_copy"] = automatic_copy
return Response(
PayrollPeriodSerializer(period).data,
payload,
status=status.HTTP_201_CREATED,
)
@ -929,57 +1399,93 @@ class PayrollPeriodTableView(PayrollAdminAPIView):
)
payload = build_payroll_period_table(period)
payload["cell_comments"] = _cell_comment_summaries(period)
payload["carryovers"] = _carryover_employee_summaries(period)
payload["carryovers"] = _automatic_balance_summaries(payload["rows"])
return Response(payload)
class PayrollStatementPaymentConfirmationView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
@transaction.atomic
def post(self, request, pk, employee_id):
command = PayrollPaymentConfirmationCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
period = get_object_or_404(
PayrollPeriod.objects.select_for_update().select_related("current_run"),
pk=pk,
)
if period.status != PayrollPeriodStatus.CLOSED:
_conflict(
"PERIOD_NOT_CLOSED",
"Подтвердить фактическую выплату можно только в закрытом периоде.",
)
if period.current_run_id is None:
_conflict(
"PAYROLL_STATEMENT_NOT_FOUND",
"В закрытом периоде нет итогового расчёта.",
)
statement = get_object_or_404(
PayrollStatement.objects.select_for_update(),
run_id=period.current_run_id,
employee_id=employee_id,
)
confirmed = command.validated_data["confirmed"]
if confirmed:
confirmation, _ = PayrollStatementPaymentConfirmation.objects.get_or_create(
statement=statement,
defaults={"confirmed_by": request.user},
)
action = "payroll.statement_payment_confirmed"
else:
PayrollStatementPaymentConfirmation.objects.filter(
statement=statement,
).delete()
confirmation = None
action = "payroll.statement_payment_confirmation_revoked"
_audit_api_mutation(
actor=request.user,
action=action,
instance=statement,
period=period,
metadata={"employee_id": employee_id},
)
return Response(
{
"statement_id": statement.pk,
"employee_id": employee_id,
"confirmed": confirmation is not None,
"confirmed_at": (
confirmation.confirmed_at.isoformat()
if confirmation is not None
else None
),
"confirmed_by": (
PayrollUserRefSerializer(confirmation.confirmed_by).data
if confirmation is not None
else None
),
}
)
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,
}
)
return Response(_carryover_ledger(period))
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,
{
"code": "MANUAL_CARRYOVER_DISABLED",
"message": (
"Отдельные переносы не создаются. Баланс складывается из "
"положительных и отрицательных корректировок."
),
},
status=status.HTTP_410_GONE,
)
@ -1001,6 +1507,53 @@ class PayrollCarryoverDetailView(PayrollAdminAPIView):
return Response(PayrollCarryoverSerializer(carryover).data)
class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
@transaction.atomic
def patch(self, request, pk):
command = PayrollCarryoverInclusionSerializer(data=request.data)
command.is_valid(raise_exception=True)
line = get_object_or_404(
PayrollInputLine.objects.select_for_update(of=("self",)).select_related(
"period", "component"
),
pk=pk,
component__kind__in=(
PayrollComponentKind.ADJUSTMENT_CREDIT,
PayrollComponentKind.ADJUSTMENT_DEBIT,
),
)
included = command.validated_data["included"]
previous = line.include_in_carryover
if previous != included:
line.include_in_carryover = included
line.save(update_fields=["include_in_carryover", "updated_at"])
_audit_api_mutation(
actor=request.user,
action="payroll.input_line_carryover_inclusion_updated",
instance=line,
period=line.period,
metadata={"before": previous, "after": included},
)
return Response(
{
"id": line.pk,
"include_in_carryover": line.include_in_carryover,
"message": (
"Запись участвует в переходящем остатке. "
"Если период уже рассчитан, запустите перерасчёт."
if line.include_in_carryover
else (
"Запись исключена только из переходящего остатка; "
"зарплата исходного периода не изменена. Если период "
"уже рассчитан, запустите перерасчёт."
)
),
}
)
class PayrollCellCommentsView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
@ -1155,9 +1708,19 @@ class DraftCollectionView(PayrollAdminAPIView):
try:
instance = serializer.save()
except IntegrityError:
logger.exception(
"Database rejected payroll draft creation",
extra={
"payroll_model": self.model._meta.label,
"payroll_actor_id": request.user.pk,
},
)
_conflict(
"DRAFT_CONFLICT",
"Параллельная операция уже создала такие входные данные.",
"DRAFT_SAVE_FAILED",
(
"Не удалось сохранить данные расчёта: база данных отклонила "
"запись. Обновите страницу и повторите попытку."
),
)
period = getattr(instance, "period", None)
_audit_api_mutation(
@ -1331,7 +1894,10 @@ class DraftDetailView(PayrollAdminAPIView):
except IntegrityError:
_conflict(
"DRAFT_CONFLICT",
"Изменение конфликтует с уже сохранёнными входными данными.",
(
"Не удалось сохранить изменения: база данных отклонила "
"запись. Обновите данные и повторите попытку."
),
)
_audit_api_mutation(
actor=request.user,
@ -1588,6 +2154,72 @@ class PayrollComponentCellClearView(PayrollAdminAPIView):
return Response(summary)
class PayrollInputLinePeriodCopyView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def get(self, request, pk):
target_period = get_object_or_404(PayrollPeriod, pk=pk)
source_period_id = _optional_int(request, "source_period_id")
if source_period_id is None:
raise ValidationError(
{"source_period_id": "Выберите период-источник."}
)
source_period = get_object_or_404(PayrollPeriod, pk=source_period_id)
return Response(
_input_copy_preview(
target_period,
source_period,
actor=request.user,
)
)
@transaction.atomic
def post(self, request, pk):
command = CopyPayrollInputLinesCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
data = command.validated_data
target_period = _lock_period_accepting_draft_edits(pk)
if target_period.lock_version != data["expected_period_lock_version"]:
_conflict(
"STALE_PERIOD",
"Период уже изменён; обновите данные и повторите перенос.",
expected_lock_version=data["expected_period_lock_version"],
actual_lock_version=target_period.lock_version,
)
source_period = get_object_or_404(
PayrollPeriod.objects.select_for_update(of=("self",)),
pk=data["source_period_id"],
)
_validate_input_copy_periods(target_period, source_period)
if data["remember_configuration"]:
PayrollInputCopyConfiguration.objects.update_or_create(
owner=request.user,
defaults={
"component_ids": data["component_ids"],
"employee_scope": data["employee_scope"],
"employee_ids": data["employee_ids"],
"existing_mode": data["existing_mode"],
"auto_apply_on_period_creation": data[
"auto_apply_on_period_creation"
],
},
)
else:
PayrollInputCopyConfiguration.objects.filter(owner=request.user).delete()
result = _apply_input_copy(
target_period=target_period,
source_period=source_period,
actor=request.user,
data=data,
)
return Response(
result,
status=(
status.HTTP_200_OK
if result["idempotent_replay"]
else status.HTTP_201_CREATED
),
)
class DraftApprovalView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],)
service = None
@ -1682,7 +2314,7 @@ class EmployeePayRateReviseView(PayrollAdminAPIView):
except IntegrityError:
_conflict(
"REVISION_CONFLICT",
"Параллельная операция уже создала ревизию ставки.",
"Не удалось создать новую версию ставки. Обновите данные и повторите попытку.",
)
_audit_api_mutation(
actor=request.user,
@ -1760,7 +2392,7 @@ class PayrollWorkRecordReviseView(PayrollAdminAPIView):
except IntegrityError:
_conflict(
"REVISION_CONFLICT",
"Параллельная операция уже создала ревизию показателей.",
"Не удалось создать новую версию показателей. Обновите данные и повторите попытку.",
)
_audit_api_mutation(
actor=request.user,

View File

@ -5,6 +5,7 @@ from rest_framework import serializers
from finance.models import (
PayrollStatement,
PayrollStatementAcknowledgement,
PayrollStatementPaymentConfirmation,
PayrollStatementLine,
PayrollDailyWorkEntry,
PayrollWorkRecord,
@ -40,6 +41,26 @@ class PayrollStatementAcknowledgementSerializer(serializers.ModelSerializer):
fields = ["viewed_at", "acknowledged_at", "disputed_at"]
class PayrollStatementPaymentConfirmationSerializer(serializers.ModelSerializer):
confirmed_by = serializers.SerializerMethodField()
class Meta:
model = PayrollStatementPaymentConfirmation
fields = ["confirmed_at", "confirmed_by"]
read_only_fields = fields
def get_confirmed_by(self, obj):
user = obj.confirmed_by
return {
"id": user.pk,
"display_name": user.get_full_name().strip() or f"Сотрудник #{user.pk}",
}
class OwnPayrollPaymentConfirmationWriteSerializer(serializers.Serializer):
confirmed = serializers.BooleanField()
def period_payload(statement):
period = statement.run.period
return {
@ -59,6 +80,14 @@ def acknowledgement_payload(statement):
return PayrollStatementAcknowledgementSerializer(acknowledgement).data
def payment_confirmation_payload(statement):
try:
confirmation = statement.payment_confirmation
except PayrollStatementPaymentConfirmation.DoesNotExist:
return None
return PayrollStatementPaymentConfirmationSerializer(confirmation).data
class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer):
"""List metadata only; detailed amounts/lines require the audited endpoint."""
@ -69,6 +98,8 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer):
read_only=True,
)
acknowledgement = serializers.SerializerMethodField()
payment_confirmation = serializers.SerializerMethodField()
can_confirm_payment = serializers.SerializerMethodField()
class Meta:
model = PayrollStatement
@ -80,6 +111,8 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer):
"currency",
"payable",
"acknowledgement",
"payment_confirmation",
"can_confirm_payment",
]
read_only_fields = fields
@ -89,6 +122,12 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer):
def get_acknowledgement(self, obj):
return acknowledgement_payload(obj)
def get_payment_confirmation(self, obj):
return payment_confirmation_payload(obj)
def get_can_confirm_payment(self, obj):
return obj.run.period.status == "closed"
class OwnPayrollStatementSerializer(serializers.ModelSerializer):
period = serializers.SerializerMethodField()
@ -99,6 +138,8 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
)
lines = PayrollStatementLineSerializer(many=True, read_only=True)
acknowledgement = serializers.SerializerMethodField()
payment_confirmation = serializers.SerializerMethodField()
can_confirm_payment = serializers.SerializerMethodField()
class Meta:
model = PayrollStatement
@ -115,9 +156,13 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
"deduction_total",
"net_pay",
"payment_total",
"opening_balance",
"closing_balance",
"payable",
"lines",
"acknowledgement",
"payment_confirmation",
"can_confirm_payment",
]
read_only_fields = fields
@ -127,6 +172,12 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
def get_acknowledgement(self, obj):
return acknowledgement_payload(obj)
def get_payment_confirmation(self, obj):
return payment_confirmation_payload(obj)
def get_can_confirm_payment(self, obj):
return obj.run.period.status == "closed"
class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer):
status_label = serializers.CharField(source="get_status_display", read_only=True)

View File

@ -4,6 +4,7 @@ from .views import (
OwnPayrollStatementAcknowledgeView,
OwnPayrollStatementDetailView,
OwnPayrollStatementListView,
OwnPayrollStatementPaymentConfirmationView,
OwnPayrollWorkRecordView,
)
from .admin_views import (
@ -26,8 +27,10 @@ from .admin_views import (
PayrollManagementAccessSettingsView,
PayrollAttendanceWorkRecordsView,
PayrollInputLineApproveView,
PayrollInputLineCarryoverInclusionView,
PayrollInputLineDetailView,
PayrollInputLineListCreateView,
PayrollInputLinePeriodCopyView,
PayrollInputHistoryView,
PayrollPeriodCalculateView,
PayrollPeriodCarryoverListCreateView,
@ -35,6 +38,7 @@ from .admin_views import (
PayrollPeriodApproveDraftsView,
PayrollPeriodCloseView,
PayrollPeriodPointBreakdownView,
PayrollStatementPaymentConfirmationView,
PayrollPeriodTableView,
PayrollRunApproveView,
PayrollRunDetailView,
@ -102,6 +106,11 @@ urlpatterns = [
PayrollPeriodTableView.as_view(),
name="admin-period-table",
),
path(
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/payment-confirmation/",
PayrollStatementPaymentConfirmationView.as_view(),
name="admin-period-payment-confirmation",
),
path(
"payroll/admin/periods/<int:pk>/carryovers/",
PayrollPeriodCarryoverListCreateView.as_view(),
@ -112,6 +121,11 @@ urlpatterns = [
PayrollCarryoverDetailView.as_view(),
name="admin-carryover-detail",
),
path(
"payroll/admin/input-lines/<int:pk>/carryover-inclusion/",
PayrollInputLineCarryoverInclusionView.as_view(),
name="admin-input-line-carryover-inclusion",
),
path(
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/cells/<str:column_id>/comments/",
PayrollCellCommentsView.as_view(),
@ -157,6 +171,11 @@ urlpatterns = [
PayrollBulkTargetPointsView.as_view(),
name="admin-period-bulk-target-points",
),
path(
"payroll/admin/periods/<int:pk>/copy-input-lines/",
PayrollInputLinePeriodCopyView.as_view(),
name="admin-period-copy-input-lines",
),
path(
"payroll/admin/periods/<int:pk>/close/",
PayrollPeriodCloseView.as_view(),
@ -272,4 +291,9 @@ urlpatterns = [
OwnPayrollStatementAcknowledgeView.as_view(),
name="own-statement-acknowledge",
),
path(
"payroll/me/statements/<uuid:public_id>/payment-confirmation/",
OwnPayrollStatementPaymentConfirmationView.as_view(),
name="own-statement-payment-confirmation",
),
]

View File

@ -1,5 +1,6 @@
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.db import transaction
from django.utils import timezone
from django.utils.cache import patch_vary_headers
from rest_framework import generics, permissions, status
@ -13,6 +14,7 @@ from finance.models import (
PayrollDailyWorkEntry,
PayrollPeriod,
PayrollStatement,
PayrollStatementPaymentConfirmation,
PayrollWorkSettings,
PayrollWorkRecord,
)
@ -31,8 +33,10 @@ from .serializers import (
OwnPayrollDailyWorkEntryWriteSerializer,
OwnPayrollStatementSerializer,
OwnPayrollStatementSummarySerializer,
OwnPayrollPaymentConfirmationWriteSerializer,
OwnPayrollWorkRecordSerializer,
PayrollStatementAcknowledgementSerializer,
PayrollStatementPaymentConfirmationSerializer,
)
@ -56,8 +60,13 @@ def own_published_statements(user):
employee=user,
run__status=PayrollRunStatus.PUBLISHED,
)
.select_related("run", "run__period")
.prefetch_related("lines", "acknowledgement")
.select_related(
"run",
"run__period",
"acknowledgement",
"payment_confirmation__confirmed_by",
)
.prefetch_related("lines")
.order_by("-run__period__date_from", "-run__revision")
)
@ -173,6 +182,70 @@ class OwnPayrollStatementAcknowledgeView(
)
class OwnPayrollStatementPaymentConfirmationView(
NoStorePayrollResponseMixin,
APIView,
):
permission_classes = [permissions.IsAuthenticated]
@transaction.atomic
def post(self, request, public_id):
command = OwnPayrollPaymentConfirmationWriteSerializer(data=request.data)
command.is_valid(raise_exception=True)
statement = (
PayrollStatement.objects.select_for_update()
.select_related("run", "run__period")
.filter(
public_id=public_id,
employee=request.user,
run__status=PayrollRunStatus.PUBLISHED,
)
.first()
)
if statement is None:
raise Http404
if statement.run.period.status != PayrollPeriodStatus.CLOSED:
raise PayrollConflict(
detail={
"code": "PERIOD_NOT_CLOSED",
"message": (
"Подтвердить получение денег можно после закрытия периода."
),
}
)
confirmed = command.validated_data["confirmed"]
if confirmed:
confirmation, _ = PayrollStatementPaymentConfirmation.objects.get_or_create(
statement=statement,
defaults={"confirmed_by": request.user},
)
action = "payroll.statement_payment_confirmed"
else:
PayrollStatementPaymentConfirmation.objects.filter(
statement=statement,
).delete()
confirmation = None
action = "payroll.statement_payment_confirmation_revoked"
PayrollAuditEvent.objects.create(
actor=request.user,
action=action,
object_type=statement._meta.label_lower,
object_id=str(statement.pk),
period=statement.run.period,
after_hash=statement.result_hash,
metadata={"channel": "employee_api"},
)
return Response(
{
"payment_confirmation": (
PayrollStatementPaymentConfirmationSerializer(confirmation).data
if confirmation is not None
else None
)
}
)
class OwnPayrollWorkRecordView(
NoStorePayrollResponseMixin,
APIView,

View File

@ -858,7 +858,7 @@ COMMUNICATIONS_PARTICIPANT_RESOLVER = (
# version after changing calculation semantics.
FINANCE_PAYROLL = {
"RULESET_ID": os.getenv("PAYROLL_RULESET_ID", "eusrr-standard"),
"RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.07.4"),
"RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.08.1"),
"EFFECTIVE_FROM": os.getenv("PAYROLL_EFFECTIVE_FROM", "2026-01-01"),
"EFFECTIVE_TO": os.getenv("PAYROLL_EFFECTIVE_TO") or None,
# Excel-compatible point basis is base accrual plus bonus inputs. Below the
@ -871,7 +871,7 @@ FINANCE_PAYROLL = {
"MONEY_QUANTUM": os.getenv("PAYROLL_MONEY_QUANTUM", "0.01"),
"ROUNDING": os.getenv("PAYROLL_ROUNDING", "half_up"),
"ALLOW_NEGATIVE_PAYABLE": os.getenv(
"PAYROLL_ALLOW_NEGATIVE_PAYABLE", "false"
"PAYROLL_ALLOW_NEGATIVE_PAYABLE", "true"
),
"BASE_RATE_CODE": os.getenv("PAYROLL_BASE_RATE_CODE", "BASE"),
# Temporary pilot mode. Set to false after the granular finance roles and

View File

@ -49,6 +49,14 @@ employees above or below the point target.
| Аванс | payment input line, reducing payable |
| Итого / выработка / перерасчёт / к выплате | reconciliation checkpoints |
Published statements persist a signed correction balance. Only explicit manual
adjustment lines change it: adjustment debits increase the balance and adjustment
credits decrease it. Point-based recalculation, ordinary earnings, deductions and
recorded payments never create a carryover. The next period uses the last published
closing balance as its opening balance until opposite corrections net it to zero.
Historical statements from before this feature start with a zero closing balance
and are not backfilled.
Checkpoint columns can block review on mismatch but never feed money back
into the calculation.

View File

@ -702,6 +702,8 @@ class PayrollStatementAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
"gross_total",
"deduction_total",
"payment_total",
"opening_balance",
"closing_balance",
"payable",
]
list_filter = ["run__period", "run__status", "currency"]

View File

@ -0,0 +1,53 @@
from decimal import Decimal
from django.db import migrations, models
def seed_actual_payment_component(apps, schema_editor):
component_model = apps.get_model("finance", "PayrollComponent")
component_model.objects.update_or_create(
code="SALARY_PAYMENT",
defaults={
"name": "Фактическая выплата",
"kind": "payment",
"requires_reason": True,
"is_active": True,
"display_order": 80,
},
)
class Migration(migrations.Migration):
dependencies = [("finance", "0018_financesettings")]
operations = [
migrations.RemoveConstraint(
model_name="payrollrun",
name="pay_run_payable_nonnegative",
),
migrations.RemoveConstraint(
model_name="payrollstatement",
name="pay_statement_payable_nonnegative",
),
migrations.AddField(
model_name="payrollstatement",
name="opening_balance",
field=models.DecimalField(
decimal_places=2,
default=Decimal("0"),
max_digits=21,
verbose_name="Входящий баланс",
),
),
migrations.AddField(
model_name="payrollstatement",
name="closing_balance",
field=models.DecimalField(
decimal_places=2,
default=Decimal("0"),
max_digits=21,
verbose_name="Исходящий баланс",
),
),
migrations.RunPython(seed_actual_payment_component, migrations.RunPython.noop),
]

View File

@ -0,0 +1,46 @@
from django.db import migrations, models
from django.utils import timezone
def ensure_updated_at_column(apps, schema_editor):
"""Reconcile databases created with the legacy input-line timestamp column."""
input_line_model = apps.get_model("finance", "PayrollInputLine")
table_name = input_line_model._meta.db_table
with schema_editor.connection.cursor() as cursor:
columns = schema_editor.connection.introspection.get_table_description(
cursor,
table_name,
)
if any(column.name == "updated_at" for column in columns):
return
database_field = models.DateTimeField(default=timezone.now)
database_field.set_attributes_from_name("updated_at")
database_field.model = input_line_model
schema_editor.add_field(input_line_model, database_field)
class Migration(migrations.Migration):
dependencies = [("finance", "0019_payroll_automatic_balances")]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunPython(
ensure_updated_at_column,
migrations.RunPython.noop,
),
],
state_operations=[
migrations.AddField(
model_name="payrollinputline",
name="updated_at",
field=models.DateTimeField(
auto_now=True,
verbose_name="Обновлена",
),
),
],
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("finance", "0020_payrollinputline_updated_at")]
operations = [
migrations.AddField(
model_name="payrollinputline",
name="include_in_carryover",
field=models.BooleanField(
default=True,
help_text=(
"Используется только для положительных и отрицательных "
"корректировок; на расчёт зарплаты самой строки не влияет."
),
verbose_name="Учитывать в переходящем остатке",
),
),
]

View File

@ -0,0 +1,72 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("finance", "0021_payrollinputline_include_in_carryover"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="PayrollInputCopyConfiguration",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("component_ids", models.JSONField(default=list, verbose_name="Виды операций")),
(
"employee_scope",
models.CharField(
choices=[
("all", "Все из источника"),
("active", "Все активные"),
("selected", "Выбранные"),
],
default="active",
max_length=16,
verbose_name="Охват сотрудников",
),
),
("employee_ids", models.JSONField(default=list, verbose_name="Выбранные сотрудники")),
(
"existing_mode",
models.CharField(
choices=[("skip", "Пропустить"), ("replace", "Заменить")],
default="skip",
max_length=16,
verbose_name="Заполненные значения",
),
),
(
"auto_apply_on_period_creation",
models.BooleanField(
default=False,
verbose_name="Автоматически применять при создании периода",
),
),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлена")),
(
"owner",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="payroll_input_copy_configuration",
to=settings.AUTH_USER_MODEL,
verbose_name="Пользователь",
),
),
],
options={
"verbose_name": "Конфигурация переноса зарплатных записей",
"verbose_name_plural": "Конфигурации переноса зарплатных записей",
},
),
]

View File

@ -0,0 +1,56 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("finance", "0022_payrollinputcopyconfiguration"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="PayrollStatementPaymentConfirmation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"confirmed_at",
models.DateTimeField(
auto_now_add=True,
verbose_name="Выплата подтверждена",
),
),
(
"confirmed_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="confirmed_payroll_statement_payments",
to=settings.AUTH_USER_MODEL,
verbose_name="Подтвердил выплату",
),
),
(
"statement",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="payment_confirmation",
to="finance.payrollstatement",
verbose_name="Расчётный листок",
),
),
],
options={
"verbose_name": "Подтверждение выплаты зарплаты",
"verbose_name_plural": "Подтверждения выплаты зарплаты",
},
),
]

View File

@ -1017,6 +1017,14 @@ class PayrollInputLine(DraftVersionedModel):
unique=True,
editable=False,
)
include_in_carryover = models.BooleanField(
"Учитывать в переходящем остатке",
default=True,
help_text=(
"Используется только для положительных и отрицательных корректировок; "
"на расчёт зарплаты самой строки не влияет."
),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
@ -1024,6 +1032,7 @@ class PayrollInputLine(DraftVersionedModel):
verbose_name="Создал",
)
created_at = models.DateTimeField("Создана", auto_now_add=True)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
@ -1116,6 +1125,47 @@ class PayrollInputLine(DraftVersionedModel):
)
class PayrollInputCopyConfiguration(models.Model):
"""Per-operator defaults for copying input lines between payroll periods."""
owner = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="payroll_input_copy_configuration",
verbose_name="Пользователь",
)
component_ids = models.JSONField("Виды операций", default=list)
employee_scope = models.CharField(
"Охват сотрудников",
max_length=16,
choices=(
("all", "Все из источника"),
("active", "Все активные"),
("selected", "Выбранные"),
),
default="active",
)
employee_ids = models.JSONField("Выбранные сотрудники", default=list)
existing_mode = models.CharField(
"Заполненные значения",
max_length=16,
choices=(("skip", "Пропустить"), ("replace", "Заменить")),
default="skip",
)
auto_apply_on_period_creation = models.BooleanField(
"Автоматически применять при создании периода",
default=False,
)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
class Meta:
verbose_name = "Конфигурация переноса зарплатных записей"
verbose_name_plural = "Конфигурации переноса зарплатных записей"
def __str__(self):
return f"Перенос зарплатных записей: {self.owner_id}"
class PayrollRun(models.Model):
"""Immutable calculation revision plus its controlled workflow state."""
@ -1263,10 +1313,6 @@ class PayrollRun(models.Model):
condition=Q(deduction_total__gte=0),
name="pay_run_deduction_nonnegative",
),
models.CheckConstraint(
condition=Q(payable_total__gte=0),
name="pay_run_payable_nonnegative",
),
]
def __str__(self):
@ -1360,6 +1406,18 @@ class PayrollStatement(models.Model):
max_digits=21,
decimal_places=2,
)
opening_balance = models.DecimalField(
"Входящий баланс",
max_digits=21,
decimal_places=2,
default=Decimal("0"),
)
closing_balance = models.DecimalField(
"Исходящий баланс",
max_digits=21,
decimal_places=2,
default=Decimal("0"),
)
payable = models.DecimalField(
"К выплате",
max_digits=21,
@ -1402,10 +1460,6 @@ class PayrollStatement(models.Model):
condition=Q(payment_total__gte=0),
name="pay_statement_payment_nonnegative",
),
models.CheckConstraint(
condition=Q(payable__gte=0),
name="pay_statement_payable_nonnegative",
),
]
def __str__(self):
@ -1540,6 +1594,31 @@ class PayrollStatementAcknowledgement(models.Model):
)
class PayrollStatementPaymentConfirmation(models.Model):
"""Administrative confirmation that the final payable amount was issued."""
statement = models.OneToOneField(
PayrollStatement,
on_delete=models.CASCADE,
related_name="payment_confirmation",
verbose_name="Расчётный листок",
)
confirmed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="confirmed_payroll_statement_payments",
verbose_name="Подтвердил выплату",
)
confirmed_at = models.DateTimeField("Выплата подтверждена", auto_now_add=True)
class Meta:
verbose_name = "Подтверждение выплаты зарплаты"
verbose_name_plural = "Подтверждения выплаты зарплаты"
def __str__(self):
return f"Выплата по листку {self.statement_id}"
class PayrollAuditEvent(models.Model):
"""Append-only redacted audit trail for payroll commands and reads."""

View File

@ -62,6 +62,7 @@ def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine,
reason=record.reason,
source_period=source_period,
is_retro=source_period is not None,
include_in_carryover=record.include_in_carryover,
)
)
return tuple(mapped_lines)
@ -110,6 +111,7 @@ def build_core_request(
rate: EmployeePayRate,
work_record: PayrollWorkRecord | None,
input_lines: list[PayrollInputLine],
opening_balance: Decimal = Decimal("0"),
rounding: str = ROUND_HALF_UP,
) -> PayrollRequest:
target_points = work_record.target_points if work_record else None
@ -120,6 +122,7 @@ def build_core_request(
currency=period.currency,
base_accrual=rate.amount,
base_source_ref=f"pay-rate:{rate.pk}:revision:{rate.revision}",
opening_balance=opening_balance,
point_base_accrual=point_base_accrual,
target_points=target_points,
actual_points=(work_record.actual_points if work_record else None),
@ -145,6 +148,7 @@ def build_core_preview_request(
target_points: Decimal,
actual_points: Decimal,
input_lines: list[PayrollInputLine],
opening_balance: Decimal = Decimal("0"),
rounding: str = ROUND_HALF_UP,
) -> PayrollRequest:
"""Build a non-persisted calculation request from draft or partial inputs."""
@ -160,6 +164,7 @@ def build_core_preview_request(
if rate is not None
else "preview:missing-pay-rate"
),
opening_balance=opening_balance,
point_base_accrual=point_base_accrual,
target_points=target_points,
actual_points=actual_points,

View File

@ -0,0 +1,62 @@
"""Payroll balances reconstructed from explicit correction input lines."""
from __future__ import annotations
from collections.abc import Iterable
from decimal import Decimal
from django.db.models import Q, Sum
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollComponentKind,
PayrollRunStatus,
)
from finance.models import PayrollInputLine, PayrollPeriod
def opening_balances(
period: PayrollPeriod,
employee_ids: Iterable[int],
) -> dict[int, Decimal]:
"""Return balances produced by included corrections in published periods."""
requested_ids = set(employee_ids)
if not requested_ids:
return {}
rows = (
PayrollInputLine.objects.filter(
employee_id__in=requested_ids,
period__currency=period.currency,
period__date_to__lt=period.date_from,
period__runs__status=PayrollRunStatus.PUBLISHED,
status=ApprovalStatus.APPROVED,
include_in_carryover=True,
component__kind__in=(
PayrollComponentKind.ADJUSTMENT_CREDIT,
PayrollComponentKind.ADJUSTMENT_DEBIT,
),
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.values("employee_id")
.annotate(
credit_total=Sum(
"amount",
filter=Q(component__kind=PayrollComponentKind.ADJUSTMENT_CREDIT),
default=Decimal("0.00"),
),
debit_total=Sum(
"amount",
filter=Q(component__kind=PayrollComponentKind.ADJUSTMENT_DEBIT),
default=Decimal("0.00"),
),
)
)
return {
row["employee_id"]: row["debit_total"] - row["credit_total"]
for row in rows
}

View File

@ -13,13 +13,13 @@ from payroll_core import PayrollRules, PointPolicy, RoundingPolicy
DEFAULT_CONFIG = {
"RULESET_ID": "eusrr-standard",
"RULESET_VERSION": "2026.07.4",
"RULESET_VERSION": "2026.08.1",
"EFFECTIVE_FROM": "2026-01-01",
"EFFECTIVE_TO": None,
"POINT_POLICY": PointPolicy.PROPORTIONAL_WITH_EXCESS.value,
"MONEY_QUANTUM": "0.01",
"ROUNDING": RoundingPolicy.HALF_UP.value,
"ALLOW_NEGATIVE_PAYABLE": False,
"ALLOW_NEGATIVE_PAYABLE": True,
"BASE_RATE_CODE": "BASE",
# Temporary pilot mode: portal administrators receive every payroll
# operation while the granular role matrix is being tested.
@ -110,11 +110,6 @@ def build_rules() -> PayrollRules:
"The finance adapter persists two decimal places; "
"MONEY_QUANTUM must be 0.01"
)
if allow_negative_payable:
raise ImproperlyConfigured(
"The finance adapter does not persist negative payable amounts"
)
return PayrollRules(
ruleset_id=str(config["RULESET_ID"]),
version=str(config["RULESET_VERSION"]),

View File

@ -19,7 +19,7 @@ from employees.services.personnel_state import resolve_employee_personnel_state
from requests_app.enums import RequestStatus
from requests_app.models import Request
from finance.enums import ApprovalStatus
from finance.enums import ApprovalStatus, InputSource
from finance.models import (
EmployeePayRate,
PayrollComponent,
@ -31,6 +31,7 @@ from finance.models import (
)
from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request
from .balances import opening_balances
from .attendance import (
calculate_attendance_day_points,
calculate_attendance_points_projection,
@ -160,6 +161,7 @@ def _input_lines_by_employee(period, employee_ids):
employee_id__in=employee_ids,
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
)
.exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:")
.select_related("component", "relates_to_period")
.order_by("employee_id", "component__display_order", "id")
)
@ -230,6 +232,7 @@ def build_payroll_period_table(period: PayrollPeriod):
for statement in current_run.statements.select_related(
"employee",
"employee__position",
"payment_confirmation__confirmed_by",
).prefetch_related("lines")
}
@ -272,6 +275,7 @@ def build_payroll_period_table(period: PayrollPeriod):
rates = _latest_rate_by_employee(period, employee_ids)
work_records = _latest_work_by_employee(period, employee_ids)
input_lines = _input_lines_by_employee(period, employee_ids)
balances = opening_balances(period, employee_ids)
components = list(PayrollComponent.objects.all())
component_definitions = {
component.code: _component_definition(
@ -335,6 +339,23 @@ def build_payroll_period_table(period: PayrollPeriod):
work is None or not work.target_points_overridden
)
totals_preliminary = False
opening_balance = statement.opening_balance
closing_balance = statement.closing_balance
confirmation = getattr(statement, "payment_confirmation", None)
payment_confirmation = (
{
"confirmed_at": confirmation.confirmed_at.isoformat(),
"confirmed_by": {
"id": confirmation.confirmed_by_id,
"display_name": (
confirmation.confirmed_by.get_full_name().strip()
or f"Сотрудник #{confirmation.confirmed_by_id}"
),
},
}
if confirmation is not None
else None
)
else:
component_amounts = _source_component_amounts(
source_lines,
@ -364,6 +385,7 @@ def build_payroll_period_table(period: PayrollPeriod):
target_points=preview_target_points,
actual_points=preview_actual_points,
input_lines=source_lines,
opening_balance=balances.get(employee.pk, Decimal("0")),
rounding=preview_rules.rounding.value,
)
preview_result = calculator.calculate(preview_request, preview_rules)
@ -381,6 +403,9 @@ def build_payroll_period_table(period: PayrollPeriod):
"payable": str(preview_totals.payable),
}
totals_preliminary = True
opening_balance = preview_request.opening_balance
closing_balance = preview_totals.closing_balance
payment_confirmation = None
has_draft = (
getattr(rate, "status", None) == ApprovalStatus.DRAFT
or getattr(work, "status", None) == ApprovalStatus.DRAFT
@ -442,6 +467,9 @@ def build_payroll_period_table(period: PayrollPeriod):
"point_amount": _decimal_text(point_amount),
"component_amounts": component_amounts,
"totals_preliminary": totals_preliminary,
"opening_balance": str(opening_balance),
"closing_balance": str(closing_balance),
"payment_confirmation": payment_confirmation,
**totals,
}
)
@ -456,6 +484,7 @@ def build_payroll_period_table(period: PayrollPeriod):
return {
"period_id": period.pk,
"period_status": period.status,
"currency": period.currency,
"calculation_rules": active_rules.to_dict(),
"run": (

View File

@ -41,6 +41,7 @@ from finance.models import (
)
from .adapter import build_core_request, employee_snapshot
from .balances import opening_balances
from .access import (
SELF_APPROVAL_OVERRIDE_PERMISSION,
can_self_approve_payroll,
@ -223,6 +224,7 @@ def _input_lines_by_employee(period, employee_ids):
employee_id__in=employee_ids,
status=ApprovalStatus.APPROVED,
)
.exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:")
.select_related("component", "relates_to_period")
.order_by("employee_id", "component__display_order", "id")
)
@ -265,6 +267,7 @@ def _calculate_current_period_inputs(
employee_ids=outside_roster,
)
input_lines = _input_lines_by_employee(period, employee_ids)
balances = opening_balances(period, employee_ids)
requests = []
results = []
@ -288,6 +291,7 @@ def _calculate_current_period_inputs(
rate=rate,
work_record=work_record,
input_lines=input_lines[rate.employee_id],
opening_balance=balances.get(rate.employee_id, Decimal("0")),
rounding=active_rules.rounding.value,
)
try:
@ -510,6 +514,8 @@ def calculate_period(
deduction_total=totals.deduction_total,
net_pay=totals.net_pay,
payment_total=totals.payment_total,
opening_balance=request.opening_balance,
closing_balance=totals.closing_balance,
payable=totals.payable,
input_hash=result.input_hash,
result_hash=result.result_hash,

View File

@ -12,7 +12,6 @@ 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__)
@ -47,7 +46,6 @@ def ensure_monthly_payroll_period(reference_date: date | None = None):
date_to=date_to,
).first()
if existing is not None:
materialize_pending_carryovers(existing)
return existing, False
period = PayrollPeriod(
@ -86,7 +84,6 @@ def ensure_monthly_payroll_period(reference_date: date | None = None):
raise
logger.info("Opened payroll period %s", period.code)
materialize_pending_carryovers(period)
return period, True

View File

@ -58,7 +58,7 @@ def _is_finite_decimal(value: object) -> bool:
class DeterministicPayrollCalculator:
"""Pure calculator with explicit Decimal and rounding semantics."""
CALCULATOR_VERSION = "1.1.0"
CALCULATOR_VERSION = "1.3.0"
DECIMAL_PRECISION = 80
MAX_DECIMAL_DIGITS = 30
MAX_FRACTIONAL_PLACES = 12
@ -90,6 +90,7 @@ class DeterministicPayrollCalculator:
calculated=True,
)
]
opening_balance = quantize(request.opening_balance)
warnings: list[CalculationWarning] = []
point_delta: Decimal | None = None
@ -157,7 +158,7 @@ class DeterministicPayrollCalculator:
):
lines.append(self._to_result_line(input_line, quantize))
totals = self._calculate_totals(lines)
totals = self._calculate_totals(lines, opening_balance=opening_balance)
if totals.payable < 0 and not rules.allow_negative_payable:
raise PayrollValidationError(
[
@ -260,10 +261,15 @@ class DeterministicPayrollCalculator:
source_period=input_line.source_period,
is_retro=input_line.is_retro,
calculated=False,
include_in_carryover=input_line.include_in_carryover,
)
@staticmethod
def _calculate_totals(lines: list[PayrollLine]) -> PayrollTotals:
def _calculate_totals(
lines: list[PayrollLine],
*,
opening_balance: Decimal = Decimal("0"),
) -> PayrollTotals:
earnings = sum(
(line.amount for line in lines if line.kind is LineKind.EARNING),
Decimal("0"),
@ -284,6 +290,26 @@ class DeterministicPayrollCalculator:
(line.amount for line in lines if line.kind is LineKind.PAYMENT),
Decimal("0"),
)
manual_adjustment_credits = sum(
(
line.amount
for line in lines
if line.kind is LineKind.ADJUSTMENT_CREDIT
and not line.calculated
and line.include_in_carryover
),
Decimal("0"),
)
manual_adjustment_debits = sum(
(
line.amount
for line in lines
if line.kind is LineKind.ADJUSTMENT_DEBIT
and not line.calculated
and line.include_in_carryover
),
Decimal("0"),
)
adjustment_total = adjustment_credits - adjustment_debits
gross = earnings + adjustment_total
net_pay = gross - deductions
@ -295,6 +321,11 @@ class DeterministicPayrollCalculator:
net_pay=net_pay,
payment_total=payments,
payable=net_pay - payments,
closing_balance=(
opening_balance
+ manual_adjustment_debits
- manual_adjustment_credits
),
)
@staticmethod
@ -412,6 +443,12 @@ class DeterministicPayrollCalculator:
issues,
minimum=Decimal("0"),
)
self._validate_decimal(
request.opening_balance,
"opening_balance",
issues,
minimum=None,
)
self._validate_decimal(
request.point_base_accrual,
"point_base_accrual",

View File

@ -87,6 +87,7 @@ class InputLine:
reason: str = ""
source_period: PayrollPeriod | None = None
is_retro: bool = False
include_in_carryover: bool = True
def to_dict(self) -> dict[str, object]:
return {
@ -101,6 +102,7 @@ class InputLine:
self.source_period.to_dict() if self.source_period else None
),
"is_retro": self.is_retro,
"include_in_carryover": self.include_in_carryover,
}
@ -141,6 +143,7 @@ class PayrollRequest:
currency: str
base_accrual: Decimal
base_source_ref: str
opening_balance: Decimal = Decimal("0")
point_base_accrual: Decimal | None = None
target_points: Decimal | None = None
actual_points: Decimal | None = None
@ -157,6 +160,7 @@ class PayrollRequest:
"currency": self.currency,
"base_accrual": decimal_text(self.base_accrual),
"base_source_ref": self.base_source_ref,
"opening_balance": decimal_text(self.opening_balance),
"point_base_accrual": (
decimal_text(self.point_base_accrual)
if self.point_base_accrual is not None
@ -230,6 +234,7 @@ class PayrollLine:
source_period: PayrollPeriod | None = None
is_retro: bool = False
calculated: bool = False
include_in_carryover: bool = True
def to_dict(self) -> dict[str, object]:
return {
@ -245,6 +250,7 @@ class PayrollLine:
),
"is_retro": self.is_retro,
"calculated": self.calculated,
"include_in_carryover": self.include_in_carryover,
}
@ -273,6 +279,7 @@ class PayrollTotals:
net_pay: Decimal
payment_total: Decimal
payable: Decimal
closing_balance: Decimal
@property
def recalculated_gross(self) -> Decimal:
@ -290,6 +297,7 @@ class PayrollTotals:
"net_pay": decimal_text(self.net_pay),
"payment_total": decimal_text(self.payment_total),
"payable": decimal_text(self.payable),
"closing_balance": decimal_text(self.closing_balance),
}

View File

@ -60,6 +60,7 @@ def input_line(
reason="",
source_period=None,
is_retro=False,
include_in_carryover=True,
):
return InputLine(
line_id=line_id,
@ -71,6 +72,7 @@ def input_line(
reason=reason,
source_period=source_period,
is_retro=is_retro,
include_in_carryover=include_in_carryover,
)
@ -643,6 +645,85 @@ def test_negative_expected_payable_is_allowed_when_rules_allow_it(period, rules)
assert result.warnings == ()
def test_balance_tracks_only_manual_corrections(period, rules):
balance_rules = replace(rules, allow_negative_payable=True)
request = make_request(
period,
base_accrual=Decimal("100000"),
opening_balance=Decimal("10000"),
lines=(
input_line(
"correction:1",
"CORRECTION_CREDIT",
LineKind.ADJUSTMENT_CREDIT,
"4000",
reason="Погашение недоплаты",
),
input_line(
"payment:1",
"SALARY_PAYMENT",
LineKind.PAYMENT,
"104000",
),
),
)
result = DeterministicPayrollCalculator().calculate(request, balance_rules)
assert result.totals.adjustment_total == Decimal("4000.00")
assert result.totals.gross_after_adjustments == Decimal("104000.00")
assert result.totals.payment_total == Decimal("104000.00")
assert result.totals.payable == Decimal("0.00")
assert result.totals.closing_balance == Decimal("6000.00")
def test_excluded_correction_changes_salary_but_not_carrying_balance(period, rules):
request = make_request(
period,
base_accrual=Decimal("100000"),
opening_balance=Decimal("7000"),
lines=(
input_line(
"correction:excluded",
"CORRECTION_CREDIT",
LineKind.ADJUSTMENT_CREDIT,
"3000",
reason="Премия вне переходящего остатка",
include_in_carryover=False,
),
),
)
result = DeterministicPayrollCalculator().calculate(request, rules)
assert result.totals.adjustment_total == Decimal("3000.00")
assert result.totals.gross_after_adjustments == Decimal("103000.00")
assert result.totals.payable == Decimal("103000.00")
assert result.totals.closing_balance == Decimal("7000.00")
def test_point_recalculation_never_creates_a_carrying_balance(period, rules):
proportional_rules = replace(
rules,
point_policy=PointPolicy.PROPORTIONAL_WITH_EXCESS,
allow_negative_payable=True,
)
request = make_request(
period,
base_accrual=Decimal("100000"),
point_base_accrual=Decimal("100000"),
target_points=Decimal("100"),
actual_points=Decimal("25"),
point_rate=Decimal("0"),
)
result = DeterministicPayrollCalculator().calculate(request, proportional_rules)
assert result.totals.gross_after_adjustments == Decimal("25000.00")
assert result.totals.payable == Decimal("25000.00")
assert result.totals.closing_balance == Decimal("0.00")
@pytest.mark.parametrize(
"invalid_period",
[

View File

@ -92,6 +92,237 @@ def make_approved_period_inputs(
)
def test_copy_input_lines_previews_scope_and_handles_existing_values(
user_factory,
auth_client_factory,
):
manager = user_factory(email="input.copy.manager@example.test")
active_employee = user_factory(email="input.copy.active@example.test")
inactive_employee = user_factory(
email="input.copy.inactive@example.test",
active=False,
)
grant(manager, "manage_payroll_inputs")
source = make_period(creator=manager)
target = PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from="2026-07-01",
date_to="2026-07-31",
pay_date="2026-08-05",
currency="RUB",
created_by=manager,
)
bonus = PayrollComponent.objects.get(code="BONUS")
advance = PayrollComponent.objects.get(code="ADVANCE")
correction_credit = PayrollComponent.objects.get(code="CORRECTION_CREDIT")
for employee in (active_employee, inactive_employee):
PayrollInputLine.objects.create(
period=source,
employee=employee,
component=bonus,
amount="1000.00",
created_by=manager,
)
PayrollInputLine.objects.create(
period=source,
employee=employee,
component=advance,
amount="500.00",
created_by=manager,
)
existing_bonus = PayrollInputLine.objects.create(
period=target,
employee=active_employee,
component=bonus,
amount="900.00",
created_by=manager,
)
client = auth_client_factory(manager)
preview = client.get(
api_url("admin-period-copy-input-lines", pk=target.pk),
{"source_period_id": source.pk},
)
assert preview.status_code == 200, preview.content
body = preview.json()
assert body["summary"] == {
"eligible_lines": 4,
"employees": 2,
"active_employees": 1,
}
component_preview = {item["id"]: item for item in body["components"]}
assert component_preview[correction_credit.pk]["line_count"] == 0
assert component_preview[correction_credit.pk]["employee_count"] == 0
assert component_preview[correction_credit.pk]["amount_total"] == "0.00"
employee_preview = {
item["id"]: item for item in body["employees"]
}
assert employee_preview[active_employee.pk]["is_active"] is True
assert employee_preview[inactive_employee.pk]["is_active"] is False
assert employee_preview[active_employee.pk]["existing_component_counts"] == {
str(bonus.pk): 1
}
skipped = client.post(
api_url("admin-period-copy-input-lines", pk=target.pk),
{
"source_period_id": source.pk,
"component_ids": [bonus.pk, advance.pk, correction_credit.pk],
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "skip",
"expected_period_lock_version": target.lock_version,
"idempotency_key": str(uuid.uuid4()),
},
format="json",
)
assert skipped.status_code == 201, skipped.content
assert skipped.json()["summary"] == {
"created": 1,
"employees": 1,
"skipped": 1,
"replaced_cells": 0,
"voided": 0,
}
assert PayrollInputLine.objects.filter(
period=target,
employee=inactive_employee,
).count() == 0
replacement_key = uuid.uuid4()
replaced = client.post(
api_url("admin-period-copy-input-lines", pk=target.pk),
{
"source_period_id": source.pk,
"component_ids": [bonus.pk, advance.pk, correction_credit.pk],
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "replace",
"expected_period_lock_version": target.lock_version,
"idempotency_key": str(replacement_key),
"remember_configuration": True,
"auto_apply_on_period_creation": True,
},
format="json",
)
assert replaced.status_code == 201, replaced.content
assert replaced.json()["summary"] == {
"created": 2,
"employees": 1,
"skipped": 0,
"replaced_cells": 2,
"voided": 2,
}
existing_bonus.refresh_from_db()
assert existing_bonus.status == "voided"
active_target_lines = PayrollInputLine.objects.filter(
period=target,
employee=active_employee,
status="draft",
)
assert active_target_lines.count() == 2
assert set(active_target_lines.values_list("component_id", flat=True)) == {
bonus.pk,
advance.pk,
}
replay = client.post(
api_url("admin-period-copy-input-lines", pk=target.pk),
{
"source_period_id": source.pk,
"component_ids": [bonus.pk, advance.pk, correction_credit.pk],
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "replace",
"expected_period_lock_version": target.lock_version,
"idempotency_key": str(replacement_key),
"remember_configuration": True,
"auto_apply_on_period_creation": True,
},
format="json",
)
assert replay.status_code == 200, replay.content
assert replay.json()["idempotent_replay"] is True
assert active_target_lines.count() == 2
PayrollInputLine.objects.create(
period=target,
employee=active_employee,
component=correction_credit,
amount="250.00",
created_by=manager,
)
august = client.post(
api_url("admin-period-list"),
{
"code": "2026-08",
"name": "Август 2026",
"date_from": "2026-08-01",
"date_to": "2026-08-31",
"pay_date": "2026-09-05",
"currency": "RUB",
},
format="json",
)
assert august.status_code == 201, august.content
assert august.json()["automatic_input_copy"]["source_period_id"] == target.pk
assert august.json()["automatic_input_copy"]["summary"]["created"] == 3
august_lines = PayrollInputLine.objects.filter(
period_id=august.json()["id"],
status="draft",
)
assert august_lines.count() == 3
assert set(august_lines.values_list("component_id", flat=True)) == {
bonus.pk,
advance.pk,
correction_credit.pk,
}
def test_input_line_database_error_is_not_reported_as_parallel_operation(
user_factory,
auth_client_factory,
monkeypatch,
):
manager = user_factory(email="input.error.manager@example.test")
employee = user_factory(email="input.error.employee@example.test")
grant(manager, "manage_payroll_inputs")
period = make_period(creator=manager)
component = PayrollComponent.objects.get(code="BONUS")
def reject_save(*args, **kwargs):
raise IntegrityError("simulated database constraint")
monkeypatch.setattr(
"api.v1.finance.admin_serializers.PayrollInputLineWriteSerializer.save",
reject_save,
)
response = auth_client_factory(manager).post(
api_url("admin-input-line-list"),
{
"period_id": period.pk,
"employee_id": employee.pk,
"component_id": component.pk,
"amount": "1000.00",
"reason": "Премия",
},
format="json",
)
assert response.status_code == 409
assert response.json()["code"] == "DRAFT_SAVE_FAILED"
assert "Параллельная операция" not in response.json()["message"]
assert response.json()["message"] == (
"Не удалось сохранить данные расчёта: база данных отклонила запись. "
"Обновите страницу и повторите попытку."
)
def test_admin_input_lists_show_newest_records_first(
user_factory,
auth_client_factory,
@ -557,7 +788,12 @@ def test_period_table_lists_all_employees_and_requires_view_all(
assert row["component_amounts"] == {"BONUS": "1000.00"}
assert row["gross_total"] == "81750.00"
assert row["payable"] == "81750.00"
assert row["opening_balance"] == "0"
assert row["closing_balance"] == "0.00"
assert row["totals_preliminary"] is True
assert all(
item["employee_id"] != employee.pk for item in body["carryovers"]
)
assert body["summary"]["preliminary_count"] == body["summary"]["employee_count"]
missing_row = next(
item for item in body["rows"] if item["employee"]["id"] == missing_employee.pk
@ -1454,7 +1690,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates(
"code": "RULESET_NOT_EFFECTIVE",
"message": (
"Для выбранного периода нет действующих правил расчёта. "
"Набор eusrr-standard, версия 2026.07.4, применяется с 01.07.2026. "
"Набор eusrr-standard, версия 2026.08.1, применяется с 01.07.2026. "
"Измените период или подключите историческую версию правил."
),
"details": {
@ -1464,7 +1700,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates(
},
"ruleset": {
"id": "eusrr-standard",
"version": "2026.07.4",
"version": "2026.08.1",
"effective_from": "2026-07-01",
},
},
@ -1492,6 +1728,48 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates(
assert PayrollRun.objects.count() == 0
def test_period_inputs_can_be_created_for_inactive_employees(
user_factory,
auth_client_factory,
):
manager = user_factory(email="inactive.inputs.manager@example.test")
inactive_employee = user_factory(
email="inactive.inputs.employee@example.test",
active=False,
)
grant(manager, "manage_payroll_inputs")
period = make_period(creator=manager)
client = auth_client_factory(manager)
work_record = client.post(
api_url("admin-work-record-list"),
{
"period_id": period.pk,
"employee_id": inactive_employee.pk,
"target_points": "100.0000",
"actual_points": "90.0000",
"reason": "Корректировка после увольнения",
},
format="json",
)
assert work_record.status_code == 201, work_record.content
assert work_record.json()["employee_id"] == inactive_employee.pk
input_line = client.post(
api_url("admin-input-line-list"),
{
"period_id": period.pk,
"employee_id": inactive_employee.pk,
"component_id": PayrollComponent.objects.get(code="BONUS").pk,
"amount": "1000.00",
"reason": "Премия после увольнения",
},
format="json",
)
assert input_line.status_code == 201, input_line.content
assert input_line.json()["employee_id"] == inactive_employee.pk
def test_period_and_rate_drafts_use_exact_optimistic_versions_and_maker_checker(
user_factory,
auth_client_factory,
@ -2461,7 +2739,20 @@ def test_staff_admin_completes_entire_payroll_workflow_with_one_command(
approver=administrator,
)
response = auth_client_factory(administrator).post(
administrator_client = auth_client_factory(administrator)
blocked_confirmation = administrator_client.post(
api_url(
"admin-period-payment-confirmation",
pk=period.pk,
employee_id=employee.pk,
),
{"confirmed": True},
format="json",
)
assert blocked_confirmation.status_code == 409
assert blocked_confirmation.json()["code"] == "PERIOD_NOT_CLOSED"
response = administrator_client.post(
api_url("admin-period-complete-workflow", pk=period.pk),
{
"expected_lock_version": period.lock_version,
@ -2503,6 +2794,62 @@ def test_staff_admin_completes_entire_payroll_workflow_with_one_command(
"payroll.admin_workflow_completed",
}
table_before_confirmation = administrator_client.get(
api_url("admin-period-table", pk=period.pk)
)
assert table_before_confirmation.status_code == 200
assert table_before_confirmation.json()["period_status"] == "closed"
employee_row = next(
row
for row in table_before_confirmation.json()["rows"]
if row["employee"]["id"] == employee.pk
)
assert employee_row["payment_confirmation"] is None
confirmed = administrator_client.post(
api_url(
"admin-period-payment-confirmation",
pk=period.pk,
employee_id=employee.pk,
),
{"confirmed": True},
format="json",
)
assert confirmed.status_code == 200, confirmed.content
assert confirmed.json()["confirmed"] is True
assert confirmed.json()["confirmed_by"]["id"] == administrator.pk
table_after_confirmation = administrator_client.get(
api_url("admin-period-table", pk=period.pk)
)
confirmed_row = next(
row
for row in table_after_confirmation.json()["rows"]
if row["employee"]["id"] == employee.pk
)
assert confirmed_row["payment_confirmation"]["confirmed_by"]["id"] == administrator.pk
revoked = administrator_client.post(
api_url(
"admin-period-payment-confirmation",
pk=period.pk,
employee_id=employee.pk,
),
{"confirmed": False},
format="json",
)
assert revoked.status_code == 200
assert revoked.json()["confirmed"] is False
assert set(
PayrollAuditEvent.objects.filter(period=period).values_list(
"action",
flat=True,
)
) >= {
"payroll.statement_payment_confirmed",
"payroll.statement_payment_confirmation_revoked",
}
def test_complete_workflow_rolls_back_every_stage_when_review_fails(
user_factory,

View File

@ -4,9 +4,16 @@ from decimal import Decimal
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from django.utils import timezone
from finance.enums import ApprovalStatus, PayrollCarryoverStatus, PayrollPeriodStatus
from finance.models import PayrollInputLine, PayrollPeriod
from finance.enums import (
ApprovalStatus,
PayrollCarryoverStatus,
PayrollPeriodStatus,
PayrollRunStatus,
)
from finance.models import PayrollComponent, PayrollInputLine, PayrollPeriod, PayrollRun
from finance.payroll.balances import opening_balances
from finance.payroll.carryovers import (
cancel_carryover,
create_carryover,
@ -165,7 +172,7 @@ def test_carryover_requires_closed_source_and_approved_transfer_cannot_cancel(
assert approved_error.value.code == "CARRYOVER_ALREADY_APPROVED"
def test_carryover_admin_api_lists_incoming_and_marks_generated_line(
def test_manual_carryover_api_is_disabled(
user_factory,
auth_client_factory,
):
@ -181,7 +188,6 @@ def test_carryover_admin_api_lists_incoming_and_marks_generated_line(
month=6,
status=PayrollPeriodStatus.CLOSED,
)
target = make_period(creator=manager, month=7)
client = auth_client_factory(manager)
created = client.post(
@ -195,48 +201,80 @@ def test_carryover_admin_api_lists_incoming_and_marks_generated_line(
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
assert created.status_code == 410, created.content
assert created.json()["code"] == "MANUAL_CARRYOVER_DISABLED"
assert "положительных и отрицательных корректировок" in created.json()["message"]
lines = client.get(
api_url("admin-input-line-list"),
{"period_id": target.pk},
def test_carryover_ledger_lists_history_and_can_exclude_a_record(
user_factory,
auth_client_factory,
):
manager = user_factory(email="carryover.ledger.manager@example.test")
employee = user_factory(
email="carryover.ledger.employee@example.test",
first_name="Анна",
last_name="Балансова",
)
assert lines.status_code == 200
line = lines.json()[0]
assert line["is_automatic_carryover"] is True
assert line["relates_to_period"]["id"] == source.pk
grant(manager, "manage_payroll_inputs", "view_all_payroll")
june = make_period(creator=manager, month=6)
july = make_period(creator=manager, month=7)
correction = PayrollInputLine.objects.create(
period=june,
employee=employee,
component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"),
amount=Decimal("3500.00"),
reason="Недоплата за июнь",
status=ApprovalStatus.APPROVED,
created_by=manager,
approved_by=manager,
approved_at=timezone.now(),
self_approval_overridden=True,
)
PayrollRun.objects.create(
period=june,
revision=1,
status=PayrollRunStatus.PUBLISHED,
ruleset_id="test",
ruleset_version="1",
ruleset_hash="rules",
calculator_version="test",
input_hash="input",
result_hash="result",
requested_by=manager,
approved_by=manager,
approved_at=timezone.now(),
self_approval_overridden=True,
published_by=manager,
published_at=timezone.now(),
)
client = auth_client_factory(manager)
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,
}
]
ledger = client.get(api_url("admin-period-carryovers", pk=july.pk))
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,
}
assert ledger.status_code == 200, ledger.content
assert ledger.json()["summary"]["balance"] == "3500.00"
assert len(ledger.json()["entries"]) == 1
entry = ledger.json()["entries"][0]
assert entry["id"] == correction.pk
assert entry["period"]["id"] == june.pk
assert entry["raw_contribution"] == "3500.00"
assert entry["contribution"] == "3500.00"
assert entry["counts_in_balance"] is True
assert opening_balances(july, [employee.pk])[employee.pk] == Decimal("3500.00")
excluded = client.patch(
api_url("admin-input-line-carryover-inclusion", pk=correction.pk),
{"included": False},
format="json",
)
assert excluded.status_code == 200, excluded.content
correction.refresh_from_db()
assert correction.include_in_carryover is False
assert correction.amount == Decimal("3500.00")
assert opening_balances(july, [employee.pk]) == {}
refreshed = client.get(api_url("admin-period-carryovers", pk=july.pk)).json()
assert refreshed["summary"]["balance"] == "0.00"
assert refreshed["entries"][0]["contribution"] == "0.00"
assert refreshed["entries"][0]["raw_contribution"] == "3500.00"

View File

@ -37,7 +37,7 @@ def test_payroll_adapter_is_safe_by_default(settings):
assert rules.point_policy is PointPolicy.PROPORTIONAL_WITH_EXCESS
assert rules.money_quantum == Decimal("0.01")
assert rules.allow_negative_payable is False
assert rules.allow_negative_payable is True
def test_simple_admin_access_defaults_to_enabled_and_accepts_boolean_strings(settings):
@ -98,11 +98,10 @@ def test_config_accepts_enum_values(settings):
@pytest.mark.parametrize(
"override",
[
{"MONEY_QUANTUM": "0.001"},
{"ALLOW_NEGATIVE_PAYABLE": "true"},
{"ROUNDING": "bankers-ish"},
"override",
[
{"MONEY_QUANTUM": "0.001"},
{"ROUNDING": "bankers-ish"},
{"POINT_POLICY": "invented"},
],
)

View File

@ -38,6 +38,7 @@ from finance.payroll.services import (
approve_run,
approve_work_record,
calculate_period,
close_period,
publish_run,
return_run_for_correction,
submit_run_for_review,
@ -180,6 +181,161 @@ def publish_calculation(run, users):
return publish_run(run.pk, actor=users["publisher"])
def test_only_manual_corrections_move_between_periods_and_can_be_settled(
june_period,
payroll_users,
):
operator = payroll_users["operator"]
approver = payroll_users["input_approver"]
employee = payroll_users["employee"]
rate = EmployeePayRate.objects.create(
employee=employee,
amount=Decimal("100000"),
point_rate=Decimal("0"),
currency="RUB",
effective_from=date(2026, 1, 1),
created_by=operator,
)
approve_pay_rate(rate.pk, actor=approver, expected_lock_version=rate.lock_version)
payment_component = PayrollComponent.objects.get(code="SALARY_PAYMENT")
debit_component = PayrollComponent.objects.get(code="CORRECTION_DEBIT")
credit_component = PayrollComponent.objects.get(code="CORRECTION_CREDIT")
june_work = PayrollWorkRecord.objects.create(
period=june_period,
employee=employee,
target_points=Decimal("100"),
actual_points=Decimal("100"),
created_by=operator,
)
june_payment = PayrollInputLine.objects.create(
period=june_period,
employee=employee,
component=payment_component,
amount=Decimal("90000"),
reason="Фактически перечислено за июнь",
created_by=operator,
)
june_correction = PayrollInputLine.objects.create(
period=june_period,
employee=employee,
component=debit_component,
amount=Decimal("10000"),
reason="Недоплата относительно зарплаты по баллам",
created_by=operator,
)
for instance, approve in (
(june_work, approve_work_record),
(june_payment, approve_input_line),
(june_correction, approve_input_line),
):
approve(instance.pk, actor=approver, expected_lock_version=instance.lock_version)
june_run = calculate_period(june_period.pk, actor=operator)
june_statement = june_run.statements.get(employee=employee)
assert june_statement.opening_balance == Decimal("0.00")
assert june_statement.gross_total == Decimal("90000.00")
assert june_statement.payable == Decimal("0.00")
assert june_statement.closing_balance == Decimal("10000.00")
publish_calculation(june_run, payroll_users)
july_period = PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from=date(2026, 7, 1),
date_to=date(2026, 7, 31),
pay_date=date(2026, 8, 5),
currency="RUB",
created_by=operator,
)
july_work = PayrollWorkRecord.objects.create(
period=july_period,
employee=employee,
target_points=Decimal("100"),
actual_points=Decimal("100"),
created_by=operator,
)
july_payment = PayrollInputLine.objects.create(
period=july_period,
employee=employee,
component=payment_component,
amount=Decimal("104000"),
reason="Зарплата за июль с частичным погашением недоплаты",
created_by=operator,
)
july_correction = PayrollInputLine.objects.create(
period=july_period,
employee=employee,
component=credit_component,
amount=Decimal("4000"),
reason="Частичное погашение недоплаты за июнь",
created_by=operator,
)
for instance, approve in (
(july_work, approve_work_record),
(july_payment, approve_input_line),
(july_correction, approve_input_line),
):
approve(instance.pk, actor=approver, expected_lock_version=instance.lock_version)
july_run = calculate_period(july_period.pk, actor=operator)
july_statement = july_run.statements.get(employee=employee)
assert july_statement.opening_balance == Decimal("10000.00")
assert july_statement.payment_total == Decimal("104000.00")
assert july_statement.closing_balance == Decimal("6000.00")
assert july_statement.payable == Decimal("0.00")
assert july_statement.gross_total == Decimal("104000.00")
publish_calculation(july_run, payroll_users)
august_period = PayrollPeriod.objects.create(
code="2026-08",
name="Август 2026",
date_from=date(2026, 8, 1),
date_to=date(2026, 8, 31),
pay_date=date(2026, 9, 5),
currency="RUB",
created_by=operator,
)
august_work = PayrollWorkRecord.objects.create(
period=august_period,
employee=employee,
target_points=Decimal("100"),
actual_points=Decimal("100"),
created_by=operator,
)
august_payment = PayrollInputLine.objects.create(
period=august_period,
employee=employee,
component=payment_component,
amount=Decimal("106000"),
reason="Зарплата за август с окончательным погашением недоплаты",
created_by=operator,
)
august_correction = PayrollInputLine.objects.create(
period=august_period,
employee=employee,
component=credit_component,
amount=Decimal("6000"),
reason="Окончательное погашение недоплаты за июнь",
created_by=operator,
)
for instance, approve in (
(august_work, approve_work_record),
(august_payment, approve_input_line),
(august_correction, approve_input_line),
):
approve(instance.pk, actor=approver, expected_lock_version=instance.lock_version)
august_run = calculate_period(august_period.pk, actor=operator)
august_statement = august_run.statements.get(employee=employee)
assert august_statement.opening_balance == Decimal("6000.00")
assert august_statement.payment_total == Decimal("106000.00")
assert august_statement.payable == Decimal("0.00")
assert august_statement.closing_balance == Decimal("0.00")
def test_excel_row_is_persisted_as_a_deterministic_statement(
june_period,
payroll_users,
@ -501,7 +657,7 @@ def test_ruleset_not_effective_keeps_domain_code_and_localized_message(
assert error.value.code == "RULESET_NOT_EFFECTIVE"
assert error.value.message == (
"Для выбранного периода нет действующих правил расчёта. "
"Набор eusrr-standard, версия 2026.07.4, применяется с 01.07.2026. "
"Набор eusrr-standard, версия 2026.08.1, применяется с 01.07.2026. "
"Измените период или подключите историческую версию правил."
)
assert error.value.details["employee_id"] == payroll_users["employee"].pk
@ -511,7 +667,7 @@ def test_ruleset_not_effective_keeps_domain_code_and_localized_message(
}
assert error.value.details["ruleset"] == {
"id": "eusrr-standard",
"version": "2026.07.4",
"version": "2026.08.1",
"effective_from": "2026-07-01",
}
assert {issue["code"] for issue in error.value.details["issues"]} == {
@ -681,6 +837,19 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
stranger = user_factory(email="api.stranger@example.test")
stranger_client = auth_client_factory(stranger)
payment_confirmation_url = reverse(
"api:v1:finance-payroll:own-statement-payment-confirmation",
kwargs={"public_id": statement.public_id},
)
blocked_confirmation = employee_client.post(
payment_confirmation_url,
{"confirmed": True},
format="json",
)
assert blocked_confirmation.status_code == 409
assert blocked_confirmation.json()["code"] == "PERIOD_NOT_CLOSED"
close_period(june_period.pk, actor=payroll_users["publisher"])
list_url = reverse("api:v1:finance-payroll:own-statement-list")
response = employee_client.get(list_url)
assert response.status_code == 200
@ -688,6 +857,8 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
assert len(items) == 1
assert items[0]["public_id"] == str(statement.public_id)
assert items[0]["payable"] == "115000.00"
assert items[0]["can_confirm_payment"] is True
assert items[0]["payment_confirmation"] is None
assert "lines" not in items[0]
assert "gross_total" not in items[0]
assert "employee" not in items[0]
@ -718,6 +889,42 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
ack_response = employee_client.post(acknowledgement_url, {}, format="json")
assert ack_response.status_code == 200
assert ack_response.json()["acknowledged_at"] is not None
payment_confirmation = employee_client.post(
payment_confirmation_url,
{"confirmed": True},
format="json",
)
assert payment_confirmation.status_code == 200
confirmation_payload = payment_confirmation.json()["payment_confirmation"]
assert confirmation_payload["confirmed_at"] is not None
assert confirmation_payload["confirmed_by"]["id"] == payroll_users["employee"].pk
refreshed_detail = employee_client.get(detail_url)
assert refreshed_detail.json()["payment_confirmation"] == confirmation_payload
assert (
stranger_client.post(
payment_confirmation_url,
{"confirmed": True},
format="json",
).status_code
== 404
)
revoked_confirmation = employee_client.post(
payment_confirmation_url,
{"confirmed": False},
format="json",
)
assert revoked_confirmation.status_code == 200
assert revoked_confirmation.json()["payment_confirmation"] is None
assert set(
PayrollAuditEvent.objects.filter(
actor=payroll_users["employee"],
object_id=str(statement.pk),
).values_list("action", flat=True)
) >= {
"payroll.statement_payment_confirmed",
"payroll.statement_payment_confirmation_revoked",
}
assert (
stranger_client.post(
acknowledgement_url,

View File

@ -68,7 +68,9 @@ def test_monthly_task_uses_local_current_month(monkeypatch):
assert period.date_to == date(2026, 9, 30)
def test_monthly_period_opening_materializes_pending_carryover(user_factory):
def test_monthly_period_opening_does_not_materialize_legacy_manual_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(
@ -94,5 +96,6 @@ def test_monthly_period_opening_materializes_pending_carryover(user_factory):
assert created is True
carryover.refresh_from_db()
assert carryover.target_period == target
assert carryover.generated_input_line.period == target
assert target.status == PayrollPeriodStatus.OPEN
assert carryover.target_period_id is None
assert carryover.generated_input_line_id is None

View File

@ -21,7 +21,7 @@ import { useUser } from "@/contexts/UserContext";
import { usePayrollDesktopWideMode } from "@/hooks/usePayrollDesktopWideMode";
import { usePayrollFinanceTab } from "@/hooks/usePayrollTabs";
import { apiClient } from "@/lib/api";
import type { PayrollAcknowledgement, PayrollStatementSummary } from "@/lib/api/finance";
import type { PayrollAcknowledgement, PayrollPaymentConfirmation, PayrollStatementSummary } from "@/lib/api/finance";
import { canOpenPayrollAdmin } from "@/lib/permissions";
import {
formatPayrollDate,
@ -38,21 +38,22 @@ function listErrorMessage(error: unknown): string {
}
function StatementStatus({ statement }: { statement: PayrollStatementSummary }) {
if (statement.acknowledgement?.disputed_at) {
return <span className="app-status-pill app-action-warning">Есть обращение</span>;
}
if (statement.acknowledgement?.acknowledged_at) {
return (
<span className="app-status-pill app-action-success gap-1.5">
<BadgeCheck size={13} />
Подтверждён
</span>
);
}
return (
<span className="app-status-pill app-badge-accent gap-1.5">
<Clock3 size={13} />
Ждёт подтверждения
<span className="flex flex-wrap gap-1.5">
{statement.acknowledgement?.disputed_at ? (
<span className="app-status-pill app-action-warning">Есть обращение</span>
) : statement.acknowledgement?.acknowledged_at ? (
<span className="app-status-pill app-action-success gap-1.5"><BadgeCheck size={13} />Листок подтверждён</span>
) : (
<span className="app-status-pill app-badge-accent gap-1.5"><Clock3 size={13} />Ждёт проверки листка</span>
)}
{statement.can_confirm_payment ? (
statement.payment_confirmation ? (
<span className="app-status-pill app-action-success gap-1.5"><BadgeCheck size={13} />Деньги получены</span>
) : (
<span className="app-status-pill app-feedback-warning gap-1.5"><Clock3 size={13} />Выплата не подтверждена</span>
)
) : null}
</span>
);
}
@ -115,6 +116,21 @@ export default function FinancesPage() {
current?.public_id === publicId ? { ...current, acknowledgement } : current
));
};
const handlePaymentConfirmationChanged = (
publicId: string,
paymentConfirmation: PayrollPaymentConfirmation | null,
) => {
setStatements((current) => current.map((statement) => (
statement.public_id === publicId
? { ...statement, payment_confirmation: paymentConfirmation }
: statement
)));
setSelectedStatement((current) => (
current?.public_id === publicId
? { ...current, payment_confirmation: paymentConfirmation }
: current
));
};
return (
<AppShell
@ -248,7 +264,9 @@ export default function FinancesPage() {
onClick={() => setSelectedStatement(latestStatement)}
>
{latestStatement.acknowledgement?.acknowledged_at
? "Посмотреть расчёт"
? latestStatement.can_confirm_payment && !latestStatement.payment_confirmation
? "Подтвердить получение денег"
: "Посмотреть расчёт"
: "Проверить и подтвердить листок"}
<ArrowRight size={16} />
</button>
@ -256,6 +274,10 @@ export default function FinancesPage() {
<p className="app-text-muted mt-2 text-center text-xs leading-relaxed">
Сначала проверьте начисления. Подтверждается получение листка, а не перевод денег.
</p>
) : latestStatement.can_confirm_payment && !latestStatement.payment_confirmation ? (
<p className="app-text-muted mt-2 text-center text-xs leading-relaxed">
После фактической выплаты откройте листок и отметьте получение денег.
</p>
) : null}
</section>
) : null}
@ -310,6 +332,7 @@ export default function FinancesPage() {
statement={selectedStatement}
onClose={() => setSelectedStatement(null)}
onAcknowledged={handleAcknowledged}
onPaymentConfirmationChanged={handlePaymentConfirmationChanged}
/>
</>
)}

View File

@ -10,6 +10,7 @@ import {
CreditCard,
FileCheck2,
Loader2,
ReceiptText,
RefreshCcw,
RotateCcw,
ShieldCheck,
@ -21,6 +22,7 @@ import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type {
PayrollAcknowledgement,
PayrollPaymentConfirmation,
PayrollStatement,
PayrollStatementSummary,
} from "@/lib/api/finance";
@ -40,6 +42,7 @@ type PayrollStatementDetailModalProps = {
statement: PayrollStatementSummary | null;
onClose: () => void;
onAcknowledged: (publicId: string, acknowledgement: PayrollAcknowledgement) => void;
onPaymentConfirmationChanged: (publicId: string, confirmation: PayrollPaymentConfirmation | null) => void;
};
const groupIcons: Record<PayrollLineGroupKey, typeof BriefcaseBusiness> = {
@ -77,12 +80,15 @@ export function PayrollStatementDetailModal({
statement,
onClose,
onAcknowledged,
onPaymentConfirmationChanged,
}: PayrollStatementDetailModalProps) {
const [detail, setDetail] = useState<PayrollStatement | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [acknowledging, setAcknowledging] = useState(false);
const [acknowledgeError, setAcknowledgeError] = useState<string | null>(null);
const [paymentConfirmationBusy, setPaymentConfirmationBusy] = useState(false);
const [paymentConfirmationError, setPaymentConfirmationError] = useState<string | null>(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
@ -93,6 +99,7 @@ export function PayrollStatementDetailModal({
setDetail(null);
setLoadError(null);
setAcknowledgeError(null);
setPaymentConfirmationError(null);
setConfirmationOpen(false);
setLoading(true);
@ -116,6 +123,8 @@ export function PayrollStatementDetailModal({
const lineGroups = useMemo(() => groupPayrollLines(detail?.lines || []), [detail?.lines]);
const acknowledgement = detail?.acknowledgement || statement?.acknowledgement || null;
const isAcknowledged = Boolean(acknowledgement?.acknowledged_at);
const paymentConfirmation = detail?.payment_confirmation || statement?.payment_confirmation || null;
const canConfirmPayment = Boolean(detail?.can_confirm_payment ?? statement?.can_confirm_payment);
const handleAcknowledge = async () => {
if (!detail || acknowledging || isAcknowledged) return;
@ -133,6 +142,27 @@ export function PayrollStatementDetailModal({
}
};
const handlePaymentConfirmation = async (confirmed: boolean) => {
if (!detail || paymentConfirmationBusy || !canConfirmPayment) return;
setPaymentConfirmationBusy(true);
setPaymentConfirmationError(null);
try {
const result = await apiClient.setMyPayrollStatementPaymentConfirmation(
detail.public_id,
confirmed,
);
setDetail((current) => current ? {
...current,
payment_confirmation: result.payment_confirmation,
} : current);
onPaymentConfirmationChanged(detail.public_id, result.payment_confirmation);
} catch (error: unknown) {
setPaymentConfirmationError(errorMessage(error));
} finally {
setPaymentConfirmationBusy(false);
}
};
const footer = detail ? (
<div className="app-divider flex flex-col gap-3 border-t pb-4 pt-4 sm:flex-row sm:items-center sm:justify-between sm:pb-6">
<div className="min-w-0">
@ -197,7 +227,7 @@ export function PayrollStatementDetailModal({
onClose={onClose}
title={statement ? `Расчётный листок · ${getPayrollPeriodLabel(statement.period)}` : "Расчётный листок"}
size="lg"
closeOnEsc={!acknowledging}
closeOnEsc={!acknowledging && !paymentConfirmationBusy}
footer={footer}
>
{loading ? (
@ -246,11 +276,13 @@ export function PayrollStatementDetailModal({
<Calculator className="app-text-muted" size={17} />
<h4 className="text-sm font-semibold text-[var(--foreground)]">Итоги расчёта</h4>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-6">
<StatCard label="Остаток на начало" value={formatPayrollMoney(detail.opening_balance, detail.currency)} />
<StatCard label="До корректировок" value={formatPayrollMoney(detail.gross_before_adjustments, detail.currency)} />
<StatCard label="Корректировки" value={formatPayrollMoney(detail.adjustment_total, detail.currency)} />
<StatCard label="Удержания" value={formatPayrollMoney(detail.deduction_total, detail.currency)} />
<StatCard label="Выплачено ранее" value={formatPayrollMoney(detail.payment_total, detail.currency)} />
<StatCard label="Остаток к переносу" value={formatPayrollMoney(detail.closing_balance, detail.currency)} />
</div>
</section>
@ -309,6 +341,39 @@ export function PayrollStatementDetailModal({
)}
</section>
<section className={`rounded-xl border px-4 py-4 ${paymentConfirmation ? "border-emerald-500/30 bg-emerald-500/[0.08]" : "border-[var(--border-subtle)] bg-[var(--surface-secondary)]"}`}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<ReceiptText className={paymentConfirmation ? "mt-0.5 shrink-0 text-emerald-500" : "app-text-muted mt-0.5 shrink-0"} size={19} />
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
{paymentConfirmation ? "Получение денег подтверждено" : "Получение денег не подтверждено"}
</p>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
{paymentConfirmation
? `${formatPayrollDateTime(paymentConfirmation.confirmed_at)} · ${paymentConfirmation.confirmed_by.display_name}`
: canConfirmPayment
? `Отметьте после фактического получения ${formatPayrollMoney(detail.payable, detail.currency)}.`
: "Отметка станет доступна после закрытия расчётного периода."}
</p>
</div>
</div>
{canConfirmPayment ? (
<label className="inline-flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-3 py-2.5 text-sm font-semibold text-[var(--foreground)]">
{paymentConfirmationBusy ? <Loader2 className="app-accent-text animate-spin" size={16} /> : (
<input
type="checkbox"
checked={Boolean(paymentConfirmation)}
onChange={(event) => void handlePaymentConfirmation(event.target.checked)}
/>
)}
Деньги получены
</label>
) : null}
</div>
{paymentConfirmationError ? <div className="app-feedback-danger mt-3 rounded-lg px-3 py-2 text-sm">{paymentConfirmationError}</div> : null}
</section>
<section className="app-surface-muted rounded-xl px-4 py-3 text-xs leading-relaxed text-[var(--muted-foreground)]">
Листок опубликован {formatPayrollDateTime(detail.published_at)}.
</section>

View File

@ -97,10 +97,12 @@ export function PayrollInputHistory({
}) {
const [expanded, setExpanded] = useState(false);
const visibleEvents = expanded ? events : events.slice(0, 5);
const canToggleHistory = !loading && !error && events.length > 5;
const toggleLabel = expanded ? "Свернуть историю" : `Показать всю историю (${events.length})`;
return (
<section className="app-surface-muted mb-4 overflow-hidden rounded-xl">
<div className="flex items-center gap-3 px-3 py-2.5 sm:px-4">
<section className="app-surface-muted mb-4 rounded-xl">
<div className={`flex items-center gap-3 rounded-t-xl bg-[var(--surface-secondary)] px-3 py-2.5 sm:px-4 ${expanded ? "sticky top-0 z-30 border-b border-[var(--border-subtle)] shadow-[var(--shadow-card)]" : ""}`}>
<span className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<History size={15} />
</span>
@ -108,6 +110,17 @@ export function PayrollInputHistory({
<h3 className="text-sm font-semibold text-[var(--foreground)]">История изменений</h3>
<p className="app-text-muted text-xs">Все действия с записями, новые события сверху</p>
</div>
{canToggleHistory ? (
<button
type="button"
className="app-action-secondary inline-flex h-8 shrink-0 items-center rounded-lg px-3 text-xs font-medium"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
>
<span className="hidden md:inline">{toggleLabel}</span>
<span className="md:hidden">{expanded ? "Скрыть" : "Показать все"}</span>
</button>
) : null}
{!loading && !error ? <span className="app-counter">{events.length}</span> : null}
</div>
@ -139,13 +152,14 @@ export function PayrollInputHistory({
</div>
))}
</div>
{events.length > 5 ? (
{canToggleHistory ? (
<button
type="button"
className="app-action-secondary m-3 inline-flex h-8 items-center rounded-lg px-3 text-xs font-medium sm:mx-4"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
>
{expanded ? "Свернуть историю" : `Показать всю историю (${events.length})`}
{toggleLabel}
</button>
) : null}
</>

View File

@ -11,6 +11,7 @@ import {
CheckCircle2,
ChevronRight,
Clock3,
Copy,
FileCheck2,
FileSpreadsheet,
Loader2,
@ -38,6 +39,7 @@ import {
PayrollRateFormModal,
PayrollWorkRecordFormModal,
} from "@/components/finance/admin/PayrollAdminModals";
import { PayrollInputCopyModal } from "@/components/finance/admin/PayrollInputCopyModal";
import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal";
import { PayrollCarryoversModal } from "@/components/finance/admin/PayrollCarryoversModal";
import { PayrollManagementAccessModal } from "@/components/finance/admin/PayrollManagementAccessModal";
@ -66,6 +68,7 @@ import type {
PayrollBulkTargetPointsResult,
PayrollCalculationBlocker,
PayrollInputLineWrite,
PayrollInputCopyResult,
PayrollInputHistoryEvent,
PayrollInputHistoryKind,
PayrollPayRateWrite,
@ -251,6 +254,7 @@ function TableToolbar({
onImportWorkbook,
onBulkPayRate,
onBulkPointRate,
onCopyInputs,
addLabel,
}: {
search: string;
@ -264,6 +268,7 @@ function TableToolbar({
onImportWorkbook?: () => void;
onBulkPayRate?: () => void;
onBulkPointRate?: () => void;
onCopyInputs?: () => void;
addLabel: string;
}) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
@ -272,7 +277,8 @@ function TableToolbar({
onFillFromAttendance
|| onImportWorkbook
|| onBulkPayRate
|| onBulkPointRate,
|| onBulkPointRate
|| onCopyInputs,
);
useEffect(() => {
@ -384,6 +390,19 @@ function TableToolbar({
Массово задать цену сверх нормы
</button>
) : null}
{onCopyInputs ? (
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
onClick={() => {
setAddMenuOpen(false);
onCopyInputs();
}}
>
<Copy className="app-text-muted" size={15} />
Перенести записи из периода
</button>
) : null}
</div>
) : null}
</div>
@ -459,6 +478,7 @@ export function PayrollAdminWorkspace({
const [bulkPayRateModalOpen, setBulkPayRateModalOpen] = useState(false);
const [bulkPointRateModalOpen, setBulkPointRateModalOpen] = useState(false);
const [bulkTargetPointsModalOpen, setBulkTargetPointsModalOpen] = useState(false);
const [inputCopyModalOpen, setInputCopyModalOpen] = useState(false);
const [editingRate, setEditingRate] = useState<PayrollAdminPayRate | null>(null);
const [workModalOpen, setWorkModalOpen] = useState(false);
const [editingWork, setEditingWork] = useState<PayrollAdminWorkRecord | null>(null);
@ -471,6 +491,7 @@ export function PayrollAdminWorkspace({
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [accessModalOpen, setAccessModalOpen] = useState(false);
const createdPeriodIdRef = useRef<number | null>(null);
const createdPeriodInputCopyRef = useRef<PayrollInputCopyResult | null>(null);
const selectedPeriod = workspace?.selected_period || null;
@ -635,7 +656,7 @@ export function PayrollAdminWorkspace({
}, [loadWorkspace, selectedPeriod?.id]);
const handleStale = useCallback(async () => {
setPeriodModalOpen(false); setRateModalOpen(false); setBulkPayRateModalOpen(false); setBulkPointRateModalOpen(false); setBulkTargetPointsModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null);
setPeriodModalOpen(false); setRateModalOpen(false); setBulkPayRateModalOpen(false); setBulkPointRateModalOpen(false); setBulkTargetPointsModalOpen(false); setInputCopyModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null);
await refreshView("Данные изменились в другой сессии. Экран обновлён — повторите действие.");
}, [refreshView]);
@ -800,6 +821,7 @@ export function PayrollAdminWorkspace({
else {
const createdPeriod = await apiClient.createPayrollAdminPeriod(payload);
createdPeriodIdRef.current = createdPeriod.id;
createdPeriodInputCopyRef.current = createdPeriod.automatic_input_copy || null;
}
};
const saveRate = async (payload: PayrollPayRateWrite) => {
@ -952,6 +974,19 @@ export function PayrollAdminWorkspace({
await refreshInlineTable("inputs");
};
const setPaymentConfirmed = async (row: PayrollPeriodTableRow, confirmed: boolean) => {
if (!selectedPeriod || selectedPeriod.status !== "closed") return;
await apiClient.setPayrollAdminPaymentConfirmation(
selectedPeriod.id,
row.employee.id,
confirmed,
);
setPeriodTable(await apiClient.getPayrollAdminPeriodTable(selectedPeriod.id));
setNotice(confirmed
? `Выплата сотруднику ${row.employee.display_name} подтверждена.`
: `Отметка о выплате сотруднику ${row.employee.display_name} снята.`);
};
const managementInfo = (
<div className="min-w-0">
{!embedded ? <Link href="/finances" className="app-link-accent inline-flex items-center gap-1.5 text-xs font-medium"><ArrowLeft size={14} /> Финансы</Link> : null}
@ -1030,7 +1065,7 @@ export function PayrollAdminWorkspace({
: null;
return (
<div className="space-y-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1">
<div className={`space-y-4 lg:min-h-0 lg:flex-1 lg:pr-1 ${embedded ? "lg:overflow-visible" : "lg:overflow-y-auto"}`}>
{headerTargetId ? (
<>
{headerTarget ? createPortal(managementInfo, headerTarget) : null}
@ -1176,7 +1211,7 @@ export function PayrollAdminWorkspace({
<PayrollWorkRecordsTable records={filteredWork} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingWork(record); setWorkModalOpen(true); }} onApprove={openApproveWork} onRevise={openReviseWork} />
</section>
) : null}
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
{tab === "summary" ? (
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<SectionIntro title="Итоговая таблица" description="Сводные исходные данные, начисления, удержания и выплаты по всем сотрудникам за выбранный период." />
@ -1190,7 +1225,7 @@ export function PayrollAdminWorkspace({
}}
>
<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)}
@ -1198,7 +1233,7 @@ export function PayrollAdminWorkspace({
) : 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); }} />
<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); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
</section>
) : null}
{tab === "approval" ? (
@ -1249,7 +1284,7 @@ export function PayrollAdminWorkspace({
</>
)}
{periodModalOpen ? <PayrollPeriodFormModal isOpen period={editingPeriod} periods={workspace.periods} onClose={() => setPeriodModalOpen(false)} onSubmit={savePeriod} onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; createdPeriodIdRef.current = null; if (createdPeriodId) { setNotice("Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null}
{periodModalOpen ? <PayrollPeriodFormModal isOpen period={editingPeriod} periods={workspace.periods} onClose={() => setPeriodModalOpen(false)} onSubmit={savePeriod} onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; const automaticInputCopy = createdPeriodInputCopyRef.current; createdPeriodIdRef.current = null; createdPeriodInputCopyRef.current = null; if (createdPeriodId) { const copiedCount = automaticInputCopy?.summary.created || 0; setNotice(copiedCount > 0 ? `Период создан и выбран. Автоматически перенесено записей: ${copiedCount}.` : "Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null}
{workspace.access_settings.can_configure ? (
<PayrollManagementAccessModal
isOpen={accessModalOpen}
@ -1272,7 +1307,8 @@ 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 && inputCopyModalOpen ? <PayrollInputCopyModal key={selectedPeriod.id} isOpen period={selectedPeriod} periods={workspace.periods} onClose={() => setInputCopyModalOpen(false)} onSaved={async (result: PayrollInputCopyResult) => { setInputCopyModalOpen(false); if (!result.summary.created) { await refreshView("Настройки переноса обновлены."); return; } const details = [result.summary.skipped ? `пропущено: ${result.summary.skipped}` : "", result.summary.replaced_cells ? `заменено полей: ${result.summary.replaced_cells}` : ""].filter(Boolean).join(", "); await refreshView(`Перенесено ${result.summary.created} записей для ${result.summary.employees} сотрудников${details ? ` (${details})` : ""}.`); }} /> : null}
{selectedPeriod && carryoversModalOpen ? <PayrollCarryoversModal key={`${selectedPeriod.id}-${carryoversEmployeeId || "all"}`} isOpen selectedPeriod={selectedPeriod} periods={workspace.periods} employees={workspace.rate_employees} initialEmployeeId={carryoversEmployeeId} canManage={permissions.manage_inputs} onChanged={() => refreshInlineTable("inputs")} onClose={() => setCarryoversModalOpen(false)} /> : 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

@ -1,26 +1,24 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ArrowDownLeft, ArrowUpRight, Loader2, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowDownLeft, ArrowUpRight, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { apiClient } from "@/lib/api";
import type {
PayrollAdminEmployee,
PayrollAdminPeriod,
PayrollCarryover,
PayrollCarryoverDirection,
PayrollCarryoverWorkspace,
PayrollCarryoverLedger,
} from "@/lib/api/finance";
import { formatPayrollDateTime, formatPayrollMoney } from "@/lib/payroll";
import { getPayrollAdminError, periodStatusMeta } from "@/lib/payroll-admin";
import { getPayrollAdminError } from "@/lib/payroll-admin";
type PayrollCarryoversModalProps = {
canManage: boolean;
employees: PayrollAdminEmployee[];
initialEmployeeId?: number | null;
isOpen: boolean;
onChanged: () => void | Promise<void>;
onChanged?: () => void | Promise<void>;
onClose: () => void;
periods: PayrollAdminPeriod[];
selectedPeriod: PayrollAdminPeriod;
@ -28,70 +26,10 @@ type PayrollCarryoversModalProps = {
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>
);
function signedAmount(value: string | number, currency: string): string {
const amount = Number(value);
if (!amount) return formatPayrollMoney("0", currency);
return `${amount > 0 ? "+" : "−"}${formatPayrollMoney(Math.abs(amount), currency)}`;
}
export function PayrollCarryoversModal({
@ -105,46 +43,34 @@ export function PayrollCarryoversModal({
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 [ledger, setLedger] = useState<PayrollCarryoverLedger | null>(null);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<number | null>(null);
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],
);
const [notice, setNotice] = useState<string | null>(null);
const [employeeFilterId, setEmployeeFilterId] = useState(initialEmployeeId ? String(initialEmployeeId) : "");
useEffect(() => {
if (!isOpen) return;
setPeriodId(selectedPeriod.id);
setWorkspace(null);
const loadLedger = useCallback(async (showLoader = true) => {
if (showLoader) setLoading(true);
setError(null);
setConfirmCancelId(null);
const employeeId = initialEmployeeId ? String(initialEmployeeId) : "";
setEmployeeFilterId(employeeId);
setForm((current) => ({ ...current, employee_id: employeeId }));
}, [initialEmployeeId, isOpen, selectedPeriod.id]);
try {
setLedger(await apiClient.getPayrollAdminCarryovers(periodId));
} catch (loadError: unknown) {
setError(getPayrollAdminError(loadError, "Не удалось загрузить переходящий остаток."));
} finally {
if (showLoader) setLoading(false);
}
}, [periodId]);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
apiClient.getPayrollAdminCarryovers(periodId)
.then((payload) => {
if (!cancelled) setWorkspace(payload);
if (!cancelled) setLedger(payload);
})
.catch((loadError: unknown) => {
if (!cancelled) setError(getPayrollAdminError(loadError, "Не удалось загрузить долги и доплаты."));
if (!cancelled) setError(getPayrollAdminError(loadError, "Не удалось загрузить переходящий остаток."));
})
.finally(() => {
if (!cancelled) setLoading(false);
@ -154,161 +80,141 @@ export function PayrollCarryoversModal({
};
}, [isOpen, periodId]);
async function reload() {
const payload = await apiClient.getPayrollAdminCarryovers(periodId);
setWorkspace(payload);
}
const entries = useMemo(() => (ledger?.entries || []).filter(
(entry) => !employeeFilterId || entry.employee.id === Number(employeeFilterId),
), [employeeFilterId, ledger]);
const balances = useMemo(() => {
const byEmployee = new Map<number, number>();
entries.forEach((entry) => {
byEmployee.set(entry.employee.id, (byEmployee.get(entry.employee.id) || 0) + Number(entry.contribution));
});
return [...byEmployee.values()].reduce(
(totals, balance) => ({
companyOwes: totals.companyOwes + Math.max(balance, 0),
employeeOwes: totals.employeeOwes + Math.max(-balance, 0),
}),
{ companyOwes: 0, employeeOwes: 0 },
);
}, [entries]);
const currency = ledger?.currency || selectedPeriod.currency;
async function createEntry(event: React.FormEvent) {
event.preventDefault();
const toggleEntry = async (entryId: number, included: boolean) => {
setSavingId(entryId);
setError(null);
setNotice(null);
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, "Не удалось зарегистрировать долг или доплату."));
const result = await apiClient.setPayrollAdminInputLineCarryoverInclusion(entryId, included);
setNotice(result.message);
await loadLedger(false);
await onChanged?.();
} catch (saveError: unknown) {
setError(getPayrollAdminError(saveError, "Не удалось изменить участие записи в остатке."));
} finally {
setBusy(false);
setSavingId(null);
}
}
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">
<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 className="grid gap-3 sm:grid-cols-2">
<label>
<span className="app-field-label">Остаток на конец периода</span>
<select className={inputClass} value={periodId} onChange={(event) => {
setPeriodId(Number(event.target.value));
setLedger(null);
setNotice(null);
setError(null);
setLoading(true);
}}>
{periods.map((period) => <option key={period.id} value={period.id}>{period.name || period.code}</option>)}
</select>
</label>
<label>
<span className="app-field-label">Сотрудник</span>
<select className={inputClass} value={employeeFilterId} onChange={(event) => setEmployeeFilterId(event.target.value)}>
<option value="">Все сотрудники</option>
{employees.map((employee) => <option key={employee.id} value={employee.id}>{employee.display_name}</option>)}
</select>
</label>
</div>
<div className="app-text-muted mt-3 space-y-1 text-xs leading-relaxed">
<p><b className="text-[var(--foreground)]">В реестре:</b> все положительные и отрицательные корректировки за выбранный и предыдущие периоды.</p>
<p><b className="text-[var(--foreground)]">В остатке:</b> утверждённые корректировки опубликованных периодов и активные корректировки текущего периода.</p>
<p><b className="text-[var(--foreground)]">Не входят:</b> зарплата по баллам, обычные премии и начисления, аванс, прочие выплаты и удержания.</p>
<p><b className="text-[var(--foreground)]">Отключение записи</b> меняет только переходящий остаток. Сумма зарплаты в исходном периоде не изменяется.</p>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className="app-feedback-success rounded-full px-2 py-1">Компания должна: {formatPayrollMoney(balances.companyOwes, currency)}</span>
<span className="app-feedback-warning rounded-full px-2 py-1">Сотрудники должны: {formatPayrollMoney(balances.employeeOwes, currency)}</span>
</div>
</div>
{error ? <div className="app-feedback-danger rounded-xl p-3 text-sm">{error}</div> : null}
{notice ? <div className="app-feedback-success rounded-xl p-3 text-sm">{notice}</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>
{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((entry) => {
const rawContribution = Number(entry.raw_contribution);
const Icon = rawContribution >= 0 ? ArrowUpRight : ArrowDownLeft;
const disabled = !canManage || savingId !== null || entry.status === "voided";
const exclusionReason = !entry.include_in_carryover
? "Исключена из остатка вручную."
: entry.status === "voided"
? "Аннулированная запись не участвует в остатке."
: "Запись начнёт участвовать после утверждения и публикации периода.";
return (
<article key={entry.id} className={`app-surface-muted rounded-xl p-3 ${entry.include_in_carryover ? "" : "opacity-70"}`}>
<div className="flex items-start gap-3">
<span className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${rawContribution >= 0 ? "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-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">{entry.employee.display_name}</p>
<p className="app-text-muted mt-0.5 text-xs">{entry.period.name || entry.period.code} · {entry.component.name}</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-[var(--foreground)]">{formatPayrollMoney(entry.amount, currency)}</p>
<p className={`text-xs ${entry.counts_in_balance ? (rawContribution >= 0 ? "text-emerald-400" : "text-amber-400") : "app-text-muted"}`}>
Вклад: {entry.counts_in_balance ? signedAmount(entry.contribution, currency) : "не учитывается"}
</p>
</div>
</div>
<div className="app-text-muted mt-2 grid gap-1 text-xs sm:grid-cols-2">
<p>Статус: <span className="text-[var(--foreground)]">{entry.status_label}</span></p>
<p>Создал: <span className="text-[var(--foreground)]">{entry.created_by.display_name}</span> · {formatPayrollDateTime(entry.created_at)}</p>
</div>
{entry.reason ? <p className="app-text-muted mt-2 text-xs">Основание: <span className="text-[var(--foreground)]">{entry.reason}</span></p> : null}
{!entry.counts_in_balance ? (
<p className="app-text-muted mt-2 text-xs">{exclusionReason} Возможный вклад: {signedAmount(entry.raw_contribution, currency)}.</p>
) : null}
<label className={`mt-3 flex items-center gap-2 text-xs ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}>
<input
type="checkbox"
checked={entry.include_in_carryover}
disabled={disabled}
onChange={(event) => void toggleEntry(entry.id, event.target.checked)}
/>
<span className="text-[var(--foreground)]">Учитывать эту корректировку в переходящем остатке</span>
{savingId === entry.id ? <Loader2 className="animate-spin" size={13} /> : null}
</label>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="app-feedback-info rounded-xl px-4 py-3 text-sm">
Новые долги и доплаты фиксируются только для закрытого периода. Выберите закрытый месяц в списке выше.
<div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
Корректировок за выбранный и предыдущие периоды нет.
</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,389 @@
"use client";
import { Check, Loader2, Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type {
PayrollAdminPeriod,
PayrollInputCopyEmployeeScope,
PayrollInputCopyExistingMode,
PayrollInputCopyPreview,
PayrollInputCopyResult,
} from "@/lib/api/finance";
import { formatPayrollMoney } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
type PayrollInputCopyModalProps = {
isOpen: boolean;
period: PayrollAdminPeriod;
periods: PayrollAdminPeriod[];
onClose: () => void;
onSaved: (result: PayrollInputCopyResult) => void | Promise<void>;
};
const selectClass = "app-select w-full rounded-lg px-3 py-2.5 text-sm";
type PayrollInputCopySourceMode = "previous" | "specific";
export function PayrollInputCopyModal({
isOpen,
period,
periods,
onClose,
onSaved,
}: PayrollInputCopyModalProps) {
const sourcePeriods = useMemo(() => periods
.filter((item) => item.id !== period.id && item.currency === period.currency && item.date_to < period.date_from)
.sort((left, right) => right.date_to.localeCompare(left.date_to)), [period, periods]);
const previousPeriod = sourcePeriods[0] || null;
const [sourceMode, setSourceMode] = useState<PayrollInputCopySourceMode>("previous");
const [sourcePeriodId, setSourcePeriodId] = useState(sourcePeriods[0]?.id || 0);
const selectedSourcePeriodId = sourceMode === "previous"
? previousPeriod?.id || 0
: sourcePeriodId;
const [preview, setPreview] = useState<PayrollInputCopyPreview | null>(null);
const [selectedComponentIds, setSelectedComponentIds] = useState<Set<number>>(new Set());
const [employeeScope, setEmployeeScope] = useState<PayrollInputCopyEmployeeScope>("active");
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<number>>(new Set());
const [existingMode, setExistingMode] = useState<PayrollInputCopyExistingMode>("skip");
const [rememberConfiguration, setRememberConfiguration] = useState(false);
const [autoApplyOnCreation, setAutoApplyOnCreation] = useState(false);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(Boolean(sourcePeriods.length));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [idempotencyKey] = useState(() => globalThis.crypto.randomUUID());
useEffect(() => {
if (!isOpen || !selectedSourcePeriodId) return;
let cancelled = false;
apiClient.getPayrollAdminInputCopyPreview(period.id, selectedSourcePeriodId)
.then((payload) => {
if (cancelled) return;
setPreview(payload);
const availableComponentIds = new Set(payload.components.map((component) => component.id));
const configuredComponentIds = payload.configuration.component_ids.filter((id) => availableComponentIds.has(id));
setSelectedComponentIds(new Set(
payload.configuration.saved
? configuredComponentIds
: payload.components.map((component) => component.id),
));
setEmployeeScope(payload.configuration.employee_scope);
setSelectedEmployeeIds(new Set(
payload.configuration.saved && payload.configuration.employee_scope === "selected"
? payload.configuration.employee_ids
: payload.employees.map((employee) => employee.id),
));
setExistingMode(payload.configuration.existing_mode);
setRememberConfiguration(payload.configuration.saved);
setAutoApplyOnCreation(payload.configuration.auto_apply_on_period_creation);
})
.catch((loadError: unknown) => {
if (!cancelled) setError(getPayrollAdminError(loadError, "Не удалось подготовить записи к переносу."));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [isOpen, period.id, selectedSourcePeriodId, sourceMode]);
const scopedEmployees = useMemo(() => (preview?.employees || []).filter((employee) => {
if (employeeScope === "active") return employee.is_active;
if (employeeScope === "selected") return selectedEmployeeIds.has(employee.id);
return true;
}), [employeeScope, preview, selectedEmployeeIds]);
const filteredEmployees = useMemo(() => {
const needle = search.trim().toLocaleLowerCase("ru-RU");
if (!needle) return preview?.employees || [];
return (preview?.employees || []).filter((employee) => (
`${employee.display_name} ${employee.position || ""} ${employee.department || ""}`
.toLocaleLowerCase("ru-RU")
.includes(needle)
));
}, [preview, search]);
const projection = useMemo(() => {
let matching = 0;
let skipped = 0;
let replacedFields = 0;
let voided = 0;
scopedEmployees.forEach((employee) => {
selectedComponentIds.forEach((componentId) => {
const sourceCount = employee.component_counts[String(componentId)] || 0;
if (!sourceCount) return;
const existingCount = employee.existing_component_counts[String(componentId)] || 0;
matching += sourceCount;
if (!existingCount) return;
if (existingMode === "skip") skipped += sourceCount;
else {
replacedFields += 1;
voided += existingCount;
}
});
});
return {
matching,
created: existingMode === "skip" ? matching - skipped : matching,
skipped,
replacedFields,
voided,
};
}, [existingMode, scopedEmployees, selectedComponentIds]);
const allEmployeesSelected = Boolean(preview?.employees.length)
&& preview!.employees.every((employee) => selectedEmployeeIds.has(employee.id));
const hasValidEmployeeSelection = employeeScope !== "selected" || selectedEmployeeIds.size > 0;
const hasConfigurationUpdate = rememberConfiguration || Boolean(preview?.configuration.saved);
const canSubmit = selectedComponentIds.size > 0
&& hasValidEmployeeSelection
&& (projection.created > 0 || hasConfigurationUpdate);
const toggleComponent = (componentId: number) => {
setSelectedComponentIds((current) => {
const next = new Set(current);
if (next.has(componentId)) next.delete(componentId);
else next.add(componentId);
return next;
});
};
const toggleEmployee = (employeeId: number) => {
setSelectedEmployeeIds((current) => {
const next = new Set(current);
if (next.has(employeeId)) next.delete(employeeId);
else next.add(employeeId);
return next;
});
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) return;
setBusy(true);
setError(null);
try {
const result = await apiClient.copyPayrollAdminInputLines(period.id, {
source_period_id: selectedSourcePeriodId,
component_ids: [...selectedComponentIds],
employee_scope: employeeScope,
employee_ids: employeeScope === "selected" ? [...selectedEmployeeIds] : [],
existing_mode: existingMode,
expected_period_lock_version: period.lock_version,
idempotency_key: idempotencyKey,
remember_configuration: rememberConfiguration,
auto_apply_on_period_creation: rememberConfiguration && autoApplyOnCreation,
});
await onSaved(result);
} catch (submitError: unknown) {
setError(getPayrollAdminError(submitError, "Не удалось перенести записи из периода."));
} finally {
setBusy(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Перенести записи из периода" size="lg">
{!sourcePeriods.length ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
Более ранних периодов в валюте {period.currency} нет.
</div>
) : (
<form onSubmit={submit}>
<div className="app-selected rounded-xl px-4 py-3 text-sm">
<p className="font-semibold text-[var(--foreground)]">Куда: {period.name || period.code}</p>
<p className="app-text-muted mt-1 text-xs">Записи источника останутся без изменений. В текущем периоде появятся новые черновики.</p>
</div>
<fieldset className="mt-4">
<legend className="app-field-label">Откуда переносить</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${sourceMode === "previous" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2.5">
<input type="radio" name="source-mode" checked={sourceMode === "previous"} onChange={() => {
setSourceMode("previous");
setPreview(null);
setLoading(true);
setError(null);
}} />
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Предыдущий период</span>
<span className="app-text-muted mt-1 block text-xs">{previousPeriod?.name || previousPeriod?.code}</span>
</span>
</span>
</label>
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${sourceMode === "specific" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2.5">
<input type="radio" name="source-mode" checked={sourceMode === "specific"} onChange={() => {
setSourceMode("specific");
setPreview(null);
setLoading(true);
setError(null);
}} />
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Определённый период</span>
<span className="app-text-muted mt-1 block text-xs">Выбрать другой доступный месяц вручную.</span>
</span>
</span>
</label>
</div>
{sourceMode === "specific" ? (
<label className="mt-3 block">
<span className="app-field-label">Выбранный период</span>
<select className={selectClass} value={sourcePeriodId} onChange={(event) => {
setSourcePeriodId(Number(event.target.value));
setPreview(null);
setLoading(true);
setError(null);
}}>
{sourcePeriods.map((item) => <option key={item.id} value={item.id}>{item.name || item.code}</option>)}
</select>
</label>
) : null}
<p className="app-text-muted mt-2 text-xs">
Конкретный период действует только для этого переноса. В сохранённую конфигурацию он не входит; при автоприменении всегда используется ближайший предыдущий период той же валюты.
</p>
</fieldset>
{loading ? (
<div className="flex min-h-48 items-center justify-center"><Loader2 className="app-accent-text animate-spin" size={22} /></div>
) : preview ? (
<>
<fieldset className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<legend className="app-field-label mb-0">Типы записей</legend>
<button type="button" className="app-action-ghost rounded-md px-2 py-1 text-xs" onClick={() => setSelectedComponentIds(
selectedComponentIds.size === preview.components.length ? new Set() : new Set(preview.components.map((component) => component.id)),
)}>{selectedComponentIds.size === preview.components.length ? "Снять выбор" : "Выбрать все"}</button>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{preview.components.map((component) => (
<label key={component.id} className={`cursor-pointer rounded-xl border px-3 py-2.5 ${selectedComponentIds.has(component.id) ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2.5">
<input type="checkbox" checked={selectedComponentIds.has(component.id)} onChange={() => toggleComponent(component.id)} />
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-[var(--foreground)]">{component.name}</span>
<span className="app-text-muted mt-0.5 block text-xs">
{component.line_count > 0
? `${component.kind_label} · ${component.line_count} записей · ${formatPayrollMoney(component.amount_total, period.currency)}`
: `${component.kind_label} · В выбранном периоде записей нет`}
</span>
</span>
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="mt-4">
<legend className="app-field-label">Каких сотрудников переносить</legend>
<div className="grid gap-2 sm:grid-cols-3">
{([
["active", "Всех активных", `${preview.summary.active_employees} сотрудников`],
["all", "Всех из источника", `${preview.summary.employees} сотрудников`],
["selected", "Только выбранных", `${selectedEmployeeIds.size} выбрано`],
] as const).map(([value, label, hint]) => (
<label key={value} className={`cursor-pointer rounded-xl border px-3 py-3 ${employeeScope === value ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2">
<input type="radio" name="employee-scope" checked={employeeScope === value} onChange={() => setEmployeeScope(value)} />
<span><span className="block text-sm font-semibold text-[var(--foreground)]">{label}</span><span className="app-text-muted mt-0.5 block text-xs">{hint}</span></span>
</span>
</label>
))}
</div>
</fieldset>
{employeeScope === "selected" ? (
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="app-field-label mb-0">Сотрудники</span>
<button type="button" className="app-action-ghost rounded-md px-2 py-1 text-xs" onClick={() => setSelectedEmployeeIds(
allEmployeesSelected ? new Set() : new Set(preview.employees.map((employee) => employee.id)),
)}>{allEmployeesSelected ? "Снять выбор" : "Выбрать всех"}</button>
</div>
<label className="relative block">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотрудникам" onChange={(event) => setSearch(event.target.value)} />
</label>
<div className="app-surface-muted mt-2 max-h-52 overflow-y-auto rounded-xl">
{filteredEmployees.map((employee) => (
<label key={employee.id} className="flex cursor-pointer items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-0">
<input type="checkbox" checked={selectedEmployeeIds.has(employee.id)} onChange={() => toggleEmployee(employee.id)} />
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium text-[var(--foreground)]">{employee.display_name}</span><span className="app-text-muted block truncate text-xs">{employee.position || employee.department || "Сотрудник"}</span></span>
<span className={`app-status-pill ${employee.is_active ? "app-action-success" : "app-action-warning"}`}>{employee.is_active ? "Активен" : "Неактивен"}</span>
</label>
))}
</div>
</div>
) : null}
<fieldset className="mt-4">
<legend className="app-field-label">Если поле уже заполнено</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${existingMode === "skip" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2.5"><input type="radio" name="existing-mode" checked={existingMode === "skip"} onChange={() => setExistingMode("skip")} /><span><span className="block text-sm font-semibold text-[var(--foreground)]">Пропустить заполненные</span><span className="app-text-muted mt-1 block text-xs">Существующие значения текущего периода сохранятся.</span></span></span>
</label>
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${existingMode === "replace" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2.5"><input type="radio" name="existing-mode" checked={existingMode === "replace"} onChange={() => setExistingMode("replace")} /><span><span className="block text-sm font-semibold text-[var(--foreground)]">Заменить заполненные</span><span className="app-text-muted mt-1 block text-xs">Текущие строки будут аннулированы и заменены копиями.</span></span></span>
</label>
</div>
</fieldset>
<div className="app-surface-muted mt-4 space-y-3 rounded-xl px-4 py-3">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={rememberConfiguration}
onChange={(event) => {
setRememberConfiguration(event.target.checked);
if (!event.target.checked) setAutoApplyOnCreation(false);
}}
/>
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Запомнить конфигурацию</span>
<span className="app-text-muted mt-0.5 block text-xs">
{preview.configuration.saved
? "Конфигурация уже сохранена. Снимите флажок, чтобы удалить её после выполнения."
: "Сохранить типы записей, охват сотрудников и режим обработки заполненных полей."}
</span>
</span>
</label>
<label className={`flex items-start gap-3 ${rememberConfiguration ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}>
<input
type="checkbox"
checked={autoApplyOnCreation}
disabled={!rememberConfiguration}
onChange={(event) => setAutoApplyOnCreation(event.target.checked)}
/>
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Автоматически применять при создании периода</span>
<span className="app-text-muted mt-0.5 block text-xs">При создании следующего периода брать ближайший предыдущий период той же валюты и создавать черновики по этой конфигурации.</span>
</span>
</label>
</div>
<div className="app-surface-muted mt-4 rounded-xl px-4 py-3 text-sm">
<p className="font-semibold text-[var(--foreground)]">Будет создано черновиков: {projection.created}</p>
<p className="app-text-muted mt-1 text-xs">
Подходящих строк: {projection.matching}
{existingMode === "skip" ? ` · пропущено: ${projection.skipped}` : ` · заменяемых полей: ${projection.replacedFields} · аннулируемых строк: ${projection.voided}`}
</p>
</div>
</>
) : null}
{error ? <div className="app-feedback-danger mt-4 rounded-lg px-3 py-2.5 text-sm">{error}</div> : null}
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium" disabled={busy} onClick={onClose}>Отмена</button>
<button type="submit" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold disabled:opacity-60" disabled={busy || loading || !canSubmit}>
{busy ? <Loader2 className="animate-spin" size={16} /> : <Check size={16} />}
{projection.created > 0
? `Перенести ${projection.created} записей`
: rememberConfiguration
? "Сохранить конфигурацию"
: "Удалить конфигурацию"}
</button>
</div>
</form>
)}
</Modal>
);
}

View File

@ -78,6 +78,7 @@ const columnIds = {
net: "net_pay",
payments: "payment_total",
payable: "payable",
paymentConfirmation: "payment_confirmation",
carryovers: "carryovers",
status: "status",
} as const;
@ -137,11 +138,18 @@ const statusColumn: ColumnDefinition = {
const carryoversColumn: ColumnDefinition = {
id: columnIds.carryovers,
label: "Долги / доплаты",
label: "Остаток к переносу",
group: "Переносы",
width: 100,
};
const paymentConfirmationColumn: ColumnDefinition = {
id: columnIds.paymentConfirmation,
label: "Зарплата выдана",
group: "Факт выплаты",
width: 105,
};
function componentColumnId(code: string): string {
return `component:${code}`;
}
@ -312,9 +320,11 @@ function ColumnHeader({
function SortableColumnHeader({
column,
children,
className = "",
}: {
column: ColumnDefinition;
children: (dragHandle: ReactNode) => ReactNode;
className?: string;
}) {
const {
attributes,
@ -328,7 +338,7 @@ function SortableColumnHeader({
return (
<th
ref={setNodeRef}
className={`${headerCellClass} whitespace-normal text-right ${isDragging ? "z-40 opacity-60" : ""}`}
className={`${headerCellClass} whitespace-normal text-right ${className} ${isDragging ? "z-40 opacity-60" : ""}`}
style={{
transform: CSS.Transform.toString(transform),
transition,
@ -387,7 +397,7 @@ function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp;
<div className="mt-5 space-y-4 text-sm">
<div>
<p className="font-semibold text-[var(--foreground)]">Как формируется</p>
<p className="app-text-muted mt-1 leading-relaxed">{help.logic}</p>
<p className="app-text-muted mt-1 whitespace-pre-line leading-relaxed">{help.logic}</p>
</div>
<div>
<p className="font-semibold text-[var(--foreground)]">Формула</p>
@ -395,7 +405,7 @@ function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp;
</div>
<div>
<p className="font-semibold text-[var(--foreground)]">Можно изменить вручную?</p>
<p className="app-text-muted mt-1 leading-relaxed">{help.manual}</p>
<p className="app-text-muted mt-1 whitespace-pre-line leading-relaxed">{help.manual}</p>
</div>
</div>
<p className="app-text-muted mt-5 border-t border-[var(--border-subtle)] pt-3 text-xs leading-relaxed">Правила {rules.ruleset_id}, версия {rules.version}. Денежные строки округляются до {rules.money_quantum} ({roundingLabel} округление).</p>
@ -498,15 +508,15 @@ function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["
},
payments: {
title: "Выплачено",
logic: "Сумма уже произведённых выплат за период, например авансов.",
formula: "Выплачено = Σ строк с типом «Выплата»",
manual: "Нет. Итог меняется через строки выплат.",
logic: "Деньги, которые уже фактически выдали сотруднику в этом периоде. Сюда попадают «Аванс», «Фактическая выплата» и другие операции с типом «Выплата». Начисления, премии, корректировки и удержания сюда не входят.",
formula: "Выплачено = Аванс + Фактическая выплата + Σ прочих операций типа «Выплата»",
manual: "Сам итог не редактируется. Введите сумму в ячейке «Аванс» или «Фактическая выплата» — она уменьшит остаток к выплате.",
},
payable: {
title: "К выплате",
logic: "Остаток, который ещё нужно выплатить сотруднику.",
logic: "Остаток зарплаты только за выбранный период. Зарплата, рассчитанная по баллам, может быть ниже или выше оклада — это нормальный результат периода и в переходящий остаток не попадает.",
formula: "К выплате = После удержаний − Выплачено",
manual: "Нет. Пересчитывается автоматически из связанных сумм.",
manual: "Нет. Начисления и корректировки меняют зарплату периода; удержания уменьшают её; «Аванс» и «Фактическая выплата» фиксируют уже выданные деньги.",
},
status: {
title: "Состояние",
@ -518,18 +528,38 @@ function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["
}
function componentCalculationHelp(component: PayrollPeriodTableComponent): CalculationHelp {
const effect = {
earning: "входит в сумму «До корректировок»",
adjustment_credit: "увеличивает сумму «Корректировки»",
adjustment_debit: "уменьшает сумму «Корректировки»",
deduction: "входит в сумму «Удержано»",
payment: "входит в сумму «Выплачено»",
const flow = {
earning: {
effect: "увеличивает «До корректировок», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → До корректировок (+) → Начислено → После удержаний → К выплате\nПереходящий остаток: не изменяется`,
balance: "В переходящий остаток не входит.",
},
adjustment_credit: {
effect: "увеличивает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Корректировки (+) → Начислено → После удержаний → К выплате\nПереходящий остаток: уменьшается`,
balance: "Уменьшает накопленный остаток недоплаты; превышение создаёт отрицательный остаток.",
},
adjustment_debit: {
effect: "уменьшает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Корректировки (−) → Начислено → После удержаний → К выплате\nПереходящий остаток: увеличивается`,
balance: "Создаёт или увеличивает накопленный остаток недоплаты.",
},
deduction: {
effect: "увеличивает «Удержано» и тем самым уменьшает «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Удержано (+) → После удержаний (−) → К выплате\nПереходящий остаток: не изменяется`,
balance: "В переходящий остаток не входит.",
},
payment: {
effect: "увеличивает «Выплачено» и тем самым уменьшает остаток «К выплате»; на начисленную зарплату не влияет",
formula: `${component.label} → Выплачено (+) → К выплате (−)\nПереходящий остаток: не изменяется`,
balance: "В переходящий остаток не входит.",
},
}[component.kind];
return {
title: component.label,
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Этот вид операции ${effect}.`,
formula: `${component.label} = Σ операций этого вида за период`,
manual: "Да. Ввод нового итога в ячейку создаёт или обновляет черновик операции на разницу. Утверждённые суммы исправляются новой операцией.",
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Она ${flow.effect}. ${flow.balance}`,
formula: `${component.label} = Σ операций этого вида за период\n\nКуда идёт:\n${flow.formula}`,
manual: "Да. Введите итоговую сумму в ячейку: система создаст новый черновик или изменит существующий на нужную разницу. Утверждённая операция не переписывается — для исправления создаётся новая операция.",
};
}
@ -645,6 +675,7 @@ function ProjectionPointsCell({
onOpen,
commentCount,
onOpenComment,
cellClassName = "",
}: {
value: string | null;
actualPoints: string | null;
@ -652,6 +683,7 @@ function ProjectionPointsCell({
onOpen: () => void;
commentCount: number;
onOpenComment: () => void;
cellClassName?: string;
}) {
const difference = comparePayrollProjection(value, actualPoints);
const className = difference
@ -665,7 +697,7 @@ function ProjectionPointsCell({
? `${label}: значение ${directionLabel} выработки, ${differenceLabel}. Нажмите для детализации по дням.`
: `${label}. Нажмите для детализации по дням.`;
return (
<td className={`${bodyCellClass} relative p-0`} title={title}>
<td className={`${bodyCellClass} relative p-0 ${cellClassName}`} title={title}>
<CellCommentButton count={commentCount} label={label} onClick={onOpenComment} />
<button
type="button"
@ -692,7 +724,7 @@ function sameNumber(left: string, right: string): boolean {
return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber === rightNumber;
}
function InlineEditableCell({ value, formattedValue, onSave, label, automatic = false, commentCount, onOpenComment }: { value: string | null; formattedValue: string; onSave?: (value: string) => Promise<void>; label: string; automatic?: boolean; commentCount: number; onOpenComment: () => void }) {
function InlineEditableCell({ value, formattedValue, onSave, label, automatic = false, commentCount, onOpenComment, cellClassName = "" }: { value: string | null; formattedValue: string; onSave?: (value: string) => Promise<void>; label: string; automatic?: boolean; commentCount: number; onOpenComment: () => void; cellClassName?: string }) {
const original = editableNumber(value);
const [draft, setDraft] = useState(original);
const [pending, setPending] = useState(false);
@ -705,7 +737,7 @@ function InlineEditableCell({ value, formattedValue, onSave, label, automatic =
if (!onSave) return (
<CommentableValueCell
className={automatic ? "text-[var(--muted-foreground)]" : ""}
className={`${automatic ? "text-[var(--muted-foreground)]" : ""} ${cellClassName}`}
commentCount={commentCount}
commentLabel={label}
onOpenComment={onOpenComment}
@ -733,7 +765,7 @@ function InlineEditableCell({ value, formattedValue, onSave, label, automatic =
};
return (
<td className={`${bodyCellClass} relative p-0`}>
<td className={`${bodyCellClass} relative p-0 ${cellClassName}`}>
<CellCommentButton count={commentCount} label={label} onClick={onOpenComment} />
<label className="relative block min-h-10 w-full max-w-full overflow-hidden">
<input
@ -795,6 +827,7 @@ export function PayrollPeriodTableView({
onSaveWork,
onSaveComponent,
onOpenCarryovers,
onSetPaymentConfirmed,
}: {
data: PayrollPeriodTable | null;
loading: boolean;
@ -810,6 +843,7 @@ export function PayrollPeriodTableView({
onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise<void>;
onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise<void>;
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise<void>;
}) {
const searchedRows = useMemo(() => {
const needle = search.trim().toLocaleLowerCase("ru-RU");
@ -829,6 +863,8 @@ export function PayrollPeriodTableView({
} | null>(null);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState<Set<number>>(new Set());
const [paymentConfirmationError, setPaymentConfirmationError] = useState<string | null>(null);
const columnsMenuRef = useRef<HTMLDivElement>(null);
const rowsMenuRef = useRef<HTMLDivElement>(null);
const {
@ -871,8 +907,16 @@ export function PayrollPeriodTableView({
}))
), [componentColumns]);
const allColumns = useMemo(
() => [...salaryColumns, ...workColumns, ...componentDefinitions, ...calculationColumns, carryoversColumn, statusColumn],
[componentDefinitions],
() => [
...salaryColumns,
...workColumns,
...componentDefinitions,
...calculationColumns,
...(data?.period_status === "closed" ? [paymentConfirmationColumn] : []),
carryoversColumn,
statusColumn,
],
[componentDefinitions, data?.period_status],
);
const defaultColumnOrder = useMemo(() => allColumns.map((column) => column.id), [allColumns]);
const columnOrder = useMemo(
@ -973,6 +1017,25 @@ export function PayrollPeriodTableView({
const componentsByColumnId = new Map(
componentColumns.map((component) => [componentColumnId(component.code), component]),
);
const groupBoundaryColumnIds = new Set(
groupSegments.slice(1).map((segment) => segment.columns[0]?.id).filter(Boolean),
);
const columnVisualClass = (
column: ColumnDefinition,
section: "header" | "body" | "footer",
) => {
const component = componentsByColumnId.get(column.id);
const sectionEdge = section === "header" ? "!border-t-2" : section === "footer" ? "!border-b-2" : "";
if (component?.kind === "adjustment_credit") {
return `!border-x-2 !border-emerald-500/80 bg-emerald-500/[0.035] ${sectionEdge}`;
}
if (component?.kind === "adjustment_debit") {
return `!border-x-2 !border-red-500/80 bg-red-500/[0.035] ${sectionEdge}`;
}
return groupBoundaryColumnIds.has(column.id)
? "!border-l-2 !border-l-[var(--border-strong)]"
: "";
};
const carryoversByEmployeeId = new Map(
(data.carryovers || []).map((summary) => [summary.employee_id, summary]),
);
@ -1005,8 +1068,13 @@ export function PayrollPeriodTableView({
};
const component = componentsByColumnId.get(column.id);
if (component) {
const accentClass = component.kind === "adjustment_credit"
? "font-bold text-emerald-400"
: component.kind === "adjustment_debit"
? "font-bold text-red-400"
: "";
return (
<span title={`${component.label} · ${kindLabels[component.kind]}`}>
<span className={accentClass} title={`${component.label} · ${kindLabels[component.kind]}`}>
<ColumnHeader label={component.label} help={componentCalculationHelp(component)} {...common}>{component.label}</ColumnHeader>
</span>
);
@ -1044,19 +1112,34 @@ export function PayrollPeriodTableView({
return <ColumnHeader label="Выплачено" help={helps.payments} {...common}>Выплачено</ColumnHeader>;
case columnIds.payable:
return <ColumnHeader label="К выплате" help={helps.payable} {...common}>К выплате</ColumnHeader>;
case columnIds.carryovers:
case columnIds.paymentConfirmation:
return (
<ColumnHeader
label="Долги / доплаты"
label="Зарплата выдана"
help={{
title: "Долги / доплаты",
logic: "Положительное значение — компания должна доплатить сотруднику. Отрицательное — долг сотрудника перед компанией. В колонке показан итог записей, связанных с выбранным периодом.",
formula: "Баланс переноса = Доплаты сотруднику − Долги сотрудника",
manual: "Да. Нажмите сумму в строке сотрудника: откроется реестр, где можно добавить или отменить неутверждённую запись.",
title: "Зарплата выдана",
logic: "Ручная отметка о том, что рассчитанная сумма «К выплате» фактически выдана сотруднику. Доступна только после закрытия периода.",
formula: "Не участвует в расчёте зарплаты, выплат, корректировок или переходящих остатков.",
manual: "Установите галочку после фактической выдачи денег. Система сохранит сотрудника, подтвердившего выплату, и время отметки.",
}}
{...common}
>
Долги / доплаты
Зарплата выдана
</ColumnHeader>
);
case columnIds.carryovers:
return (
<ColumnHeader
label="Остаток к переносу"
help={{
title: "Остаток к переносу",
logic: "Накопленная разница, созданная только явными положительными и отрицательными корректировками. После публикации она переходит в следующий период той же валюты.\n\nЗарплата по баллам, оклад, премии, отпускные, разовые начисления, удержания и выплаты этот остаток не создают.\n\nПлюс — накопленная недоплата. Минус — накопленная переплата. Ноль — корректировки полностью скомпенсированы.",
formula: "Остаток на конец = Остаток на начало + Отрицательная корректировка − Положительная корректировка\n\nОстаток на начало следующего периода = Остаток на конец опубликованного периода",
manual: "Отдельно не редактируется.\n• Отрицательная корректировка создаёт или увеличивает недоплату.\n• Положительная корректировка погашает недоплату; превышение создаёт переплату.\n• Противоположные корректировки на одинаковую сумму сводят остаток к нулю.",
}}
{...common}
>
Остаток к переносу
</ColumnHeader>
);
case columnIds.status:
@ -1069,42 +1152,85 @@ export function PayrollPeriodTableView({
const renderBodyCell = (column: ColumnDefinition, row: PayrollPeriodTableRow) => {
const comments = commentProps(row, column.id, column.label);
const component = componentsByColumnId.get(column.id);
const cellVisualClass = columnVisualClass(column, "body");
if (component) {
return <InlineEditableCell value={row.component_amounts[component.code] || null} formattedValue={formatMoney(row.component_amounts[component.code] || null, data.currency)} onSave={canEdit && onSaveComponent ? (value) => onSaveComponent(row, component.code, value) : undefined} label={`${component.label}: ${row.employee.display_name}`} {...comments} />;
return <InlineEditableCell cellClassName={cellVisualClass} value={row.component_amounts[component.code] || null} formattedValue={formatMoney(row.component_amounts[component.code] || null, data.currency)} onSave={canEdit && onSaveComponent ? (value) => onSaveComponent(row, component.code, value) : undefined} label={`${component.label}: ${row.employee.display_name}`} {...comments} />;
}
switch (column.id) {
case columnIds.rateAmount:
return <InlineEditableCell value={row.rate_amount} formattedValue={formatMoney(row.rate_amount, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "amount", value) : undefined} label={`Оклад: ${row.employee.display_name}`} {...comments} />;
return <InlineEditableCell cellClassName={cellVisualClass} value={row.rate_amount} formattedValue={formatMoney(row.rate_amount, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "amount", value) : undefined} label={`Оклад: ${row.employee.display_name}`} {...comments} />;
case columnIds.inNormPointRate:
return <InlineEditableCell value={row.in_norm_point_rate} formattedValue={formatMoney(row.in_norm_point_rate, data.currency)} label={`Цена балла в пределах нормы: ${row.employee.display_name}`} automatic {...comments} />;
return <InlineEditableCell cellClassName={cellVisualClass} value={row.in_norm_point_rate} formattedValue={formatMoney(row.in_norm_point_rate, data.currency)} label={`Цена балла в пределах нормы: ${row.employee.display_name}`} automatic {...comments} />;
case columnIds.excessPointRate:
return <InlineEditableCell value={row.point_rate ?? row.in_norm_point_rate} formattedValue={formatMoney(row.point_rate ?? row.in_norm_point_rate, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} automatic={row.point_rate === null} {...comments} />;
return <InlineEditableCell cellClassName={cellVisualClass} value={row.point_rate ?? row.in_norm_point_rate} formattedValue={formatMoney(row.point_rate ?? row.in_norm_point_rate, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} automatic={row.point_rate === null} {...comments} />;
case columnIds.targetPoints:
return <InlineEditableCell value={row.target_points} formattedValue={formatPoints(row.target_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "target_points", value) : undefined} label={`Норма: ${row.employee.display_name}`} automatic={row.target_points_automatic} {...comments} />;
return <InlineEditableCell cellClassName={cellVisualClass} value={row.target_points} formattedValue={formatPoints(row.target_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "target_points", value) : undefined} label={`Норма: ${row.employee.display_name}`} automatic={row.target_points_automatic} {...comments} />;
case columnIds.attendancePoints:
return <ProjectionPointsCell value={row.attendance_points} actualPoints={row.actual_points} label={`Баллы по посещаемости: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} {...comments} />;
return <ProjectionPointsCell cellClassName={cellVisualClass} value={row.attendance_points} actualPoints={row.actual_points} label={`Баллы по посещаемости: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} {...comments} />;
case columnIds.personnelPoints:
return <ProjectionPointsCell value={row.personnel_points} actualPoints={row.actual_points} label={`Баллы по кадровым событиям: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} {...comments} />;
return <ProjectionPointsCell cellClassName={cellVisualClass} value={row.personnel_points} actualPoints={row.actual_points} label={`Баллы по кадровым событиям: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} {...comments} />;
case columnIds.actualPoints:
return <InlineEditableCell value={row.actual_points} formattedValue={formatPoints(row.actual_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "actual_points", value) : undefined} label={`Баллы: ${row.employee.display_name}`} {...comments} />;
return <InlineEditableCell cellClassName={cellVisualClass} value={row.actual_points} formattedValue={formatPoints(row.actual_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "actual_points", value) : undefined} label={`Баллы: ${row.employee.display_name}`} {...comments} />;
case columnIds.pointDelta:
return <CommentableValueCell commentLabel={`Отклонение: ${row.employee.display_name}`} {...comments}>{formatPoints(row.point_delta)}</CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`Отклонение: ${row.employee.display_name}`} {...comments}>{formatPoints(row.point_delta)}</CommentableValueCell>;
case columnIds.pointAmount:
return <CommentableValueCell commentLabel={`По баллам: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.point_amount} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`По баллам: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.point_amount} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.grossBeforeAdjustments:
return <CommentableValueCell commentLabel={`До корректировок: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.gross_before_adjustments} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`До корректировок: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.gross_before_adjustments} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.adjustments:
return <CommentableValueCell commentLabel={`Корректировки: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.adjustment_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`Корректировки: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.adjustment_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.gross:
return <CommentableValueCell className="font-semibold" commentLabel={`Начислено: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.gross_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={`font-semibold ${cellVisualClass}`} commentLabel={`Начислено: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.gross_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.deductions:
return <CommentableValueCell commentLabel={`Удержано: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.deduction_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`Удержано: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.deduction_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.net:
return <CommentableValueCell commentLabel={`После удержаний: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.net_pay} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`После удержаний: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.net_pay} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.payments:
return <CommentableValueCell commentLabel={`Выплачено: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.payment_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`Выплачено: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.payment_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.payable:
return <CommentableValueCell className="font-semibold" commentLabel={`К выплате: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.payable} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
return <CommentableValueCell className={`font-semibold ${cellVisualClass}`} commentLabel={`К выплате: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.payable} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.paymentConfirmation: {
if (row.status !== "calculated" || row.totals_preliminary) {
return <td className={`app-text-muted border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center ${cellVisualClass}`} title="Для сотрудника нет итогового расчётного листка">—</td>;
}
const confirmation = row.payment_confirmation;
const busy = paymentConfirmationBusyIds.has(row.employee.id);
const title = confirmation
? `Подтвердил: ${confirmation.confirmed_by.display_name} · ${new Date(confirmation.confirmed_at).toLocaleString("ru-RU")}`
: `Отметить фактическую выплату сотруднику ${row.employee.display_name}`;
return (
<td className={`border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center ${cellVisualClass}`}>
<label className={`inline-flex items-center justify-center gap-1.5 text-[10px] font-semibold ${onSetPaymentConfirmed ? "cursor-pointer" : "cursor-default"}`} title={title}>
{busy ? <Loader2 className="app-accent-text animate-spin" size={14} /> : (
<input
type="checkbox"
checked={Boolean(confirmation)}
disabled={!onSetPaymentConfirmed}
onChange={(event) => {
if (!onSetPaymentConfirmed) return;
const confirmed = event.target.checked;
setPaymentConfirmationError(null);
setPaymentConfirmationBusyIds((current) => new Set(current).add(row.employee.id));
void onSetPaymentConfirmed(row, confirmed)
.catch((confirmationError: unknown) => {
setPaymentConfirmationError(getPayrollAdminError(confirmationError, "Не удалось изменить отметку о выплате."));
})
.finally(() => {
setPaymentConfirmationBusyIds((current) => {
const next = new Set(current);
next.delete(row.employee.id);
return next;
});
});
}}
aria-label={`${confirmation ? "Снять" : "Установить"} отметку о выплате для ${row.employee.display_name}`}
/>
)}
<span className={confirmation ? "text-emerald-400" : "app-text-muted"}>{confirmation ? "Выдана" : "Не выдана"}</span>
</label>
</td>
);
}
case columnIds.carryovers: {
const summary = carryoversByEmployeeId.get(row.employee.id);
const count = summary?.count || 0;
@ -1114,15 +1240,15 @@ export function PayrollPeriodTableView({
const formattedBalance = formatSignedMoney(balance, data.currency);
const details = summary
? [
Number(summary.company_owes) ? `доплаты ${formatMoney(summary.company_owes, data.currency)}` : "",
Number(summary.employee_owes) ? `долги ${formatMoney(summary.employee_owes, data.currency)}` : "",
Number(summary.company_owes) ? `недоплата ${formatMoney(summary.company_owes, data.currency)}` : "",
Number(summary.employee_owes) ? `переплата ${formatMoney(summary.employee_owes, data.currency)}` : "",
].filter(Boolean).join("; ")
: "записей пока нет";
: "переходящего остатка нет";
return (
<td className="relative overflow-hidden border-b border-r border-[var(--border-subtle)] p-0 text-center">
<td className={`relative overflow-hidden border-b border-r border-[var(--border-subtle)] p-0 text-center ${cellVisualClass}`}>
<CellCommentButton
count={comments.commentCount}
label={`Долги / доплаты: ${row.employee.display_name}`}
label={`Остаток к переносу: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
<button
@ -1131,7 +1257,7 @@ export function PayrollPeriodTableView({
onClick={() => onOpenCarryovers?.(row.employee)}
disabled={!onOpenCarryovers}
title={`${row.employee.display_name}: ${details}. Открыть реестр.`}
aria-label={`${row.employee.display_name}: открыть долги и доплаты, записей ${count}`}
aria-label={`${row.employee.display_name}: открыть переходящие остатки, записей ${count}`}
>
<span className={balance !== null && balance > 0 ? "text-emerald-400" : balance !== null && balance < 0 ? "text-amber-400" : "app-text-muted"}>
{formattedBalance}
@ -1143,48 +1269,55 @@ export function PayrollPeriodTableView({
}
case columnIds.status: {
const meta = statusMeta[row.status];
return <td className="relative overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center"><CellCommentButton count={comments.commentCount} label={`Состояние: ${row.employee.display_name}`} onClick={comments.onOpenComment} /><span className={`app-status-pill px-2 text-[10px] ${meta.className}`}>{meta.label}</span></td>;
return <td className={`relative overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center ${cellVisualClass}`}><CellCommentButton count={comments.commentCount} label={`Состояние: ${row.employee.display_name}`} onClick={comments.onOpenComment} /><span className={`app-status-pill px-2 text-[10px] ${meta.className}`}>{meta.label}</span></td>;
}
default:
return <CommentableValueCell commentLabel={`${column.label}: ${row.employee.display_name}`} {...comments}>—</CommentableValueCell>;
return <CommentableValueCell className={cellVisualClass} commentLabel={`${column.label}: ${row.employee.display_name}`} {...comments}>—</CommentableValueCell>;
}
};
const renderFooterCell = (column: ColumnDefinition) => {
const component = componentsByColumnId.get(column.id);
if (component) return <td className={bodyCellClass}>{formatMoney(sumRows(rows, (row) => row.component_amounts[component.code] || null), data.currency)}</td>;
const footerCellClass = `${bodyCellClass} ${columnVisualClass(column, "footer")}`;
if (component) return <td className={footerCellClass}>{formatMoney(sumRows(rows, (row) => row.component_amounts[component.code] || null), data.currency)}</td>;
switch (column.id) {
case columnIds.rateAmount:
return <td className={bodyCellClass}>{formatMoney(total("rate_amount"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("rate_amount"), data.currency)}</td>;
case columnIds.inNormPointRate:
case columnIds.excessPointRate:
return <td className={bodyCellClass}>—</td>;
return <td className={footerCellClass}>—</td>;
case columnIds.targetPoints:
return <td className={bodyCellClass}>{formatPoints(total("target_points"))}</td>;
return <td className={footerCellClass}>{formatPoints(total("target_points"))}</td>;
case columnIds.attendancePoints:
return <td className={bodyCellClass}>{formatPoints(total("attendance_points"))}</td>;
return <td className={footerCellClass}>{formatPoints(total("attendance_points"))}</td>;
case columnIds.personnelPoints:
return <td className={bodyCellClass}>{formatPoints(total("personnel_points"))}</td>;
return <td className={footerCellClass}>{formatPoints(total("personnel_points"))}</td>;
case columnIds.actualPoints:
return <td className={bodyCellClass}>{formatPoints(total("actual_points"))}</td>;
return <td className={footerCellClass}>{formatPoints(total("actual_points"))}</td>;
case columnIds.pointDelta:
return <td className={bodyCellClass}>{formatPoints(total("point_delta"))}</td>;
return <td className={footerCellClass}>{formatPoints(total("point_delta"))}</td>;
case columnIds.pointAmount:
return <td className={bodyCellClass}>{formatMoney(total("point_amount"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("point_amount"), data.currency)}</td>;
case columnIds.grossBeforeAdjustments:
return <td className={bodyCellClass}>{formatMoney(total("gross_before_adjustments"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("gross_before_adjustments"), data.currency)}</td>;
case columnIds.adjustments:
return <td className={bodyCellClass}>{formatMoney(total("adjustment_total"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("adjustment_total"), data.currency)}</td>;
case columnIds.gross:
return <td className={bodyCellClass}>{formatMoney(total("gross_total"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("gross_total"), data.currency)}</td>;
case columnIds.deductions:
return <td className={bodyCellClass}>{formatMoney(total("deduction_total"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("deduction_total"), data.currency)}</td>;
case columnIds.net:
return <td className={bodyCellClass}>{formatMoney(total("net_pay"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("net_pay"), data.currency)}</td>;
case columnIds.payments:
return <td className={bodyCellClass}>{formatMoney(total("payment_total"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("payment_total"), data.currency)}</td>;
case columnIds.payable:
return <td className={bodyCellClass}>{formatMoney(total("payable"), data.currency)}</td>;
return <td className={footerCellClass}>{formatMoney(total("payable"), data.currency)}</td>;
case columnIds.paymentConfirmation:
return (
<td className={`${footerCellClass} text-center`}>
{rows.filter((row) => row.payment_confirmation).length}/{rows.filter((row) => row.status === "calculated" && !row.totals_preliminary).length}
</td>
);
case columnIds.carryovers: {
const visibleEmployeeIds = new Set(rows.map((row) => row.employee.id));
const carryovers = (data.carryovers || []).filter((item) => visibleEmployeeIds.has(item.employee_id));
@ -1194,11 +1327,11 @@ export function PayrollPeriodTableView({
0,
)
: null;
return <td className={`${bodyCellClass} text-center`}>{formatSignedMoney(balance, data.currency)}</td>;
return <td className={`${footerCellClass} text-center`}>{formatSignedMoney(balance, data.currency)}</td>;
}
case columnIds.status:
default:
return <td className="border-r border-[var(--border-subtle)] px-3 py-3" />;
return <td className={`border-r border-[var(--border-subtle)] px-3 py-3 ${columnVisualClass(column, "footer")}`} />;
}
};
@ -1347,6 +1480,7 @@ export function PayrollPeriodTableView({
</div>
</div>
</div>
{paymentConfirmationError ? <div className="app-feedback-danger mb-3 rounded-lg px-3 py-2.5 text-sm">{paymentConfirmationError}</div> : null}
{data.summary.preliminary_count ? (
<div className="app-feedback-warning mb-3 rounded-lg px-3 py-2.5 text-sm">
@ -1360,6 +1494,9 @@ export function PayrollPeriodTableView({
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-300" aria-hidden="true" />Зелёный — значение выше выработки больше чем на 25%</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-orange-400" aria-hidden="true" />Оранжевый — ниже на 5–25%</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-red-400" aria-hidden="true" />Красный — ниже больше чем на 25%</span>
<span className="inline-flex items-center gap-1.5"><span className="h-3 w-3 rounded-sm border-2 border-emerald-500" aria-hidden="true" />Зелёная рамка — положительная корректировка</span>
<span className="inline-flex items-center gap-1.5"><span className="h-3 w-3 rounded-sm border-2 border-red-500" aria-hidden="true" />Красная рамка — отрицательная корректировка</span>
<span className="inline-flex items-center gap-1.5"><span className="h-3 border-l-2 border-[var(--border-strong)]" aria-hidden="true" />Толстая линия — граница блока</span>
<span className="inline-flex items-center gap-1.5"><CircleHelp className="app-accent-text" size={13} />Знак вопроса — логика и формула расчёта</span>
<span className="inline-flex items-center gap-1.5"><span className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[10px] leading-3">+</span>Плюс — добавить комментарий; К:N — открыть обсуждение</span>
</div>
@ -1385,7 +1522,7 @@ export function PayrollPeriodTableView({
<tr>
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-40 align-middle`}>Сотрудник</th>
{groupSegments.map((segment, index) => (
<th key={`${segment.group}-${index}`} colSpan={segment.columns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>
<th key={`${segment.group}-${index}`} colSpan={segment.columns.length} className={`${headerCellClass} text-center uppercase tracking-wide ${index > 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}>
{segment.group}
</th>
))}
@ -1393,7 +1530,7 @@ export function PayrollPeriodTableView({
<SortableContext items={visibleColumnIds} strategy={horizontalListSortingStrategy}>
<tr>
{visibleColumns.map((column) => (
<SortableColumnHeader key={column.id} column={column}>
<SortableColumnHeader key={column.id} column={column} className={columnVisualClass(column, "header")}>
{(dragHandle) => renderColumnHeader(column, dragHandle)}
</SortableColumnHeader>
))}
@ -1402,8 +1539,8 @@ export function PayrollPeriodTableView({
</thead>
<tbody>
{rows.length ? rows.map((row) => (
<tr key={row.employee.id} className="bg-[var(--surface-primary)] hover:bg-[var(--surface-secondary)]">
<td className="sticky left-0 z-20 overflow-hidden border-b border-r border-[var(--border-subtle)] bg-[var(--surface-primary)] px-2 py-2.5">
<tr key={row.employee.id} className={row.payment_confirmation ? "bg-emerald-500/[0.08] hover:bg-emerald-500/[0.12]" : "bg-[var(--surface-primary)] hover:bg-[var(--surface-secondary)]"}>
<td className={`sticky left-0 z-20 overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 ${row.payment_confirmation ? "bg-emerald-500/[0.12] backdrop-blur-xl" : "bg-[var(--surface-primary)]"}`}>
<CellCommentButton
count={commentCountByCell.get(commentCellKey(row.employee.id, "employee")) || 0}
label={`Сотрудник: ${formatEmployeeName(row.employee)}`}

View File

@ -14,6 +14,10 @@ test("finance API uses own published payroll endpoints", async () => {
await finance.getMyPayrollStatements();
await finance.getMyPayrollStatement("3f845e30-4ae1-4dd7-a474-66c90f9ef013");
await finance.acknowledgeMyPayrollStatement("3f845e30-4ae1-4dd7-a474-66c90f9ef013");
await finance.setMyPayrollStatementPaymentConfirmation(
"3f845e30-4ae1-4dd7-a474-66c90f9ef013",
true,
);
assert.deepEqual(calls, [
{
@ -28,6 +32,10 @@ test("finance API uses own published payroll endpoints", async () => {
endpoint: "/api/v1/finance/payroll/me/statements/3f845e30-4ae1-4dd7-a474-66c90f9ef013/acknowledge/",
options: { method: "POST" },
},
{
endpoint: "/api/v1/finance/payroll/me/statements/3f845e30-4ae1-4dd7-a474-66c90f9ef013/payment-confirmation/",
options: { method: "POST", body: JSON.stringify({ confirmed: true }) },
},
]);
});

View File

@ -21,6 +21,14 @@ export type PayrollAcknowledgement = {
disputed_at: string | null;
};
export type PayrollPaymentConfirmation = {
confirmed_at: string;
confirmed_by: {
id: number;
display_name: string;
};
};
export type PayrollStatementSummary = {
public_id: string;
period: PayrollPeriodSummary;
@ -29,6 +37,8 @@ export type PayrollStatementSummary = {
currency: string;
payable: string;
acknowledgement: PayrollAcknowledgement | null;
payment_confirmation: PayrollPaymentConfirmation | null;
can_confirm_payment: boolean;
};
export type PayrollStatementLine = {
@ -51,6 +61,8 @@ export type PayrollStatement = PayrollStatementSummary & {
deduction_total: string;
net_pay: string;
payment_total: string;
opening_balance: string;
closing_balance: string;
lines: PayrollStatementLine[];
};
@ -176,6 +188,7 @@ export type PayrollAdminPeriod = PayrollPeriodSummary & {
status: PayrollPeriodStatus;
lock_version: number;
current_run_id: number | null;
automatic_input_copy?: PayrollInputCopyResult | null;
};
export type PayrollAdminRun = {
@ -307,6 +320,36 @@ export type PayrollCarryoverWorkspace = {
incoming: PayrollCarryover[];
};
export type PayrollCarryoverLedgerEntry = {
id: number;
period: PayrollCarryoverPeriod;
employee: PayrollAdminEmployee;
component: PayrollComponent;
amount: string;
raw_contribution: string;
contribution: string;
reason: string;
status: PayrollApprovalStatus;
status_label: string;
include_in_carryover: boolean;
counts_in_balance: boolean;
created_by: PayrollAdminUserRef;
created_at: string;
};
export type PayrollCarryoverLedger = {
period_id: number;
currency: string;
summary: {
included_count: number;
excluded_count: number;
company_owes: string;
employee_owes: string;
balance: string;
};
entries: PayrollCarryoverLedgerEntry[];
};
export type PayrollCarryoverWrite = {
employee_id: number;
direction: PayrollCarryoverDirection;
@ -385,6 +428,12 @@ export type PayrollPeriodTableRow = {
net_pay: string | null;
payment_total: string | null;
payable: string | null;
opening_balance: string;
closing_balance: string;
payment_confirmation: {
confirmed_at: string;
confirmed_by: PayrollAdminUserRef;
} | null;
};
export type PayrollPeriodTableCarryoverSummary = {
@ -397,6 +446,7 @@ export type PayrollPeriodTableCarryoverSummary = {
export type PayrollPeriodTable = {
period_id: number;
period_status: PayrollPeriodStatus;
currency: string;
calculation_rules: {
ruleset_id: string;
@ -426,6 +476,14 @@ export type PayrollPeriodTable = {
};
};
export type PayrollPaymentConfirmationResult = {
statement_id: number;
employee_id: number;
confirmed: boolean;
confirmed_at: string | null;
confirmed_by: PayrollAdminUserRef | null;
};
export type PayrollPointBreakdownDay = {
date: string;
is_workday: boolean;
@ -506,6 +564,7 @@ export type PayrollComponent = {
code: string;
name: string;
kind: PayrollLineKind;
kind_label: string;
requires_reason: boolean;
is_active: boolean;
display_order: number;
@ -696,6 +755,64 @@ export type PayrollWorkbookImportResult = {
record_ids: number[];
};
export type PayrollInputCopyEmployeeScope = "all" | "active" | "selected";
export type PayrollInputCopyExistingMode = "skip" | "replace";
export type PayrollInputCopyPreview = {
source_period: PayrollAdminPeriod;
target_period: PayrollAdminPeriod;
summary: {
eligible_lines: number;
employees: number;
active_employees: number;
};
components: Array<PayrollComponent & {
line_count: number;
employee_count: number;
amount_total: string;
}>;
employees: Array<PayrollAdminEmployee & {
is_active: boolean;
line_count: number;
component_counts: Record<string, number>;
existing_component_counts: Record<string, number>;
}>;
configuration: {
saved: boolean;
component_ids: number[];
employee_scope: PayrollInputCopyEmployeeScope;
employee_ids: number[];
existing_mode: PayrollInputCopyExistingMode;
auto_apply_on_period_creation: boolean;
};
};
export type PayrollInputCopyWrite = {
source_period_id: number;
component_ids: number[];
employee_scope: PayrollInputCopyEmployeeScope;
employee_ids: number[];
existing_mode: PayrollInputCopyExistingMode;
expected_period_lock_version: number;
idempotency_key: string;
remember_configuration: boolean;
auto_apply_on_period_creation: boolean;
};
export type PayrollInputCopyResult = {
source_period_id: number;
target_period_id: number;
summary: {
created: number;
employees: number;
skipped: number;
replaced_cells: number;
voided: number;
};
record_ids: number[];
idempotent_replay: boolean;
};
export type PayrollAdminInputLine = {
id: number;
period_id: number;
@ -707,6 +824,7 @@ export type PayrollAdminInputLine = {
relates_to_period_id: number | null;
relates_to_period: PayrollCarryoverPeriod | null;
is_automatic_carryover: boolean;
include_in_carryover: boolean;
reason: string;
status: PayrollApprovalStatus;
lock_version: number;
@ -870,6 +988,14 @@ export function createFinanceApi(request: RequestFn) {
`/api/v1/finance/payroll/me/statements/${encodeURIComponent(publicId)}/acknowledge/`,
{ method: "POST" },
),
setMyPayrollStatementPaymentConfirmation: (
publicId: string,
confirmed: boolean,
): Promise<{ payment_confirmation: PayrollPaymentConfirmation | null }> =>
request(
`/api/v1/finance/payroll/me/statements/${encodeURIComponent(publicId)}/payment-confirmation/`,
jsonOptions("POST", { confirmed }),
),
getMyPayrollDailyWork: (periodId?: number): Promise<PayrollOwnDailyWorkWorkspace> =>
request(`/api/v1/finance/payroll/me/work-records/${buildQuery({ period_id: periodId })}`),
saveMyPayrollDailyWorkEntry: (
@ -894,8 +1020,25 @@ export function createFinanceApi(request: RequestFn) {
request(`${adminPrefix}/periods/`),
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>
request(`${adminPrefix}/periods/${periodId}/table/`),
getPayrollAdminCarryovers: (periodId: number): Promise<PayrollCarryoverWorkspace> =>
setPayrollAdminPaymentConfirmation: (
periodId: number,
employeeId: number,
confirmed: boolean,
): Promise<PayrollPaymentConfirmationResult> =>
request(
`${adminPrefix}/periods/${periodId}/employees/${employeeId}/payment-confirmation/`,
jsonOptions("POST", { confirmed }),
),
getPayrollAdminCarryovers: (periodId: number): Promise<PayrollCarryoverLedger> =>
request(`${adminPrefix}/periods/${periodId}/carryovers/`),
setPayrollAdminInputLineCarryoverInclusion: (
lineId: number,
included: boolean,
): Promise<{ id: number; include_in_carryover: boolean; message: string }> =>
request(
`${adminPrefix}/input-lines/${lineId}/carryover-inclusion/`,
jsonOptions("PATCH", { included }),
),
createPayrollAdminCarryover: (
periodId: number,
payload: PayrollCarryoverWrite,
@ -980,6 +1123,16 @@ export function createFinanceApi(request: RequestFn) {
payload: PayrollBulkTargetPointsWrite,
): Promise<PayrollBulkTargetPointsResult> =>
request(`${adminPrefix}/periods/${periodId}/bulk-target-points/`, jsonOptions("POST", payload)),
getPayrollAdminInputCopyPreview: (
periodId: number,
sourcePeriodId: number,
): Promise<PayrollInputCopyPreview> =>
request(`${adminPrefix}/periods/${periodId}/copy-input-lines/${buildQuery({ source_period_id: sourcePeriodId })}`),
copyPayrollAdminInputLines: (
periodId: number,
payload: PayrollInputCopyWrite,
): Promise<PayrollInputCopyResult> =>
request(`${adminPrefix}/periods/${periodId}/copy-input-lines/`, jsonOptions("POST", payload)),
approveAllPayrollAdminDrafts: (periodId: number): Promise<PayrollApproveDraftsResult> =>
request(`${adminPrefix}/periods/${periodId}/approve-drafts/`, jsonOptions("POST")),
getPayrollAdminWorkRecords: (params: PayrollAdminListParams = {}): Promise<PayrollAdminListPayload<PayrollAdminWorkRecord>> =>