feat(finance): add native payroll management
This commit is contained in:
parent
7c143ec44e
commit
716c8337de
@ -7,6 +7,8 @@ by :mod:`finance.payroll.services`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from rest_framework import serializers
|
||||
@ -20,6 +22,7 @@ from finance.models import (
|
||||
PayrollRun,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
from finance.payroll.work_norm import calculate_period_target_points
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
@ -278,6 +281,56 @@ class EmployeePayRateRevisionSerializer(serializers.Serializer):
|
||||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||||
|
||||
|
||||
class BulkPointRateCommandSerializer(serializers.Serializer):
|
||||
MODE_FIXED = "fixed"
|
||||
MODE_IN_NORM = "in_norm"
|
||||
|
||||
employee_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
allow_empty=False,
|
||||
)
|
||||
mode = serializers.ChoiceField(
|
||||
choices=(MODE_FIXED, MODE_IN_NORM),
|
||||
default=MODE_FIXED,
|
||||
)
|
||||
point_rate = serializers.DecimalField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
min_value=0,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||||
|
||||
def validate_employee_ids(self, value):
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
if attrs["mode"] == self.MODE_FIXED and attrs.get("point_rate") is None:
|
||||
attrs["point_rate"] = Decimal("0")
|
||||
elif attrs["mode"] == self.MODE_IN_NORM:
|
||||
attrs["point_rate"] = None
|
||||
return attrs
|
||||
|
||||
|
||||
class BulkPayRateCommandSerializer(serializers.Serializer):
|
||||
employee_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
allow_empty=False,
|
||||
)
|
||||
amount = serializers.DecimalField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
min_value=Decimal("0.0001"),
|
||||
)
|
||||
effective_from = serializers.DateField()
|
||||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||||
|
||||
def validate_employee_ids(self, value):
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
|
||||
class PayrollWorkRecordSerializer(serializers.ModelSerializer):
|
||||
employee = PayrollEmployeeMiniSerializer(read_only=True)
|
||||
employee_id = serializers.IntegerField(read_only=True)
|
||||
@ -298,6 +351,7 @@ class PayrollWorkRecordSerializer(serializers.ModelSerializer):
|
||||
"employee_id",
|
||||
"employee",
|
||||
"target_points",
|
||||
"target_points_overridden",
|
||||
"actual_points",
|
||||
"expected_point_amount",
|
||||
"expected_gross",
|
||||
@ -336,6 +390,13 @@ class PayrollWorkRecordWriteSerializer(
|
||||
source="employee",
|
||||
queryset=Employee.objects.filter(is_active=True),
|
||||
)
|
||||
target_points = serializers.DecimalField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
min_value=Decimal("0.0001"),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PayrollWorkRecord
|
||||
@ -352,6 +413,25 @@ class PayrollWorkRecordWriteSerializer(
|
||||
"expected_lock_version",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
target_was_sent = "target_points" in attrs
|
||||
should_calculate = (
|
||||
self.instance is None and not target_was_sent
|
||||
) or attrs.get("target_points") is None
|
||||
if should_calculate:
|
||||
period = attrs.get("period") or self.instance.period
|
||||
employee = attrs.get("employee") or self.instance.employee
|
||||
target_points, _, _ = calculate_period_target_points(
|
||||
period,
|
||||
employee=employee,
|
||||
)
|
||||
attrs["target_points"] = target_points
|
||||
attrs["target_points_overridden"] = False
|
||||
elif target_was_sent:
|
||||
attrs["target_points_overridden"] = True
|
||||
return attrs
|
||||
|
||||
|
||||
class PayrollWorkRecordRevisionSerializer(serializers.Serializer):
|
||||
target_points = serializers.DecimalField(
|
||||
|
||||
@ -42,12 +42,15 @@ from finance.payroll.services import (
|
||||
approve_pay_rate,
|
||||
approve_run,
|
||||
approve_work_record,
|
||||
bulk_set_pay_rate,
|
||||
bulk_set_point_rate,
|
||||
calculate_period,
|
||||
close_period,
|
||||
publish_run,
|
||||
return_run_for_correction,
|
||||
submit_run_for_review,
|
||||
)
|
||||
from finance.payroll.reporting import build_payroll_period_table
|
||||
from finance.payroll.attendance import (
|
||||
apply_attendance_work_preview,
|
||||
build_attendance_work_preview,
|
||||
@ -56,6 +59,8 @@ from finance.payroll.attendance import (
|
||||
from .admin_serializers import (
|
||||
AttendanceWorkImportCommandSerializer,
|
||||
ApprovalCommandSerializer,
|
||||
BulkPayRateCommandSerializer,
|
||||
BulkPointRateCommandSerializer,
|
||||
CalculatePayrollCommandSerializer,
|
||||
EmployeePayRateRevisionSerializer,
|
||||
EmployeePayRateSerializer,
|
||||
@ -570,6 +575,17 @@ class PayrollAdminPeriodDetailView(PayrollAdminAPIView):
|
||||
return Response(PayrollPeriodSerializer(period).data)
|
||||
|
||||
|
||||
class PayrollPeriodTableView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
|
||||
|
||||
def get(self, request, pk):
|
||||
period = get_object_or_404(
|
||||
PayrollPeriod.objects.select_related("current_run"),
|
||||
pk=pk,
|
||||
)
|
||||
return Response(build_payroll_period_table(period))
|
||||
|
||||
|
||||
class DraftCollectionView(PayrollAdminAPIView):
|
||||
allowed_permissions = INPUT_VIEW_PERMISSIONS
|
||||
model = None
|
||||
@ -779,6 +795,52 @@ class EmployeePayRateDetailView(DraftDetailView):
|
||||
).prefetch_related("employee__departments_links__department")
|
||||
|
||||
|
||||
class PayrollBulkPointRateView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||||
|
||||
def post(self, request, pk):
|
||||
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
|
||||
command = BulkPointRateCommandSerializer(data=request.data)
|
||||
command.is_valid(raise_exception=True)
|
||||
result = bulk_set_point_rate(
|
||||
pk,
|
||||
actor=request.user,
|
||||
**command.validated_data,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"mode": result["mode"],
|
||||
"point_rate": (
|
||||
f"{result['point_rate']:.4f}"
|
||||
if result["point_rate"] is not None
|
||||
else None
|
||||
),
|
||||
"summary": result["summary"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PayrollBulkPayRateView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||||
|
||||
def post(self, request, pk):
|
||||
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
|
||||
command = BulkPayRateCommandSerializer(data=request.data)
|
||||
command.is_valid(raise_exception=True)
|
||||
result = bulk_set_pay_rate(
|
||||
pk,
|
||||
actor=request.user,
|
||||
**command.validated_data,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"amount": f"{result['amount']:.4f}",
|
||||
"effective_from": result["effective_from"].isoformat(),
|
||||
"summary": result["summary"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PayrollWorkRecordListCreateView(DraftCollectionView):
|
||||
model = PayrollWorkRecord
|
||||
read_serializer_class = PayrollWorkRecordSerializer
|
||||
@ -1025,6 +1087,7 @@ class PayrollWorkRecordReviseView(PayrollAdminAPIView):
|
||||
command.is_valid(raise_exception=True)
|
||||
values = {
|
||||
"target_points": source.target_points,
|
||||
"target_points_overridden": source.target_points_overridden,
|
||||
"actual_points": source.actual_points,
|
||||
"expected_point_amount": source.expected_point_amount,
|
||||
"expected_gross": source.expected_gross,
|
||||
@ -1032,6 +1095,8 @@ class PayrollWorkRecordReviseView(PayrollAdminAPIView):
|
||||
"expected_payable": source.expected_payable,
|
||||
**command.validated_data,
|
||||
}
|
||||
if "target_points" in command.validated_data:
|
||||
values["target_points_overridden"] = True
|
||||
revision = PayrollWorkRecord(
|
||||
period=source.period,
|
||||
employee=source.employee,
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from finance.models import (
|
||||
PayrollStatement,
|
||||
PayrollStatementAcknowledgement,
|
||||
PayrollStatementLine,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
|
||||
|
||||
@ -114,3 +118,64 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_acknowledgement(self, obj):
|
||||
return acknowledgement_payload(obj)
|
||||
|
||||
|
||||
class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer):
|
||||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PayrollWorkRecord
|
||||
fields = [
|
||||
"id",
|
||||
"period_id",
|
||||
"target_points",
|
||||
"target_points_overridden",
|
||||
"actual_points",
|
||||
"revision",
|
||||
"status",
|
||||
"status_label",
|
||||
"lock_version",
|
||||
"replaces_id",
|
||||
"reason",
|
||||
"source",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"approved_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class OwnPayrollDailyWorkEntrySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PayrollDailyWorkEntry
|
||||
fields = [
|
||||
"id",
|
||||
"period_id",
|
||||
"work_date",
|
||||
"target_points",
|
||||
"actual_points",
|
||||
"note",
|
||||
"lock_version",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class OwnPayrollDailyWorkEntryWriteSerializer(serializers.Serializer):
|
||||
period_id = serializers.IntegerField(min_value=1)
|
||||
work_date = serializers.DateField()
|
||||
actual_points = serializers.DecimalField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
min_value=Decimal("0"),
|
||||
)
|
||||
note = serializers.CharField(
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
trim_whitespace=True,
|
||||
)
|
||||
expected_lock_version = serializers.IntegerField(
|
||||
min_value=0,
|
||||
required=False,
|
||||
)
|
||||
|
||||
@ -4,12 +4,15 @@ from .views import (
|
||||
OwnPayrollStatementAcknowledgeView,
|
||||
OwnPayrollStatementDetailView,
|
||||
OwnPayrollStatementListView,
|
||||
OwnPayrollWorkRecordView,
|
||||
)
|
||||
from .admin_views import (
|
||||
EmployeePayRateApproveView,
|
||||
EmployeePayRateDetailView,
|
||||
EmployeePayRateListCreateView,
|
||||
EmployeePayRateReviseView,
|
||||
PayrollBulkPayRateView,
|
||||
PayrollBulkPointRateView,
|
||||
PayrollAdminComponentListView,
|
||||
PayrollAdminEmployeeListView,
|
||||
PayrollAdminPeriodDetailView,
|
||||
@ -21,6 +24,7 @@ from .admin_views import (
|
||||
PayrollInputLineListCreateView,
|
||||
PayrollPeriodCalculateView,
|
||||
PayrollPeriodCloseView,
|
||||
PayrollPeriodTableView,
|
||||
PayrollRunApproveView,
|
||||
PayrollRunDetailView,
|
||||
PayrollRunPublishView,
|
||||
@ -65,11 +69,26 @@ urlpatterns = [
|
||||
PayrollPeriodCalculateView.as_view(),
|
||||
name="admin-period-calculate",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/table/",
|
||||
PayrollPeriodTableView.as_view(),
|
||||
name="admin-period-table",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/attendance-work-records/",
|
||||
PayrollAttendanceWorkRecordsView.as_view(),
|
||||
name="admin-period-attendance-work-records",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/bulk-pay-rate/",
|
||||
PayrollBulkPayRateView.as_view(),
|
||||
name="admin-period-bulk-pay-rate",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/bulk-point-rate/",
|
||||
PayrollBulkPointRateView.as_view(),
|
||||
name="admin-period-bulk-point-rate",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/close/",
|
||||
PayrollPeriodCloseView.as_view(),
|
||||
@ -155,6 +174,11 @@ urlpatterns = [
|
||||
PayrollRunPublishView.as_view(),
|
||||
name="admin-run-publish",
|
||||
),
|
||||
path(
|
||||
"payroll/me/work-records/",
|
||||
OwnPayrollWorkRecordView.as_view(),
|
||||
name="own-work-records",
|
||||
),
|
||||
path(
|
||||
"payroll/me/statements/",
|
||||
OwnPayrollStatementListView.as_view(),
|
||||
|
||||
@ -1,18 +1,37 @@
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from finance.enums import PayrollRunStatus
|
||||
from finance.models import PayrollAuditEvent, PayrollStatement
|
||||
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
|
||||
from finance.models import (
|
||||
PayrollAuditEvent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollPeriod,
|
||||
PayrollStatement,
|
||||
PayrollWorkSettings,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
from finance.payroll.exceptions import PayrollOperationError
|
||||
from finance.payroll.services import acknowledge_statement
|
||||
from finance.payroll.services import (
|
||||
acknowledge_statement,
|
||||
save_own_daily_work_entry,
|
||||
)
|
||||
from finance.payroll.work_norm import (
|
||||
calculate_period_target_points,
|
||||
resolve_employee_schedule,
|
||||
)
|
||||
|
||||
from .serializers import (
|
||||
OwnPayrollDailyWorkEntrySerializer,
|
||||
OwnPayrollDailyWorkEntryWriteSerializer,
|
||||
OwnPayrollStatementSerializer,
|
||||
OwnPayrollStatementSummarySerializer,
|
||||
OwnPayrollWorkRecordSerializer,
|
||||
PayrollStatementAcknowledgementSerializer,
|
||||
)
|
||||
|
||||
@ -43,6 +62,28 @@ def own_published_statements(user):
|
||||
)
|
||||
|
||||
|
||||
def nearest_payroll_period(periods, reference_date):
|
||||
if not periods:
|
||||
return None
|
||||
|
||||
def distance(period):
|
||||
if period.date_from <= reference_date <= period.date_to:
|
||||
return 0
|
||||
if reference_date < period.date_from:
|
||||
return (period.date_from - reference_date).days
|
||||
return (reference_date - period.date_to).days
|
||||
|
||||
return min(
|
||||
periods,
|
||||
key=lambda period: (
|
||||
distance(period),
|
||||
0 if period.date_from <= reference_date <= period.date_to else 1,
|
||||
-period.date_from.toordinal(),
|
||||
-period.pk,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OwnPayrollStatementListView(
|
||||
NoStorePayrollResponseMixin,
|
||||
generics.ListAPIView,
|
||||
@ -130,3 +171,144 @@ class OwnPayrollStatementAcknowledgeView(
|
||||
PayrollStatementAcknowledgementSerializer(acknowledgement).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class OwnPayrollWorkRecordView(
|
||||
NoStorePayrollResponseMixin,
|
||||
APIView,
|
||||
):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
periods = list(
|
||||
PayrollPeriod.objects.select_related("current_run").order_by(
|
||||
"-date_from",
|
||||
"-id",
|
||||
)
|
||||
)
|
||||
records = (
|
||||
PayrollWorkRecord.objects.filter(employee=request.user)
|
||||
.exclude(status=ApprovalStatus.VOIDED)
|
||||
.order_by("period_id", "-revision", "-id")
|
||||
)
|
||||
current_by_period = {}
|
||||
for record in records:
|
||||
current_by_period.setdefault(record.period_id, record)
|
||||
requested_period_id = request.query_params.get("period_id")
|
||||
if requested_period_id:
|
||||
selected_period = get_object_or_404(
|
||||
PayrollPeriod,
|
||||
pk=requested_period_id,
|
||||
)
|
||||
else:
|
||||
selected_period = nearest_payroll_period(
|
||||
periods,
|
||||
timezone.localdate(),
|
||||
)
|
||||
entries = (
|
||||
PayrollDailyWorkEntry.objects.filter(
|
||||
employee=request.user,
|
||||
period=selected_period,
|
||||
).order_by("-work_date", "-id")
|
||||
if selected_period is not None
|
||||
else PayrollDailyWorkEntry.objects.none()
|
||||
)
|
||||
daily_target_points = PayrollWorkSettings.get_daily_target_points()
|
||||
schedule, schedule_source = resolve_employee_schedule(request.user)
|
||||
period_payloads = []
|
||||
for period in periods:
|
||||
record = current_by_period.get(period.pk)
|
||||
automatic_target, workdays_count, _ = calculate_period_target_points(
|
||||
period,
|
||||
employee=request.user,
|
||||
daily_target_points=daily_target_points,
|
||||
schedule=schedule,
|
||||
)
|
||||
uses_saved_target = (
|
||||
record is not None and record.target_points_overridden
|
||||
)
|
||||
period_payloads.append(
|
||||
{
|
||||
"id": period.pk,
|
||||
"code": period.code,
|
||||
"name": period.name,
|
||||
"date_from": period.date_from,
|
||||
"date_to": period.date_to,
|
||||
"pay_date": period.pay_date,
|
||||
"status": period.status,
|
||||
"status_label": period.get_status_display(),
|
||||
"editable": (
|
||||
period.status == PayrollPeriodStatus.OPEN
|
||||
and period.date_from <= timezone.localdate()
|
||||
),
|
||||
"record": (
|
||||
OwnPayrollWorkRecordSerializer(record).data
|
||||
if record is not None
|
||||
else None
|
||||
),
|
||||
"summary": {
|
||||
"target_points": str(
|
||||
record.target_points
|
||||
if uses_saved_target
|
||||
else automatic_target
|
||||
),
|
||||
"actual_points": str(
|
||||
record.actual_points
|
||||
if record is not None
|
||||
else "0.0000"
|
||||
),
|
||||
"target_source": (
|
||||
"saved_record" if uses_saved_target else schedule_source
|
||||
),
|
||||
"workdays_count": workdays_count,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"daily_target_points": str(daily_target_points),
|
||||
"selected_period_id": (
|
||||
selected_period.pk if selected_period is not None else None
|
||||
),
|
||||
"periods": period_payloads,
|
||||
"entries": OwnPayrollDailyWorkEntrySerializer(
|
||||
entries,
|
||||
many=True,
|
||||
).data,
|
||||
}
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
command = OwnPayrollDailyWorkEntryWriteSerializer(data=request.data)
|
||||
command.is_valid(raise_exception=True)
|
||||
get_object_or_404(
|
||||
PayrollPeriod.objects.only("id"),
|
||||
pk=command.validated_data["period_id"],
|
||||
)
|
||||
try:
|
||||
entry, record, operation = save_own_daily_work_entry(
|
||||
actor=request.user,
|
||||
**command.validated_data,
|
||||
)
|
||||
except PayrollOperationError as exc:
|
||||
raise PayrollConflict(
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"details": exc.details,
|
||||
}
|
||||
) from exc
|
||||
response_status = (
|
||||
status.HTTP_201_CREATED
|
||||
if operation == "created"
|
||||
else status.HTTP_200_OK
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"operation": operation,
|
||||
"entry": OwnPayrollDailyWorkEntrySerializer(entry).data,
|
||||
"record": OwnPayrollWorkRecordSerializer(record).data,
|
||||
},
|
||||
status=response_status,
|
||||
)
|
||||
|
||||
@ -373,8 +373,8 @@ class DocumentAcknowledgementAdmin(admin.ModelAdmin):
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Запрещаем ручное добавление ознакомлений."""
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Запрещаем удаление ознакомлений (для аудита)."""
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
@ -847,12 +847,15 @@ 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.1"),
|
||||
"RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.07.2"),
|
||||
"EFFECTIVE_FROM": os.getenv("PAYROLL_EFFECTIVE_FROM", "2026-01-01"),
|
||||
"EFFECTIVE_TO": os.getenv("PAYROLL_EFFECTIVE_TO") or None,
|
||||
# Disabled until the point formula is confirmed against representative
|
||||
# rows from the company's actual spreadsheets.
|
||||
"POINT_POLICY": os.getenv("PAYROLL_POINT_POLICY", "disabled"),
|
||||
# Below target the base accrual follows completion percentage; above target
|
||||
# the full base is retained and excess points use the configured point rate.
|
||||
"POINT_POLICY": os.getenv(
|
||||
"PAYROLL_POINT_POLICY",
|
||||
"proportional_with_excess",
|
||||
),
|
||||
"MONEY_QUANTUM": os.getenv("PAYROLL_MONEY_QUANTUM", "0.01"),
|
||||
"ROUNDING": os.getenv("PAYROLL_ROUNDING", "half_up"),
|
||||
"ALLOW_NEGATIVE_PAYABLE": os.getenv(
|
||||
|
||||
@ -12,12 +12,14 @@ from .models import (
|
||||
EmployeePayRate,
|
||||
PayrollAuditEvent,
|
||||
PayrollComponent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
PayrollRun,
|
||||
PayrollStatement,
|
||||
PayrollStatementAcknowledgement,
|
||||
PayrollStatementLine,
|
||||
PayrollWorkSettings,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
from .payroll.access import has_payroll_permission, has_simple_admin_access
|
||||
@ -110,6 +112,8 @@ class PayrollSensitiveAdminMixin:
|
||||
view_permissions = ()
|
||||
|
||||
def _has_payroll_access(self, request):
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
return any(
|
||||
has_payroll_permission(request.user, permission)
|
||||
for permission in self.view_permissions
|
||||
@ -157,7 +161,7 @@ class ImmutableApprovedAdminMixin(PayrollSensitiveAdminMixin):
|
||||
return readonly
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return has_payroll_permission(
|
||||
@ -226,7 +230,7 @@ class PayrollComponentAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
|
||||
)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj is None:
|
||||
@ -283,7 +287,7 @@ class PayrollPeriodAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
|
||||
)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
@admin.action(description="Рассчитать выбранные периоды")
|
||||
def calculate_selected(self, request, queryset):
|
||||
@ -470,6 +474,41 @@ class PayrollWorkRecordAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PayrollDailyWorkEntry)
|
||||
class PayrollDailyWorkEntryAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"work_date",
|
||||
"employee",
|
||||
"target_points",
|
||||
"actual_points",
|
||||
"period",
|
||||
"updated_at",
|
||||
]
|
||||
list_filter = ["period", "work_date"]
|
||||
search_fields = [
|
||||
"employee__last_name",
|
||||
"employee__first_name",
|
||||
"employee__email",
|
||||
"note",
|
||||
]
|
||||
readonly_fields = ["lock_version", "created_at", "updated_at"]
|
||||
|
||||
|
||||
@admin.register(PayrollWorkSettings)
|
||||
class PayrollWorkSettingsAdmin(admin.ModelAdmin):
|
||||
fields = ["daily_target_points", "updated_at"]
|
||||
list_display = ["daily_target_points", "updated_at"]
|
||||
readonly_fields = ["updated_at"]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
if PayrollWorkSettings.objects.exists():
|
||||
return False
|
||||
return super().has_add_permission(request)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
@admin.register(PayrollInputLine)
|
||||
class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
|
||||
exclude = ["reversal_of", "self_approval_overridden"]
|
||||
@ -525,7 +564,10 @@ class PayrollStatementLineInline(admin.TabularInline):
|
||||
readonly_fields = fields
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
@admin.register(PayrollRun)
|
||||
@ -561,10 +603,10 @@ class PayrollRunAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
|
||||
]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return self._has_payroll_access(request)
|
||||
@ -649,10 +691,10 @@ class PayrollStatementAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
|
||||
inlines = [PayrollStatementLineInline]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return self._has_payroll_access(request)
|
||||
@ -685,13 +727,13 @@ class PayrollStatementAcknowledgementAdmin(
|
||||
]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return self._has_payroll_access(request)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
@admin.register(PayrollAuditEvent)
|
||||
@ -710,10 +752,10 @@ class PayrollAuditEventAdmin(PayrollSensitiveAdminMixin, admin.ModelAdmin):
|
||||
readonly_fields = [field.name for field in PayrollAuditEvent._meta.concrete_fields]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return has_payroll_permission(request.user, "finance.audit_payroll")
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
40
backend/finance/migrations/0010_payrolldailyworkentry.py
Normal file
40
backend/finance/migrations/0010_payrolldailyworkentry.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.4 on 2026-07-20 07:46
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0009_alter_payroll_input_sources'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PayrollDailyWorkEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('work_date', models.DateField(verbose_name='Дата выработки')),
|
||||
('target_points', models.DecimalField(decimal_places=4, max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))], verbose_name='Норма баллов за день')),
|
||||
('actual_points', models.DecimalField(decimal_places=4, max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='Фактические баллы за день')),
|
||||
('note', models.TextField(blank=True, verbose_name='Комментарий')),
|
||||
('lock_version', models.PositiveIntegerField(default=0, verbose_name='Версия записи')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлена')),
|
||||
('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_daily_work_entries', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
|
||||
('period', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='daily_work_entries', to='finance.payrollperiod', verbose_name='Период')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Ежедневная выработка',
|
||||
'verbose_name_plural': 'Ежедневная выработка',
|
||||
'ordering': ['-work_date', '-id'],
|
||||
'indexes': [models.Index(fields=['period', 'employee', 'work_date'], name='pay_daily_period_employee_idx')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('target_points__gt', 0)), name='pay_daily_target_positive'), models.CheckConstraint(condition=models.Q(('actual_points__gte', 0)), name='pay_daily_actual_nonnegative'), models.UniqueConstraint(fields=('period', 'employee', 'work_date'), name='pay_daily_employee_date_unique')],
|
||||
},
|
||||
),
|
||||
]
|
||||
28
backend/finance/migrations/0011_payrollworksettings.py
Normal file
28
backend/finance/migrations/0011_payrollworksettings.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.4 on 2026-07-20 08:11
|
||||
|
||||
import django.core.validators
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0010_payrolldailyworkentry'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PayrollWorkSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('singleton', models.BooleanField(default=True, editable=False, unique=True)),
|
||||
('daily_target_points', models.DecimalField(decimal_places=4, default=Decimal('5'), help_text='Единая дневная норма для всех сотрудников.', max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))], verbose_name='Дневная норма баллов')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Настройки выработки',
|
||||
'verbose_name_plural': 'Настройки выработки',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.4 on 2026-07-20 08:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def mark_daily_work_targets_as_automatic(apps, schema_editor):
|
||||
work_record = apps.get_model("finance", "PayrollWorkRecord")
|
||||
work_record.objects.filter(
|
||||
source="api",
|
||||
source_ref__startswith="daily-work:",
|
||||
).update(target_points_overridden=False)
|
||||
|
||||
|
||||
def mark_all_targets_as_overridden(apps, schema_editor):
|
||||
work_record = apps.get_model("finance", "PayrollWorkRecord")
|
||||
work_record.objects.update(target_points_overridden=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0011_payrollworksettings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payrollworkrecord',
|
||||
name='target_points_overridden',
|
||||
field=models.BooleanField(default=True, editable=False, verbose_name='Норма указана вручную'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
mark_daily_work_targets_as_automatic,
|
||||
mark_all_targets_as_overridden,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.2.4 on 2026-07-20 08:54
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0012_payrollworkrecord_target_points_overridden'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='payrollauditevent',
|
||||
name='period',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='audit_events', to='finance.payrollperiod', verbose_name='Период'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrolldailyworkentry',
|
||||
name='period',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_work_entries', to='finance.payrollperiod', verbose_name='Период'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollinputline',
|
||||
name='period',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='input_lines', to='finance.payrollperiod', verbose_name='Период выплаты'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollinputline',
|
||||
name='relates_to_period',
|
||||
field=models.ForeignKey(blank=True, help_text='Заполняется для перерасчёта прошлого периода.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='retro_input_lines', to='finance.payrollperiod', verbose_name='Относится к периоду'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollinputline',
|
||||
name='reversal_of',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='reversed_by', to='finance.payrollinputline', verbose_name='Сторнирует строку'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollrun',
|
||||
name='period',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='finance.payrollperiod', verbose_name='Период'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollrun',
|
||||
name='supersedes',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='superseded_by_runs', to='finance.payrollrun', verbose_name='Пересчитывает ревизию'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollstatement',
|
||||
name='run',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statements', to='finance.payrollrun', verbose_name='Запуск'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollstatement',
|
||||
name='supersedes',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='superseded_by_statement', to='finance.payrollstatement', verbose_name='Заменяет листок'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollstatementacknowledgement',
|
||||
name='statement',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='acknowledgement', to='finance.payrollstatement', verbose_name='Расчётный листок'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollstatementline',
|
||||
name='statement',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='finance.payrollstatement', verbose_name='Расчётный листок'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollworkrecord',
|
||||
name='period',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_records', to='finance.payrollperiod', verbose_name='Период'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payrollworkrecord',
|
||||
name='replaces',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='replaced_by', to='finance.payrollworkrecord', verbose_name='Заменяет запись'),
|
||||
),
|
||||
]
|
||||
@ -32,6 +32,8 @@ component_code_validator = RegexValidator(
|
||||
message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-",
|
||||
)
|
||||
|
||||
DEFAULT_DAILY_TARGET_POINTS = Decimal("5")
|
||||
|
||||
|
||||
class PayrollPeriod(models.Model):
|
||||
"""A calculation and publication period controlled by one active run."""
|
||||
@ -383,12 +385,120 @@ class EmployeePayRate(DraftVersionedModel):
|
||||
)
|
||||
|
||||
|
||||
class PayrollWorkSettings(models.Model):
|
||||
"""Global settings for employee-entered daily work metrics."""
|
||||
|
||||
singleton = models.BooleanField(default=True, unique=True, editable=False)
|
||||
daily_target_points = models.DecimalField(
|
||||
"Дневная норма баллов",
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
default=DEFAULT_DAILY_TARGET_POINTS,
|
||||
validators=[MinValueValidator(Decimal("0.0001"))],
|
||||
help_text="Единая дневная норма для всех сотрудников.",
|
||||
)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Настройки выработки"
|
||||
verbose_name_plural = "Настройки выработки"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.singleton = True
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Дневная норма: {self.daily_target_points}"
|
||||
|
||||
@classmethod
|
||||
def get_daily_target_points(cls):
|
||||
value = (
|
||||
cls.objects.filter(singleton=True)
|
||||
.values_list("daily_target_points", flat=True)
|
||||
.first()
|
||||
)
|
||||
return value if value is not None else DEFAULT_DAILY_TARGET_POINTS
|
||||
|
||||
|
||||
class PayrollDailyWorkEntry(models.Model):
|
||||
"""Employee-entered daily work metrics aggregated into a payroll period."""
|
||||
|
||||
period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="daily_work_entries",
|
||||
verbose_name="Период",
|
||||
)
|
||||
employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="payroll_daily_work_entries",
|
||||
verbose_name="Сотрудник",
|
||||
)
|
||||
work_date = models.DateField("Дата выработки")
|
||||
target_points = models.DecimalField(
|
||||
"Норма баллов за день",
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
validators=[MinValueValidator(Decimal("0.0001"))],
|
||||
)
|
||||
actual_points = models.DecimalField(
|
||||
"Фактические баллы за день",
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
validators=[MinValueValidator(Decimal("0"))],
|
||||
)
|
||||
note = models.TextField("Комментарий", blank=True)
|
||||
lock_version = models.PositiveIntegerField("Версия записи", default=0)
|
||||
created_at = models.DateTimeField("Создана", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлена", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Ежедневная выработка"
|
||||
verbose_name_plural = "Ежедневная выработка"
|
||||
ordering = ["-work_date", "-id"]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["period", "employee", "work_date"],
|
||||
name="pay_daily_period_employee_idx",
|
||||
)
|
||||
]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(target_points__gt=0),
|
||||
name="pay_daily_target_positive",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(actual_points__gte=0),
|
||||
name="pay_daily_actual_nonnegative",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["period", "employee", "work_date"],
|
||||
name="pay_daily_employee_date_unique",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.work_date}: {self.employee} ({self.actual_points})"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if (
|
||||
self.period_id
|
||||
and self.work_date
|
||||
and not self.period.date_from <= self.work_date <= self.period.date_to
|
||||
):
|
||||
raise ValidationError(
|
||||
{"work_date": "Дата выработки должна входить в расчётный период."}
|
||||
)
|
||||
|
||||
|
||||
class PayrollWorkRecord(DraftVersionedModel):
|
||||
"""Approved monthly work metrics and optional Excel control totals."""
|
||||
|
||||
period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="work_records",
|
||||
verbose_name="Период",
|
||||
)
|
||||
@ -404,6 +514,11 @@ class PayrollWorkRecord(DraftVersionedModel):
|
||||
decimal_places=4,
|
||||
validators=[MinValueValidator(Decimal("0.0001"))],
|
||||
)
|
||||
target_points_overridden = models.BooleanField(
|
||||
"Норма указана вручную",
|
||||
default=True,
|
||||
editable=False,
|
||||
)
|
||||
actual_points = models.DecimalField(
|
||||
"Фактические баллы",
|
||||
max_digits=19,
|
||||
@ -447,7 +562,7 @@ class PayrollWorkRecord(DraftVersionedModel):
|
||||
)
|
||||
replaces = models.OneToOneField(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.RESTRICT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="replaced_by",
|
||||
@ -636,7 +751,7 @@ class PayrollInputLine(DraftVersionedModel):
|
||||
|
||||
period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="input_lines",
|
||||
verbose_name="Период выплаты",
|
||||
)
|
||||
@ -660,7 +775,7 @@ class PayrollInputLine(DraftVersionedModel):
|
||||
)
|
||||
relates_to_period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="retro_input_lines",
|
||||
@ -676,7 +791,7 @@ class PayrollInputLine(DraftVersionedModel):
|
||||
)
|
||||
reversal_of = models.OneToOneField(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.RESTRICT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reversed_by",
|
||||
@ -803,7 +918,7 @@ class PayrollRun(models.Model):
|
||||
|
||||
period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="runs",
|
||||
verbose_name="Период",
|
||||
)
|
||||
@ -816,7 +931,7 @@ class PayrollRun(models.Model):
|
||||
)
|
||||
supersedes = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.RESTRICT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="superseded_by_runs",
|
||||
@ -975,7 +1090,7 @@ class PayrollStatement(models.Model):
|
||||
|
||||
run = models.ForeignKey(
|
||||
PayrollRun,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="statements",
|
||||
verbose_name="Запуск",
|
||||
)
|
||||
@ -993,7 +1108,7 @@ class PayrollStatement(models.Model):
|
||||
)
|
||||
supersedes = models.OneToOneField(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.RESTRICT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="superseded_by_statement",
|
||||
@ -1114,7 +1229,7 @@ class PayrollStatementLine(models.Model):
|
||||
|
||||
statement = models.ForeignKey(
|
||||
PayrollStatement,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="lines",
|
||||
verbose_name="Расчётный листок",
|
||||
)
|
||||
@ -1175,7 +1290,7 @@ class PayrollStatementAcknowledgement(models.Model):
|
||||
|
||||
statement = models.OneToOneField(
|
||||
PayrollStatement,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="acknowledgement",
|
||||
verbose_name="Расчётный листок",
|
||||
)
|
||||
@ -1238,7 +1353,7 @@ class PayrollAuditEvent(models.Model):
|
||||
object_id = models.CharField("Идентификатор объекта", max_length=80)
|
||||
period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="audit_events",
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from payroll_core import (
|
||||
ExpectedTotals,
|
||||
InputLine,
|
||||
@ -39,14 +41,7 @@ def _expected_totals(work_record: PayrollWorkRecord) -> ExpectedTotals | None:
|
||||
)
|
||||
|
||||
|
||||
def build_core_request(
|
||||
*,
|
||||
period: PayrollPeriod,
|
||||
rate: EmployeePayRate,
|
||||
work_record: PayrollWorkRecord | None,
|
||||
input_lines: list[PayrollInputLine],
|
||||
) -> PayrollRequest:
|
||||
core_period = _core_period(period)
|
||||
def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine, ...]:
|
||||
mapped_lines = []
|
||||
for record in input_lines:
|
||||
source_period = None
|
||||
@ -67,23 +62,61 @@ def build_core_request(
|
||||
is_retro=source_period is not None,
|
||||
)
|
||||
)
|
||||
return tuple(mapped_lines)
|
||||
|
||||
|
||||
def build_core_request(
|
||||
*,
|
||||
period: PayrollPeriod,
|
||||
rate: EmployeePayRate,
|
||||
work_record: PayrollWorkRecord | None,
|
||||
input_lines: list[PayrollInputLine],
|
||||
) -> PayrollRequest:
|
||||
return PayrollRequest(
|
||||
employee_ref=f"employee:{rate.employee_id}",
|
||||
period=core_period,
|
||||
period=_core_period(period),
|
||||
currency=period.currency,
|
||||
base_accrual=rate.amount,
|
||||
base_source_ref=f"pay-rate:{rate.pk}:revision:{rate.revision}",
|
||||
target_points=(work_record.target_points if work_record else None),
|
||||
actual_points=(work_record.actual_points if work_record else None),
|
||||
point_rate=rate.point_rate,
|
||||
lines=tuple(mapped_lines),
|
||||
lines=_mapped_input_lines(input_lines),
|
||||
expected_totals=(
|
||||
_expected_totals(work_record) if work_record is not None else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_core_preview_request(
|
||||
*,
|
||||
period: PayrollPeriod,
|
||||
employee_id: int,
|
||||
rate: EmployeePayRate | None,
|
||||
target_points: Decimal,
|
||||
actual_points: Decimal,
|
||||
input_lines: list[PayrollInputLine],
|
||||
) -> PayrollRequest:
|
||||
"""Build a non-persisted calculation request from draft or partial inputs."""
|
||||
|
||||
return PayrollRequest(
|
||||
employee_ref=f"employee:{employee_id}",
|
||||
period=_core_period(period),
|
||||
currency=period.currency,
|
||||
base_accrual=rate.amount if rate is not None else Decimal("0"),
|
||||
base_source_ref=(
|
||||
f"pay-rate:{rate.pk}:revision:{rate.revision}"
|
||||
if rate is not None
|
||||
else "preview:missing-pay-rate"
|
||||
),
|
||||
target_points=target_points,
|
||||
actual_points=actual_points,
|
||||
point_rate=rate.point_rate if rate is not None else Decimal("0"),
|
||||
lines=_mapped_input_lines(input_lines),
|
||||
expected_totals=None,
|
||||
)
|
||||
|
||||
|
||||
def employee_snapshot(employee) -> dict[str, object]:
|
||||
"""Return the minimum identity snapshot needed for a historical payslip."""
|
||||
|
||||
|
||||
@ -416,10 +416,13 @@ def build_attendance_work_preview(
|
||||
) -> dict:
|
||||
"""Build a deterministic, non-mutating attendance import preview."""
|
||||
|
||||
if build_rules().point_policy != PointPolicy.DISABLED:
|
||||
if build_rules().point_policy not in {
|
||||
PointPolicy.DISABLED,
|
||||
PointPolicy.PROPORTIONAL_WITH_EXCESS,
|
||||
}:
|
||||
_operation_error(
|
||||
"ATTENDANCE_UNIT_POLICY_CONFLICT",
|
||||
"Импорт часов доступен только пока начисление по баллам отключено.",
|
||||
"Текущая политика начисления несовместима с импортом часов.",
|
||||
)
|
||||
|
||||
attendance = AttendanceRecord.objects.filter(
|
||||
|
||||
@ -13,12 +13,10 @@ from payroll_core import PayrollRules, PointPolicy, RoundingPolicy
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"RULESET_ID": "eusrr-standard",
|
||||
"RULESET_VERSION": "2026.07.1",
|
||||
"RULESET_VERSION": "2026.07.2",
|
||||
"EFFECTIVE_FROM": "2026-01-01",
|
||||
"EFFECTIVE_TO": None,
|
||||
# The spreadsheet screenshot is not enough to prove the point formula.
|
||||
# Production must opt in after reconciliation against representative rows.
|
||||
"POINT_POLICY": PointPolicy.DISABLED.value,
|
||||
"POINT_POLICY": PointPolicy.PROPORTIONAL_WITH_EXCESS.value,
|
||||
"MONEY_QUANTUM": "0.01",
|
||||
"ROUNDING": RoundingPolicy.HALF_UP.value,
|
||||
"ALLOW_NEGATIVE_PAYABLE": False,
|
||||
@ -88,7 +86,8 @@ def _parse_point_policy(value) -> PointPolicy:
|
||||
return PointPolicy(str(value).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ImproperlyConfigured(
|
||||
"FINANCE_PAYROLL['POINT_POLICY'] must be disabled or excess_only"
|
||||
"FINANCE_PAYROLL['POINT_POLICY'] must be disabled, excess_only "
|
||||
"or proportional_with_excess"
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
400
backend/finance/payroll/reporting.py
Normal file
400
backend/finance/payroll/reporting.py
Normal file
@ -0,0 +1,400 @@
|
||||
"""Read-only reporting projections for payroll administration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import replace
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from payroll_core import DeterministicPayrollCalculator
|
||||
|
||||
from finance.enums import ApprovalStatus
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollComponent,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
|
||||
from .adapter import build_core_preview_request
|
||||
from .config import base_rate_code, build_rules
|
||||
from .work_norm import calculate_period_target_points
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
SYSTEM_COMPONENT_CODES = {"BASE", "POINT_EXCESS"}
|
||||
|
||||
|
||||
def _decimal_text(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _in_norm_point_rate(amount, target_points, *, rounding):
|
||||
if amount in (None, "") or target_points in (None, ""):
|
||||
return None
|
||||
target = Decimal(str(target_points))
|
||||
if target <= 0:
|
||||
return None
|
||||
return str(
|
||||
(Decimal(str(amount)) / target).quantize(
|
||||
Decimal("0.0001"),
|
||||
rounding=rounding,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _employee_department(employee):
|
||||
links = getattr(employee, "_prefetched_objects_cache", {}).get(
|
||||
"departments_links",
|
||||
[],
|
||||
)
|
||||
active_links = sorted(
|
||||
(link for link in links if link.is_active),
|
||||
key=lambda link: (link.department.name, link.pk),
|
||||
)
|
||||
return active_links[0].department.name if active_links else None
|
||||
|
||||
|
||||
def _employee_payload(employee):
|
||||
return {
|
||||
"id": employee.pk,
|
||||
"display_name": employee.get_full_name().strip() or f"Сотрудник #{employee.pk}",
|
||||
"position": employee.position.name if employee.position_id else None,
|
||||
"department": _employee_department(employee),
|
||||
"is_active": employee.is_active,
|
||||
}
|
||||
|
||||
|
||||
def _latest_rate_by_employee(period, employee_ids):
|
||||
grouped = defaultdict(list)
|
||||
rates = (
|
||||
EmployeePayRate.objects.filter(
|
||||
employee_id__in=employee_ids,
|
||||
rate_code=base_rate_code(),
|
||||
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
||||
effective_from__lte=period.date_from,
|
||||
)
|
||||
.order_by("employee_id", "effective_from", "revision", "id")
|
||||
)
|
||||
for rate in rates:
|
||||
grouped[rate.employee_id].append(rate)
|
||||
return {
|
||||
employee_id: max(
|
||||
records,
|
||||
key=lambda record: (
|
||||
record.effective_from,
|
||||
record.revision,
|
||||
record.pk,
|
||||
),
|
||||
)
|
||||
for employee_id, records in grouped.items()
|
||||
}
|
||||
|
||||
|
||||
def _latest_work_by_employee(period, employee_ids):
|
||||
records = PayrollWorkRecord.objects.filter(
|
||||
period=period,
|
||||
employee_id__in=employee_ids,
|
||||
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
||||
).order_by("employee_id", "revision", "id")
|
||||
selected = {}
|
||||
for record in records:
|
||||
selected[record.employee_id] = record
|
||||
return selected
|
||||
|
||||
|
||||
def _input_lines_by_employee(period, employee_ids):
|
||||
grouped = defaultdict(list)
|
||||
records = (
|
||||
PayrollInputLine.objects.filter(
|
||||
period=period,
|
||||
employee_id__in=employee_ids,
|
||||
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
||||
)
|
||||
.select_related("component", "relates_to_period")
|
||||
.order_by("employee_id", "component__display_order", "id")
|
||||
)
|
||||
for record in records:
|
||||
grouped[record.employee_id].append(record)
|
||||
return grouped
|
||||
|
||||
|
||||
def _component_definition(code, label, kind, order):
|
||||
return {
|
||||
"code": code,
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"display_order": order,
|
||||
}
|
||||
|
||||
|
||||
def _component_amounts(lines, component_definitions, component_order):
|
||||
amounts = defaultdict(lambda: Decimal("0"))
|
||||
for line in lines:
|
||||
code = line.code
|
||||
if code in SYSTEM_COMPONENT_CODES:
|
||||
continue
|
||||
amounts[code] += line.amount
|
||||
component = component_definitions.get(code)
|
||||
component_order.setdefault(
|
||||
code,
|
||||
_component_definition(
|
||||
code,
|
||||
line.label,
|
||||
line.kind,
|
||||
component["display_order"] if component else 10_000,
|
||||
),
|
||||
)
|
||||
return {code: str(amount) for code, amount in amounts.items()}
|
||||
|
||||
|
||||
def _source_component_amounts(records, component_definitions, component_order):
|
||||
amounts = defaultdict(lambda: Decimal("0"))
|
||||
for record in records:
|
||||
component = record.component
|
||||
amounts[component.code] += record.amount
|
||||
component_order.setdefault(
|
||||
component.code,
|
||||
_component_definition(
|
||||
component.code,
|
||||
component.name,
|
||||
component.kind,
|
||||
component.display_order,
|
||||
),
|
||||
)
|
||||
return {code: str(amount) for code, amount in amounts.items()}
|
||||
|
||||
|
||||
def _row_money_total(rows, field):
|
||||
values = [Decimal(row[field]) for row in rows if row[field] is not None]
|
||||
return str(sum(values, Decimal("0"))) if values else None
|
||||
|
||||
|
||||
def build_payroll_period_table(period: PayrollPeriod):
|
||||
"""Include every active employee and inactive employees with period work."""
|
||||
|
||||
current_run = period.current_run
|
||||
statements = {}
|
||||
if current_run is not None:
|
||||
statements = {
|
||||
statement.employee_id: statement
|
||||
for statement in current_run.statements.select_related(
|
||||
"employee",
|
||||
"employee__position",
|
||||
).prefetch_related("lines")
|
||||
}
|
||||
|
||||
period_employee_ids = set(
|
||||
PayrollWorkRecord.objects.filter(
|
||||
period=period,
|
||||
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
||||
actual_points__gt=0,
|
||||
).values_list("employee_id", flat=True)
|
||||
)
|
||||
employee_ids = (
|
||||
set(Employee.objects.filter(is_active=True).values_list("id", flat=True))
|
||||
| period_employee_ids
|
||||
)
|
||||
employees = list(
|
||||
Employee.objects.filter(id__in=employee_ids)
|
||||
.select_related("position")
|
||||
.prefetch_related("departments_links__department")
|
||||
.order_by("last_name", "first_name", "id")
|
||||
)
|
||||
|
||||
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)
|
||||
components = list(PayrollComponent.objects.all())
|
||||
component_definitions = {
|
||||
component.code: _component_definition(
|
||||
component.code,
|
||||
component.name,
|
||||
component.kind,
|
||||
component.display_order,
|
||||
)
|
||||
for component in components
|
||||
}
|
||||
component_order = {
|
||||
component.code: component_definitions[component.code]
|
||||
for component in components
|
||||
if component.is_active and component.code not in SYSTEM_COMPONENT_CODES
|
||||
}
|
||||
calculator = DeterministicPayrollCalculator()
|
||||
preview_rules = replace(build_rules(), allow_negative_payable=True)
|
||||
rows = []
|
||||
|
||||
for employee in employees:
|
||||
statement = statements.get(employee.pk)
|
||||
rate = rates.get(employee.pk)
|
||||
work = work_records.get(employee.pk)
|
||||
source_lines = input_lines.get(employee.pk, [])
|
||||
automatic_target_points, _, target_points_source = (
|
||||
calculate_period_target_points(period, employee=employee)
|
||||
)
|
||||
if statement is not None:
|
||||
snapshot = statement.input_snapshot or {}
|
||||
result_lines = list(statement.lines.all())
|
||||
point_amount = next(
|
||||
(
|
||||
line.amount
|
||||
for line in result_lines
|
||||
if line.code == "POINT_EXCESS"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
component_amounts = _component_amounts(
|
||||
result_lines,
|
||||
component_definitions,
|
||||
component_order,
|
||||
)
|
||||
status = "calculated"
|
||||
totals = {
|
||||
"gross_before_adjustments": str(statement.gross_before_adjustments),
|
||||
"adjustment_total": str(statement.adjustment_total),
|
||||
"gross_total": str(statement.gross_total),
|
||||
"deduction_total": str(statement.deduction_total),
|
||||
"net_pay": str(statement.net_pay),
|
||||
"payment_total": str(statement.payment_total),
|
||||
"payable": str(statement.payable),
|
||||
}
|
||||
rate_amount = snapshot.get("base_accrual")
|
||||
point_rate = snapshot.get("point_rate")
|
||||
target_points = snapshot.get("target_points")
|
||||
actual_points = snapshot.get("actual_points")
|
||||
point_delta = _decimal_text(statement.point_delta)
|
||||
target_points_automatic = bool(
|
||||
work is None or not work.target_points_overridden
|
||||
)
|
||||
totals_preliminary = False
|
||||
else:
|
||||
component_amounts = _source_component_amounts(
|
||||
source_lines,
|
||||
component_definitions,
|
||||
component_order,
|
||||
)
|
||||
rate_amount = _decimal_text(rate.amount) if rate else None
|
||||
point_rate = _decimal_text(rate.point_rate) if rate else None
|
||||
preview_target_points = (
|
||||
work.target_points if work else automatic_target_points
|
||||
)
|
||||
preview_actual_points = work.actual_points if work else Decimal("0")
|
||||
target_points = _decimal_text(preview_target_points)
|
||||
actual_points = _decimal_text(work.actual_points) if work else None
|
||||
target_points_automatic = bool(
|
||||
work is None or not work.target_points_overridden
|
||||
)
|
||||
point_delta = (
|
||||
str(preview_actual_points - preview_target_points)
|
||||
if work is not None
|
||||
else None
|
||||
)
|
||||
preview_request = build_core_preview_request(
|
||||
period=period,
|
||||
employee_id=employee.pk,
|
||||
rate=rate,
|
||||
target_points=preview_target_points,
|
||||
actual_points=preview_actual_points,
|
||||
input_lines=source_lines,
|
||||
)
|
||||
preview_result = calculator.calculate(preview_request, preview_rules)
|
||||
point_amount = next(
|
||||
(
|
||||
line.amount
|
||||
for line in preview_result.lines
|
||||
if line.code == "POINT_EXCESS"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
preview_totals = preview_result.totals
|
||||
totals = {
|
||||
"gross_before_adjustments": str(
|
||||
preview_totals.gross_before_adjustments
|
||||
),
|
||||
"adjustment_total": str(preview_totals.adjustment_total),
|
||||
"gross_total": str(preview_totals.gross_after_adjustments),
|
||||
"deduction_total": str(preview_totals.deduction_total),
|
||||
"net_pay": str(preview_totals.net_pay),
|
||||
"payment_total": str(preview_totals.payment_total),
|
||||
"payable": str(preview_totals.payable),
|
||||
}
|
||||
totals_preliminary = True
|
||||
has_draft = (
|
||||
getattr(rate, "status", None) == ApprovalStatus.DRAFT
|
||||
or getattr(work, "status", None) == ApprovalStatus.DRAFT
|
||||
or any(line.status == ApprovalStatus.DRAFT for line in source_lines)
|
||||
)
|
||||
if rate is None or work is None:
|
||||
status = "incomplete"
|
||||
elif has_draft:
|
||||
status = "draft"
|
||||
else:
|
||||
status = "ready"
|
||||
|
||||
in_norm_point_rate = _in_norm_point_rate(
|
||||
rate_amount,
|
||||
target_points,
|
||||
rounding=preview_rules.rounding.value,
|
||||
)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"employee": _employee_payload(employee),
|
||||
"status": status,
|
||||
"rate_status": rate.status if rate else None,
|
||||
"work_status": work.status if work else None,
|
||||
"rate_amount": rate_amount,
|
||||
"in_norm_point_rate": in_norm_point_rate,
|
||||
"point_rate": point_rate,
|
||||
"target_points": target_points,
|
||||
"target_points_automatic": target_points_automatic,
|
||||
"target_points_source": target_points_source,
|
||||
"actual_points": actual_points,
|
||||
"point_delta": point_delta,
|
||||
"point_amount": _decimal_text(point_amount),
|
||||
"component_amounts": component_amounts,
|
||||
"totals_preliminary": totals_preliminary,
|
||||
**totals,
|
||||
}
|
||||
)
|
||||
|
||||
status_counts = defaultdict(int)
|
||||
for row in rows:
|
||||
status_counts[row["status"]] += 1
|
||||
component_columns = sorted(
|
||||
component_order.values(),
|
||||
key=lambda item: (item["display_order"], item["label"], item["code"]),
|
||||
)
|
||||
|
||||
return {
|
||||
"period_id": period.pk,
|
||||
"currency": period.currency,
|
||||
"run": (
|
||||
{
|
||||
"id": current_run.pk,
|
||||
"revision": current_run.revision,
|
||||
"status": current_run.status,
|
||||
}
|
||||
if current_run is not None
|
||||
else None
|
||||
),
|
||||
"component_columns": component_columns,
|
||||
"rows": rows,
|
||||
"summary": {
|
||||
"employee_count": len(rows),
|
||||
"calculated_count": status_counts["calculated"],
|
||||
"ready_count": status_counts["ready"],
|
||||
"draft_count": status_counts["draft"],
|
||||
"incomplete_count": status_counts["incomplete"],
|
||||
"preliminary_count": sum(
|
||||
1 for row in rows if row["totals_preliminary"]
|
||||
),
|
||||
"gross_total": _row_money_total(rows, "gross_total"),
|
||||
"deduction_total": _row_money_total(rows, "deduction_total"),
|
||||
"payable_total": _row_money_total(rows, "payable"),
|
||||
},
|
||||
}
|
||||
@ -8,9 +8,10 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models import Max, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from payroll_core import (
|
||||
@ -20,6 +21,7 @@ from payroll_core import (
|
||||
|
||||
from finance.enums import (
|
||||
ApprovalStatus,
|
||||
InputSource,
|
||||
PayrollPeriodStatus,
|
||||
PayrollRunStatus,
|
||||
)
|
||||
@ -27,12 +29,14 @@ from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollAuditEvent,
|
||||
PayrollComponent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
PayrollRun,
|
||||
PayrollStatement,
|
||||
PayrollStatementAcknowledgement,
|
||||
PayrollStatementLine,
|
||||
PayrollWorkSettings,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
|
||||
@ -50,6 +54,9 @@ from .config import (
|
||||
ruleset_period_details,
|
||||
)
|
||||
from .exceptions import PayrollOperationError, PayrollPermissionDenied
|
||||
from .work_norm import calculate_period_target_points
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
def _require_permission(actor, permission: str) -> None:
|
||||
@ -732,6 +739,748 @@ def close_period(period_id: int, *, actor) -> PayrollPeriod:
|
||||
return period
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_set_point_rate(
|
||||
period_id: int,
|
||||
*,
|
||||
actor,
|
||||
employee_ids: list[int],
|
||||
mode: str = "fixed",
|
||||
point_rate: Decimal | None = None,
|
||||
reason: str,
|
||||
) -> dict[str, object]:
|
||||
"""Set a fixed excess price or copy each employee's in-norm point price."""
|
||||
|
||||
_require_permission(actor, "finance.manage_payroll_inputs")
|
||||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||||
_ensure_period_accepts_approvals(period)
|
||||
|
||||
selected_employee_ids = sorted(set(employee_ids))
|
||||
rates = list(
|
||||
EmployeePayRate.objects.select_for_update()
|
||||
.filter(
|
||||
employee_id__in=selected_employee_ids,
|
||||
rate_code=base_rate_code(),
|
||||
effective_from__lte=period.date_to,
|
||||
)
|
||||
.exclude(status=ApprovalStatus.VOIDED)
|
||||
.select_related("employee", "replaces")
|
||||
.order_by("employee_id", "effective_from", "revision", "pk")
|
||||
)
|
||||
rates_by_employee: dict[int, list[EmployeePayRate]] = defaultdict(list)
|
||||
for rate in rates:
|
||||
rates_by_employee[rate.employee_id].append(rate)
|
||||
|
||||
work_by_employee: dict[int, PayrollWorkRecord] = {}
|
||||
for record in PayrollWorkRecord.objects.filter(
|
||||
period=period,
|
||||
employee_id__in=selected_employee_ids,
|
||||
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
||||
).order_by("employee_id", "revision", "pk"):
|
||||
work_by_employee[record.employee_id] = record
|
||||
|
||||
target_by_employee: dict[int, Decimal] = {}
|
||||
if mode == "in_norm":
|
||||
for employee_id in selected_employee_ids:
|
||||
work = work_by_employee.get(employee_id)
|
||||
if work is not None:
|
||||
target_by_employee[employee_id] = work.target_points
|
||||
continue
|
||||
employee_rates = rates_by_employee.get(employee_id, [])
|
||||
if not employee_rates:
|
||||
continue
|
||||
target_by_employee[employee_id] = calculate_period_target_points(
|
||||
period,
|
||||
employee=employee_rates[-1].employee,
|
||||
)[0]
|
||||
|
||||
summary = {
|
||||
"selected_employees": len(selected_employee_ids),
|
||||
"updated_drafts": 0,
|
||||
"created_revisions": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
changed_records: list[EmployeePayRate] = []
|
||||
|
||||
for employee_id in selected_employee_ids:
|
||||
employee_rates = rates_by_employee.get(employee_id, [])
|
||||
period_start_dates = [
|
||||
rate.effective_from
|
||||
for rate in employee_rates
|
||||
if rate.effective_from <= period.date_from
|
||||
]
|
||||
relevant_dates = set()
|
||||
if period_start_dates:
|
||||
relevant_dates.add(max(period_start_dates))
|
||||
relevant_dates.update(
|
||||
rate.effective_from
|
||||
for rate in employee_rates
|
||||
if period.date_from < rate.effective_from <= period.date_to
|
||||
)
|
||||
|
||||
if not relevant_dates:
|
||||
summary["skipped"] += 1
|
||||
continue
|
||||
|
||||
for effective_from in sorted(relevant_dates):
|
||||
current = max(
|
||||
(
|
||||
rate
|
||||
for rate in employee_rates
|
||||
if rate.effective_from == effective_from
|
||||
),
|
||||
key=lambda rate: (rate.revision, rate.pk),
|
||||
)
|
||||
if mode == "in_norm":
|
||||
target_points = target_by_employee[employee_id]
|
||||
resolved_point_rate = (current.amount / target_points).quantize(
|
||||
Decimal("0.0001"),
|
||||
rounding=build_rules().rounding.value,
|
||||
)
|
||||
else:
|
||||
resolved_point_rate = point_rate
|
||||
if resolved_point_rate is None:
|
||||
_operation_error(
|
||||
"POINT_RATE_REQUIRED",
|
||||
"Не задана фиксированная цена балла сверх нормы.",
|
||||
)
|
||||
if current.point_rate == resolved_point_rate:
|
||||
summary["unchanged"] += 1
|
||||
continue
|
||||
|
||||
if current.status == ApprovalStatus.DRAFT:
|
||||
if (
|
||||
not has_simple_admin_access(actor)
|
||||
and current.created_by_id != actor.pk
|
||||
):
|
||||
summary["skipped"] += 1
|
||||
continue
|
||||
previous_point_rate = current.point_rate
|
||||
current.point_rate = resolved_point_rate
|
||||
current._expected_lock_version = current.lock_version
|
||||
try:
|
||||
current.full_clean()
|
||||
current.save()
|
||||
except ValidationError as exc:
|
||||
_operation_error(
|
||||
"INPUT_VALIDATION_FAILED",
|
||||
"Цена балла не прошла проверку.",
|
||||
errors=(
|
||||
exc.message_dict
|
||||
if hasattr(exc, "message_dict")
|
||||
else exc.messages
|
||||
),
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action="payroll.rate_bulk_point_rate_updated",
|
||||
instance=current,
|
||||
period=period,
|
||||
metadata={
|
||||
"previous_point_rate": str(previous_point_rate),
|
||||
"point_rate": str(resolved_point_rate),
|
||||
"point_rate_mode": mode,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
summary["updated_drafts"] += 1
|
||||
changed_records.append(current)
|
||||
continue
|
||||
|
||||
if current.status != ApprovalStatus.APPROVED:
|
||||
summary["skipped"] += 1
|
||||
continue
|
||||
|
||||
revision = EmployeePayRate(
|
||||
employee=current.employee,
|
||||
rate_code=current.rate_code,
|
||||
amount=current.amount,
|
||||
point_rate=resolved_point_rate,
|
||||
currency=current.currency,
|
||||
effective_from=current.effective_from,
|
||||
revision=current.revision + 1,
|
||||
replaces=current,
|
||||
reason=reason,
|
||||
status=ApprovalStatus.DRAFT,
|
||||
source=InputSource.MANUAL,
|
||||
created_by=actor,
|
||||
)
|
||||
try:
|
||||
revision.full_clean()
|
||||
revision.save(force_insert=True)
|
||||
except ValidationError as exc:
|
||||
_operation_error(
|
||||
"INPUT_VALIDATION_FAILED",
|
||||
"Новая версия ставки не прошла проверку.",
|
||||
errors=(
|
||||
exc.message_dict
|
||||
if hasattr(exc, "message_dict")
|
||||
else exc.messages
|
||||
),
|
||||
)
|
||||
except IntegrityError:
|
||||
_operation_error(
|
||||
"RATE_REVISION_CONFLICT",
|
||||
(
|
||||
"Для одной из ставок уже создана новая версия; "
|
||||
"обновите данные."
|
||||
),
|
||||
rate_id=current.pk,
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action="payroll.rate_bulk_point_rate_revision_created",
|
||||
instance=revision,
|
||||
period=period,
|
||||
metadata={
|
||||
"replaces_id": current.pk,
|
||||
"previous_point_rate": str(current.point_rate),
|
||||
"point_rate": str(resolved_point_rate),
|
||||
"point_rate_mode": mode,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
summary["created_revisions"] += 1
|
||||
changed_records.append(revision)
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"point_rate": point_rate,
|
||||
"summary": summary,
|
||||
"records": changed_records,
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_set_pay_rate(
|
||||
period_id: int,
|
||||
*,
|
||||
actor,
|
||||
employee_ids: list[int],
|
||||
amount: Decimal,
|
||||
effective_from,
|
||||
reason: str,
|
||||
) -> dict[str, object]:
|
||||
"""Create or revise one base rate for each selected employee."""
|
||||
|
||||
_require_permission(actor, "finance.manage_payroll_inputs")
|
||||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||||
_ensure_period_accepts_approvals(period)
|
||||
if not period.date_from <= effective_from <= period.date_to:
|
||||
_operation_error(
|
||||
"RATE_DATE_OUTSIDE_PERIOD",
|
||||
"Дата начала ставки должна входить в выбранный расчётный период.",
|
||||
period_date_from=str(period.date_from),
|
||||
period_date_to=str(period.date_to),
|
||||
)
|
||||
|
||||
selected_employee_ids = sorted(set(employee_ids))
|
||||
employees = {
|
||||
employee.pk: employee
|
||||
for employee in Employee.objects.filter(
|
||||
pk__in=selected_employee_ids,
|
||||
is_active=True,
|
||||
).order_by("pk")
|
||||
}
|
||||
rates = list(
|
||||
EmployeePayRate.objects.select_for_update()
|
||||
.filter(
|
||||
employee_id__in=employees,
|
||||
rate_code=base_rate_code(),
|
||||
effective_from__lte=effective_from,
|
||||
)
|
||||
.select_related("employee", "replaces")
|
||||
.order_by("employee_id", "effective_from", "revision", "pk")
|
||||
)
|
||||
rates_by_employee: dict[int, list[EmployeePayRate]] = defaultdict(list)
|
||||
for rate in rates:
|
||||
rates_by_employee[rate.employee_id].append(rate)
|
||||
|
||||
summary = {
|
||||
"selected_employees": len(selected_employee_ids),
|
||||
"created_drafts": 0,
|
||||
"updated_drafts": 0,
|
||||
"created_revisions": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
changed_records: list[EmployeePayRate] = []
|
||||
|
||||
for employee_id in selected_employee_ids:
|
||||
employee = employees.get(employee_id)
|
||||
if employee is None:
|
||||
summary["skipped"] += 1
|
||||
continue
|
||||
|
||||
employee_rates = rates_by_employee.get(employee_id, [])
|
||||
exact_rates = [
|
||||
rate
|
||||
for rate in employee_rates
|
||||
if rate.effective_from == effective_from
|
||||
]
|
||||
current_rates = [
|
||||
rate for rate in exact_rates if rate.status != ApprovalStatus.VOIDED
|
||||
]
|
||||
current = (
|
||||
max(current_rates, key=lambda rate: (rate.revision, rate.pk))
|
||||
if current_rates
|
||||
else None
|
||||
)
|
||||
|
||||
if current is not None and current.amount == amount:
|
||||
summary["unchanged"] += 1
|
||||
continue
|
||||
|
||||
if current is not None and current.status == ApprovalStatus.DRAFT:
|
||||
if (
|
||||
not has_simple_admin_access(actor)
|
||||
and current.created_by_id != actor.pk
|
||||
):
|
||||
summary["skipped"] += 1
|
||||
continue
|
||||
previous_amount = current.amount
|
||||
current.amount = amount
|
||||
current._expected_lock_version = current.lock_version
|
||||
try:
|
||||
current.full_clean()
|
||||
current.save()
|
||||
except ValidationError as exc:
|
||||
_operation_error(
|
||||
"INPUT_VALIDATION_FAILED",
|
||||
"Ставка не прошла проверку.",
|
||||
errors=(
|
||||
exc.message_dict
|
||||
if hasattr(exc, "message_dict")
|
||||
else exc.messages
|
||||
),
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action="payroll.rate_bulk_amount_updated",
|
||||
instance=current,
|
||||
period=period,
|
||||
metadata={
|
||||
"previous_amount": str(previous_amount),
|
||||
"amount": str(amount),
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
summary["updated_drafts"] += 1
|
||||
changed_records.append(current)
|
||||
continue
|
||||
|
||||
if current is not None and current.status == ApprovalStatus.APPROVED:
|
||||
rate = EmployeePayRate(
|
||||
employee=current.employee,
|
||||
rate_code=current.rate_code,
|
||||
amount=amount,
|
||||
point_rate=current.point_rate,
|
||||
currency=current.currency,
|
||||
effective_from=current.effective_from,
|
||||
revision=current.revision + 1,
|
||||
replaces=current,
|
||||
reason=reason,
|
||||
status=ApprovalStatus.DRAFT,
|
||||
source=InputSource.MANUAL,
|
||||
created_by=actor,
|
||||
)
|
||||
action = "payroll.rate_bulk_amount_revision_created"
|
||||
metadata = {
|
||||
"replaces_id": current.pk,
|
||||
"previous_amount": str(current.amount),
|
||||
"amount": str(amount),
|
||||
"reason": reason,
|
||||
}
|
||||
summary_key = "created_revisions"
|
||||
else:
|
||||
previous_rates = [
|
||||
rate
|
||||
for rate in employee_rates
|
||||
if rate.status != ApprovalStatus.VOIDED
|
||||
and rate.effective_from < effective_from
|
||||
]
|
||||
previous = (
|
||||
max(
|
||||
previous_rates,
|
||||
key=lambda rate: (
|
||||
rate.effective_from,
|
||||
rate.revision,
|
||||
rate.pk,
|
||||
),
|
||||
)
|
||||
if previous_rates
|
||||
else None
|
||||
)
|
||||
rate = EmployeePayRate(
|
||||
employee=employee,
|
||||
rate_code=base_rate_code(),
|
||||
amount=amount,
|
||||
point_rate=(previous.point_rate if previous else Decimal("0")),
|
||||
currency=period.currency,
|
||||
effective_from=effective_from,
|
||||
revision=max((item.revision for item in exact_rates), default=0) + 1,
|
||||
reason=reason,
|
||||
status=ApprovalStatus.DRAFT,
|
||||
source=InputSource.MANUAL,
|
||||
created_by=actor,
|
||||
)
|
||||
action = "payroll.rate_bulk_created"
|
||||
metadata = {
|
||||
"amount": str(amount),
|
||||
"point_rate": str(rate.point_rate),
|
||||
"reason": reason,
|
||||
}
|
||||
summary_key = "created_drafts"
|
||||
|
||||
try:
|
||||
rate.full_clean()
|
||||
rate.save(force_insert=True)
|
||||
except ValidationError as exc:
|
||||
_operation_error(
|
||||
"INPUT_VALIDATION_FAILED",
|
||||
"Новая ставка не прошла проверку.",
|
||||
errors=(
|
||||
exc.message_dict
|
||||
if hasattr(exc, "message_dict")
|
||||
else exc.messages
|
||||
),
|
||||
)
|
||||
except IntegrityError:
|
||||
_operation_error(
|
||||
"RATE_BULK_CONFLICT",
|
||||
"Одна из ставок уже изменена; обновите экран и повторите.",
|
||||
employee_id=employee_id,
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action=action,
|
||||
instance=rate,
|
||||
period=period,
|
||||
metadata=metadata,
|
||||
)
|
||||
summary[summary_key] += 1
|
||||
changed_records.append(rate)
|
||||
|
||||
return {
|
||||
"amount": amount,
|
||||
"effective_from": effective_from,
|
||||
"summary": summary,
|
||||
"records": changed_records,
|
||||
}
|
||||
|
||||
|
||||
def _sync_daily_work_record(
|
||||
*,
|
||||
period: PayrollPeriod,
|
||||
actor,
|
||||
) -> PayrollWorkRecord:
|
||||
totals = PayrollDailyWorkEntry.objects.filter(
|
||||
period=period,
|
||||
employee=actor,
|
||||
).aggregate(
|
||||
actual_points=Sum("actual_points"),
|
||||
)
|
||||
actual_points = totals["actual_points"] or Decimal("0")
|
||||
|
||||
records = list(
|
||||
PayrollWorkRecord.objects.select_for_update()
|
||||
.filter(period=period, employee=actor)
|
||||
.order_by("revision", "pk")
|
||||
)
|
||||
active_records = [
|
||||
record for record in records if record.status != ApprovalStatus.VOIDED
|
||||
]
|
||||
current = (
|
||||
max(active_records, key=lambda record: (record.revision, record.pk))
|
||||
if active_records
|
||||
else None
|
||||
)
|
||||
calculated_target, workdays_count, target_source = (
|
||||
calculate_period_target_points(period, employee=actor)
|
||||
)
|
||||
uses_saved_target = (
|
||||
current is not None and current.target_points_overridden
|
||||
)
|
||||
target_points = current.target_points if uses_saved_target else calculated_target
|
||||
if target_points <= 0:
|
||||
_operation_error(
|
||||
"DAILY_WORK_EMPTY",
|
||||
"Для периода не удалось определить положительную норму выработки.",
|
||||
)
|
||||
if (
|
||||
current is not None
|
||||
and current.target_points == target_points
|
||||
and current.actual_points == actual_points
|
||||
):
|
||||
return current
|
||||
|
||||
reason = "Агрегация ежедневной выработки сотрудника"
|
||||
if current is not None and current.status == ApprovalStatus.DRAFT:
|
||||
previous_target = current.target_points
|
||||
previous_actual = current.actual_points
|
||||
current.target_points = target_points
|
||||
current.actual_points = actual_points
|
||||
current.expected_point_amount = None
|
||||
current.expected_gross = None
|
||||
current.expected_recalculated_gross = None
|
||||
current.expected_payable = None
|
||||
if not uses_saved_target:
|
||||
current.reason = reason
|
||||
current.source = InputSource.API
|
||||
current.source_ref = (
|
||||
f"daily-work:{period.pk}:{actor.pk}:r{current.revision}"
|
||||
)
|
||||
current.target_points_overridden = False
|
||||
current._expected_lock_version = current.lock_version
|
||||
try:
|
||||
current.full_clean()
|
||||
current.save()
|
||||
except ValidationError as exc:
|
||||
_operation_error(
|
||||
"INPUT_VALIDATION_FAILED",
|
||||
"Итоговая выработка не прошла проверку.",
|
||||
errors=(
|
||||
exc.message_dict
|
||||
if hasattr(exc, "message_dict")
|
||||
else exc.messages
|
||||
),
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action="payroll.daily_work_aggregate_updated",
|
||||
instance=current,
|
||||
period=period,
|
||||
metadata={
|
||||
"channel": "employee_api",
|
||||
"previous_target_points": str(previous_target),
|
||||
"previous_actual_points": str(previous_actual),
|
||||
"target_points": str(target_points),
|
||||
"actual_points": str(actual_points),
|
||||
"target_source": (
|
||||
"saved_record" if uses_saved_target else target_source
|
||||
),
|
||||
"workdays_count": workdays_count,
|
||||
},
|
||||
)
|
||||
return current
|
||||
|
||||
if current is not None and current.status == ApprovalStatus.APPROVED:
|
||||
if PayrollWorkRecord.objects.filter(replaces=current).exists():
|
||||
_operation_error(
|
||||
"WORK_REVISION_CONFLICT",
|
||||
"Для итоговой выработки уже создавалась новая версия.",
|
||||
record_id=current.pk,
|
||||
)
|
||||
revision = current.revision + 1
|
||||
record = PayrollWorkRecord(
|
||||
period=period,
|
||||
employee=actor,
|
||||
target_points=target_points,
|
||||
target_points_overridden=uses_saved_target,
|
||||
actual_points=actual_points,
|
||||
revision=revision,
|
||||
replaces=current,
|
||||
reason=reason,
|
||||
status=ApprovalStatus.DRAFT,
|
||||
source=(current.source if uses_saved_target else InputSource.API),
|
||||
source_ref=f"daily-work:{period.pk}:{actor.pk}:r{revision}",
|
||||
created_by=actor,
|
||||
)
|
||||
action = "payroll.daily_work_aggregate_revision_created"
|
||||
metadata = {
|
||||
"channel": "employee_api",
|
||||
"replaces_id": current.pk,
|
||||
"target_points": str(target_points),
|
||||
"actual_points": str(actual_points),
|
||||
"target_source": (
|
||||
"saved_record" if uses_saved_target else target_source
|
||||
),
|
||||
"workdays_count": workdays_count,
|
||||
}
|
||||
elif current is None:
|
||||
revision = max((record.revision for record in records), default=0) + 1
|
||||
record = PayrollWorkRecord(
|
||||
period=period,
|
||||
employee=actor,
|
||||
target_points=target_points,
|
||||
target_points_overridden=False,
|
||||
actual_points=actual_points,
|
||||
revision=revision,
|
||||
reason=reason,
|
||||
status=ApprovalStatus.DRAFT,
|
||||
source=InputSource.API,
|
||||
source_ref=f"daily-work:{period.pk}:{actor.pk}:r{revision}",
|
||||
created_by=actor,
|
||||
)
|
||||
action = "payroll.daily_work_aggregate_created"
|
||||
metadata = {
|
||||
"channel": "employee_api",
|
||||
"target_points": str(target_points),
|
||||
"actual_points": str(actual_points),
|
||||
"target_source": target_source,
|
||||
"workdays_count": workdays_count,
|
||||
}
|
||||
else:
|
||||
_operation_error(
|
||||
"WORK_RECORD_STATE_INVALID",
|
||||
"Итоговую запись выработки нельзя изменить.",
|
||||
record_status=current.status,
|
||||
)
|
||||
|
||||
try:
|
||||
record.full_clean()
|
||||
record.save(force_insert=True)
|
||||
except ValidationError as exc:
|
||||
_operation_error(
|
||||
"INPUT_VALIDATION_FAILED",
|
||||
"Итоговая выработка не прошла проверку.",
|
||||
errors=(
|
||||
exc.message_dict
|
||||
if hasattr(exc, "message_dict")
|
||||
else exc.messages
|
||||
),
|
||||
)
|
||||
except IntegrityError:
|
||||
_operation_error(
|
||||
"WORK_RECORD_CONFLICT",
|
||||
"Итоговая выработка уже изменена; обновите данные и повторите.",
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action=action,
|
||||
instance=record,
|
||||
period=period,
|
||||
metadata=metadata,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def save_own_daily_work_entry(
|
||||
period_id: int,
|
||||
*,
|
||||
actor,
|
||||
work_date,
|
||||
actual_points: Decimal,
|
||||
note: str = "",
|
||||
expected_lock_version: int | None = None,
|
||||
) -> tuple[PayrollDailyWorkEntry, PayrollWorkRecord, str]:
|
||||
"""Upsert one daily entry and refresh the employee's period aggregate."""
|
||||
|
||||
if actor is None or not getattr(actor, "is_authenticated", False):
|
||||
_operation_error(
|
||||
"AUTHENTICATION_REQUIRED",
|
||||
"Для сохранения выработки необходимо войти в систему.",
|
||||
)
|
||||
period = (
|
||||
PayrollPeriod.objects.select_for_update()
|
||||
.select_related("current_run")
|
||||
.get(pk=period_id)
|
||||
)
|
||||
if period.status != PayrollPeriodStatus.OPEN:
|
||||
_operation_error(
|
||||
"WORK_PERIOD_LOCKED",
|
||||
"Выработка за этот период уже закрыта для изменений.",
|
||||
period_status=period.status,
|
||||
)
|
||||
if not period.date_from <= work_date <= period.date_to:
|
||||
_operation_error(
|
||||
"WORK_DATE_OUTSIDE_PERIOD",
|
||||
"Дата выработки должна входить в выбранный расчётный период.",
|
||||
period_date_from=str(period.date_from),
|
||||
period_date_to=str(period.date_to),
|
||||
)
|
||||
if work_date > timezone.localdate():
|
||||
_operation_error(
|
||||
"WORK_DATE_IN_FUTURE",
|
||||
"Нельзя указать выработку за будущую дату.",
|
||||
)
|
||||
|
||||
entry = (
|
||||
PayrollDailyWorkEntry.objects.select_for_update()
|
||||
.filter(period=period, employee=actor, work_date=work_date)
|
||||
.first()
|
||||
)
|
||||
note = note.strip()
|
||||
if entry is not None:
|
||||
if expected_lock_version is None:
|
||||
_operation_error(
|
||||
"DAILY_WORK_VERSION_REQUIRED",
|
||||
"Обновите данные и повторите сохранение.",
|
||||
)
|
||||
if entry.lock_version != expected_lock_version:
|
||||
_operation_error(
|
||||
"STALE_DAILY_WORK_ENTRY",
|
||||
"Запись уже изменена; обновите данные и повторите.",
|
||||
expected_lock_version=expected_lock_version,
|
||||
actual_lock_version=entry.lock_version,
|
||||
)
|
||||
if entry.actual_points == actual_points and entry.note == note:
|
||||
aggregate = _sync_daily_work_record(period=period, actor=actor)
|
||||
return entry, aggregate, "unchanged"
|
||||
|
||||
previous_actual = entry.actual_points
|
||||
entry.actual_points = actual_points
|
||||
entry.note = note
|
||||
entry.lock_version += 1
|
||||
operation = "updated"
|
||||
action = "payroll.daily_work_entry_updated"
|
||||
metadata = {
|
||||
"channel": "employee_api",
|
||||
"work_date": str(work_date),
|
||||
"previous_actual_points": str(previous_actual),
|
||||
"target_points": str(entry.target_points),
|
||||
"actual_points": str(actual_points),
|
||||
}
|
||||
else:
|
||||
target_points = PayrollWorkSettings.get_daily_target_points()
|
||||
entry = PayrollDailyWorkEntry(
|
||||
period=period,
|
||||
employee=actor,
|
||||
work_date=work_date,
|
||||
target_points=target_points,
|
||||
actual_points=actual_points,
|
||||
note=note,
|
||||
)
|
||||
operation = "created"
|
||||
action = "payroll.daily_work_entry_created"
|
||||
metadata = {
|
||||
"channel": "employee_api",
|
||||
"work_date": str(work_date),
|
||||
"target_points": str(target_points),
|
||||
"actual_points": str(actual_points),
|
||||
}
|
||||
|
||||
try:
|
||||
entry.full_clean()
|
||||
entry.save()
|
||||
except ValidationError as exc:
|
||||
_operation_error(
|
||||
"INPUT_VALIDATION_FAILED",
|
||||
"Ежедневная выработка не прошла проверку.",
|
||||
errors=(
|
||||
exc.message_dict
|
||||
if hasattr(exc, "message_dict")
|
||||
else exc.messages
|
||||
),
|
||||
)
|
||||
except IntegrityError:
|
||||
_operation_error(
|
||||
"DAILY_WORK_ENTRY_CONFLICT",
|
||||
"Запись за эту дату уже создана; обновите данные.",
|
||||
)
|
||||
_audit(
|
||||
actor=actor,
|
||||
action=action,
|
||||
instance=entry,
|
||||
period=period,
|
||||
metadata=metadata,
|
||||
)
|
||||
aggregate = _sync_daily_work_record(period=period, actor=actor)
|
||||
return entry, aggregate, operation
|
||||
|
||||
|
||||
def _approve_record(
|
||||
record,
|
||||
*,
|
||||
|
||||
86
backend/finance/payroll/work_norm.py
Normal file
86
backend/finance/payroll/work_norm.py
Normal file
@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from attendance.services import (
|
||||
get_employee_work_schedule_payload,
|
||||
get_standard_work_schedule,
|
||||
get_standard_work_schedule_payload,
|
||||
)
|
||||
|
||||
from finance.models import PayrollPeriod, PayrollWorkSettings
|
||||
|
||||
QUANTUM = Decimal("0.0001")
|
||||
WEEKDAY_NAMES = (
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
)
|
||||
|
||||
|
||||
def resolve_employee_schedule(employee) -> tuple[dict, str]:
|
||||
individual = get_employee_work_schedule_payload(employee)
|
||||
if individual is not None:
|
||||
return individual, "individual_schedule"
|
||||
standard = get_standard_work_schedule()
|
||||
if standard is not None:
|
||||
return standard.to_logstorm_payload(), "standard_schedule"
|
||||
return get_standard_work_schedule_payload(), "default_schedule"
|
||||
|
||||
|
||||
def _override_date(value) -> date | None:
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def count_period_workdays(period: PayrollPeriod, schedule: dict) -> int:
|
||||
workdays = set(schedule.get("workdays") or WEEKDAY_NAMES[:5])
|
||||
overrides = {}
|
||||
for item in schedule.get("date_overrides") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
override_date = _override_date(item.get("date"))
|
||||
if override_date is not None and "is_workday" in item:
|
||||
overrides[override_date] = bool(item["is_workday"])
|
||||
|
||||
total = 0
|
||||
current = period.date_from
|
||||
while current <= period.date_to:
|
||||
is_workday = current.weekday() < len(WEEKDAY_NAMES) and (
|
||||
WEEKDAY_NAMES[current.weekday()] in workdays
|
||||
)
|
||||
total += int(overrides.get(current, is_workday))
|
||||
current += timedelta(days=1)
|
||||
return total
|
||||
|
||||
|
||||
def calculate_period_target_points(
|
||||
period: PayrollPeriod,
|
||||
*,
|
||||
employee,
|
||||
daily_target_points: Decimal | None = None,
|
||||
schedule: dict | None = None,
|
||||
) -> tuple[Decimal, int, str]:
|
||||
if schedule is None:
|
||||
schedule, source = resolve_employee_schedule(employee)
|
||||
else:
|
||||
source = "schedule"
|
||||
daily_target = (
|
||||
daily_target_points
|
||||
if daily_target_points is not None
|
||||
else PayrollWorkSettings.get_daily_target_points()
|
||||
)
|
||||
workdays_count = count_period_workdays(period, schedule)
|
||||
target_points = (daily_target * workdays_count).quantize(QUANTUM)
|
||||
return target_points, workdays_count, source
|
||||
@ -89,6 +89,12 @@ The initial `EXCESS_ONLY` point policy pays only points above the target:
|
||||
for a shortfall. This policy must remain in shadow/reconciliation mode until
|
||||
the company confirms how points work for other rows.
|
||||
|
||||
`PROPORTIONAL_WITH_EXCESS` adjusts the base accrual by completion when actual
|
||||
points are below target:
|
||||
`base_accrual * min(actual / target, 1)`. When actual points exceed target, the
|
||||
full base accrual is retained and the excess is paid separately using
|
||||
`(actual - target) * point_rate`.
|
||||
|
||||
## Host boundary
|
||||
|
||||
The core knows an employee only as an opaque `employee_ref`. The host adapter
|
||||
|
||||
@ -79,13 +79,20 @@ class DeterministicPayrollCalculator:
|
||||
)
|
||||
with localcontext(decimal_context):
|
||||
quantize = self._quantizer(rules)
|
||||
base_amount = request.base_accrual
|
||||
if rules.point_policy is PointPolicy.PROPORTIONAL_WITH_EXCESS:
|
||||
completion_ratio = min(
|
||||
request.actual_points / request.target_points,
|
||||
Decimal("1"),
|
||||
)
|
||||
base_amount *= completion_ratio
|
||||
lines: list[PayrollLine] = [
|
||||
PayrollLine(
|
||||
line_id="system:base",
|
||||
code="BASE",
|
||||
label="Base accrual",
|
||||
kind=LineKind.EARNING,
|
||||
amount=quantize(request.base_accrual),
|
||||
amount=quantize(base_amount),
|
||||
source_ref=request.base_source_ref,
|
||||
calculated=True,
|
||||
)
|
||||
@ -93,7 +100,10 @@ class DeterministicPayrollCalculator:
|
||||
warnings: list[CalculationWarning] = []
|
||||
point_delta: Decimal | None = None
|
||||
|
||||
if rules.point_policy is PointPolicy.EXCESS_ONLY:
|
||||
if rules.point_policy in {
|
||||
PointPolicy.EXCESS_ONLY,
|
||||
PointPolicy.PROPORTIONAL_WITH_EXCESS,
|
||||
}:
|
||||
point_delta = request.actual_points - request.target_points
|
||||
eligible_points = max(point_delta, Decimal("0"))
|
||||
point_amount = quantize(eligible_points * request.point_rate)
|
||||
@ -108,7 +118,10 @@ class DeterministicPayrollCalculator:
|
||||
calculated=True,
|
||||
)
|
||||
)
|
||||
if point_delta < 0:
|
||||
if (
|
||||
point_delta < 0
|
||||
and rules.point_policy is PointPolicy.EXCESS_ONLY
|
||||
):
|
||||
warnings.append(
|
||||
CalculationWarning(
|
||||
code="POINTS_BELOW_TARGET_NO_DEDUCTION",
|
||||
@ -481,7 +494,10 @@ class DeterministicPayrollCalculator:
|
||||
exclusive_minimum=True,
|
||||
)
|
||||
|
||||
if rules.point_policy is PointPolicy.EXCESS_ONLY:
|
||||
if rules.point_policy in {
|
||||
PointPolicy.EXCESS_ONLY,
|
||||
PointPolicy.PROPORTIONAL_WITH_EXCESS,
|
||||
}:
|
||||
self._validate_decimal(
|
||||
request.target_points,
|
||||
"target_points",
|
||||
|
||||
@ -23,6 +23,7 @@ class PointPolicy(str, Enum):
|
||||
|
||||
DISABLED = "disabled"
|
||||
EXCESS_ONLY = "excess_only"
|
||||
PROPORTIONAL_WITH_EXCESS = "proportional_with_excess"
|
||||
|
||||
|
||||
class RoundingPolicy(str, Enum):
|
||||
|
||||
@ -139,6 +139,57 @@ def test_points_below_target_do_not_silently_reduce_pay(period, rules):
|
||||
]
|
||||
|
||||
|
||||
def test_proportional_policy_reduces_base_below_target(period, rules):
|
||||
proportional_rules = replace(
|
||||
rules,
|
||||
point_policy=PointPolicy.PROPORTIONAL_WITH_EXCESS,
|
||||
)
|
||||
request = make_request(
|
||||
period,
|
||||
base_accrual=Decimal("90000"),
|
||||
target_points=Decimal("115"),
|
||||
actual_points=Decimal("100"),
|
||||
)
|
||||
|
||||
result = DeterministicPayrollCalculator().calculate(
|
||||
request,
|
||||
proportional_rules,
|
||||
)
|
||||
|
||||
base_line = next(line for line in result.lines if line.code == "BASE")
|
||||
point_line = next(line for line in result.lines if line.code == "POINT_EXCESS")
|
||||
assert base_line.amount == Decimal("78260.87")
|
||||
assert point_line.amount == Decimal("0.00")
|
||||
assert result.point_delta == Decimal("-15")
|
||||
assert result.totals.payable == Decimal("78260.87")
|
||||
assert result.warnings == ()
|
||||
|
||||
|
||||
def test_proportional_policy_keeps_full_base_and_pays_excess(period, rules):
|
||||
proportional_rules = replace(
|
||||
rules,
|
||||
point_policy=PointPolicy.PROPORTIONAL_WITH_EXCESS,
|
||||
)
|
||||
request = make_request(
|
||||
period,
|
||||
base_accrual=Decimal("90000"),
|
||||
target_points=Decimal("115"),
|
||||
actual_points=Decimal("120"),
|
||||
point_rate=Decimal("100"),
|
||||
)
|
||||
|
||||
result = DeterministicPayrollCalculator().calculate(
|
||||
request,
|
||||
proportional_rules,
|
||||
)
|
||||
|
||||
base_line = next(line for line in result.lines if line.code == "BASE")
|
||||
point_line = next(line for line in result.lines if line.code == "POINT_EXCESS")
|
||||
assert base_line.amount == Decimal("90000.00")
|
||||
assert point_line.amount == Decimal("500.00")
|
||||
assert result.totals.payable == Decimal("90500.00")
|
||||
|
||||
|
||||
def test_debit_deduction_and_advance_have_distinct_effects(period, rules):
|
||||
request = make_request(
|
||||
period,
|
||||
|
||||
@ -129,7 +129,7 @@ class ProcurementSettingsAdmin(admin.ModelAdmin):
|
||||
return super().has_add_permission(request)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
def available_processing_departments_count(self, obj):
|
||||
count = obj.available_processing_departments.count()
|
||||
|
||||
@ -52,6 +52,59 @@ def make_period(*, creator, code="2026-06"):
|
||||
)
|
||||
|
||||
|
||||
def test_work_record_target_is_optional_and_can_return_to_automatic(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="automatic.work.manager@example.test")
|
||||
employee = user_factory(email="automatic.work.employee@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
client = auth_client_factory(manager)
|
||||
|
||||
created = client.post(
|
||||
api_url("admin-work-record-list"),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"employee_id": employee.pk,
|
||||
"target_points": None,
|
||||
"actual_points": "12.0000",
|
||||
"reason": "",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert created.status_code == 201, created.content
|
||||
assert created.json()["target_points"] == "110.0000"
|
||||
assert created.json()["target_points_overridden"] is False
|
||||
|
||||
manual = client.patch(
|
||||
api_url("admin-work-record-detail", pk=created.json()["id"]),
|
||||
{
|
||||
"target_points": "77.0000",
|
||||
"expected_lock_version": created.json()["lock_version"],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert manual.status_code == 200, manual.content
|
||||
assert manual.json()["target_points"] == "77.0000"
|
||||
assert manual.json()["target_points_overridden"] is True
|
||||
|
||||
automatic = client.patch(
|
||||
api_url("admin-work-record-detail", pk=created.json()["id"]),
|
||||
{
|
||||
"target_points": None,
|
||||
"expected_lock_version": manual.json()["lock_version"],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert automatic.status_code == 200, automatic.content
|
||||
assert automatic.json()["target_points"] == "110.0000"
|
||||
assert automatic.json()["target_points_overridden"] is False
|
||||
|
||||
|
||||
def test_admin_workspace_rejects_anonymous_and_generic_model_permission(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
@ -126,6 +179,165 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all(
|
||||
}
|
||||
|
||||
|
||||
def test_period_table_lists_all_employees_and_requires_view_all(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="table.manager@example.test")
|
||||
viewer = user_factory(email="table.viewer@example.test")
|
||||
employee = user_factory(
|
||||
email="table.employee@example.test",
|
||||
first_name="Анна",
|
||||
last_name="Итогова",
|
||||
)
|
||||
missing_employee = user_factory(email="table.missing@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
grant(viewer, "view_all_payroll")
|
||||
period = make_period(creator=manager)
|
||||
EmployeePayRate.objects.create(
|
||||
employee=employee,
|
||||
amount="80000",
|
||||
point_rate="150",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="110",
|
||||
actual_points="115",
|
||||
created_by=manager,
|
||||
)
|
||||
bonus = PayrollComponent.objects.get(code="BONUS")
|
||||
PayrollInputLine.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
component=bonus,
|
||||
amount="1000",
|
||||
reason="Премия",
|
||||
created_by=manager,
|
||||
)
|
||||
url = api_url("admin-period-table", pk=period.pk)
|
||||
|
||||
denied = auth_client_factory(manager).get(url)
|
||||
assert denied.status_code == 403
|
||||
assert denied.json()["code"] == "PERMISSION_DENIED"
|
||||
|
||||
response = auth_client_factory(viewer).get(url)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert_no_store(response)
|
||||
body = response.json()
|
||||
assert body["period_id"] == period.pk
|
||||
assert body["run"] is None
|
||||
assert body["summary"]["employee_count"] >= 4
|
||||
assert {column["code"] for column in body["component_columns"]} == set(
|
||||
PayrollComponent.objects.filter(is_active=True)
|
||||
.exclude(code__in={"BASE", "POINT_EXCESS"})
|
||||
.values_list("code", flat=True)
|
||||
)
|
||||
row = next(item for item in body["rows"] if item["employee"]["id"] == employee.pk)
|
||||
assert row["employee"]["display_name"] == "Анна Итогова"
|
||||
assert row["status"] == "draft"
|
||||
assert row["rate_amount"] == "80000.0000"
|
||||
assert row["in_norm_point_rate"] == "727.2727"
|
||||
assert row["target_points"] == "110.0000"
|
||||
assert row["actual_points"] == "115.0000"
|
||||
assert row["point_delta"] == "5.0000"
|
||||
assert row["point_amount"] == "750.00"
|
||||
assert row["component_amounts"] == {"BONUS": "1000.00"}
|
||||
assert row["gross_total"] == "81750.00"
|
||||
assert row["payable"] == "81750.00"
|
||||
assert row["totals_preliminary"] is True
|
||||
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
|
||||
)
|
||||
assert missing_row["status"] == "incomplete"
|
||||
assert missing_row["rate_amount"] is None
|
||||
assert missing_row["target_points"] == "110.0000"
|
||||
assert missing_row["target_points_automatic"] is True
|
||||
assert missing_row["actual_points"] is None
|
||||
assert missing_row["payable"] == "0.00"
|
||||
|
||||
|
||||
def test_period_table_includes_all_active_and_only_inactive_employees_with_work(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="table.scope.manager@example.test")
|
||||
viewer = user_factory(email="table.scope.viewer@example.test")
|
||||
active_without_work = user_factory(
|
||||
email="table.scope.active@example.test",
|
||||
first_name="Активный",
|
||||
last_name="Сотрудник",
|
||||
)
|
||||
inactive_with_work = user_factory(
|
||||
email="table.scope.inactive.with.work@example.test",
|
||||
first_name="Уволенный",
|
||||
last_name="С Выработкой",
|
||||
active=False,
|
||||
)
|
||||
inactive_without_work = user_factory(
|
||||
email="table.scope.inactive.without.work@example.test",
|
||||
first_name="Уволенный",
|
||||
last_name="Без Выработки",
|
||||
active=False,
|
||||
)
|
||||
inactive_with_zero_work = user_factory(
|
||||
email="table.scope.inactive.zero.work@example.test",
|
||||
first_name="Уволенный",
|
||||
last_name="С Нулевой Выработкой",
|
||||
active=False,
|
||||
)
|
||||
grant(viewer, "view_all_payroll")
|
||||
period = make_period(creator=manager)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=inactive_with_work,
|
||||
target_points="110",
|
||||
actual_points="25",
|
||||
created_by=manager,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=inactive_with_zero_work,
|
||||
target_points="110",
|
||||
actual_points="0",
|
||||
created_by=manager,
|
||||
)
|
||||
EmployeePayRate.objects.create(
|
||||
employee=inactive_without_work,
|
||||
amount="80000",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
bonus = PayrollComponent.objects.get(code="BONUS")
|
||||
PayrollInputLine.objects.create(
|
||||
period=period,
|
||||
employee=inactive_without_work,
|
||||
component=bonus,
|
||||
amount="1000",
|
||||
reason="Начисление без выработки",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
response = auth_client_factory(viewer).get(
|
||||
api_url("admin-period-table", pk=period.pk),
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
employee_ids = {row["employee"]["id"] for row in response.json()["rows"]}
|
||||
assert active_without_work.pk in employee_ids
|
||||
assert inactive_with_work.pk in employee_ids
|
||||
assert inactive_without_work.pk not in employee_ids
|
||||
assert inactive_with_zero_work.pk not in employee_ids
|
||||
|
||||
|
||||
def test_staff_admin_gets_full_pilot_access_and_can_manage_any_draft(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
@ -262,7 +474,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates(
|
||||
"code": "RULESET_NOT_EFFECTIVE",
|
||||
"message": (
|
||||
"Для выбранного периода нет действующих правил расчёта. "
|
||||
"Набор eusrr-standard, версия 2026.07.1, применяется с 01.07.2026. "
|
||||
"Набор eusrr-standard, версия 2026.07.2, применяется с 01.07.2026. "
|
||||
"Измените период или подключите историческую версию правил."
|
||||
),
|
||||
"details": {
|
||||
@ -272,7 +484,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates(
|
||||
},
|
||||
"ruleset": {
|
||||
"id": "eusrr-standard",
|
||||
"version": "2026.07.1",
|
||||
"version": "2026.07.2",
|
||||
"effective_from": "2026-07-01",
|
||||
},
|
||||
},
|
||||
@ -465,6 +677,413 @@ def test_period_and_rate_drafts_use_exact_optimistic_versions_and_maker_checker(
|
||||
assert revision.json()["replaces_id"] == rate_id
|
||||
|
||||
|
||||
def test_bulk_point_rate_updates_drafts_and_revises_approved_rates(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="bulk.point.manager@example.test")
|
||||
approver = user_factory(email="bulk.point.approver@example.test")
|
||||
draft_employee = user_factory(email="bulk.point.draft@example.test")
|
||||
approved_employee = user_factory(email="bulk.point.approved@example.test")
|
||||
unchanged_employee = user_factory(email="bulk.point.unchanged@example.test")
|
||||
untouched_employee = user_factory(email="bulk.point.untouched@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
approved_at = timezone.now()
|
||||
|
||||
draft = EmployeePayRate.objects.create(
|
||||
employee=draft_employee,
|
||||
amount="80000",
|
||||
point_rate="10",
|
||||
effective_from="2026-01-01",
|
||||
reason="Исходный черновик",
|
||||
created_by=manager,
|
||||
)
|
||||
approved = EmployeePayRate.objects.create(
|
||||
employee=approved_employee,
|
||||
amount="90000",
|
||||
point_rate="20",
|
||||
effective_from="2026-01-01",
|
||||
status="approved",
|
||||
created_by=approver,
|
||||
approved_by=manager,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
unchanged = EmployeePayRate.objects.create(
|
||||
employee=unchanged_employee,
|
||||
amount="95000",
|
||||
point_rate="125",
|
||||
effective_from="2026-01-01",
|
||||
status="approved",
|
||||
created_by=approver,
|
||||
approved_by=manager,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
untouched = EmployeePayRate.objects.create(
|
||||
employee=untouched_employee,
|
||||
amount="100000",
|
||||
point_rate="30",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
response = auth_client_factory(manager).post(
|
||||
api_url("admin-period-bulk-point-rate", pk=period.pk),
|
||||
{
|
||||
"employee_ids": [
|
||||
draft_employee.pk,
|
||||
approved_employee.pk,
|
||||
unchanged_employee.pk,
|
||||
],
|
||||
"point_rate": "125.0000",
|
||||
"reason": "Единая цена балла на период",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert_no_store(response)
|
||||
assert response.json() == {
|
||||
"mode": "fixed",
|
||||
"point_rate": "125.0000",
|
||||
"summary": {
|
||||
"selected_employees": 3,
|
||||
"updated_drafts": 1,
|
||||
"created_revisions": 1,
|
||||
"unchanged": 1,
|
||||
"skipped": 0,
|
||||
},
|
||||
}
|
||||
draft.refresh_from_db()
|
||||
approved.refresh_from_db()
|
||||
unchanged.refresh_from_db()
|
||||
untouched.refresh_from_db()
|
||||
assert draft.point_rate == Decimal("125")
|
||||
assert draft.lock_version == 1
|
||||
assert draft.reason == "Исходный черновик"
|
||||
assert approved.status == "approved"
|
||||
assert unchanged.status == "approved"
|
||||
assert untouched.point_rate == Decimal("30")
|
||||
|
||||
revision = EmployeePayRate.objects.get(replaces=approved)
|
||||
assert revision.point_rate == Decimal("125")
|
||||
assert revision.amount == approved.amount
|
||||
assert revision.status == "draft"
|
||||
assert revision.created_by == manager
|
||||
assert revision.reason == "Единая цена балла на период"
|
||||
assert not EmployeePayRate.objects.filter(replaces=unchanged).exists()
|
||||
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.rate_bulk_point_rate_updated",
|
||||
object_id=str(draft.pk),
|
||||
period=period,
|
||||
).count() == 1
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.rate_bulk_point_rate_revision_created",
|
||||
object_id=str(revision.pk),
|
||||
period=period,
|
||||
).count() == 1
|
||||
|
||||
|
||||
def test_bulk_point_rate_can_copy_each_employees_in_norm_price(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="bulk.point.in.norm.manager@example.test")
|
||||
first_employee = user_factory(email="bulk.point.in.norm.first@example.test")
|
||||
second_employee = user_factory(email="bulk.point.in.norm.second@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
first_rate = EmployeePayRate.objects.create(
|
||||
employee=first_employee,
|
||||
amount="80000",
|
||||
point_rate="0",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
second_rate = EmployeePayRate.objects.create(
|
||||
employee=second_employee,
|
||||
amount="90000",
|
||||
point_rate="0",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=first_employee,
|
||||
target_points="100",
|
||||
actual_points="100",
|
||||
created_by=manager,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=second_employee,
|
||||
target_points="120",
|
||||
actual_points="120",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
response = auth_client_factory(manager).post(
|
||||
api_url("admin-period-bulk-point-rate", pk=period.pk),
|
||||
{
|
||||
"employee_ids": [first_employee.pk, second_employee.pk],
|
||||
"mode": "in_norm",
|
||||
"reason": "Сверх нормы оплачивать по основной цене балла",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.json() == {
|
||||
"mode": "in_norm",
|
||||
"point_rate": None,
|
||||
"summary": {
|
||||
"selected_employees": 2,
|
||||
"updated_drafts": 2,
|
||||
"created_revisions": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
},
|
||||
}
|
||||
first_rate.refresh_from_db()
|
||||
second_rate.refresh_from_db()
|
||||
assert first_rate.point_rate == Decimal("800.0000")
|
||||
assert second_rate.point_rate == Decimal("750.0000")
|
||||
|
||||
|
||||
def test_bulk_point_rate_defaults_an_empty_fixed_price_to_zero(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="bulk.point.zero.manager@example.test")
|
||||
employee = user_factory(email="bulk.point.zero.employee@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
rate = EmployeePayRate.objects.create(
|
||||
employee=employee,
|
||||
amount="80000",
|
||||
point_rate="125",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
response = auth_client_factory(manager).post(
|
||||
api_url("admin-period-bulk-point-rate", pk=period.pk),
|
||||
{
|
||||
"employee_ids": [employee.pk],
|
||||
"reason": "Убрать доплату сверх нормы",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.json()["mode"] == "fixed"
|
||||
assert response.json()["point_rate"] == "0.0000"
|
||||
rate.refresh_from_db()
|
||||
assert rate.point_rate == Decimal("0.0000")
|
||||
|
||||
|
||||
def test_bulk_point_rate_rejects_locked_period_without_partial_changes(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="bulk.point.locked.manager@example.test")
|
||||
employee = user_factory(email="bulk.point.locked.employee@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
period.status = "closed"
|
||||
period.save(update_fields=["status"])
|
||||
rate = EmployeePayRate.objects.create(
|
||||
employee=employee,
|
||||
amount="80000",
|
||||
point_rate="10",
|
||||
effective_from="2026-01-01",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
response = auth_client_factory(manager).post(
|
||||
api_url("admin-period-bulk-point-rate", pk=period.pk),
|
||||
{
|
||||
"employee_ids": [employee.pk],
|
||||
"point_rate": "125.0000",
|
||||
"reason": "Не должно примениться",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == "PERIOD_INPUTS_LOCKED"
|
||||
rate.refresh_from_db()
|
||||
assert rate.point_rate == Decimal("10")
|
||||
assert rate.lock_version == 0
|
||||
|
||||
|
||||
def test_bulk_pay_rate_creates_updates_and_revises_selected_employee_rates(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="bulk.rate.manager@example.test")
|
||||
approver = user_factory(email="bulk.rate.approver@example.test")
|
||||
new_employee = user_factory(email="bulk.rate.new@example.test")
|
||||
inherited_employee = user_factory(email="bulk.rate.inherited@example.test")
|
||||
draft_employee = user_factory(email="bulk.rate.draft@example.test")
|
||||
approved_employee = user_factory(email="bulk.rate.approved@example.test")
|
||||
unchanged_employee = user_factory(email="bulk.rate.unchanged@example.test")
|
||||
untouched_employee = user_factory(email="bulk.rate.untouched@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
approved_at = timezone.now()
|
||||
|
||||
EmployeePayRate.objects.create(
|
||||
employee=inherited_employee,
|
||||
amount="80000",
|
||||
point_rate="75",
|
||||
effective_from="2026-05-01",
|
||||
status="approved",
|
||||
created_by=approver,
|
||||
approved_by=manager,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
draft = EmployeePayRate.objects.create(
|
||||
employee=draft_employee,
|
||||
amount="85000",
|
||||
point_rate="20",
|
||||
effective_from="2026-06-01",
|
||||
reason="Исходный черновик",
|
||||
created_by=manager,
|
||||
)
|
||||
approved = EmployeePayRate.objects.create(
|
||||
employee=approved_employee,
|
||||
amount="90000",
|
||||
point_rate="30",
|
||||
effective_from="2026-06-01",
|
||||
status="approved",
|
||||
created_by=approver,
|
||||
approved_by=manager,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
unchanged = EmployeePayRate.objects.create(
|
||||
employee=unchanged_employee,
|
||||
amount="100000",
|
||||
point_rate="40",
|
||||
effective_from="2026-06-01",
|
||||
status="approved",
|
||||
created_by=approver,
|
||||
approved_by=manager,
|
||||
approved_at=approved_at,
|
||||
)
|
||||
untouched = EmployeePayRate.objects.create(
|
||||
employee=untouched_employee,
|
||||
amount="70000",
|
||||
point_rate="15",
|
||||
effective_from="2026-06-01",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
response = auth_client_factory(manager).post(
|
||||
api_url("admin-period-bulk-pay-rate", pk=period.pk),
|
||||
{
|
||||
"employee_ids": [
|
||||
new_employee.pk,
|
||||
inherited_employee.pk,
|
||||
draft_employee.pk,
|
||||
approved_employee.pk,
|
||||
unchanged_employee.pk,
|
||||
],
|
||||
"amount": "100000.0000",
|
||||
"effective_from": "2026-06-01",
|
||||
"reason": "Единая ставка на период",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert_no_store(response)
|
||||
assert response.json() == {
|
||||
"amount": "100000.0000",
|
||||
"effective_from": "2026-06-01",
|
||||
"summary": {
|
||||
"selected_employees": 5,
|
||||
"created_drafts": 2,
|
||||
"updated_drafts": 1,
|
||||
"created_revisions": 1,
|
||||
"unchanged": 1,
|
||||
"skipped": 0,
|
||||
},
|
||||
}
|
||||
|
||||
created = EmployeePayRate.objects.get(
|
||||
employee=new_employee,
|
||||
effective_from="2026-06-01",
|
||||
)
|
||||
inherited = EmployeePayRate.objects.get(
|
||||
employee=inherited_employee,
|
||||
effective_from="2026-06-01",
|
||||
)
|
||||
revision = EmployeePayRate.objects.get(replaces=approved)
|
||||
draft.refresh_from_db()
|
||||
unchanged.refresh_from_db()
|
||||
untouched.refresh_from_db()
|
||||
|
||||
assert created.amount == Decimal("100000")
|
||||
assert created.point_rate == Decimal("0")
|
||||
assert created.status == "draft"
|
||||
assert inherited.amount == Decimal("100000")
|
||||
assert inherited.point_rate == Decimal("75")
|
||||
assert inherited.revision == 1
|
||||
assert draft.amount == Decimal("100000")
|
||||
assert draft.point_rate == Decimal("20")
|
||||
assert draft.reason == "Исходный черновик"
|
||||
assert draft.lock_version == 1
|
||||
assert revision.amount == Decimal("100000")
|
||||
assert revision.point_rate == Decimal("30")
|
||||
assert revision.replaces == approved
|
||||
assert revision.status == "draft"
|
||||
assert revision.reason == "Единая ставка на период"
|
||||
assert unchanged.status == "approved"
|
||||
assert untouched.amount == Decimal("70000")
|
||||
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.rate_bulk_created",
|
||||
period=period,
|
||||
).count() == 2
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.rate_bulk_amount_updated",
|
||||
object_id=str(draft.pk),
|
||||
period=period,
|
||||
).count() == 1
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.rate_bulk_amount_revision_created",
|
||||
object_id=str(revision.pk),
|
||||
period=period,
|
||||
).count() == 1
|
||||
|
||||
|
||||
def test_bulk_pay_rate_rejects_date_outside_selected_period(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="bulk.rate.date.manager@example.test")
|
||||
employee = user_factory(email="bulk.rate.date.employee@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
|
||||
response = auth_client_factory(manager).post(
|
||||
api_url("admin-period-bulk-pay-rate", pk=period.pk),
|
||||
{
|
||||
"employee_ids": [employee.pk],
|
||||
"amount": "100000.0000",
|
||||
"effective_from": "2026-07-01",
|
||||
"reason": "Дата вне периода",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == "RATE_DATE_OUTSIDE_PERIOD"
|
||||
assert not EmployeePayRate.objects.filter(employee=employee).exists()
|
||||
|
||||
|
||||
def test_native_api_runs_complete_service_workflow_and_redacts_aggregate_money(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
@ -567,6 +1186,31 @@ def test_native_api_runs_complete_service_workflow_and_redacts_aggregate_money(
|
||||
assert_no_store(calculation)
|
||||
run_id = calculation.json()["id"]
|
||||
|
||||
table = auth_client_factory(viewer).get(
|
||||
api_url("admin-period-table", pk=period.pk),
|
||||
)
|
||||
assert table.status_code == 200, table.content
|
||||
table_body = table.json()
|
||||
assert table_body["run"] == {
|
||||
"id": run_id,
|
||||
"revision": 1,
|
||||
"status": "calculated",
|
||||
}
|
||||
employee_row = next(
|
||||
row for row in table_body["rows"] if row["employee"]["id"] == employee.pk
|
||||
)
|
||||
assert employee_row["status"] == "calculated"
|
||||
assert employee_row["rate_amount"] == "80000"
|
||||
assert employee_row["target_points"] == "110"
|
||||
assert employee_row["actual_points"] == "110"
|
||||
assert employee_row["component_amounts"] == {"BONUS": "100.00"}
|
||||
assert employee_row["gross_total"] == "80100.00"
|
||||
assert employee_row["deduction_total"] == "0.00"
|
||||
assert employee_row["payable"] == "80100.00"
|
||||
assert employee_row["totals_preliminary"] is False
|
||||
assert table_body["summary"]["calculated_count"] == 1
|
||||
assert table_body["summary"]["gross_total"] == "80100.00"
|
||||
|
||||
blocked_draft = maker_client.post(
|
||||
api_url("admin-input-line-list"),
|
||||
{
|
||||
|
||||
@ -9,9 +9,18 @@ from django.urls import reverse
|
||||
from finance.admin import (
|
||||
EmployeePayRateAdmin,
|
||||
PayrollAuditEventAdmin,
|
||||
PayrollPeriodAdmin,
|
||||
PayrollStatementAdmin,
|
||||
)
|
||||
from finance.models import EmployeePayRate, PayrollAuditEvent, PayrollStatement
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollAuditEvent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollPeriod,
|
||||
PayrollRun,
|
||||
PayrollStatement,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -41,6 +50,142 @@ def admin_request(user):
|
||||
return request
|
||||
|
||||
|
||||
def test_superuser_has_unrestricted_finance_admin_permissions(user_factory):
|
||||
superuser = user_factory(
|
||||
email="payroll.superuser@example.test",
|
||||
staff=True,
|
||||
superuser=True,
|
||||
)
|
||||
manager = user_factory(email="payroll.manager@example.test", staff=True)
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
superuser_request = admin_request(superuser)
|
||||
manager_request = admin_request(manager)
|
||||
period_admin = PayrollPeriodAdmin(PayrollPeriod, admin.site)
|
||||
|
||||
assert period_admin.has_module_permission(superuser_request) is True
|
||||
assert period_admin.has_view_permission(superuser_request) is True
|
||||
assert period_admin.has_add_permission(superuser_request) is True
|
||||
assert period_admin.has_change_permission(superuser_request) is True
|
||||
assert period_admin.has_delete_permission(superuser_request) is True
|
||||
assert "delete_selected" in period_admin.get_actions(superuser_request)
|
||||
|
||||
assert period_admin.has_delete_permission(manager_request) is False
|
||||
assert "delete_selected" not in period_admin.get_actions(manager_request)
|
||||
assert EmployeePayRateAdmin(
|
||||
EmployeePayRate,
|
||||
admin.site,
|
||||
).has_delete_permission(superuser_request) is True
|
||||
assert PayrollStatementAdmin(
|
||||
PayrollStatement,
|
||||
admin.site,
|
||||
).has_delete_permission(superuser_request) is True
|
||||
assert PayrollAuditEventAdmin(
|
||||
PayrollAuditEvent,
|
||||
admin.site,
|
||||
).has_delete_permission(superuser_request) is True
|
||||
|
||||
|
||||
def test_period_admin_confirms_and_cascades_related_payroll_objects(user_factory):
|
||||
superuser = user_factory(
|
||||
email="payroll.delete@example.test",
|
||||
staff=True,
|
||||
superuser=True,
|
||||
)
|
||||
employee = user_factory(email="payroll.delete.employee@example.test")
|
||||
period = PayrollPeriod.objects.create(
|
||||
code="2026-03",
|
||||
name="Март 2026",
|
||||
date_from="2026-03-01",
|
||||
date_to="2026-03-31",
|
||||
pay_date="2026-04-05",
|
||||
created_by=superuser,
|
||||
)
|
||||
daily_entry = PayrollDailyWorkEntry.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
work_date="2026-03-02",
|
||||
target_points="5",
|
||||
actual_points="4",
|
||||
)
|
||||
work_record = PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="105",
|
||||
actual_points="84",
|
||||
created_by=superuser,
|
||||
)
|
||||
replacement_work_record = PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="105",
|
||||
actual_points="90",
|
||||
revision=2,
|
||||
replaces=work_record,
|
||||
reason="Уточнение выработки",
|
||||
created_by=superuser,
|
||||
)
|
||||
run = PayrollRun.objects.create(
|
||||
period=period,
|
||||
revision=1,
|
||||
ruleset_id="test",
|
||||
ruleset_version="1",
|
||||
ruleset_hash="rules",
|
||||
calculator_version="test",
|
||||
input_hash="input",
|
||||
result_hash="result",
|
||||
requested_by=superuser,
|
||||
)
|
||||
replacement_run = PayrollRun.objects.create(
|
||||
period=period,
|
||||
revision=2,
|
||||
supersedes=run,
|
||||
recalculation_reason="Уточнение исходных данных",
|
||||
ruleset_id="test",
|
||||
ruleset_version="1",
|
||||
ruleset_hash="rules",
|
||||
calculator_version="test",
|
||||
input_hash="input-2",
|
||||
result_hash="result-2",
|
||||
requested_by=superuser,
|
||||
)
|
||||
period.refresh_from_db()
|
||||
period.current_run = replacement_run
|
||||
period.save(update_fields=["current_run"])
|
||||
audit_event = PayrollAuditEvent.objects.create(
|
||||
actor=superuser,
|
||||
action="payroll.period_created",
|
||||
object_type="finance.payrollperiod",
|
||||
object_id=str(period.pk),
|
||||
period=period,
|
||||
)
|
||||
|
||||
model_admin = PayrollPeriodAdmin(PayrollPeriod, admin.site)
|
||||
request = admin_request(superuser)
|
||||
deleted_objects, model_count, perms_needed, protected = (
|
||||
model_admin.get_deleted_objects([period], request)
|
||||
)
|
||||
|
||||
assert deleted_objects
|
||||
assert model_count[PayrollDailyWorkEntry._meta.verbose_name_plural] == 1
|
||||
assert model_count[PayrollWorkRecord._meta.verbose_name_plural] == 2
|
||||
assert model_count[PayrollRun._meta.verbose_name_plural] == 2
|
||||
assert model_count[PayrollAuditEvent._meta.verbose_name_plural] == 1
|
||||
assert perms_needed == set()
|
||||
assert protected == []
|
||||
|
||||
period.delete()
|
||||
|
||||
assert not PayrollPeriod.objects.filter(pk=period.pk).exists()
|
||||
assert not PayrollDailyWorkEntry.objects.filter(pk=daily_entry.pk).exists()
|
||||
assert not PayrollWorkRecord.objects.filter(pk=work_record.pk).exists()
|
||||
assert not PayrollWorkRecord.objects.filter(
|
||||
pk=replacement_work_record.pk,
|
||||
).exists()
|
||||
assert not PayrollRun.objects.filter(pk=run.pk).exists()
|
||||
assert not PayrollRun.objects.filter(pk=replacement_run.pk).exists()
|
||||
assert not PayrollAuditEvent.objects.filter(pk=audit_event.pk).exists()
|
||||
|
||||
|
||||
def test_simple_mode_staff_can_open_and_edit_any_payroll_draft(
|
||||
settings,
|
||||
user_factory,
|
||||
|
||||
@ -35,7 +35,7 @@ def test_payroll_adapter_is_safe_by_default(settings):
|
||||
|
||||
rules = build_rules()
|
||||
|
||||
assert rules.point_policy is PointPolicy.DISABLED
|
||||
assert rules.point_policy is PointPolicy.PROPORTIONAL_WITH_EXCESS
|
||||
assert rules.money_quantum == Decimal("0.01")
|
||||
assert rules.allow_negative_payable is False
|
||||
|
||||
|
||||
482
backend/tests/finance/test_payroll_daily_work_api.py
Normal file
482
backend/tests/finance/test_payroll_daily_work_api.py
Normal file
@ -0,0 +1,482 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from attendance.models import EmployeeWorkSchedule, StandardWorkSchedule
|
||||
from finance.models import (
|
||||
PayrollAuditEvent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollPeriod,
|
||||
PayrollWorkSettings,
|
||||
PayrollWorkRecord,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def api_url():
|
||||
return reverse("api:v1:finance-payroll:own-work-records")
|
||||
|
||||
|
||||
def make_period(*, creator, code="2026-06", status="open"):
|
||||
return PayrollPeriod.objects.create(
|
||||
code=code,
|
||||
name="Июнь 2026",
|
||||
date_from="2026-06-01",
|
||||
date_to="2026-06-30",
|
||||
pay_date="2026-07-05",
|
||||
currency="RUB",
|
||||
status=status,
|
||||
created_by=creator,
|
||||
)
|
||||
|
||||
|
||||
def test_daily_work_api_requires_authentication(auth_client_factory):
|
||||
response = auth_client_factory().get(api_url())
|
||||
|
||||
assert response.status_code in {401, 403}
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
|
||||
|
||||
def test_daily_work_defaults_to_period_nearest_to_today(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.nearest.employee@example.test")
|
||||
manager = user_factory(email="daily.nearest.manager@example.test")
|
||||
today = timezone.localdate()
|
||||
|
||||
close_past = PayrollPeriod.objects.create(
|
||||
code="nearest-past",
|
||||
name="Ближайший прошедший",
|
||||
date_from=today - timedelta(days=12),
|
||||
date_to=today - timedelta(days=2),
|
||||
pay_date=today + timedelta(days=3),
|
||||
currency="RUB",
|
||||
status="open",
|
||||
created_by=manager,
|
||||
)
|
||||
current = PayrollPeriod.objects.create(
|
||||
code="nearest-current",
|
||||
name="Текущий",
|
||||
date_from=today - timedelta(days=1),
|
||||
date_to=today + timedelta(days=1),
|
||||
pay_date=today + timedelta(days=5),
|
||||
currency="RUB",
|
||||
status="calculated",
|
||||
created_by=manager,
|
||||
)
|
||||
far_future = PayrollPeriod.objects.create(
|
||||
code="nearest-future",
|
||||
name="Дальний будущий",
|
||||
date_from=today + timedelta(days=30),
|
||||
date_to=today + timedelta(days=60),
|
||||
pay_date=today + timedelta(days=65),
|
||||
currency="RUB",
|
||||
status="open",
|
||||
created_by=manager,
|
||||
)
|
||||
client = auth_client_factory(employee)
|
||||
|
||||
response = client.get(api_url())
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.json()["selected_period_id"] == current.pk
|
||||
|
||||
current.delete()
|
||||
response_without_current = client.get(api_url())
|
||||
assert response_without_current.status_code == 200
|
||||
assert response_without_current.json()["selected_period_id"] == close_past.pk
|
||||
|
||||
explicit = client.get(api_url(), {"period_id": far_future.pk})
|
||||
assert explicit.status_code == 200
|
||||
assert explicit.json()["selected_period_id"] == far_future.pk
|
||||
|
||||
|
||||
def test_employee_daily_entries_are_isolated_and_aggregated(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.work.employee@example.test")
|
||||
stranger = user_factory(email="daily.work.stranger@example.test")
|
||||
manager = user_factory(email="daily.work.manager@example.test")
|
||||
period = make_period(creator=manager)
|
||||
client = auth_client_factory(employee)
|
||||
|
||||
first = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-02",
|
||||
"target_points": "999.0000",
|
||||
"actual_points": "10.0000",
|
||||
"note": "Первая смена",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert first.status_code == 201, first.content
|
||||
assert first.json()["operation"] == "created"
|
||||
assert first.json()["entry"]["work_date"] == "2026-06-02"
|
||||
assert first.json()["entry"]["target_points"] == "5.0000"
|
||||
assert first.json()["record"]["target_points"] == "110.0000"
|
||||
assert first.json()["record"]["actual_points"] == "10.0000"
|
||||
assert first.json()["record"]["status"] == "draft"
|
||||
aggregate_id = first.json()["record"]["id"]
|
||||
|
||||
second = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-03",
|
||||
"actual_points": "7.0000",
|
||||
"note": "Вторая смена",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert second.status_code == 201, second.content
|
||||
assert second.json()["record"]["id"] == aggregate_id
|
||||
assert second.json()["record"]["target_points"] == "110.0000"
|
||||
assert second.json()["record"]["actual_points"] == "17.0000"
|
||||
|
||||
first_entry = PayrollDailyWorkEntry.objects.get(
|
||||
employee=employee,
|
||||
work_date="2026-06-02",
|
||||
)
|
||||
updated = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-02",
|
||||
"actual_points": "12.0000",
|
||||
"note": "Уточнённый результат",
|
||||
"expected_lock_version": first_entry.lock_version,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert updated.status_code == 200, updated.content
|
||||
assert updated.json()["operation"] == "updated"
|
||||
assert updated.json()["entry"]["lock_version"] == 1
|
||||
assert updated.json()["record"]["target_points"] == "110.0000"
|
||||
assert updated.json()["record"]["actual_points"] == "19.0000"
|
||||
|
||||
stale = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-02",
|
||||
"actual_points": "20.0000",
|
||||
"expected_lock_version": 0,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["code"] == "STALE_DAILY_WORK_ENTRY"
|
||||
|
||||
auth_client_factory(stranger).post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-02",
|
||||
"actual_points": "5.0000",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
workspace = client.get(api_url(), {"period_id": period.pk})
|
||||
assert workspace.status_code == 200
|
||||
assert workspace.json()["daily_target_points"] == "5"
|
||||
assert workspace.json()["selected_period_id"] == period.pk
|
||||
assert len(workspace.json()["entries"]) == 2
|
||||
assert {
|
||||
entry["actual_points"] for entry in workspace.json()["entries"]
|
||||
} == {"12.0000", "7.0000"}
|
||||
assert "employee" not in workspace.json()["entries"][0]
|
||||
period_payload = workspace.json()["periods"][0]
|
||||
assert period_payload["editable"] is True
|
||||
assert period_payload["record"]["actual_points"] == "19.0000"
|
||||
assert "expected_gross" not in period_payload["record"]
|
||||
|
||||
aggregate = PayrollWorkRecord.objects.get(pk=aggregate_id)
|
||||
assert aggregate.employee == employee
|
||||
assert aggregate.created_by == employee
|
||||
assert aggregate.target_points == Decimal("110")
|
||||
assert aggregate.actual_points == Decimal("19")
|
||||
assert aggregate.source == "api"
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
actor=employee,
|
||||
action="payroll.daily_work_entry_created",
|
||||
period=period,
|
||||
).count() == 2
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
actor=employee,
|
||||
action="payroll.daily_work_entry_updated",
|
||||
period=period,
|
||||
).count() == 1
|
||||
|
||||
|
||||
def test_daily_work_change_creates_revision_of_approved_aggregate(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.revision.employee@example.test")
|
||||
manager = user_factory(email="daily.revision.manager@example.test")
|
||||
period = make_period(creator=manager)
|
||||
client = auth_client_factory(employee)
|
||||
created = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-10",
|
||||
"actual_points": "8.0000",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
source = PayrollWorkRecord.objects.get(pk=created.json()["record"]["id"])
|
||||
source.status = "approved"
|
||||
source.approved_by = manager
|
||||
source.approved_at = timezone.now()
|
||||
source.save(update_fields=["status", "approved_by", "approved_at"])
|
||||
entry = PayrollDailyWorkEntry.objects.get(employee=employee, period=period)
|
||||
|
||||
response = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-10",
|
||||
"actual_points": "9.0000",
|
||||
"expected_lock_version": entry.lock_version,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
source.refresh_from_db()
|
||||
revision = PayrollWorkRecord.objects.get(replaces=source)
|
||||
assert source.status == "approved"
|
||||
assert revision.status == "draft"
|
||||
assert revision.revision == 2
|
||||
assert revision.actual_points == Decimal("9")
|
||||
assert revision.created_by == employee
|
||||
assert response.json()["record"]["id"] == revision.pk
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.daily_work_aggregate_revision_created",
|
||||
object_id=str(revision.pk),
|
||||
period=period,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_daily_work_rejects_locked_period_and_date_outside_period(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.locked.employee@example.test")
|
||||
manager = user_factory(email="daily.locked.manager@example.test")
|
||||
period = make_period(creator=manager)
|
||||
client = auth_client_factory(employee)
|
||||
|
||||
outside = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-07-01",
|
||||
"actual_points": "8.0000",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert outside.status_code == 409
|
||||
assert outside.json()["code"] == "WORK_DATE_OUTSIDE_PERIOD"
|
||||
|
||||
period.status = "calculated"
|
||||
period.save(update_fields=["status"])
|
||||
locked = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-15",
|
||||
"actual_points": "8.0000",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert locked.status_code == 409
|
||||
assert locked.json()["code"] == "WORK_PERIOD_LOCKED"
|
||||
assert not PayrollDailyWorkEntry.objects.filter(employee=employee).exists()
|
||||
|
||||
|
||||
def test_global_daily_target_applies_to_new_entries_and_preserves_history(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.target.employee@example.test")
|
||||
manager = user_factory(email="daily.target.manager@example.test")
|
||||
period = make_period(creator=manager)
|
||||
settings = PayrollWorkSettings.objects.create(daily_target_points="7.5")
|
||||
client = auth_client_factory(employee)
|
||||
|
||||
first = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-02",
|
||||
"actual_points": "8.0000",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert first.status_code == 201, first.content
|
||||
assert first.json()["entry"]["target_points"] == "7.5000"
|
||||
settings.daily_target_points = Decimal("9")
|
||||
settings.save(update_fields=["daily_target_points", "updated_at"])
|
||||
|
||||
existing_entry = PayrollDailyWorkEntry.objects.get(
|
||||
employee=employee,
|
||||
work_date="2026-06-02",
|
||||
)
|
||||
updated = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-02",
|
||||
"actual_points": "8.5000",
|
||||
"expected_lock_version": existing_entry.lock_version,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
second = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-03",
|
||||
"actual_points": "9.0000",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
workspace = client.get(api_url(), {"period_id": period.pk})
|
||||
|
||||
assert updated.status_code == 200, updated.content
|
||||
assert updated.json()["entry"]["target_points"] == "7.5000"
|
||||
assert second.status_code == 201, second.content
|
||||
assert second.json()["entry"]["target_points"] == "9.0000"
|
||||
assert updated.json()["record"]["target_points"] == "198.0000"
|
||||
assert second.json()["record"]["target_points"] == "198.0000"
|
||||
assert workspace.json()["daily_target_points"] == "9.0000"
|
||||
|
||||
|
||||
def test_period_summary_is_calculated_without_creating_database_record(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.summary.employee@example.test")
|
||||
manager = user_factory(email="daily.summary.manager@example.test")
|
||||
period = make_period(creator=manager)
|
||||
|
||||
client = auth_client_factory(employee)
|
||||
response = client.get(
|
||||
api_url(),
|
||||
{"period_id": period.pk},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
period_payload = response.json()["periods"][0]
|
||||
assert period_payload["record"] is None
|
||||
assert period_payload["summary"] == {
|
||||
"target_points": "110.0000",
|
||||
"actual_points": "0.0000",
|
||||
"target_source": "default_schedule",
|
||||
"workdays_count": 22,
|
||||
}
|
||||
assert not PayrollWorkRecord.objects.filter(
|
||||
employee=employee,
|
||||
period=period,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_period_summary_uses_individual_schedule_before_standard_schedule(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.schedule.employee@example.test")
|
||||
manager = user_factory(email="daily.schedule.manager@example.test")
|
||||
period = make_period(creator=manager)
|
||||
PayrollWorkSettings.objects.create(daily_target_points="7.5")
|
||||
StandardWorkSchedule.objects.create(
|
||||
workdays=["Monday", "Tuesday"],
|
||||
date_overrides=[
|
||||
{"date": "2026-06-02", "is_workday": False},
|
||||
{"date": "2026-06-06", "is_workday": True},
|
||||
],
|
||||
)
|
||||
client = auth_client_factory(employee)
|
||||
|
||||
standard_response = client.get(api_url(), {"period_id": period.pk})
|
||||
|
||||
assert standard_response.json()["periods"][0]["summary"] == {
|
||||
"target_points": "75.0000",
|
||||
"actual_points": "0.0000",
|
||||
"target_source": "standard_schedule",
|
||||
"workdays_count": 10,
|
||||
}
|
||||
|
||||
EmployeeWorkSchedule.objects.create(
|
||||
employee=employee,
|
||||
workdays=["Wednesday"],
|
||||
date_overrides=[
|
||||
{"date": "2026-06-06", "is_workday": True},
|
||||
],
|
||||
)
|
||||
individual_response = client.get(api_url(), {"period_id": period.pk})
|
||||
|
||||
assert individual_response.json()["periods"][0]["summary"] == {
|
||||
"target_points": "37.5000",
|
||||
"actual_points": "0.0000",
|
||||
"target_source": "individual_schedule",
|
||||
"workdays_count": 5,
|
||||
}
|
||||
|
||||
|
||||
def test_saved_manual_target_has_priority_over_schedule_calculation(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
employee = user_factory(email="daily.manual.employee@example.test")
|
||||
manager = user_factory(email="daily.manual.manager@example.test")
|
||||
period = make_period(creator=manager)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="42",
|
||||
actual_points="3",
|
||||
source="manual",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
client = auth_client_factory(employee)
|
||||
response = client.get(
|
||||
api_url(),
|
||||
{"period_id": period.pk},
|
||||
)
|
||||
|
||||
summary = response.json()["periods"][0]["summary"]
|
||||
assert summary["target_points"] == "42.0000"
|
||||
assert summary["actual_points"] == "3.0000"
|
||||
assert summary["target_source"] == "saved_record"
|
||||
|
||||
saved = client.post(
|
||||
api_url(),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"work_date": "2026-06-02",
|
||||
"actual_points": "6.0000",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert saved.status_code == 201, saved.content
|
||||
record = PayrollWorkRecord.objects.get(period=period, employee=employee)
|
||||
assert record.target_points == Decimal("42")
|
||||
assert record.actual_points == Decimal("6")
|
||||
assert record.source == "manual"
|
||||
@ -200,7 +200,7 @@ def test_excel_row_is_persisted_as_a_deterministic_statement(
|
||||
|
||||
assert statement.gross_before_adjustments == Decimal("95000.00")
|
||||
assert statement.adjustment_total == Decimal("20000.00")
|
||||
assert statement.point_delta is None
|
||||
assert statement.point_delta == Decimal("0.0000")
|
||||
assert statement.gross_total == Decimal("115000.00")
|
||||
assert statement.net_pay == Decimal("115000.00")
|
||||
assert statement.payable == Decimal("115000.00")
|
||||
@ -209,6 +209,7 @@ def test_excel_row_is_persisted_as_a_deterministic_statement(
|
||||
assert statement.result_snapshot["warnings"] == []
|
||||
assert list(statement.lines.values_list("code", flat=True)) == [
|
||||
"BASE",
|
||||
"POINT_EXCESS",
|
||||
"BONUS",
|
||||
"CORRECTION_CREDIT",
|
||||
]
|
||||
@ -440,7 +441,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.1, применяется с 01.07.2026. "
|
||||
"Набор eusrr-standard, версия 2026.07.2, применяется с 01.07.2026. "
|
||||
"Измените период или подключите историческую версию правил."
|
||||
)
|
||||
assert error.value.details["employee_id"] == payroll_users["employee"].pk
|
||||
@ -450,7 +451,7 @@ def test_ruleset_not_effective_keeps_domain_code_and_localized_message(
|
||||
}
|
||||
assert error.value.details["ruleset"] == {
|
||||
"id": "eusrr-standard",
|
||||
"version": "2026.07.1",
|
||||
"version": "2026.07.2",
|
||||
"effective_from": "2026-07-01",
|
||||
}
|
||||
assert {issue["code"] for issue in error.value.details["issues"]} == {
|
||||
@ -644,7 +645,7 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
|
||||
assert stranger_client.get(detail_url).status_code == 404
|
||||
detail_response = employee_client.get(detail_url)
|
||||
assert detail_response.status_code == 200
|
||||
assert len(detail_response.json()["lines"]) == 3
|
||||
assert len(detail_response.json()["lines"]) == 4
|
||||
assert PayrollAuditEvent.objects.filter(
|
||||
action="payroll.statement_viewed",
|
||||
actor=payroll_users["employee"],
|
||||
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cross-env TURBOPACK=0 next dev",
|
||||
"dev": "next dev --webpack",
|
||||
"dev:turbo": "next dev --turbo",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "node --experimental-strip-types --test src/**/*.test.js",
|
||||
|
||||
@ -81,6 +81,10 @@ type PaginatedResponse<T> = { results: T[]; next?: string | null };
|
||||
type DocumentSection = "folders" | "regulations";
|
||||
const DOCUMENTS_PAGE_SIZE = 10000;
|
||||
|
||||
function documentSectionFromParam(value: string | null): DocumentSection {
|
||||
return value === "regulations" ? "regulations" : "folders";
|
||||
}
|
||||
|
||||
function isPaginatedResponse<T>(value: unknown): value is PaginatedResponse<T> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return Array.isArray((value as { results?: unknown }).results);
|
||||
@ -136,11 +140,12 @@ function DocumentsPageContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const requestedSection = documentSectionFromParam(searchParams.get("section"));
|
||||
// State
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [folders, setFolders] = useState<FolderNode[]>([]);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||
const [activeSection, setActiveSection] = useState<DocumentSection>("regulations");
|
||||
const [activeSection, setActiveSection] = useState<DocumentSection>(requestedSection);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<DocumentTagOption[]>([]);
|
||||
@ -182,6 +187,24 @@ function DocumentsPageContent() {
|
||||
// Bulk selection
|
||||
const linkedDocumentId = Number(searchParams.get("document") || "");
|
||||
|
||||
const selectDocumentSection = useCallback((section: DocumentSection) => {
|
||||
setActiveSection(section);
|
||||
if (section === "regulations") {
|
||||
setSelectedFolderId(null);
|
||||
}
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set("section", section);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSection(requestedSection);
|
||||
if (requestedSection === "regulations") {
|
||||
setSelectedFolderId(null);
|
||||
}
|
||||
}, [requestedSection]);
|
||||
|
||||
const clearDocumentParam = () => {
|
||||
if (!searchParams.get("document")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
@ -736,7 +759,7 @@ function DocumentsPageContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
selectDocumentSection("folders");
|
||||
setShowFolderDropdown(false);
|
||||
}}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1.5 transition hover:opacity-85"
|
||||
@ -752,7 +775,7 @@ function DocumentsPageContent() {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFolderId(null);
|
||||
setActiveSection("folders");
|
||||
selectDocumentSection("folders");
|
||||
}}
|
||||
className="flex h-7 w-6 shrink-0 items-center justify-center transition hover:bg-sky-500"
|
||||
title="Сбросить фильтр"
|
||||
@ -764,7 +787,7 @@ function DocumentsPageContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
selectDocumentSection("folders");
|
||||
setShowFolderDropdown((prev) => !prev);
|
||||
}}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center transition hover:bg-[var(--surface-tertiary)]"
|
||||
@ -799,7 +822,7 @@ function DocumentsPageContent() {
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={(id) => {
|
||||
console.log("🗂️ Выбрана папка:", id);
|
||||
setActiveSection("folders");
|
||||
selectDocumentSection("folders");
|
||||
setSelectedFolderId(id);
|
||||
setShowFolderDropdown(false);
|
||||
}}
|
||||
@ -811,7 +834,7 @@ function DocumentsPageContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection("regulations");
|
||||
selectDocumentSection("regulations");
|
||||
setSelectedFolderId(null);
|
||||
setShowFolderDropdown(false);
|
||||
}}
|
||||
@ -1034,7 +1057,7 @@ function DocumentsPageContent() {
|
||||
<nav className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
selectDocumentSection("folders");
|
||||
setSelectedFolderId(null);
|
||||
}}
|
||||
className="app-link-accent"
|
||||
@ -1055,7 +1078,7 @@ function DocumentsPageContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
selectDocumentSection("folders");
|
||||
setSelectedFolderId(crumb.id);
|
||||
}}
|
||||
className="app-link-accent"
|
||||
|
||||
@ -14,9 +14,12 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PayrollDailyWorkSection } from "@/components/finance/PayrollDailyWorkSection";
|
||||
import { PayrollStatementDetailModal } from "@/components/finance/PayrollStatementDetailModal";
|
||||
import { PayrollAdminWorkspace } from "@/components/finance/admin/PayrollAdminWorkspace";
|
||||
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 { canOpenPayrollAdmin } from "@/lib/permissions";
|
||||
@ -72,12 +75,14 @@ function EmptyPayroll() {
|
||||
|
||||
export default function FinancesPage() {
|
||||
const { user } = useUser();
|
||||
const [activeTab, setActiveTab] = useState<"statement" | "management">("statement");
|
||||
const showPayrollAdmin = canOpenPayrollAdmin(user);
|
||||
const [activeTab, setActiveTab] = usePayrollFinanceTab(user?.id, showPayrollAdmin);
|
||||
const [statements, setStatements] = useState<PayrollStatementSummary[]>([]);
|
||||
const [selectedStatement, setSelectedStatement] = useState<PayrollStatementSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [desktopWideMode, changeDesktopWideMode] = usePayrollDesktopWideMode(user?.id);
|
||||
|
||||
const loadStatements = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -102,8 +107,6 @@ export default function FinancesPage() {
|
||||
[statements],
|
||||
);
|
||||
const previousStatements = statements.slice(1);
|
||||
const showPayrollAdmin = canOpenPayrollAdmin(user);
|
||||
|
||||
const handleAcknowledged = (publicId: string, acknowledgement: PayrollAcknowledgement) => {
|
||||
setStatements((current) => current.map((statement) => (
|
||||
statement.public_id === publicId ? { ...statement, acknowledgement } : statement
|
||||
@ -114,8 +117,12 @@ export default function FinancesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell desktopWideMode={activeTab === "management"}>
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<AppShell
|
||||
desktopWideMode={activeTab === "management" && desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
<div className={`min-w-0 space-y-6 ${activeTab === "management" && desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}>
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<p className="app-card-caption">Финансы</p>
|
||||
{activeTab === "management" && showPayrollAdmin ? (
|
||||
@ -135,6 +142,16 @@ export default function FinancesPage() {
|
||||
>
|
||||
Расчётный лист
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedStatement(null);
|
||||
setActiveTab("work");
|
||||
}}
|
||||
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "work" ? "app-pill-active" : "app-pill"}`}
|
||||
>
|
||||
Выработка
|
||||
</button>
|
||||
{showPayrollAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
@ -148,14 +165,18 @@ export default function FinancesPage() {
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{activeTab === "management" && showPayrollAdmin ? (
|
||||
<PayrollAdminWorkspace
|
||||
embedded
|
||||
actionsTargetId="finance-management-actions"
|
||||
desktopWideMode={desktopWideMode}
|
||||
headerTargetId="finance-management-header"
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
/>
|
||||
) : activeTab === "work" ? (
|
||||
<PayrollDailyWorkSection />
|
||||
) : (
|
||||
<>
|
||||
{loading ? (
|
||||
@ -278,14 +299,15 @@ export default function FinancesPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<PayrollStatementDetailModal
|
||||
isOpen={Boolean(selectedStatement)}
|
||||
statement={selectedStatement}
|
||||
onClose={() => setSelectedStatement(null)}
|
||||
onAcknowledged={handleAcknowledged}
|
||||
/>
|
||||
<PayrollStatementDetailModal
|
||||
isOpen={Boolean(selectedStatement)}
|
||||
statement={selectedStatement}
|
||||
onClose={() => setSelectedStatement(null)}
|
||||
onAcknowledged={handleAcknowledged}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,21 +7,29 @@ import { useEffect } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PayrollAdminWorkspace } from "@/components/finance/admin/PayrollAdminWorkspace";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { usePayrollDesktopWideMode } from "@/hooks/usePayrollDesktopWideMode";
|
||||
import { canOpenPayrollAdmin } from "@/lib/permissions";
|
||||
|
||||
export default function PayrollManagePage() {
|
||||
const { user, loading } = useUser();
|
||||
const router = useRouter();
|
||||
const canManagePayroll = canOpenPayrollAdmin(user);
|
||||
const [desktopWideMode, changeDesktopWideMode] = usePayrollDesktopWideMode(user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user && !canManagePayroll) router.replace("/finances");
|
||||
}, [canManagePayroll, loading, router, user]);
|
||||
|
||||
return (
|
||||
<AppShell desktopWideMode>
|
||||
<AppShell
|
||||
desktopWideMode={desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
{!loading && canManagePayroll ? (
|
||||
<PayrollAdminWorkspace />
|
||||
<PayrollAdminWorkspace
|
||||
desktopWideMode={desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
/>
|
||||
) : (
|
||||
<section className="app-surface flex min-h-64 items-center justify-center rounded-2xl">
|
||||
<Loader2 className="app-accent-text animate-spin" size={26} />
|
||||
|
||||
@ -907,6 +907,32 @@ html[data-bs-theme="dark"] .shadow-xl {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
.app-table-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--accent-primary) 48%, var(--surface-tertiary)) var(--surface-secondary);
|
||||
-ms-overflow-style: auto;
|
||||
}
|
||||
|
||||
.app-table-scroll::-webkit-scrollbar {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.app-table-scroll::-webkit-scrollbar-track {
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.app-table-scroll::-webkit-scrollbar-thumb {
|
||||
border: 2px solid var(--surface-secondary);
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--accent-primary) 48%, var(--surface-tertiary));
|
||||
}
|
||||
|
||||
.app-table-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tasks-board-scroll,
|
||||
.tasks-column-scroll {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
|
||||
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ReactNode, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useNotifications } from "@/hooks/useApi";
|
||||
@ -42,6 +42,7 @@ type HeaderProps = {
|
||||
type LeftNavContentProps = {
|
||||
onNavigate?: () => void;
|
||||
compact?: boolean;
|
||||
documentSection?: "folders" | "regulations";
|
||||
};
|
||||
|
||||
type PaginatedResponse<T> = {
|
||||
@ -61,7 +62,8 @@ const navItems = [
|
||||
// { href: "/equipment", label: "Оборудование", icon: Monitor },
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
|
||||
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks },
|
||||
{ href: "/documents", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents },
|
||||
{ href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const },
|
||||
{ href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, documentSection: "regulations" as const },
|
||||
{ href: "/finances", label: "Финансы", icon: Wallet },
|
||||
];
|
||||
|
||||
@ -329,7 +331,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
|
||||
);
|
||||
}
|
||||
|
||||
function LeftNavContent({ onNavigate, compact = false }: LeftNavContentProps) {
|
||||
function LeftNavContent({ onNavigate, compact = false, documentSection = "folders" }: LeftNavContentProps) {
|
||||
const pathname = usePathname();
|
||||
const { user } = useUser();
|
||||
const { unreadCategoryCounts, markCategoryAsRead } = useNotifications();
|
||||
@ -351,30 +353,37 @@ function LeftNavContent({ onNavigate, compact = false }: LeftNavContentProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const isNavItemActive = (href: string) => pathname === href || (href !== "/" && pathname.startsWith(`${href}/`));
|
||||
const isNavItemActive = (href: string, itemDocumentSection?: "folders" | "regulations") => {
|
||||
const hrefPath = href.split("?", 1)[0];
|
||||
if (hrefPath === "/documents" && itemDocumentSection) {
|
||||
const isDocumentsPath = pathname === "/documents" || pathname.startsWith("/documents/");
|
||||
return isDocumentsPath && documentSection === itemDocumentSection;
|
||||
}
|
||||
return pathname === hrefPath || (hrefPath !== "/" && pathname.startsWith(`${hrefPath}/`));
|
||||
};
|
||||
|
||||
const navLinkClass = (href: string) =>
|
||||
`relative flex items-center rounded-lg transition ${compact ? "h-10 w-10 justify-center p-0" : "gap-3 px-3 py-2"} ${isNavItemActive(href) ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
|
||||
const navLinkClass = (href: string, itemDocumentSection?: "folders" | "regulations") =>
|
||||
`relative flex items-center rounded-lg transition ${compact ? "h-10 w-10 justify-center p-0" : "gap-3 px-3 py-2"} ${isNavItemActive(href, itemDocumentSection) ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
|
||||
}`;
|
||||
|
||||
const navIconClass = (href: string) => (isNavItemActive(href) ? "app-accent-text" : "app-text-muted");
|
||||
const navIconClass = (href: string, itemDocumentSection?: "folders" | "regulations") => (isNavItemActive(href, itemDocumentSection) ? "app-accent-text" : "app-text-muted");
|
||||
|
||||
return (
|
||||
<div className={`app-surface rounded-2xl ${compact ? "p-2" : "p-5"}`}>
|
||||
<div className="space-y-2 text-sm">
|
||||
{visibleNavItems.map(({ href, label, icon: Icon, category, autoReadOnNavigate }) => {
|
||||
{visibleNavItems.map(({ href, label, icon: Icon, category, autoReadOnNavigate, documentSection: itemDocumentSection }) => {
|
||||
const count = category ? unreadCategoryCounts[category] || 0 : 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={navLinkClass(href)}
|
||||
className={navLinkClass(href, itemDocumentSection)}
|
||||
onClick={() => handleNavClick(category, autoReadOnNavigate)}
|
||||
title={compact ? label : undefined}
|
||||
aria-label={compact ? label : undefined}
|
||||
>
|
||||
<Icon size={18} className={navIconClass(href)} />
|
||||
<Icon size={18} className={navIconClass(href, itemDocumentSection)} />
|
||||
<span className={compact ? "sr-only" : "flex-1"}>{label}</span>
|
||||
{count > 0 && (
|
||||
<span className={`flex items-center justify-center rounded-full bg-sky-500 text-[10px] font-bold text-white ${compact ? "absolute -right-1 -top-1 h-4 min-w-4 px-1" : "h-5 min-w-[20px] px-1.5"}`}>
|
||||
@ -389,6 +398,20 @@ function LeftNavContent({ onNavigate, compact = false }: LeftNavContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchAwareLeftNavContent(props: Omit<LeftNavContentProps, "documentSection">) {
|
||||
const searchParams = useSearchParams();
|
||||
const documentSection = searchParams.get("section") === "regulations" ? "regulations" : "folders";
|
||||
return <LeftNavContent {...props} documentSection={documentSection} />;
|
||||
}
|
||||
|
||||
function RoutedLeftNavContent(props: Omit<LeftNavContentProps, "documentSection">) {
|
||||
return (
|
||||
<Suspense fallback={<LeftNavContent {...props} />}>
|
||||
<SearchAwareLeftNavContent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftNav({ fixedDesktop = false, compact = false }: { fixedDesktop?: boolean; compact?: boolean }) {
|
||||
return (
|
||||
<aside className={`hidden flex-shrink-0 transition-[width] duration-200 lg:block ${compact ? "w-14" : "w-64"}`}>
|
||||
@ -399,7 +422,7 @@ function LeftNav({ fixedDesktop = false, compact = false }: { fixedDesktop?: boo
|
||||
: "lg:sticky lg:top-8 lg:max-h-[calc(100dvh-7.5rem)] lg:pb-2"
|
||||
}`}
|
||||
>
|
||||
<LeftNavContent compact={compact} />
|
||||
<RoutedLeftNavContent compact={compact} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@ -679,7 +702,7 @@ export function AppShell({ children, desktopWideMode = false, onDesktopWideModeC
|
||||
isOpen={isMobileLeftNavOpen}
|
||||
onClose={() => setIsMobileLeftNavOpen(false)}
|
||||
>
|
||||
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
|
||||
<RoutedLeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
|
||||
</MobileLeftDrawer>
|
||||
|
||||
<MobileCalendarDrawer
|
||||
|
||||
344
frontend/src/components/finance/PayrollDailyWorkSection.tsx
Normal file
344
frontend/src/components/finance/PayrollDailyWorkSection.tsx
Normal file
@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
ClipboardList,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
PayrollOwnDailyWorkEntry,
|
||||
PayrollOwnDailyWorkWorkspace,
|
||||
PayrollOwnWorkPeriod,
|
||||
} from "@/lib/api/finance";
|
||||
import { approvalStatusMeta, getPayrollAdminError, periodStatusMeta } from "@/lib/payroll-admin";
|
||||
import { formatPayrollDate, getPayrollPeriodRange } from "@/lib/payroll";
|
||||
|
||||
function localDateInput(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function defaultWorkDate(period: PayrollOwnWorkPeriod): string {
|
||||
const today = localDateInput();
|
||||
if (today < period.date_from) return period.date_from;
|
||||
if (today > period.date_to) return period.date_to;
|
||||
return today;
|
||||
}
|
||||
|
||||
function formatPoints(value: string | null | undefined): string {
|
||||
if (value == null || value === "") return "0";
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return value;
|
||||
return number.toLocaleString("ru-RU", { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function currentEntryForDate(
|
||||
entries: PayrollOwnDailyWorkEntry[],
|
||||
workDate: string,
|
||||
): PayrollOwnDailyWorkEntry | null {
|
||||
return entries.find((entry) => entry.work_date === workDate) || null;
|
||||
}
|
||||
|
||||
export function PayrollDailyWorkSection() {
|
||||
const [workspace, setWorkspace] = useState<PayrollOwnDailyWorkWorkspace | null>(null);
|
||||
const [selectedPeriodId, setSelectedPeriodId] = useState<number | null>(null);
|
||||
const [workDate, setWorkDate] = useState("");
|
||||
const [actualPoints, setActualPoints] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const fillForm = useCallback((
|
||||
payload: PayrollOwnDailyWorkWorkspace,
|
||||
preferredDate?: string,
|
||||
) => {
|
||||
const period = payload.periods.find((item) => item.id === payload.selected_period_id);
|
||||
if (!period) {
|
||||
setWorkDate("");
|
||||
setActualPoints("");
|
||||
setNote("");
|
||||
return;
|
||||
}
|
||||
const nextDate = preferredDate && preferredDate >= period.date_from && preferredDate <= period.date_to
|
||||
? preferredDate
|
||||
: defaultWorkDate(period);
|
||||
const entry = currentEntryForDate(payload.entries, nextDate);
|
||||
setWorkDate(nextDate);
|
||||
setActualPoints(entry?.actual_points || "");
|
||||
setNote(entry?.note || "");
|
||||
}, []);
|
||||
|
||||
const loadWorkspace = useCallback(async (
|
||||
periodId?: number,
|
||||
preferredDate?: string,
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await apiClient.getMyPayrollDailyWork(periodId);
|
||||
setWorkspace(payload);
|
||||
setSelectedPeriodId(payload.selected_period_id);
|
||||
fillForm(payload, preferredDate);
|
||||
} catch (loadError) {
|
||||
setError(getPayrollAdminError(loadError, "Не удалось загрузить ежедневную выработку."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fillForm]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkspace();
|
||||
}, [loadWorkspace]);
|
||||
|
||||
const selectedPeriod = useMemo(
|
||||
() => workspace?.periods.find((period) => period.id === selectedPeriodId) || null,
|
||||
[selectedPeriodId, workspace?.periods],
|
||||
);
|
||||
const selectedEntry = useMemo(
|
||||
() => currentEntryForDate(workspace?.entries || [], workDate),
|
||||
[workDate, workspace?.entries],
|
||||
);
|
||||
const maxWorkDate = selectedPeriod
|
||||
? [selectedPeriod.date_to, localDateInput()].sort()[0]
|
||||
: "";
|
||||
const numericActual = Number(actualPoints);
|
||||
const canSubmit = Boolean(
|
||||
selectedPeriod?.editable
|
||||
&& workDate
|
||||
&& workDate <= maxWorkDate
|
||||
&& actualPoints.trim()
|
||||
&& Number.isFinite(numericActual)
|
||||
&& numericActual >= 0,
|
||||
);
|
||||
|
||||
const selectWorkDate = (date: string) => {
|
||||
setWorkDate(date);
|
||||
const entry = currentEntryForDate(workspace?.entries || [], date);
|
||||
setActualPoints(entry?.actual_points || "");
|
||||
setNote(entry?.note || "");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedPeriod || !canSubmit) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await apiClient.saveMyPayrollDailyWorkEntry({
|
||||
period_id: selectedPeriod.id,
|
||||
work_date: workDate,
|
||||
actual_points: actualPoints,
|
||||
note: note.trim(),
|
||||
...(selectedEntry ? { expected_lock_version: selectedEntry.lock_version } : {}),
|
||||
});
|
||||
setNotice(result.operation === "created" ? "Выработка за день добавлена." : "Выработка за день обновлена.");
|
||||
await loadWorkspace(selectedPeriod.id, workDate);
|
||||
} catch (saveError) {
|
||||
setError(getPayrollAdminError(saveError, "Не удалось сохранить ежедневную выработку."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !workspace) {
|
||||
return (
|
||||
<section className="app-surface flex min-h-64 items-center justify-center rounded-2xl">
|
||||
<Loader2 className="app-accent-text animate-spin" size={28} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspace?.periods.length) {
|
||||
return (
|
||||
<section className="app-surface rounded-2xl p-5 sm:p-8">
|
||||
<div className="app-surface-muted py-10 text-center">
|
||||
<ClipboardList className="app-text-muted mx-auto" size={28} />
|
||||
<p className="app-text-muted mt-3 text-sm">Расчётные периоды пока не созданы.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const aggregate = selectedPeriod?.record || null;
|
||||
const summary = selectedPeriod?.summary || null;
|
||||
const aggregateStatus = aggregate ? approvalStatusMeta[aggregate.status] : null;
|
||||
const periodStatus = selectedPeriod ? periodStatusMeta[selectedPeriod.status] : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="app-accent-text" size={18} />
|
||||
<h2 className="text-base font-semibold text-[var(--foreground)]">
|
||||
{selectedEntry ? "Изменить запись за день" : "Добавить запись за день"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<label>
|
||||
<span className="app-field-label">Дата</span>
|
||||
<input
|
||||
type="date"
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
min={selectedPeriod?.date_from}
|
||||
max={maxWorkDate}
|
||||
value={workDate}
|
||||
disabled={!selectedPeriod?.editable}
|
||||
required
|
||||
onChange={(event) => selectWorkDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Норма баллов</span>
|
||||
<input
|
||||
type="text"
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
value={formatPoints(selectedEntry?.target_points || workspace.daily_target_points)}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Фактические баллы</span>
|
||||
<input
|
||||
type="number"
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
value={actualPoints}
|
||||
disabled={!selectedPeriod?.editable}
|
||||
required
|
||||
onChange={(event) => setActualPoints(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-4 block">
|
||||
<span className="app-field-label">Комментарий</span>
|
||||
<textarea
|
||||
className="app-input min-h-20 w-full resize-y rounded-lg px-3 py-2.5 text-sm"
|
||||
value={note}
|
||||
disabled={!selectedPeriod?.editable}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!selectedPeriod?.editable ? (
|
||||
<div className="app-feedback-warning mt-4 rounded-lg px-3 py-2.5 text-sm">
|
||||
Период закрыт для изменения выработки.
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div className="app-feedback-danger mt-4 rounded-lg px-3 py-2.5 text-sm">{error}</div> : null}
|
||||
{notice ? <div className="app-feedback-success mt-4 rounded-lg px-3 py-2.5 text-sm">{notice}</div> : null}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<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:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canSubmit || saving}
|
||||
>
|
||||
{saving ? <Loader2 className="animate-spin" size={16} /> : selectedEntry ? <Save size={16} /> : <Check size={16} />}
|
||||
{selectedEntry ? "Сохранить изменения" : "Добавить выработку"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="app-card-caption">Выработка</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-[var(--foreground)]">Ежедневные баллы</h2>
|
||||
</div>
|
||||
<select
|
||||
className="app-select min-w-0 rounded-lg px-3 py-2.5 text-sm sm:min-w-56"
|
||||
value={selectedPeriodId || ""}
|
||||
onChange={(event) => void loadWorkspace(Number(event.target.value))}
|
||||
aria-label="Расчётный период выработки"
|
||||
>
|
||||
{workspace.periods.map((period) => (
|
||||
<option key={period.id} value={period.id}>{period.name || period.code}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedPeriod ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="app-surface-muted rounded-xl p-3">
|
||||
<p className="app-text-muted text-xs">Период</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">{getPayrollPeriodRange(selectedPeriod)}</p>
|
||||
{periodStatus ? <span className={`app-status-pill mt-2 ${periodStatus.className}`}>{periodStatus.label}</span> : null}
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl p-3">
|
||||
<p className="app-text-muted text-xs">Норма за период</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--foreground)]">{formatPoints(summary?.target_points)}</p>
|
||||
<p className="app-text-muted mt-1 text-[11px]">
|
||||
{summary?.target_source === "saved_record" ? "Из сохранённой записи" : `По графику: ${summary?.workdays_count || 0} дн.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="app-text-muted text-xs">Фактически за период</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--foreground)]">
|
||||
{formatPoints(summary?.actual_points)} / {formatPoints(summary?.target_points)}
|
||||
</p>
|
||||
</div>
|
||||
{aggregateStatus ? <span className={`app-status-pill ${aggregateStatus.className}`}>{aggregateStatus.label}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-[var(--foreground)]">Записи за период</h2>
|
||||
<span className="app-counter h-6 min-w-6 px-1.5 text-xs">{workspace.entries.length}</span>
|
||||
</div>
|
||||
{workspace.entries.length ? (
|
||||
<div className="space-y-2">
|
||||
{workspace.entries.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.id}
|
||||
className={`app-surface-muted grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl p-3 text-left transition hover:border-[var(--border-strong)] ${entry.work_date === workDate ? "app-selected" : ""}`}
|
||||
onClick={() => selectWorkDate(entry.work_date)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-[var(--foreground)]">{formatPayrollDate(entry.work_date)}</span>
|
||||
<span className="app-text-muted mt-1 block truncate text-xs">{entry.note || "Без комментария"}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-3">
|
||||
<span className="text-right">
|
||||
<span className="block text-sm font-semibold text-[var(--foreground)]">{formatPoints(entry.actual_points)}</span>
|
||||
<span className="app-text-muted block text-[11px]">из {formatPoints(entry.target_points)}</span>
|
||||
</span>
|
||||
{selectedPeriod?.editable ? <Pencil className="app-text-muted" size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
|
||||
<p className="app-text-muted text-sm">За выбранный период записей пока нет.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Check, Loader2 } from "lucide-react";
|
||||
import { AlertTriangle, Check, Loader2, Search } from "lucide-react";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
@ -10,23 +10,34 @@ import type {
|
||||
PayrollAdminPayRate,
|
||||
PayrollAdminPeriod,
|
||||
PayrollAdminWorkRecord,
|
||||
PayrollBulkPayRateResult,
|
||||
PayrollBulkPayRateWrite,
|
||||
PayrollBulkPointRateMode,
|
||||
PayrollBulkPointRateResult,
|
||||
PayrollBulkPointRateWrite,
|
||||
PayrollComponent,
|
||||
PayrollInputLineWrite,
|
||||
PayrollPayRateWrite,
|
||||
PayrollPeriodWrite,
|
||||
PayrollWorkRecordWrite,
|
||||
} from "@/lib/api/finance";
|
||||
import { getPayrollAdminError, isPayrollAdminStaleConflict } from "@/lib/payroll-admin";
|
||||
import {
|
||||
getDefaultPayrollPeriodForm,
|
||||
getPayrollAdminError,
|
||||
isPayrollAdminStaleConflict,
|
||||
} from "@/lib/payroll-admin";
|
||||
|
||||
const inputClass = "app-input w-full rounded-lg px-3 py-2.5 text-sm";
|
||||
const selectClass = "app-select w-full rounded-lg px-3 py-2.5 text-sm";
|
||||
|
||||
function ModalActions({
|
||||
busy,
|
||||
disabled = false,
|
||||
onClose,
|
||||
submitLabel,
|
||||
}: {
|
||||
busy: boolean;
|
||||
disabled?: boolean;
|
||||
onClose: () => void;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
@ -43,7 +54,7 @@ function ModalActions({
|
||||
<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}
|
||||
disabled={busy || disabled}
|
||||
>
|
||||
{busy ? <Loader2 className="animate-spin" size={16} /> : <Check size={16} />}
|
||||
{submitLabel}
|
||||
@ -86,6 +97,7 @@ async function submitModalForm(
|
||||
export function PayrollPeriodFormModal({
|
||||
isOpen,
|
||||
period,
|
||||
periods,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onSaved,
|
||||
@ -93,6 +105,7 @@ export function PayrollPeriodFormModal({
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
period?: PayrollAdminPeriod | null;
|
||||
periods: PayrollAdminPeriod[];
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: PayrollPeriodWrite) => Promise<unknown>;
|
||||
onSaved: () => void | Promise<void>;
|
||||
@ -105,14 +118,7 @@ export function PayrollPeriodFormModal({
|
||||
date_to: period.date_to,
|
||||
pay_date: period.pay_date,
|
||||
currency: period.currency,
|
||||
} : {
|
||||
code: "",
|
||||
name: "",
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
pay_date: null,
|
||||
currency: "RUB",
|
||||
});
|
||||
} : getDefaultPayrollPeriodForm(periods));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -231,7 +237,8 @@ export function PayrollRateFormModal({
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Цена балла сверх нормы</span>
|
||||
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.01" value={pointRate} required onChange={(event) => setPointRate(event.target.value)} />
|
||||
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.01" value={pointRate} placeholder="0" onChange={(event) => setPointRate(event.target.value)} />
|
||||
<span className="app-text-muted mt-1 block text-xs">Если оставить пустым, сверх нормы доплата не начисляется.</span>
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Действует с</span>
|
||||
@ -253,6 +260,466 @@ export function PayrollRateFormModal({
|
||||
);
|
||||
}
|
||||
|
||||
export function PayrollBulkPointRateModal({
|
||||
isOpen,
|
||||
period,
|
||||
rates,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onSaved,
|
||||
onStale,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
period: PayrollAdminPeriod;
|
||||
rates: PayrollAdminPayRate[];
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: PayrollBulkPointRateWrite) => Promise<PayrollBulkPointRateResult>;
|
||||
onSaved: (result: PayrollBulkPointRateResult) => void | Promise<void>;
|
||||
onStale?: () => void | Promise<void>;
|
||||
}) {
|
||||
const employeeOptions = useMemo(() => {
|
||||
const grouped = new Map<number, PayrollAdminPayRate[]>();
|
||||
for (const rate of rates) {
|
||||
if (rate.status === "voided") continue;
|
||||
const records = grouped.get(rate.employee.id) || [];
|
||||
records.push(rate);
|
||||
grouped.set(rate.employee.id, records);
|
||||
}
|
||||
return Array.from(grouped.values()).map((records) => {
|
||||
const current = [...records].sort((left, right) => {
|
||||
const dateOrder = right.effective_from.localeCompare(left.effective_from);
|
||||
return dateOrder || right.revision - left.revision || right.id - left.id;
|
||||
})[0];
|
||||
const pointRates = new Set(records.map((record) => record.point_rate));
|
||||
return {
|
||||
employee: current.employee,
|
||||
pointRate: pointRates.size === 1 ? current.point_rate : null,
|
||||
hasDraft: records.some((record) => record.status === "draft"),
|
||||
rateCount: new Set(records.map((record) => record.effective_from)).size,
|
||||
};
|
||||
}).sort((left, right) => (
|
||||
left.employee.display_name.localeCompare(right.employee.display_name, "ru")
|
||||
));
|
||||
}, [rates]);
|
||||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<number>>(
|
||||
() => new Set(employeeOptions.map((option) => option.employee.id)),
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [mode, setMode] = useState<PayrollBulkPointRateMode>("fixed");
|
||||
const [pointRate, setPointRate] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||||
if (!needle) return employeeOptions;
|
||||
return employeeOptions.filter(({ employee }) => (
|
||||
`${employee.display_name} ${employee.position || ""} ${employee.department || ""}`
|
||||
.toLocaleLowerCase("ru-RU")
|
||||
.includes(needle)
|
||||
));
|
||||
}, [employeeOptions, search]);
|
||||
const allSelected = employeeOptions.length > 0
|
||||
&& employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id));
|
||||
const numericPointRate = Number(pointRate);
|
||||
const fixedValueValid = pointRate.trim() === ""
|
||||
|| (Number.isFinite(numericPointRate) && numericPointRate >= 0);
|
||||
const canSubmit = selectedEmployeeIds.size > 0
|
||||
&& (mode === "in_norm" || fixedValueValid)
|
||||
&& reason.trim().length > 0;
|
||||
|
||||
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 = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
let result: PayrollBulkPointRateResult | null = null;
|
||||
void submitModalForm(async () => {
|
||||
result = await onSubmit({
|
||||
employee_ids: Array.from(selectedEmployeeIds),
|
||||
mode,
|
||||
point_rate: mode === "fixed" ? pointRate.trim() || "0" : null,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
}, {
|
||||
fallback: "Не удалось массово задать цену балла.",
|
||||
setBusy,
|
||||
setError,
|
||||
onSaved: async () => {
|
||||
if (result) await onSaved(result);
|
||||
},
|
||||
onStale,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Цена балла сверх нормы" size="lg">
|
||||
<form onSubmit={submit}>
|
||||
<div className="app-selected rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
|
||||
<p className="app-text-muted mt-1 text-xs leading-relaxed">
|
||||
Черновики будут обновлены. Для утверждённых ставок система создаст новые черновые версии.
|
||||
</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 ${mode === "fixed" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
|
||||
<span className="flex items-start gap-2.5">
|
||||
<input type="radio" name="point-rate-mode" value="fixed" checked={mode === "fixed"} onChange={() => setMode("fixed")} />
|
||||
<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 ${mode === "in_norm" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
|
||||
<span className="flex items-start gap-2.5">
|
||||
<input type="radio" name="point-rate-mode" value="in_norm" checked={mode === "in_norm"} onChange={() => setMode("in_norm")} />
|
||||
<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="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className="app-field-label">Цена сверх нормы</span>
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={pointRate}
|
||||
disabled={mode === "in_norm"}
|
||||
placeholder={mode === "in_norm" ? "Рассчитается отдельно" : "0"}
|
||||
onChange={(event) => setPointRate(event.target.value)}
|
||||
/>
|
||||
{mode === "fixed" ? <span className="app-text-muted mt-1 block text-xs">Пустое поле будет сохранено как 0.</span> : null}
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Выбрано сотрудников</span>
|
||||
<div className="app-input flex min-h-10 items-center rounded-lg px-3 text-sm font-medium">
|
||||
{selectedEmployeeIds.size} из {employeeOptions.length}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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(
|
||||
allSelected ? new Set() : new Set(employeeOptions.map((option) => option.employee.id)),
|
||||
)}
|
||||
>
|
||||
{allSelected ? "Снять выбор" : "Выбрать всех"}
|
||||
</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-64 overflow-y-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
{filteredOptions.length ? filteredOptions.map((option) => (
|
||||
<label
|
||||
key={option.employee.id}
|
||||
className="flex cursor-pointer items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0 hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEmployeeIds.has(option.employee.id)}
|
||||
onChange={() => toggleEmployee(option.employee.id)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{option.employee.display_name}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block truncate text-xs">
|
||||
{option.employee.position || option.employee.department || "Сотрудник"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block text-xs font-medium text-[var(--foreground)]">
|
||||
{option.pointRate === null ? `${option.rateCount} ставки` : `${option.pointRate} ${period.currency}`}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block text-[11px]">
|
||||
{mode === "in_norm" ? "Станет как цена в норме" : option.hasDraft ? "Обновится черновик" : "Новая версия"}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)) : (
|
||||
<p className="app-text-muted px-4 py-8 text-center text-sm">Сотрудники не найдены.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="app-field-label">Основание изменения *</span>
|
||||
<textarea
|
||||
className={`${inputClass} min-h-20 resize-y`}
|
||||
value={reason}
|
||||
required
|
||||
placeholder="Укажите основание для массового изменения"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<FormError message={error} />
|
||||
<ModalActions
|
||||
busy={busy}
|
||||
disabled={!canSubmit}
|
||||
onClose={onClose}
|
||||
submitLabel={`Применить к ${selectedEmployeeIds.size}`}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function PayrollBulkPayRateModal({
|
||||
isOpen,
|
||||
period,
|
||||
employees,
|
||||
rates,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onSaved,
|
||||
onStale,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
period: PayrollAdminPeriod;
|
||||
employees: PayrollAdminEmployee[];
|
||||
rates: PayrollAdminPayRate[];
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: PayrollBulkPayRateWrite) => Promise<PayrollBulkPayRateResult>;
|
||||
onSaved: (result: PayrollBulkPayRateResult) => void | Promise<void>;
|
||||
onStale?: () => void | Promise<void>;
|
||||
}) {
|
||||
const employeeOptions = useMemo(() => employees.map((employee) => {
|
||||
const relevantRates = rates
|
||||
.filter((rate) => (
|
||||
rate.employee.id === employee.id
|
||||
&& rate.status !== "voided"
|
||||
&& rate.effective_from <= period.date_to
|
||||
))
|
||||
.sort((left, right) => {
|
||||
const dateOrder = right.effective_from.localeCompare(left.effective_from);
|
||||
return dateOrder || right.revision - left.revision || right.id - left.id;
|
||||
});
|
||||
return { employee, rates: relevantRates };
|
||||
}).sort((left, right) => (
|
||||
left.employee.display_name.localeCompare(right.employee.display_name, "ru")
|
||||
)), [employees, period.date_to, rates]);
|
||||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<number>>(
|
||||
() => new Set(employeeOptions.map((option) => option.employee.id)),
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [effectiveFrom, setEffectiveFrom] = useState(period.date_from);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||||
if (!needle) return employeeOptions;
|
||||
return employeeOptions.filter(({ employee }) => (
|
||||
`${employee.display_name} ${employee.position || ""} ${employee.department || ""}`
|
||||
.toLocaleLowerCase("ru-RU")
|
||||
.includes(needle)
|
||||
));
|
||||
}, [employeeOptions, search]);
|
||||
const allSelected = employeeOptions.length > 0
|
||||
&& employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id));
|
||||
const numericAmount = Number(amount);
|
||||
const canSubmit = selectedEmployeeIds.size > 0
|
||||
&& amount.trim() !== ""
|
||||
&& Number.isFinite(numericAmount)
|
||||
&& numericAmount > 0
|
||||
&& effectiveFrom >= period.date_from
|
||||
&& effectiveFrom <= period.date_to
|
||||
&& reason.trim().length > 0;
|
||||
|
||||
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 = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
let result: PayrollBulkPayRateResult | null = null;
|
||||
void submitModalForm(async () => {
|
||||
result = await onSubmit({
|
||||
employee_ids: Array.from(selectedEmployeeIds),
|
||||
amount,
|
||||
effective_from: effectiveFrom,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
}, {
|
||||
fallback: "Не удалось массово добавить ставку.",
|
||||
setBusy,
|
||||
setError,
|
||||
onSaved: async () => {
|
||||
if (result) await onSaved(result);
|
||||
},
|
||||
onStale,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Массовое добавление ставки" size="lg">
|
||||
<form onSubmit={submit}>
|
||||
<div className="app-selected rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
|
||||
<p className="app-text-muted mt-1 text-xs leading-relaxed">
|
||||
Новые ставки сохранятся черновиками. Для утверждённой ставки на выбранную дату будет создана новая версия.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className="app-field-label">Ставка / оклад *</span>
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
inputMode="decimal"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
required
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">Действует с *</span>
|
||||
<input
|
||||
type="date"
|
||||
className={inputClass}
|
||||
min={period.date_from}
|
||||
max={period.date_to}
|
||||
value={effectiveFrom}
|
||||
required
|
||||
onChange={(event) => setEffectiveFrom(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="app-field-label mb-0">
|
||||
Сотрудники · {selectedEmployeeIds.size} из {employeeOptions.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-ghost rounded-md px-2 py-1 text-xs"
|
||||
onClick={() => setSelectedEmployeeIds(
|
||||
allSelected ? new Set() : new Set(employeeOptions.map((option) => option.employee.id)),
|
||||
)}
|
||||
>
|
||||
{allSelected ? "Снять выбор" : "Выбрать всех"}
|
||||
</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-64 overflow-y-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
{filteredOptions.length ? filteredOptions.map((option) => {
|
||||
const relevant = option.rates.filter((rate) => rate.effective_from <= effectiveFrom);
|
||||
const exact = relevant.find((rate) => rate.effective_from === effectiveFrom);
|
||||
const inherited = relevant[0];
|
||||
const action = exact?.status === "draft"
|
||||
? "Обновится черновик"
|
||||
: exact?.status === "approved"
|
||||
? "Новая версия"
|
||||
: "Новая ставка";
|
||||
return (
|
||||
<label
|
||||
key={option.employee.id}
|
||||
className="flex cursor-pointer items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0 hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEmployeeIds.has(option.employee.id)}
|
||||
onChange={() => toggleEmployee(option.employee.id)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{option.employee.display_name}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block truncate text-xs">
|
||||
{option.employee.position || option.employee.department || "Сотрудник"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block text-xs font-medium text-[var(--foreground)]">
|
||||
{inherited ? `${inherited.point_rate} ${period.currency} / балл сверх нормы` : "Цена сверх нормы: 0"}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block text-[11px]">{action}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}) : (
|
||||
<p className="app-text-muted px-4 py-8 text-center text-sm">Сотрудники не найдены.</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="app-text-muted mt-2 text-xs">
|
||||
Цена балла сверх нормы наследуется из последней ставки сотрудника; если ставки ещё не было, будет установлено 0.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="app-field-label">Основание изменения *</span>
|
||||
<textarea
|
||||
className={`${inputClass} min-h-20 resize-y`}
|
||||
value={reason}
|
||||
required
|
||||
placeholder="Укажите основание для массового добавления"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<FormError message={error} />
|
||||
<ModalActions
|
||||
busy={busy}
|
||||
disabled={!canSubmit}
|
||||
onClose={onClose}
|
||||
submitLabel={`Применить к ${selectedEmployeeIds.size}`}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function PayrollWorkRecordFormModal({
|
||||
isOpen,
|
||||
period,
|
||||
@ -275,7 +742,7 @@ export function PayrollWorkRecordFormModal({
|
||||
const fromAttendance = record?.source === "attendance";
|
||||
const [form, setForm] = useState(() => ({
|
||||
employee_id: record ? String(record.employee.id) : "",
|
||||
target_points: record?.target_points || "",
|
||||
target_points: record?.target_points_overridden ? record.target_points : "",
|
||||
actual_points: record?.actual_points || "",
|
||||
expected_point_amount: record?.expected_point_amount || "",
|
||||
expected_gross: record?.expected_gross || "",
|
||||
@ -292,7 +759,7 @@ export function PayrollWorkRecordFormModal({
|
||||
void submitModalForm(() => onSubmit({
|
||||
period_id: period.id,
|
||||
employee_id: Number(form.employee_id),
|
||||
target_points: form.target_points,
|
||||
target_points: form.target_points.trim() || null,
|
||||
actual_points: form.actual_points,
|
||||
expected_point_amount: optionalMoney(form.expected_point_amount),
|
||||
expected_gross: optionalMoney(form.expected_gross),
|
||||
@ -321,7 +788,7 @@ export function PayrollWorkRecordFormModal({
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">{fromAttendance ? "Норма, часы" : "Норма баллов"}</span>
|
||||
<input type="number" className={inputClass} inputMode="decimal" min="0.0001" step="0.0001" required value={form.target_points} onChange={(event) => setForm({ ...form, target_points: event.target.value })} />
|
||||
<input type="number" className={inputClass} inputMode="decimal" min="0.0001" step="0.0001" placeholder="Рассчитается автоматически" value={form.target_points} onChange={(event) => setForm({ ...form, target_points: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="app-field-label">{fromAttendance ? "Отработано, часы" : "Фактические баллы"}</span>
|
||||
|
||||
@ -130,14 +130,14 @@ export function PayrollRatesTable({
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
|
||||
<div className="md:min-w-[900px]">
|
||||
<div className="app-surface-muted hidden grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
|
||||
<span>Сотрудник</span><span>Оклад</span><span>Цена балла</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
<span>Сотрудник</span><span>Оклад</span><span>Цена сверх нормы</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] md:items-center">
|
||||
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Оклад</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Цена балла</span><span className="text-sm text-[var(--foreground)]">{formatPayrollMoney(record.point_rate, record.currency)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Цена сверх нормы</span><span className="text-sm text-[var(--foreground)]">{formatPayrollMoney(record.point_rate, record.currency)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">С</span><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
|
||||
@ -12,6 +12,8 @@ import {
|
||||
Clock3,
|
||||
FileCheck2,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
@ -25,6 +27,8 @@ import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
PayrollConfirmModal,
|
||||
PayrollBulkPayRateModal,
|
||||
PayrollBulkPointRateModal,
|
||||
PayrollInputLineFormModal,
|
||||
PayrollPeriodFormModal,
|
||||
PayrollRateFormModal,
|
||||
@ -37,7 +41,9 @@ import {
|
||||
PayrollRatesTable,
|
||||
PayrollWorkRecordsTable,
|
||||
} from "@/components/finance/admin/PayrollAdminTables";
|
||||
import { PayrollPeriodTableView } from "@/components/finance/admin/PayrollPeriodTable";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { usePayrollAdminTab } from "@/hooks/usePayrollTabs";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
PayrollAdminInputLine,
|
||||
@ -47,8 +53,12 @@ import type {
|
||||
PayrollAdminWorkspace as WorkspacePayload,
|
||||
PayrollAdminWorkRecord,
|
||||
PayrollApprovalStatus,
|
||||
PayrollBulkPayRateResult,
|
||||
PayrollBulkPointRateResult,
|
||||
PayrollInputLineWrite,
|
||||
PayrollPayRateWrite,
|
||||
PayrollPeriodTable,
|
||||
PayrollPeriodTableRow,
|
||||
PayrollPeriodWrite,
|
||||
PayrollWorkRecordWrite,
|
||||
} from "@/lib/api/finance";
|
||||
@ -57,10 +67,10 @@ import {
|
||||
getPrimaryPayrollRunAction,
|
||||
normalizePayrollAdminList,
|
||||
PAYROLL_ADMIN_TABS,
|
||||
PAYROLL_SELECTED_PERIOD_STORAGE_PREFIX,
|
||||
payrollRunActionLabels,
|
||||
periodStatusMeta,
|
||||
runStatusMeta,
|
||||
type PayrollAdminTab,
|
||||
type PayrollRunAction,
|
||||
} from "@/lib/payroll-admin";
|
||||
import { buildPayrollAttendanceApplyNotice } from "@/lib/payroll-attendance";
|
||||
@ -110,6 +120,8 @@ function TableToolbar({
|
||||
onStatus,
|
||||
onAdd,
|
||||
onFillFromAttendance,
|
||||
onBulkPayRate,
|
||||
onBulkPointRate,
|
||||
addLabel,
|
||||
}: {
|
||||
search: string;
|
||||
@ -118,8 +130,31 @@ function TableToolbar({
|
||||
onStatus: (value: "" | PayrollApprovalStatus) => void;
|
||||
onAdd?: () => void;
|
||||
onFillFromAttendance?: () => void;
|
||||
onBulkPayRate?: () => void;
|
||||
onBulkPointRate?: () => void;
|
||||
addLabel: string;
|
||||
}) {
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!addMenuOpen) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (addMenuRef.current && !addMenuRef.current.contains(event.target as Node)) {
|
||||
setAddMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setAddMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", closeOnOutsideClick);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeOnOutsideClick);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [addMenuOpen]);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
@ -135,9 +170,54 @@ function TableToolbar({
|
||||
</button>
|
||||
) : null}
|
||||
{onAdd ? (
|
||||
<button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
|
||||
<Plus size={16} /> {addLabel}
|
||||
</button>
|
||||
<div ref={addMenuRef} className="relative flex shrink-0 items-center gap-1">
|
||||
<button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
|
||||
<Plus size={16} /> {addLabel}
|
||||
</button>
|
||||
{onBulkPayRate || onBulkPointRate ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-ghost flex h-10 w-8 items-center justify-center rounded-md"
|
||||
onClick={() => setAddMenuOpen((current) => !current)}
|
||||
aria-label="Дополнительные действия со ставками"
|
||||
aria-expanded={addMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
title="Дополнительные действия"
|
||||
>
|
||||
<ChevronRight size={15} className={`transition-transform duration-200 ${addMenuOpen ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
) : null}
|
||||
{addMenuOpen && (onBulkPayRate || onBulkPointRate) ? (
|
||||
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-64 rounded-xl py-1.5" role="menu">
|
||||
{onBulkPayRate ? (
|
||||
<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);
|
||||
onBulkPayRate();
|
||||
}}
|
||||
>
|
||||
<UsersRound className="app-text-muted" size={15} />
|
||||
Массово добавить ставку
|
||||
</button>
|
||||
) : null}
|
||||
{onBulkPointRate ? (
|
||||
<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);
|
||||
onBulkPointRate();
|
||||
}}
|
||||
>
|
||||
<Banknote className="app-text-muted" size={15} />
|
||||
Массово задать цену сверх нормы
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@ -168,21 +248,30 @@ function RunCard({ run, current = false }: { run: PayrollAdminRun; current?: boo
|
||||
|
||||
type PayrollAdminWorkspaceProps = {
|
||||
actionsTargetId?: string;
|
||||
desktopWideMode?: boolean;
|
||||
embedded?: boolean;
|
||||
headerTargetId?: string;
|
||||
onDesktopWideModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, headerTargetId }: PayrollAdminWorkspaceProps) {
|
||||
export function PayrollAdminWorkspace({
|
||||
actionsTargetId,
|
||||
desktopWideMode = false,
|
||||
embedded = false,
|
||||
headerTargetId,
|
||||
onDesktopWideModeChange,
|
||||
}: PayrollAdminWorkspaceProps) {
|
||||
const { user } = useUser();
|
||||
const [workspace, setWorkspace] = useState<WorkspacePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<PayrollAdminTab>("readiness");
|
||||
const [tab, setTab] = usePayrollAdminTab(user?.id);
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<"" | PayrollApprovalStatus>("");
|
||||
const [rates, setRates] = useState<PayrollAdminPayRate[]>([]);
|
||||
const [workRecords, setWorkRecords] = useState<PayrollAdminWorkRecord[]>([]);
|
||||
const [inputLines, setInputLines] = useState<PayrollAdminInputLine[]>([]);
|
||||
const [periodTable, setPeriodTable] = useState<PayrollPeriodTable | null>(null);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [tableError, setTableError] = useState<string | null>(null);
|
||||
const [tableVersion, setTableVersion] = useState(0);
|
||||
@ -191,6 +280,8 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
const [periodModalOpen, setPeriodModalOpen] = useState(false);
|
||||
const [editingPeriod, setEditingPeriod] = useState<PayrollAdminPeriod | null>(null);
|
||||
const [rateModalOpen, setRateModalOpen] = useState(false);
|
||||
const [bulkPayRateModalOpen, setBulkPayRateModalOpen] = useState(false);
|
||||
const [bulkPointRateModalOpen, setBulkPointRateModalOpen] = useState(false);
|
||||
const [editingRate, setEditingRate] = useState<PayrollAdminPayRate | null>(null);
|
||||
const [workModalOpen, setWorkModalOpen] = useState(false);
|
||||
const [editingWork, setEditingWork] = useState<PayrollAdminWorkRecord | null>(null);
|
||||
@ -202,24 +293,59 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
|
||||
const selectedPeriod = workspace?.selected_period || null;
|
||||
|
||||
const loadWorkspace = useCallback(async (periodId?: number, silent = false) => {
|
||||
const loadWorkspace = useCallback(async (
|
||||
periodId?: number,
|
||||
silent = false,
|
||||
fallbackToLatest = false,
|
||||
) => {
|
||||
if (!silent) setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const payload = await apiClient.getPayrollAdminWorkspace(periodId);
|
||||
setWorkspace(payload);
|
||||
} catch (error) {
|
||||
if (fallbackToLatest && periodId !== undefined) {
|
||||
try {
|
||||
const payload = await apiClient.getPayrollAdminWorkspace();
|
||||
setWorkspace(payload);
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
setLoadError(getPayrollAdminError(fallbackError, "Не удалось загрузить управление зарплатой."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLoadError(getPayrollAdminError(error, "Не удалось загрузить управление зарплатой."));
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadWorkspace(); }, [loadWorkspace]);
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
const storedValue = window.localStorage.getItem(
|
||||
`${PAYROLL_SELECTED_PERIOD_STORAGE_PREFIX}.${user.id}`,
|
||||
);
|
||||
const storedPeriodId = storedValue ? Number(storedValue) : undefined;
|
||||
void loadWorkspace(
|
||||
storedPeriodId && Number.isInteger(storedPeriodId) && storedPeriodId > 0
|
||||
? storedPeriodId
|
||||
: undefined,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}, [loadWorkspace, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id || !selectedPeriod?.id) return;
|
||||
window.localStorage.setItem(
|
||||
`${PAYROLL_SELECTED_PERIOD_STORAGE_PREFIX}.${user.id}`,
|
||||
String(selectedPeriod.id),
|
||||
);
|
||||
}, [selectedPeriod?.id, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPeriod) {
|
||||
setRates([]); setWorkRecords([]); setInputLines([]);
|
||||
setRates([]); setWorkRecords([]); setInputLines([]); setPeriodTable(null);
|
||||
return;
|
||||
}
|
||||
if (tab === "readiness") return;
|
||||
@ -232,6 +358,10 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
setRates([]); setWorkRecords([]); setInputLines([]); setTableError(null); setTableLoading(false);
|
||||
return;
|
||||
}
|
||||
if (tab === "summary" && !workspace?.permissions.view_all) {
|
||||
setPeriodTable(null); setTableError(null); setTableLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setTableLoading(true);
|
||||
@ -239,7 +369,21 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
const params = { period_id: selectedPeriod.id };
|
||||
const load = async () => {
|
||||
try {
|
||||
if (tab === "rates") {
|
||||
if (tab === "summary") {
|
||||
setPeriodTable(null);
|
||||
const [tablePayload, ratesPayload, workPayload, inputsPayload] = await Promise.all([
|
||||
apiClient.getPayrollAdminPeriodTable(selectedPeriod.id),
|
||||
apiClient.getPayrollAdminPayRates(params),
|
||||
apiClient.getPayrollAdminWorkRecords(params),
|
||||
apiClient.getPayrollAdminInputLines(params),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setPeriodTable(tablePayload);
|
||||
setRates(normalizePayrollAdminList(ratesPayload));
|
||||
setWorkRecords(normalizePayrollAdminList(workPayload));
|
||||
setInputLines(normalizePayrollAdminList(inputsPayload));
|
||||
}
|
||||
} else if (tab === "rates") {
|
||||
const payload = await apiClient.getPayrollAdminPayRates(params);
|
||||
if (!cancelled) setRates(normalizePayrollAdminList(payload));
|
||||
} else if (tab === "work") {
|
||||
@ -284,7 +428,7 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
}, [loadWorkspace, selectedPeriod?.id]);
|
||||
|
||||
const handleStale = useCallback(async () => {
|
||||
setPeriodModalOpen(false); setRateModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null);
|
||||
setPeriodModalOpen(false); setRateModalOpen(false); setBulkPayRateModalOpen(false); setBulkPointRateModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null);
|
||||
await refreshView("Данные изменились в другой сессии. Экран обновлён — повторите действие.");
|
||||
}, [refreshView]);
|
||||
|
||||
@ -358,7 +502,8 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
const permissions = workspace.permissions;
|
||||
const canOpenInputTabs = permissions.manage_inputs || permissions.approve_inputs || permissions.view_all;
|
||||
const visibleTabs = PAYROLL_ADMIN_TABS.filter((item) => (
|
||||
!["rates", "work", "inputs"].includes(item.value) || canOpenInputTabs
|
||||
(item.value !== "summary" || permissions.view_all)
|
||||
&& (!["rates", "work", "inputs"].includes(item.value) || canOpenInputTabs)
|
||||
));
|
||||
const primaryAction = selectedPeriod ? getPrimaryPayrollRunAction(workspace.current_run, selectedPeriod.status, permissions, workspace.readiness.calculation.ready, user?.id) : null;
|
||||
const canReturn = Boolean(workspace.current_run && ["calculated", "review", "approved"].includes(workspace.current_run.status) && (workspace.current_run.status === "calculated" ? permissions.calculate : permissions.approve_run));
|
||||
@ -388,6 +533,133 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
else await apiClient.createPayrollAdminInputLine(payload);
|
||||
};
|
||||
|
||||
const refreshInlineTable = async (source: "rates" | "work" | "inputs") => {
|
||||
if (!selectedPeriod) return;
|
||||
const params = { period_id: selectedPeriod.id };
|
||||
const tableRequest = apiClient.getPayrollAdminPeriodTable(selectedPeriod.id);
|
||||
if (source === "rates") {
|
||||
const [tablePayload, sourcePayload] = await Promise.all([tableRequest, apiClient.getPayrollAdminPayRates(params)]);
|
||||
setPeriodTable(tablePayload);
|
||||
setRates(normalizePayrollAdminList(sourcePayload));
|
||||
} else if (source === "work") {
|
||||
const [tablePayload, sourcePayload] = await Promise.all([tableRequest, apiClient.getPayrollAdminWorkRecords(params)]);
|
||||
setPeriodTable(tablePayload);
|
||||
setWorkRecords(normalizePayrollAdminList(sourcePayload));
|
||||
} else {
|
||||
const [tablePayload, sourcePayload] = await Promise.all([tableRequest, apiClient.getPayrollAdminInputLines(params)]);
|
||||
setPeriodTable(tablePayload);
|
||||
setInputLines(normalizePayrollAdminList(sourcePayload));
|
||||
}
|
||||
};
|
||||
|
||||
const saveRateCell = async (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => {
|
||||
if (!canManageSourceData || !selectedPeriod) return;
|
||||
const rawValue = value.trim().replace(",", ".");
|
||||
const normalized = field === "point_rate" && rawValue === "" ? "0" : rawValue;
|
||||
const numeric = Number(normalized);
|
||||
if (!Number.isFinite(numeric) || numeric < 0 || (field === "amount" && numeric === 0)) {
|
||||
throw new Error(field === "amount" ? "Оклад должен быть больше нуля." : "Укажите корректную цену балла сверх нормы.");
|
||||
}
|
||||
const record = rates
|
||||
.filter((item) => item.employee.id === row.employee.id && item.status !== "voided" && item.effective_from <= selectedPeriod.date_from)
|
||||
.sort((left, right) => right.effective_from.localeCompare(left.effective_from) || right.revision - left.revision || right.id - left.id)[0];
|
||||
let draft = record;
|
||||
if (draft?.status === "approved") {
|
||||
draft = await apiClient.revisePayrollAdminPayRate(draft.id, "Изменено в итоговой таблице");
|
||||
}
|
||||
if (draft) {
|
||||
await apiClient.updatePayrollAdminPayRate(draft.id, { [field]: normalized, expected_lock_version: draft.lock_version });
|
||||
} else {
|
||||
const amount = field === "amount" ? normalized : row.rate_amount;
|
||||
if (!amount) throw new Error("Сначала заполните оклад.");
|
||||
await apiClient.createPayrollAdminPayRate({
|
||||
employee_id: row.employee.id,
|
||||
rate_code: "BASE",
|
||||
amount,
|
||||
point_rate: field === "point_rate" ? normalized : row.point_rate || "0",
|
||||
currency: selectedPeriod.currency,
|
||||
effective_from: selectedPeriod.date_from,
|
||||
reason: "Введено в итоговой таблице",
|
||||
});
|
||||
}
|
||||
await refreshInlineTable("rates");
|
||||
};
|
||||
|
||||
const saveWorkCell = async (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => {
|
||||
if (!canManageSourceData || !selectedPeriod) return;
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (field === "actual_points" && (!normalized || !Number.isFinite(Number(normalized)) || Number(normalized) < 0)) {
|
||||
throw new Error("Фактические баллы должны быть числом не меньше нуля.");
|
||||
}
|
||||
if (field === "target_points" && normalized && (!Number.isFinite(Number(normalized)) || Number(normalized) <= 0)) {
|
||||
throw new Error("Норма должна быть больше нуля или оставлена пустой для автоматического расчёта.");
|
||||
}
|
||||
const record = workRecords
|
||||
.filter((item) => item.employee.id === row.employee.id && item.status !== "voided")
|
||||
.sort((left, right) => right.revision - left.revision || right.id - left.id)[0];
|
||||
let draft = record;
|
||||
if (draft?.status === "approved") {
|
||||
draft = await apiClient.revisePayrollAdminWorkRecord(draft.id, "Изменено в итоговой таблице");
|
||||
}
|
||||
if (draft) {
|
||||
await apiClient.updatePayrollAdminWorkRecord(draft.id, {
|
||||
[field]: field === "target_points" ? normalized || null : normalized,
|
||||
expected_lock_version: draft.lock_version,
|
||||
});
|
||||
} else {
|
||||
await apiClient.createPayrollAdminWorkRecord({
|
||||
period_id: selectedPeriod.id,
|
||||
employee_id: row.employee.id,
|
||||
target_points: field === "target_points" ? normalized || null : null,
|
||||
actual_points: field === "actual_points" ? normalized : "0",
|
||||
expected_point_amount: null,
|
||||
expected_gross: null,
|
||||
expected_recalculated_gross: null,
|
||||
expected_payable: null,
|
||||
reason: "Введено в итоговой таблице",
|
||||
});
|
||||
}
|
||||
await refreshInlineTable("work");
|
||||
};
|
||||
|
||||
const saveComponentCell = async (row: PayrollPeriodTableRow, componentCode: string, value: string) => {
|
||||
if (!canManageSourceData || !selectedPeriod) return;
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
const desiredAmount = Number(normalized);
|
||||
if (!Number.isFinite(desiredAmount) || desiredAmount <= 0) {
|
||||
throw new Error("Сумма операции должна быть больше нуля.");
|
||||
}
|
||||
const component = workspace.components.find((item) => item.code === componentCode);
|
||||
if (!component) throw new Error("Вид операции не найден.");
|
||||
const matchingLines = inputLines.filter((item) => item.employee.id === row.employee.id && item.component.id === component.id && item.status !== "voided");
|
||||
const draft = matchingLines
|
||||
.filter((item) => item.status === "draft")
|
||||
.sort((left, right) => right.id - left.id)[0];
|
||||
const currentAmount = Number(row.component_amounts[componentCode] || "0");
|
||||
if (draft) {
|
||||
const amountWithoutDraft = currentAmount - Number(draft.amount);
|
||||
const replacementAmount = desiredAmount - amountWithoutDraft;
|
||||
if (replacementAmount <= 0) {
|
||||
throw new Error("Значение ниже уже утверждённой суммы. Исправьте её во вкладке «Начисления и выплаты».");
|
||||
}
|
||||
await apiClient.updatePayrollAdminInputLine(draft.id, { amount: replacementAmount.toFixed(2), expected_lock_version: draft.lock_version });
|
||||
} else {
|
||||
const addition = desiredAmount - currentAmount;
|
||||
if (addition <= 0) {
|
||||
throw new Error("Утверждённую сумму можно уменьшить через корректирующую операцию во вкладке «Начисления и выплаты».");
|
||||
}
|
||||
await apiClient.createPayrollAdminInputLine({
|
||||
period_id: selectedPeriod.id,
|
||||
employee_id: row.employee.id,
|
||||
component_id: component.id,
|
||||
amount: addition.toFixed(2),
|
||||
relates_to_period_id: null,
|
||||
reason: "Введено в итоговой таблице",
|
||||
});
|
||||
}
|
||||
await refreshInlineTable("inputs");
|
||||
};
|
||||
|
||||
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}
|
||||
@ -396,7 +668,19 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
</div>
|
||||
);
|
||||
const managementActions = (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{onDesktopWideModeChange ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDesktopWideModeChange(!desktopWideMode)}
|
||||
className="app-action-ghost hidden h-8 w-8 items-center justify-center rounded-md lg:inline-flex"
|
||||
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть финансы"}
|
||||
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть финансы"}
|
||||
aria-pressed={desktopWideMode}
|
||||
>
|
||||
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||
</button>
|
||||
) : null}
|
||||
{workspace.periods.length ? (
|
||||
<select className="app-select min-w-52 rounded-lg px-3 py-2.5 text-sm" value={selectedPeriod?.id || ""} onChange={(event) => void loadWorkspace(Number(event.target.value), true)} aria-label="Расчётный период">
|
||||
{workspace.periods.map((period) => <option value={period.id} key={period.id}>{period.name || period.code}</option>)}
|
||||
@ -436,7 +720,11 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
<section className="app-surface rounded-2xl p-2 sm:p-3">
|
||||
<div className="flex gap-1 overflow-x-auto" role="tablist" aria-label="Разделы управления зарплатой">
|
||||
{visibleTabs.map((item) => {
|
||||
const count = item.value === "approval" ? workspace.pending_approvals.rates + workspace.pending_approvals.work_records + workspace.pending_approvals.input_lines : 0;
|
||||
const count = item.value === "summary"
|
||||
? periodTable?.summary.employee_count ?? workspace.employees.length
|
||||
: item.value === "approval"
|
||||
? workspace.pending_approvals.rates + workspace.pending_approvals.work_records + workspace.pending_approvals.input_lines
|
||||
: 0;
|
||||
return <button type="button" role="tab" aria-selected={tab === item.value} key={item.value} className={`${tab === item.value ? "app-chip-active" : "app-chip"} inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium sm:text-sm`} onClick={() => { setTab(item.value); setSearch(""); setStatus(""); }}>{item.label}{count > 0 ? <span className={`${tab === item.value ? "bg-white/20 text-white" : "app-counter"} inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px]`}>{count}</span> : null}</button>;
|
||||
})}
|
||||
</div>
|
||||
@ -465,7 +753,7 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{tab === "rates" ? <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}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
|
||||
{tab === "rates" ? <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}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} onBulkPayRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
|
||||
{tab === "work" ? (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Выработка" description="Норму и факт можно ввести вручную в баллах или заполнить рабочими часами из посещаемости." />
|
||||
@ -483,6 +771,12 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
</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}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} 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 === "summary" ? (
|
||||
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Итоговая таблица" description="Сводные исходные данные, начисления, удержания и выплаты по всем сотрудникам за выбранный период." />
|
||||
<PayrollPeriodTableView data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} canEdit={canManageSourceData} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} />
|
||||
</section>
|
||||
) : null}
|
||||
{tab === "approval" ? (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Проверка данных" description="Здесь можно утвердить подготовленные черновики и продолжить расчёт." />
|
||||
@ -515,8 +809,10 @@ export function PayrollAdminWorkspace({ actionsTargetId, embedded = false, heade
|
||||
</>
|
||||
)}
|
||||
|
||||
{periodModalOpen ? <PayrollPeriodFormModal isOpen period={editingPeriod} 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; createdPeriodIdRef.current = null; if (createdPeriodId) { setNotice("Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && rateModalOpen ? <PayrollRateFormModal isOpen period={selectedPeriod} employees={workspace.employees} rate={editingRate} onClose={() => setRateModalOpen(false)} onSubmit={saveRate} onSaved={async () => { setRateModalOpen(false); await refreshView("Черновик ставки сохранён."); }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && bulkPayRateModalOpen ? <PayrollBulkPayRateModal isOpen period={selectedPeriod} employees={workspace.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 && 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
|
||||
|
||||
@ -308,7 +308,7 @@ export function PayrollAttendanceWorkModal({
|
||||
{preview.policy.description || preview.policy.label}
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-[var(--foreground)]">
|
||||
Часы попадут в норму и факт выработки, но при текущей политике не изменяют начисление по баллам.
|
||||
Часы попадут в норму и факт выработки: до нормы они пропорционально формируют оклад, сверх нормы — доплату по заданной цене.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
334
frontend/src/components/finance/admin/PayrollPeriodTable.tsx
Normal file
334
frontend/src/components/finance/admin/PayrollPeriodTable.tsx
Normal file
@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
PayrollPeriodTable,
|
||||
PayrollPeriodTableRow,
|
||||
PayrollPeriodTableStatus,
|
||||
} from "@/lib/api/finance";
|
||||
import { formatPayrollMoney } from "@/lib/payroll";
|
||||
import { getPayrollAdminError } from "@/lib/payroll-admin";
|
||||
|
||||
const statusMeta: Record<PayrollPeriodTableStatus, { label: string; className: string }> = {
|
||||
calculated: { label: "Рассчитано", className: "app-feedback-success" },
|
||||
ready: { label: "Готово", className: "app-selected" },
|
||||
draft: { label: "Есть черновики", className: "app-feedback-warning" },
|
||||
incomplete: { label: "Не заполнено", className: "app-feedback-danger" },
|
||||
};
|
||||
|
||||
const kindLabels = {
|
||||
earning: "Начисление",
|
||||
adjustment_credit: "Корректировка +",
|
||||
adjustment_debit: "Корректировка −",
|
||||
deduction: "Удержание",
|
||||
payment: "Выплата",
|
||||
};
|
||||
|
||||
const headerCellClass = "app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]";
|
||||
const bodyCellClass = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums";
|
||||
|
||||
function formatPoints(value: string | null): string {
|
||||
if (value == null || value === "") return "—";
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return value;
|
||||
return numeric.toLocaleString("ru-RU", { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function formatMoney(value: string | null, currency: string): string {
|
||||
return value == null
|
||||
? "—"
|
||||
: formatPayrollMoney(value, currency).replace(/[\u00a0\u202f]/g, " ");
|
||||
}
|
||||
|
||||
function CalculatedMoney({ value, currency, preliminary }: { value: string | null; currency: string; preliminary: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block max-w-full"
|
||||
title={preliminary ? "Предварительный расчёт по текущим данным" : undefined}
|
||||
>
|
||||
{formatMoney(value, currency)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function editableNumber(value: string | null): string {
|
||||
if (value == null || value === "") return "";
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? String(numeric) : value;
|
||||
}
|
||||
|
||||
function sameNumber(left: string, right: string): boolean {
|
||||
if (!left.trim() && !right.trim()) return true;
|
||||
const leftNumber = Number(left.replace(",", "."));
|
||||
const rightNumber = Number(right.replace(",", "."));
|
||||
return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber === rightNumber;
|
||||
}
|
||||
|
||||
function InlineEditableCell({ value, formattedValue, onSave, label, automatic = false }: { value: string | null; formattedValue: string; onSave?: (value: string) => Promise<void>; label: string; automatic?: boolean }) {
|
||||
const original = editableNumber(value);
|
||||
const [draft, setDraft] = useState(original);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [cellError, setCellError] = useState<string | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) setDraft(editableNumber(value));
|
||||
}, [pending, value]);
|
||||
|
||||
if (!onSave) return <td className={`${bodyCellClass} ${automatic ? "text-[var(--muted-foreground)]" : ""}`}>{formattedValue}</td>;
|
||||
|
||||
const commit = async () => {
|
||||
if (cancelledRef.current) {
|
||||
cancelledRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (sameNumber(draft, original)) return;
|
||||
setPending(true);
|
||||
setCellError(null);
|
||||
try {
|
||||
await onSave(draft.trim().replace(",", "."));
|
||||
} catch (error) {
|
||||
setDraft(original);
|
||||
setCellError(getPayrollAdminError(error, "Не удалось сохранить ячейку."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<td className={`${bodyCellClass} p-0`}>
|
||||
<label className="relative block min-h-10 w-full max-w-full overflow-hidden">
|
||||
<input
|
||||
className={`min-h-10 w-full min-w-0 max-w-full bg-transparent px-1.5 py-2 text-right text-[11px] tabular-nums outline-none transition-colors placeholder:text-[var(--muted-foreground)] hover:bg-[var(--surface-tertiary)] focus:bg-[var(--surface-tertiary)] focus:text-[var(--foreground)] focus:ring-2 focus:ring-inset ${automatic ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"} ${cellError ? "ring-2 ring-inset ring-red-500/70" : "focus:ring-[var(--accent-primary)]"}`}
|
||||
inputMode="decimal"
|
||||
size={1}
|
||||
value={draft}
|
||||
placeholder="—"
|
||||
disabled={pending}
|
||||
aria-label={label}
|
||||
aria-invalid={Boolean(cellError)}
|
||||
title={cellError || `${label}. Enter или выход из ячейки — сохранить, Esc — отменить.`}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={() => void commit()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") event.currentTarget.blur();
|
||||
if (event.key === "Escape") {
|
||||
cancelledRef.current = true;
|
||||
setDraft(original);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pending ? <Loader2 className="app-accent-text pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 animate-spin" size={11} /> : null}
|
||||
</label>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function sumRows(rows: PayrollPeriodTableRow[], value: (row: PayrollPeriodTableRow) => string | null): string | null {
|
||||
let hasValue = false;
|
||||
const total = rows.reduce((sum, row) => {
|
||||
const raw = value(row);
|
||||
if (raw == null || raw === "") return sum;
|
||||
const numeric = Number(raw);
|
||||
if (!Number.isFinite(numeric)) return sum;
|
||||
hasValue = true;
|
||||
return sum + numeric;
|
||||
}, 0);
|
||||
return hasValue ? String(total) : null;
|
||||
}
|
||||
|
||||
export function PayrollPeriodTableView({
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
onSearch,
|
||||
canEdit = false,
|
||||
onSaveRate,
|
||||
onSaveWork,
|
||||
onSaveComponent,
|
||||
}: {
|
||||
data: PayrollPeriodTable | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
search: string;
|
||||
onSearch: (value: string) => void;
|
||||
canEdit?: boolean;
|
||||
onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise<void>;
|
||||
onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise<void>;
|
||||
onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise<void>;
|
||||
}) {
|
||||
const rows = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||||
if (!needle) return data?.rows || [];
|
||||
return (data?.rows || []).filter((row) => (
|
||||
`${row.employee.display_name} ${row.employee.position || ""} ${row.employee.department || ""}`
|
||||
.toLocaleLowerCase("ru-RU")
|
||||
.includes(needle)
|
||||
));
|
||||
}, [data?.rows, search]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <div className="flex min-h-56 items-center justify-center"><Loader2 className="app-accent-text animate-spin" size={24} /></div>;
|
||||
}
|
||||
if (error) return <div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
const tableWidth = 1265 + data.component_columns.length * 75;
|
||||
const total = (field: keyof PayrollPeriodTableRow) => sumRows(rows, (row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "string" ? value : null;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full">
|
||||
<div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Сотрудников <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.employee_count}</span></span>
|
||||
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Рассчитано <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.calculated_count}</span></span>
|
||||
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Готово <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.ready_count}</span></span>
|
||||
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Черновики <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.draft_count}</span></span>
|
||||
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Не заполнено <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.incomplete_count}</span></span>
|
||||
{data.summary.preliminary_count ? <span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Предварительно <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.preliminary_count}</span></span> : null}
|
||||
{data.run ? <span className="app-selected inline-flex items-center rounded-full px-3 py-1.5 text-xs">Ревизия {data.run.revision}</span> : null}
|
||||
</div>
|
||||
<label className="relative block min-w-0 lg:w-72">
|
||||
<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) => onSearch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{data.summary.preliminary_count ? (
|
||||
<div className="app-feedback-warning mb-3 rounded-lg px-3 py-2.5 text-sm">
|
||||
Значения рассчитаны предварительно по текущим данным. Они обновляются после сохранения ячеек и станут официальными после запуска расчёта.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-text-muted mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
{canEdit ? <span>Enter или переход в другую ячейку — сохранить, Esc — отменить.</span> : null}
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[var(--muted-foreground)]" aria-hidden="true" />Серый текст — заполнено автоматически</span>
|
||||
</div>
|
||||
|
||||
<div className="app-table-scroll max-h-[min(70vh,760px)] w-full max-w-full overflow-auto overscroll-contain rounded-xl border border-[var(--border-subtle)]">
|
||||
<table className="table-fixed border-separate border-spacing-0" style={{ width: `max(100%, ${tableWidth}px)` }} aria-label="Итоговая таблица расчётного периода">
|
||||
<colgroup>
|
||||
<col style={{ width: 150 }} />
|
||||
<col style={{ width: 75 }} />
|
||||
<col style={{ width: 75 }} />
|
||||
<col style={{ width: 75 }} />
|
||||
<col style={{ width: 55 }} />
|
||||
<col style={{ width: 55 }} />
|
||||
<col style={{ width: 55 }} />
|
||||
<col style={{ width: 75 }} />
|
||||
{data.component_columns.map((component) => <col key={component.code} style={{ width: 75 }} />)}
|
||||
{Array.from({ length: 7 }, (_, index) => <col key={`total-${index}`} style={{ width: 80 }} />)}
|
||||
<col style={{ width: 90 }} />
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr>
|
||||
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-30 align-middle`}>Сотрудник</th>
|
||||
<th colSpan={3} className={`${headerCellClass} text-center uppercase tracking-wide`}>Оклад и баллы</th>
|
||||
<th colSpan={4} className={`${headerCellClass} text-center uppercase tracking-wide`}>Выработка</th>
|
||||
{data.component_columns.length ? <th colSpan={data.component_columns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>Начисления и выплаты</th> : null}
|
||||
<th colSpan={7} className={`${headerCellClass} text-center uppercase tracking-wide`}>Расчёт</th>
|
||||
<th rowSpan={2} className={`${headerCellClass} align-middle`}>Состояние</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className={`${headerCellClass} text-right`}>Оклад</th>
|
||||
<th className={`${headerCellClass} whitespace-normal text-right`} title="Цена одного балла в пределах нормы: оклад, разделённый на норму баллов">
|
||||
<span className="block">Цена балла</span>
|
||||
<span className="block">в норме</span>
|
||||
</th>
|
||||
<th className={`${headerCellClass} whitespace-normal text-right`} title="Цена каждого балла сверх нормы">
|
||||
<span className="block">Цена балла</span>
|
||||
<span className="block">сверх нормы</span>
|
||||
</th>
|
||||
<th className={`${headerCellClass} text-right`}>Норма</th>
|
||||
<th className={`${headerCellClass} text-right`}>Баллы</th>
|
||||
<th className={`${headerCellClass} text-right`}>Отклонение</th>
|
||||
<th className={`${headerCellClass} text-right`} title="Доплата за баллы сверх нормы">Доплата</th>
|
||||
{data.component_columns.map((component) => (
|
||||
<th key={component.code} className={`${headerCellClass} text-right whitespace-normal break-words`} title={`${component.label} · ${kindLabels[component.kind]}`}>
|
||||
{component.label}
|
||||
</th>
|
||||
))}
|
||||
<th className={`${headerCellClass} text-right`} title="До корректировок">До коррект.</th>
|
||||
<th className={`${headerCellClass} text-right`}>Корректировки</th>
|
||||
<th className={`${headerCellClass} text-right`}>Начислено</th>
|
||||
<th className={`${headerCellClass} text-right`}>Удержано</th>
|
||||
<th className={`${headerCellClass} text-right`} title="После удержаний">После удерж.</th>
|
||||
<th className={`${headerCellClass} text-right`}>Выплачено</th>
|
||||
<th className={`${headerCellClass} text-right`}>К выплате</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length ? rows.map((row) => {
|
||||
const meta = statusMeta[row.status];
|
||||
return (
|
||||
<tr key={row.employee.id} className="bg-[var(--surface-primary)] hover:bg-[var(--surface-secondary)]">
|
||||
<td className="sticky left-0 z-10 overflow-hidden border-b border-r border-[var(--border-subtle)] bg-[var(--surface-primary)] px-2 py-2.5">
|
||||
<p className="max-w-full truncate text-xs font-semibold text-[var(--foreground)]" title={row.employee.display_name}>{row.employee.display_name}</p>
|
||||
<p className="app-text-muted mt-0.5 max-w-full truncate text-[10px]">{row.employee.position || row.employee.department || "Сотрудник"}{row.employee.is_active ? "" : " · неактивен"}</p>
|
||||
</td>
|
||||
<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}`} />
|
||||
<InlineEditableCell value={row.in_norm_point_rate} formattedValue={formatMoney(row.in_norm_point_rate, data.currency)} label={`Цена балла в пределах нормы: ${row.employee.display_name}`} automatic />
|
||||
<InlineEditableCell value={row.point_rate} formattedValue={formatMoney(row.point_rate, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} />
|
||||
<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} />
|
||||
<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}`} />
|
||||
<td className={bodyCellClass}>{formatPoints(row.point_delta)}</td>
|
||||
<td className={bodyCellClass}><CalculatedMoney value={row.point_amount} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
{data.component_columns.map((component) => (
|
||||
<InlineEditableCell key={component.code} 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}`} />
|
||||
))}
|
||||
<td className={bodyCellClass}><CalculatedMoney value={row.gross_before_adjustments} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
<td className={bodyCellClass}><CalculatedMoney value={row.adjustment_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
<td className={`${bodyCellClass} font-semibold`}><CalculatedMoney value={row.gross_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
<td className={bodyCellClass}><CalculatedMoney value={row.deduction_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
<td className={bodyCellClass}><CalculatedMoney value={row.net_pay} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
<td className={bodyCellClass}><CalculatedMoney value={row.payment_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
<td className={`${bodyCellClass} font-semibold`}><CalculatedMoney value={row.payable} currency={data.currency} preliminary={row.totals_preliminary} /></td>
|
||||
<td className="overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center"><span className={`app-status-pill px-2 text-[10px] ${meta.className}`}>{meta.label}</span></td>
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr><td colSpan={16 + data.component_columns.length} className="app-text-muted px-4 py-10 text-center text-sm">Сотрудники не найдены.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
{rows.length ? (
|
||||
<tfoot>
|
||||
<tr className="app-surface-muted font-semibold">
|
||||
<td className="sticky left-0 z-10 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 text-xs text-[var(--foreground)]">Итого · {rows.length}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("rate_amount"), data.currency)}</td>
|
||||
<td className={bodyCellClass}>—</td>
|
||||
<td className={bodyCellClass}>—</td>
|
||||
<td className={bodyCellClass}>{formatPoints(total("target_points"))}</td>
|
||||
<td className={bodyCellClass}>{formatPoints(total("actual_points"))}</td>
|
||||
<td className={bodyCellClass}>{formatPoints(total("point_delta"))}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("point_amount"), data.currency)}</td>
|
||||
{data.component_columns.map((component) => (
|
||||
<td key={component.code} className={bodyCellClass}>{formatMoney(sumRows(rows, (row) => row.component_amounts[component.code] || null), data.currency)}</td>
|
||||
))}
|
||||
<td className={bodyCellClass}>{formatMoney(total("gross_before_adjustments"), data.currency)}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("adjustment_total"), data.currency)}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("gross_total"), data.currency)}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("deduction_total"), data.currency)}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("net_pay"), data.currency)}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("payment_total"), data.currency)}</td>
|
||||
<td className={bodyCellClass}>{formatMoney(total("payable"), data.currency)}</td>
|
||||
<td className="border-r border-[var(--border-subtle)] px-3 py-3" />
|
||||
</tr>
|
||||
</tfoot>
|
||||
) : null}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/hooks/usePayrollDesktopWideMode.ts
Normal file
45
frontend/src/hooks/usePayrollDesktopWideMode.ts
Normal file
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import { PAYROLL_DESKTOP_WIDE_MODE_STORAGE_PREFIX } from "@/lib/payroll-admin";
|
||||
|
||||
const PAYROLL_DESKTOP_WIDE_MODE_EVENT = "payroll-desktop-wide-mode-change";
|
||||
|
||||
function storageKey(userId: number): string {
|
||||
return `${PAYROLL_DESKTOP_WIDE_MODE_STORAGE_PREFIX}.${userId}`;
|
||||
}
|
||||
|
||||
export function usePayrollDesktopWideMode(userId?: number | null) {
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!userId) return () => undefined;
|
||||
const key = storageKey(userId);
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) notify();
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(PAYROLL_DESKTOP_WIDE_MODE_EVENT, notify);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(PAYROLL_DESKTOP_WIDE_MODE_EVENT, notify);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const getSnapshot = useCallback(() => (
|
||||
userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false
|
||||
), [userId]);
|
||||
|
||||
const desktopWideMode = useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const changeDesktopWideMode = useCallback((enabled: boolean) => {
|
||||
if (!userId) return;
|
||||
window.localStorage.setItem(storageKey(userId), enabled ? "1" : "0");
|
||||
window.dispatchEvent(new Event(PAYROLL_DESKTOP_WIDE_MODE_EVENT));
|
||||
}, [userId]);
|
||||
|
||||
return [desktopWideMode, changeDesktopWideMode] as const;
|
||||
}
|
||||
85
frontend/src/hooks/usePayrollTabs.ts
Normal file
85
frontend/src/hooks/usePayrollTabs.ts
Normal file
@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import type { PayrollAdminTab } from "@/lib/payroll-admin";
|
||||
|
||||
export type PayrollFinanceTab = "statement" | "work" | "management";
|
||||
|
||||
const PAYROLL_TAB_CHANGE_EVENT = "payroll-tab-change";
|
||||
const FINANCE_TAB_STORAGE_PREFIX = "finances.payroll.active-tab";
|
||||
const ADMIN_TAB_STORAGE_PREFIX = "finances.payroll.admin.active-tab";
|
||||
const ALL_FINANCE_TABS: ReadonlySet<string> = new Set(["statement", "work", "management"]);
|
||||
const EMPLOYEE_FINANCE_TABS: ReadonlySet<string> = new Set(["statement", "work"]);
|
||||
const ADMIN_TABS: ReadonlySet<string> = new Set([
|
||||
"readiness",
|
||||
"rates",
|
||||
"work",
|
||||
"inputs",
|
||||
"summary",
|
||||
"approval",
|
||||
]);
|
||||
|
||||
function useStoredPayrollTab<T extends string>({
|
||||
userId,
|
||||
storagePrefix,
|
||||
allowedTabs,
|
||||
fallback,
|
||||
}: {
|
||||
userId?: number | null;
|
||||
storagePrefix: string;
|
||||
allowedTabs: ReadonlySet<string>;
|
||||
fallback: T;
|
||||
}) {
|
||||
const key = userId ? `${storagePrefix}.${userId}` : null;
|
||||
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!key) return () => undefined;
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) notify();
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(PAYROLL_TAB_CHANGE_EVENT, notify);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(PAYROLL_TAB_CHANGE_EVENT, notify);
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!key) return fallback;
|
||||
const stored = window.localStorage.getItem(key);
|
||||
return allowedTabs.has(stored || "") ? stored as T : fallback;
|
||||
}, [allowedTabs, fallback, key]);
|
||||
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, () => fallback);
|
||||
|
||||
const setValue = useCallback((nextValue: T) => {
|
||||
if (!key || !allowedTabs.has(nextValue)) return;
|
||||
window.localStorage.setItem(key, nextValue);
|
||||
window.dispatchEvent(new Event(PAYROLL_TAB_CHANGE_EVENT));
|
||||
}, [allowedTabs, key]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
|
||||
export function usePayrollFinanceTab(
|
||||
userId?: number | null,
|
||||
canOpenManagement = false,
|
||||
) {
|
||||
return useStoredPayrollTab<PayrollFinanceTab>({
|
||||
userId,
|
||||
storagePrefix: FINANCE_TAB_STORAGE_PREFIX,
|
||||
allowedTabs: canOpenManagement ? ALL_FINANCE_TABS : EMPLOYEE_FINANCE_TABS,
|
||||
fallback: "statement",
|
||||
});
|
||||
}
|
||||
|
||||
export function usePayrollAdminTab(userId?: number | null) {
|
||||
return useStoredPayrollTab<PayrollAdminTab>({
|
||||
userId,
|
||||
storagePrefix: ADMIN_TAB_STORAGE_PREFIX,
|
||||
allowedTabs: ADMIN_TABS,
|
||||
fallback: "readiness",
|
||||
});
|
||||
}
|
||||
@ -54,6 +54,7 @@ test("finance API keeps payroll management inside the native admin REST namespac
|
||||
});
|
||||
|
||||
await finance.getPayrollAdminWorkspace(17);
|
||||
await finance.getPayrollAdminPeriodTable(17);
|
||||
await finance.getPayrollAdminPayRates({ period_id: 17, status: "draft" });
|
||||
await finance.updatePayrollAdminPayRate(21, {
|
||||
amount: "115000.00",
|
||||
@ -73,6 +74,10 @@ test("finance API keeps payroll management inside the native admin REST namespac
|
||||
endpoint: "/api/v1/finance/payroll/admin/workspace/?period_id=17",
|
||||
options: undefined,
|
||||
},
|
||||
{
|
||||
endpoint: "/api/v1/finance/payroll/admin/periods/17/table/",
|
||||
options: undefined,
|
||||
},
|
||||
{
|
||||
endpoint: "/api/v1/finance/payroll/admin/pay-rates/?period_id=17&status=draft&page_size=200",
|
||||
options: undefined,
|
||||
|
||||
@ -60,6 +60,71 @@ export type PayrollStatementPage = {
|
||||
results: PayrollStatementSummary[];
|
||||
};
|
||||
|
||||
export type PayrollOwnWorkRecord = {
|
||||
id: number;
|
||||
period_id: number;
|
||||
target_points: string;
|
||||
target_points_overridden: boolean;
|
||||
actual_points: string;
|
||||
revision: number;
|
||||
status: PayrollApprovalStatus;
|
||||
status_label: string;
|
||||
lock_version: number;
|
||||
replaces_id: number | null;
|
||||
reason: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
approved_at: string | null;
|
||||
};
|
||||
|
||||
export type PayrollOwnDailyWorkEntry = {
|
||||
id: number;
|
||||
period_id: number;
|
||||
work_date: string;
|
||||
target_points: string;
|
||||
actual_points: string;
|
||||
note: string;
|
||||
lock_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PayrollOwnWorkPeriod = PayrollPeriodSummary & {
|
||||
id: number;
|
||||
status: PayrollPeriodStatus;
|
||||
status_label: string;
|
||||
editable: boolean;
|
||||
record: PayrollOwnWorkRecord | null;
|
||||
summary: {
|
||||
target_points: string;
|
||||
actual_points: string;
|
||||
target_source: "saved_record" | "individual_schedule" | "standard_schedule" | "default_schedule";
|
||||
workdays_count: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PayrollOwnDailyWorkWorkspace = {
|
||||
daily_target_points: string;
|
||||
selected_period_id: number | null;
|
||||
periods: PayrollOwnWorkPeriod[];
|
||||
entries: PayrollOwnDailyWorkEntry[];
|
||||
};
|
||||
|
||||
export type PayrollOwnDailyWorkWrite = {
|
||||
period_id: number;
|
||||
work_date: string;
|
||||
actual_points: string;
|
||||
note: string;
|
||||
expected_lock_version?: number;
|
||||
};
|
||||
|
||||
export type PayrollOwnDailyWorkSaveResult = {
|
||||
operation: "created" | "updated" | "unchanged";
|
||||
entry: PayrollOwnDailyWorkEntry;
|
||||
record: PayrollOwnWorkRecord;
|
||||
};
|
||||
|
||||
export type PayrollApprovalStatus = "draft" | "approved" | "voided";
|
||||
export type PayrollPeriodStatus = "open" | "calculated" | "review" | "approved" | "published" | "closed";
|
||||
export type PayrollRunStatus = "calculated" | "review" | "approved" | "published" | "returned" | "superseded";
|
||||
@ -156,6 +221,59 @@ export type PayrollAdminWorkspace = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PayrollPeriodTableStatus = "calculated" | "ready" | "draft" | "incomplete";
|
||||
|
||||
export type PayrollPeriodTableComponent = {
|
||||
code: string;
|
||||
label: string;
|
||||
kind: PayrollLineKind;
|
||||
display_order: number;
|
||||
};
|
||||
|
||||
export type PayrollPeriodTableRow = {
|
||||
employee: PayrollAdminEmployee & { is_active: boolean };
|
||||
status: PayrollPeriodTableStatus;
|
||||
rate_status: PayrollApprovalStatus | null;
|
||||
work_status: PayrollApprovalStatus | null;
|
||||
rate_amount: string | null;
|
||||
in_norm_point_rate: string | null;
|
||||
point_rate: string | null;
|
||||
target_points: string | null;
|
||||
target_points_automatic: boolean;
|
||||
target_points_source: string;
|
||||
actual_points: string | null;
|
||||
point_delta: string | null;
|
||||
point_amount: string | null;
|
||||
component_amounts: Record<string, string>;
|
||||
totals_preliminary: boolean;
|
||||
gross_before_adjustments: string | null;
|
||||
adjustment_total: string | null;
|
||||
gross_total: string | null;
|
||||
deduction_total: string | null;
|
||||
net_pay: string | null;
|
||||
payment_total: string | null;
|
||||
payable: string | null;
|
||||
};
|
||||
|
||||
export type PayrollPeriodTable = {
|
||||
period_id: number;
|
||||
currency: string;
|
||||
run: Pick<PayrollAdminRun, "id" | "revision" | "status"> | null;
|
||||
component_columns: PayrollPeriodTableComponent[];
|
||||
rows: PayrollPeriodTableRow[];
|
||||
summary: {
|
||||
employee_count: number;
|
||||
calculated_count: number;
|
||||
ready_count: number;
|
||||
draft_count: number;
|
||||
incomplete_count: number;
|
||||
preliminary_count: number;
|
||||
gross_total: string | null;
|
||||
deduction_total: string | null;
|
||||
payable_total: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type PayrollComponent = {
|
||||
id: number;
|
||||
code: string;
|
||||
@ -191,6 +309,7 @@ export type PayrollAdminWorkRecord = {
|
||||
employee_id?: number;
|
||||
employee: PayrollAdminEmployee;
|
||||
target_points: string;
|
||||
target_points_overridden: boolean;
|
||||
actual_points: string;
|
||||
expected_point_amount: string | null;
|
||||
expected_gross: string | null;
|
||||
@ -342,10 +461,51 @@ export type PayrollPayRateWrite = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PayrollBulkPointRateMode = "fixed" | "in_norm";
|
||||
|
||||
export type PayrollBulkPointRateWrite = {
|
||||
employee_ids: number[];
|
||||
mode: PayrollBulkPointRateMode;
|
||||
point_rate?: string | null;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PayrollBulkPointRateResult = {
|
||||
mode: PayrollBulkPointRateMode;
|
||||
point_rate: string | null;
|
||||
summary: {
|
||||
selected_employees: number;
|
||||
updated_drafts: number;
|
||||
created_revisions: number;
|
||||
unchanged: number;
|
||||
skipped: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PayrollBulkPayRateWrite = {
|
||||
employee_ids: number[];
|
||||
amount: string;
|
||||
effective_from: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PayrollBulkPayRateResult = {
|
||||
amount: string;
|
||||
effective_from: string;
|
||||
summary: {
|
||||
selected_employees: number;
|
||||
created_drafts: number;
|
||||
updated_drafts: number;
|
||||
created_revisions: number;
|
||||
unchanged: number;
|
||||
skipped: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PayrollWorkRecordWrite = {
|
||||
period_id: number;
|
||||
employee_id: number;
|
||||
target_points: string;
|
||||
target_points?: string | null;
|
||||
actual_points: string;
|
||||
expected_point_amount?: string | null;
|
||||
expected_gross?: string | null;
|
||||
@ -395,10 +555,21 @@ export function createFinanceApi(request: RequestFn) {
|
||||
`/api/v1/finance/payroll/me/statements/${encodeURIComponent(publicId)}/acknowledge/`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
getMyPayrollDailyWork: (periodId?: number): Promise<PayrollOwnDailyWorkWorkspace> =>
|
||||
request(`/api/v1/finance/payroll/me/work-records/${buildQuery({ period_id: periodId })}`),
|
||||
saveMyPayrollDailyWorkEntry: (
|
||||
payload: PayrollOwnDailyWorkWrite,
|
||||
): Promise<PayrollOwnDailyWorkSaveResult> =>
|
||||
request(
|
||||
"/api/v1/finance/payroll/me/work-records/",
|
||||
jsonOptions("POST", payload),
|
||||
),
|
||||
getPayrollAdminWorkspace: (periodId?: number): Promise<PayrollAdminWorkspace> =>
|
||||
request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`),
|
||||
getPayrollAdminPeriods: (): Promise<PayrollAdminListPayload<PayrollAdminPeriod>> =>
|
||||
request(`${adminPrefix}/periods/`),
|
||||
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/table/`),
|
||||
createPayrollAdminPeriod: (payload: PayrollPeriodWrite): Promise<PayrollAdminPeriod> =>
|
||||
request(`${adminPrefix}/periods/`, jsonOptions("POST", payload)),
|
||||
updatePayrollAdminPeriod: (
|
||||
@ -419,6 +590,16 @@ export function createFinanceApi(request: RequestFn) {
|
||||
request(`${adminPrefix}/pay-rates/${rateId}/approve/`, jsonOptions("POST", { expected_lock_version: expectedLockVersion })),
|
||||
revisePayrollAdminPayRate: (rateId: number, reason: string): Promise<PayrollAdminPayRate> =>
|
||||
request(`${adminPrefix}/pay-rates/${rateId}/revise/`, jsonOptions("POST", { reason })),
|
||||
bulkCreatePayrollAdminPayRates: (
|
||||
periodId: number,
|
||||
payload: PayrollBulkPayRateWrite,
|
||||
): Promise<PayrollBulkPayRateResult> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/bulk-pay-rate/`, jsonOptions("POST", payload)),
|
||||
bulkSetPayrollAdminPointRate: (
|
||||
periodId: number,
|
||||
payload: PayrollBulkPointRateWrite,
|
||||
): Promise<PayrollBulkPointRateResult> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/bulk-point-rate/`, jsonOptions("POST", payload)),
|
||||
getPayrollAdminWorkRecords: (params: PayrollAdminListParams = {}): Promise<PayrollAdminListPayload<PayrollAdminWorkRecord>> =>
|
||||
request(`${adminPrefix}/work-records/${buildQuery({ ...params, page_size: 200 })}`),
|
||||
createPayrollAdminWorkRecord: (payload: PayrollWorkRecordWrite): Promise<PayrollAdminWorkRecord> =>
|
||||
|
||||
@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
canApprovePayrollDraft,
|
||||
getDefaultPayrollPeriodForm,
|
||||
getPayrollAdminError,
|
||||
getPrimaryPayrollRunAction,
|
||||
isPayrollAdminStaleConflict,
|
||||
@ -29,6 +30,51 @@ test("payroll admin normalizes DRF and plain list payloads", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("new payroll period defaults cover the current month and next payment date", () => {
|
||||
assert.deepEqual(
|
||||
getDefaultPayrollPeriodForm([], new Date(2026, 6, 20, 12)),
|
||||
{
|
||||
code: "2026-07",
|
||||
name: "Июль 2026",
|
||||
date_from: "2026-07-01",
|
||||
date_to: "2026-07-31",
|
||||
pay_date: "2026-08-05",
|
||||
currency: "RUB",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("new payroll period skips occupied months and handles a year boundary", () => {
|
||||
const periods = [
|
||||
{
|
||||
code: "2026-12",
|
||||
name: "Декабрь 2026",
|
||||
date_from: "2026-12-01",
|
||||
date_to: "2026-12-31",
|
||||
pay_date: "2027-01-05",
|
||||
},
|
||||
{
|
||||
code: "2027-01",
|
||||
name: "Январь 2027",
|
||||
date_from: "2027-01-01",
|
||||
date_to: "2027-01-31",
|
||||
pay_date: "2027-02-05",
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
getDefaultPayrollPeriodForm(periods, new Date(2026, 11, 15, 12)),
|
||||
{
|
||||
code: "2027-02",
|
||||
name: "Февраль 2027",
|
||||
date_from: "2027-02-01",
|
||||
date_to: "2027-02-28",
|
||||
pay_date: "2027-03-05",
|
||||
currency: "RUB",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("payroll workflow exposes only the next permitted primary action", () => {
|
||||
assert.equal(getPrimaryPayrollRunAction(null, "open", permissions, true), "calculate");
|
||||
assert.equal(
|
||||
|
||||
@ -3,17 +3,23 @@ import type {
|
||||
PayrollAdminPermissions,
|
||||
PayrollAdminRun,
|
||||
PayrollApprovalStatus,
|
||||
PayrollPeriodSummary,
|
||||
PayrollPeriodWrite,
|
||||
PayrollPeriodStatus,
|
||||
PayrollRunStatus,
|
||||
} from "@/lib/api/finance";
|
||||
|
||||
export type PayrollAdminTab = "readiness" | "rates" | "work" | "inputs" | "approval";
|
||||
export const PAYROLL_DESKTOP_WIDE_MODE_STORAGE_PREFIX = "finances.payroll.desktop-wide-mode";
|
||||
export const PAYROLL_SELECTED_PERIOD_STORAGE_PREFIX = "finances.payroll.selected-period";
|
||||
|
||||
export type PayrollAdminTab = "readiness" | "rates" | "work" | "inputs" | "summary" | "approval";
|
||||
|
||||
export const PAYROLL_ADMIN_TABS: Array<{ value: PayrollAdminTab; label: string }> = [
|
||||
{ value: "readiness", label: "Готовность" },
|
||||
{ value: "rates", label: "Ставки" },
|
||||
{ value: "work", label: "Выработка" },
|
||||
{ value: "inputs", label: "Начисления и выплаты" },
|
||||
{ value: "summary", label: "Итоговая таблица" },
|
||||
{ value: "approval", label: "Проверка" },
|
||||
];
|
||||
|
||||
@ -45,6 +51,63 @@ export function normalizePayrollAdminList<T>(payload: PayrollAdminListPayload<T>
|
||||
return Array.isArray(payload) ? payload : payload.results || [];
|
||||
}
|
||||
|
||||
const RUSSIAN_MONTH_NAMES = [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
];
|
||||
|
||||
function formatDateInput(year: number, monthIndex: number, day: number): string {
|
||||
const date = new Date(year, monthIndex, day);
|
||||
return [
|
||||
String(date.getFullYear()).padStart(4, "0"),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
export function getDefaultPayrollPeriodForm(
|
||||
periods: PayrollPeriodSummary[],
|
||||
referenceDate = new Date(),
|
||||
): PayrollPeriodWrite {
|
||||
let year = referenceDate.getFullYear();
|
||||
let monthIndex = referenceDate.getMonth();
|
||||
|
||||
while (true) {
|
||||
const code = `${String(year).padStart(4, "0")}-${String(monthIndex + 1).padStart(2, "0")}`;
|
||||
const dateFrom = formatDateInput(year, monthIndex, 1);
|
||||
const dateTo = formatDateInput(year, monthIndex + 1, 0);
|
||||
const conflicts = periods.some((period) => (
|
||||
period.code === code
|
||||
|| (period.date_from <= dateTo && period.date_to >= dateFrom)
|
||||
));
|
||||
|
||||
if (!conflicts) {
|
||||
return {
|
||||
code,
|
||||
name: `${RUSSIAN_MONTH_NAMES[monthIndex]} ${year}`,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
pay_date: formatDateInput(year, monthIndex + 1, 5),
|
||||
currency: "RUB",
|
||||
};
|
||||
}
|
||||
|
||||
const nextMonth = new Date(year, monthIndex + 1, 1);
|
||||
year = nextMonth.getFullYear();
|
||||
monthIndex = nextMonth.getMonth();
|
||||
}
|
||||
}
|
||||
|
||||
type ParsedPayrollAdminError = {
|
||||
status: number | null;
|
||||
code: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user