Merge branch 'develop'

This commit is contained in:
lizerk 2026-07-20 14:01:05 +03:00
commit a5b58873c4
48 changed files with 5481 additions and 139 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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': 'Настройки выработки',
},
),
]

View File

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

View File

@ -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='Заменяет запись'),
),
]

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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

View File

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

View File

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

View File

@ -23,6 +23,7 @@ class PointPolicy(str, Enum):
DISABLED = "disabled"
EXCESS_ONLY = "excess_only"
PROPORTIONAL_WITH_EXCESS = "proportional_with_excess"
class RoundingPolicy(str, Enum):

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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