diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py index 473b9b42..a776ecc5 100644 --- a/backend/api/v1/finance/admin_serializers.py +++ b/backend/api/v1/finance/admin_serializers.py @@ -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( diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 64bf9641..5aaa024b 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -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, diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py index 493d641c..ff560af5 100644 --- a/backend/api/v1/finance/serializers.py +++ b/backend/api/v1/finance/serializers.py @@ -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, + ) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index e6b4f00d..f994c7fd 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -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//table/", + PayrollPeriodTableView.as_view(), + name="admin-period-table", + ), path( "payroll/admin/periods//attendance-work-records/", PayrollAttendanceWorkRecordsView.as_view(), name="admin-period-attendance-work-records", ), + path( + "payroll/admin/periods//bulk-pay-rate/", + PayrollBulkPayRateView.as_view(), + name="admin-period-bulk-pay-rate", + ), + path( + "payroll/admin/periods//bulk-point-rate/", + PayrollBulkPointRateView.as_view(), + name="admin-period-bulk-point-rate", + ), path( "payroll/admin/periods//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(), diff --git a/backend/api/v1/finance/views.py b/backend/api/v1/finance/views.py index f538d192..a463d335 100644 --- a/backend/api/v1/finance/views.py +++ b/backend/api/v1/finance/views.py @@ -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, + ) diff --git a/backend/documents/admin.py b/backend/documents/admin.py index 85a4b76f..f0d60533 100644 --- a/backend/documents/admin.py +++ b/backend/documents/admin.py @@ -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 diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index fcde7771..da6c4ccc 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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( diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 52cc2a92..183dbb01 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -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 diff --git a/backend/finance/migrations/0010_payrolldailyworkentry.py b/backend/finance/migrations/0010_payrolldailyworkentry.py new file mode 100644 index 00000000..e296569b --- /dev/null +++ b/backend/finance/migrations/0010_payrolldailyworkentry.py @@ -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')], + }, + ), + ] diff --git a/backend/finance/migrations/0011_payrollworksettings.py b/backend/finance/migrations/0011_payrollworksettings.py new file mode 100644 index 00000000..c01c65a0 --- /dev/null +++ b/backend/finance/migrations/0011_payrollworksettings.py @@ -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': 'Настройки выработки', + }, + ), + ] diff --git a/backend/finance/migrations/0012_payrollworkrecord_target_points_overridden.py b/backend/finance/migrations/0012_payrollworkrecord_target_points_overridden.py new file mode 100644 index 00000000..fff1bf3c --- /dev/null +++ b/backend/finance/migrations/0012_payrollworkrecord_target_points_overridden.py @@ -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, + ), + ] diff --git a/backend/finance/migrations/0013_alter_payrollauditevent_period_and_more.py b/backend/finance/migrations/0013_alter_payrollauditevent_period_and_more.py new file mode 100644 index 00000000..379ce440 --- /dev/null +++ b/backend/finance/migrations/0013_alter_payrollauditevent_period_and_more.py @@ -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='Заменяет запись'), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 08168873..a88a1a91 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -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", diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index e876a61e..7b3b0b5c 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -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.""" diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index 9e2880b4..e8caab2e 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -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( diff --git a/backend/finance/payroll/config.py b/backend/finance/payroll/config.py index 70e53ce8..9a54f81b 100644 --- a/backend/finance/payroll/config.py +++ b/backend/finance/payroll/config.py @@ -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 diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py new file mode 100644 index 00000000..1a039d5e --- /dev/null +++ b/backend/finance/payroll/reporting.py @@ -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"), + }, + } diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 63e12561..9fbe8fc0 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -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, *, diff --git a/backend/finance/payroll/work_norm.py b/backend/finance/payroll/work_norm.py new file mode 100644 index 00000000..d3d85380 --- /dev/null +++ b/backend/finance/payroll/work_norm.py @@ -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 diff --git a/backend/payroll_core/README.md b/backend/payroll_core/README.md index 8dd3c778..006576a6 100644 --- a/backend/payroll_core/README.md +++ b/backend/payroll_core/README.md @@ -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 diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py index 1cd96a9d..26fe7798 100644 --- a/backend/payroll_core/calculator.py +++ b/backend/payroll_core/calculator.py @@ -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", diff --git a/backend/payroll_core/domain.py b/backend/payroll_core/domain.py index 9ab8de72..c06188a5 100644 --- a/backend/payroll_core/domain.py +++ b/backend/payroll_core/domain.py @@ -23,6 +23,7 @@ class PointPolicy(str, Enum): DISABLED = "disabled" EXCESS_ONLY = "excess_only" + PROPORTIONAL_WITH_EXCESS = "proportional_with_excess" class RoundingPolicy(str, Enum): diff --git a/backend/payroll_core/tests/test_calculator.py b/backend/payroll_core/tests/test_calculator.py index f40c2f92..4485291a 100644 --- a/backend/payroll_core/tests/test_calculator.py +++ b/backend/payroll_core/tests/test_calculator.py @@ -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, diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index 2b990197..d3c96ca4 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -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() diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index b1923594..bda16bad 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -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"), { diff --git a/backend/tests/finance/test_payroll_admin_permissions.py b/backend/tests/finance/test_payroll_admin_permissions.py index 38c50a47..26921e2b 100644 --- a/backend/tests/finance/test_payroll_admin_permissions.py +++ b/backend/tests/finance/test_payroll_admin_permissions.py @@ -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, diff --git a/backend/tests/finance/test_payroll_config.py b/backend/tests/finance/test_payroll_config.py index eefac4ce..01e91016 100644 --- a/backend/tests/finance/test_payroll_config.py +++ b/backend/tests/finance/test_payroll_config.py @@ -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 diff --git a/backend/tests/finance/test_payroll_daily_work_api.py b/backend/tests/finance/test_payroll_daily_work_api.py new file mode 100644 index 00000000..5763b1f6 --- /dev/null +++ b/backend/tests/finance/test_payroll_daily_work_api.py @@ -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" diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index 0353eef0..e3be08c0 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -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"], diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/frontend/package.json b/frontend/package.json index 028d0741..433ef71e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 47bd210e..2ad16fc2 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -81,6 +81,10 @@ type PaginatedResponse = { 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(value: unknown): value is PaginatedResponse { 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([]); const [folders, setFolders] = useState([]); const [selectedFolderId, setSelectedFolderId] = useState(null); - const [activeSection, setActiveSection] = useState("regulations"); + const [activeSection, setActiveSection] = useState(requestedSection); const [search, setSearch] = useState(""); const [selectedTags, setSelectedTags] = useState([]); const [availableTags, setAvailableTags] = useState([]); @@ -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() { + {showPayrollAdmin ? (