feat(payroll): refine carryovers and period workflows
This commit is contained in:
parent
4f45e567b1
commit
37fd5efc33
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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),
|
||||
]
|
||||
@ -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="Обновлена",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -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="Учитывать в переходящем остатке",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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": "Конфигурации переноса зарплатных записей",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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": "Подтверждения выплаты зарплаты",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
62
backend/finance/payroll/balances.py
Normal file
62
backend/finance/payroll/balances.py
Normal 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
|
||||
}
|
||||
@ -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"]),
|
||||
|
||||
@ -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": (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
[
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"},
|
||||
],
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
389
frontend/src/components/finance/admin/PayrollInputCopyModal.tsx
Normal file
389
frontend/src/components/finance/admin/PayrollInputCopyModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)}`}
|
||||
|
||||
@ -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 }) },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@ -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>> =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user