diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py index a776ecc5..f68fd7fc 100644 --- a/backend/api/v1/finance/admin_serializers.py +++ b/backend/api/v1/finance/admin_serializers.py @@ -276,6 +276,7 @@ class EmployeePayRateRevisionSerializer(serializers.Serializer): decimal_places=4, min_value=0, required=False, + allow_null=True, ) currency = serializers.CharField(max_length=3, required=False) reason = serializers.CharField(allow_blank=False, trim_whitespace=True) @@ -291,7 +292,7 @@ class BulkPointRateCommandSerializer(serializers.Serializer): ) mode = serializers.ChoiceField( choices=(MODE_FIXED, MODE_IN_NORM), - default=MODE_FIXED, + required=False, ) point_rate = serializers.DecimalField( max_digits=19, @@ -307,9 +308,19 @@ class BulkPointRateCommandSerializer(serializers.Serializer): 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: + mode = attrs.get("mode") + if mode is None: + mode = ( + self.MODE_FIXED + if attrs.get("point_rate") is not None + else self.MODE_IN_NORM + ) + attrs["mode"] = mode + if mode == self.MODE_FIXED and attrs.get("point_rate") is None: + raise serializers.ValidationError( + {"point_rate": "Укажите фиксированную цену или выберите автоматический расчёт."} + ) + if mode == self.MODE_IN_NORM: attrs["point_rate"] = None return attrs @@ -331,6 +342,38 @@ class BulkPayRateCommandSerializer(serializers.Serializer): return list(dict.fromkeys(value)) +class BulkTargetPointsCommandSerializer(serializers.Serializer): + MODE_FIXED = "fixed" + MODE_AUTOMATIC = "automatic" + + employee_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + allow_empty=False, + ) + mode = serializers.ChoiceField(choices=(MODE_FIXED, MODE_AUTOMATIC)) + target_points = serializers.DecimalField( + max_digits=19, + decimal_places=4, + min_value=Decimal("0.0001"), + 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("target_points") is None: + raise serializers.ValidationError( + {"target_points": "Укажите норму баллов."} + ) + if attrs["mode"] == self.MODE_AUTOMATIC: + attrs["target_points"] = None + return attrs + + class PayrollWorkRecordSerializer(serializers.ModelSerializer): employee = PayrollEmployeeMiniSerializer(read_only=True) employee_id = serializers.IntegerField(read_only=True) @@ -556,6 +599,11 @@ class ApprovalCommandSerializer(serializers.Serializer): expected_lock_version = serializers.IntegerField(min_value=0) +class ClearComponentCellCommandSerializer(serializers.Serializer): + employee_id = serializers.IntegerField(min_value=1) + component_id = serializers.IntegerField(min_value=1) + + class CalculatePayrollCommandSerializer(serializers.Serializer): expected_lock_version = serializers.IntegerField(min_value=0) recalculation_reason = serializers.CharField( diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 9d6cfdd7..c44a8e1b 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -10,7 +10,7 @@ from __future__ import annotations from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from django.db import IntegrityError, transaction -from django.db.models import F, OuterRef, Q, Subquery +from django.db.models import Q from django.http import Http404 from django.shortcuts import get_object_or_404 from rest_framework import permissions, status @@ -40,13 +40,17 @@ from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionD from finance.payroll.services import ( approve_input_line, approve_pay_rate, + approve_period_drafts, approve_run, approve_work_record, bulk_set_pay_rate, bulk_set_point_rate, + bulk_set_target_points, calculate_period, + clear_component_input_lines, close_period, publish_run, + pay_rates_relevant_to_period, return_run_for_correction, submit_run_for_review, ) @@ -61,7 +65,9 @@ from .admin_serializers import ( ApprovalCommandSerializer, BulkPayRateCommandSerializer, BulkPointRateCommandSerializer, + BulkTargetPointsCommandSerializer, CalculatePayrollCommandSerializer, + ClearComponentCellCommandSerializer, EmployeePayRateRevisionSerializer, EmployeePayRateSerializer, EmployeePayRateWriteSerializer, @@ -588,6 +594,16 @@ class PayrollPeriodTableView(PayrollAdminAPIView): return Response(build_payroll_period_table(period)) +class PayrollPeriodApproveDraftsView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],) + + def post(self, request, pk): + if not PayrollPeriod.objects.filter(pk=pk).exists(): + raise Http404 + summary = approve_period_drafts(pk, actor=request.user) + return Response({"period_id": pk, "summary": summary}) + + class DraftCollectionView(PayrollAdminAPIView): allowed_permissions = INPUT_VIEW_PERMISSIONS model = None @@ -766,24 +782,7 @@ class EmployeePayRateListCreateView(DraftCollectionView): if period_id is None: return queryset period = get_object_or_404(PayrollPeriod, pk=period_id) - latest_effective_date = ( - EmployeePayRate.objects.filter( - employee_id=OuterRef("employee_id"), - rate_code=OuterRef("rate_code"), - effective_from__lte=period.date_from, - ) - .order_by("-effective_from") - .values("effective_from")[:1] - ) - return queryset.annotate( - _latest_effective_date=Subquery(latest_effective_date) - ).filter( - Q(effective_from=F("_latest_effective_date")) - | Q( - effective_from__gt=period.date_from, - effective_from__lte=period.date_to, - ) - ) + return pay_rates_relevant_to_period(period, queryset=queryset) class EmployeePayRateDetailView(DraftDetailView): @@ -848,6 +847,31 @@ class PayrollBulkPayRateView(PayrollAdminAPIView): ) +class PayrollBulkTargetPointsView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + def post(self, request, pk): + get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk) + command = BulkTargetPointsCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + result = bulk_set_target_points( + pk, + actor=request.user, + **command.validated_data, + ) + return Response( + { + "mode": result["mode"], + "target_points": ( + f"{result['target_points']:.4f}" + if result["target_points"] is not None + else None + ), + "summary": result["summary"], + } + ) + + class PayrollWorkRecordListCreateView(DraftCollectionView): model = PayrollWorkRecord read_serializer_class = PayrollWorkRecordSerializer @@ -952,6 +976,21 @@ class PayrollInputLineDetailView(DraftDetailView): ).prefetch_related("employee__departments_links__department") +class PayrollComponentCellClearView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + def post(self, request, pk): + command = ClearComponentCellCommandSerializer(data=request.data) + command.is_valid(raise_exception=True) + summary = clear_component_input_lines( + pk, + employee_id=command.validated_data["employee_id"], + component_id=command.validated_data["component_id"], + actor=request.user, + ) + return Response(summary) + + class DraftApprovalView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],) service = None diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index f994c7fd..9dc983c5 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -13,6 +13,8 @@ from .admin_views import ( EmployeePayRateReviseView, PayrollBulkPayRateView, PayrollBulkPointRateView, + PayrollBulkTargetPointsView, + PayrollComponentCellClearView, PayrollAdminComponentListView, PayrollAdminEmployeeListView, PayrollAdminPeriodDetailView, @@ -23,6 +25,7 @@ from .admin_views import ( PayrollInputLineDetailView, PayrollInputLineListCreateView, PayrollPeriodCalculateView, + PayrollPeriodApproveDraftsView, PayrollPeriodCloseView, PayrollPeriodTableView, PayrollRunApproveView, @@ -69,6 +72,11 @@ urlpatterns = [ PayrollPeriodCalculateView.as_view(), name="admin-period-calculate", ), + path( + "payroll/admin/periods//approve-drafts/", + PayrollPeriodApproveDraftsView.as_view(), + name="admin-period-approve-drafts", + ), path( "payroll/admin/periods//table/", PayrollPeriodTableView.as_view(), @@ -89,6 +97,11 @@ urlpatterns = [ PayrollBulkPointRateView.as_view(), name="admin-period-bulk-point-rate", ), + path( + "payroll/admin/periods//bulk-target-points/", + PayrollBulkTargetPointsView.as_view(), + name="admin-period-bulk-target-points", + ), path( "payroll/admin/periods//close/", PayrollPeriodCloseView.as_view(), @@ -149,6 +162,11 @@ urlpatterns = [ PayrollInputLineApproveView.as_view(), name="admin-input-line-approve", ), + path( + "payroll/admin/periods//component-cell/clear/", + PayrollComponentCellClearView.as_view(), + name="admin-component-cell-clear", + ), path( "payroll/admin/runs//", PayrollRunDetailView.as_view(), diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index da6c4ccc..f4d47ac3 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -847,11 +847,12 @@ 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.2"), + "RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.07.4"), "EFFECTIVE_FROM": os.getenv("PAYROLL_EFFECTIVE_FROM", "2026-01-01"), "EFFECTIVE_TO": os.getenv("PAYROLL_EFFECTIVE_TO") or None, - # Below target the base accrual follows completion percentage; above target - # the full base is retained and excess points use the configured point rate. + # Excel-compatible point basis is base accrual plus bonus inputs. Below the + # target it is reduced proportionally; above target the configured excess + # price is used. A blank price resolves to the same in-norm point price. "POINT_POLICY": os.getenv( "PAYROLL_POINT_POLICY", "proportional_with_excess", diff --git a/backend/finance/README.md b/backend/finance/README.md index a2e2d7e0..65abe705 100644 --- a/backend/finance/README.md +++ b/backend/finance/README.md @@ -150,14 +150,16 @@ attendance through the period-scoped preview/apply command. This integration does not live in `payroll_core`: it translates verified portal attendance into the versioned payroll input contract. -Policy `hours_as_work_units_v1` maps one verified attendance hour to one work -unit. It is accepted only while the payroll point policy is `disabled`, so -hours cannot accidentally become paid production points. Missing days, -technical issues, open shifts, unverified remote work and work outside the -effective schedule block the affected employee. The apply command never -auto-approves: it creates a draft, updates an administrator-accessible draft, or -creates a draft revision that leaves the approved record intact until the -record is explicitly approved. +Policy `attendance_to_daily_points_v2` keeps attendance hours and payroll points +in separate units. Every verified workday contributes the configured daily +point target; actual points for that day are `daily target × worked hours ÷ +expected hours`. A complete shift therefore earns the daily target, a partial +shift earns its fraction, and overtime may exceed it. Missing days, technical +issues, open shifts, unverified remote work and work outside the effective +schedule block the affected employee. The apply command never auto-approves: +it creates a draft, updates an administrator-accessible draft, or creates a +draft revision that leaves the approved record intact until the record is +explicitly approved. ## Operational boundaries diff --git a/backend/finance/migrations/0014_pay_rate_automatic_excess_price.py b/backend/finance/migrations/0014_pay_rate_automatic_excess_price.py new file mode 100644 index 00000000..2d15bf05 --- /dev/null +++ b/backend/finance/migrations/0014_pay_rate_automatic_excess_price.py @@ -0,0 +1,41 @@ +from decimal import Decimal + +from django.db import migrations, models +import django.core.validators + + +def use_automatic_price_for_legacy_defaults(apps, schema_editor): + employee_pay_rate = apps.get_model("finance", "EmployeePayRate") + employee_pay_rate.objects.filter(point_rate=Decimal("0")).update(point_rate=None) + + +def restore_legacy_zero_price(apps, schema_editor): + employee_pay_rate = apps.get_model("finance", "EmployeePayRate") + employee_pay_rate.objects.filter(point_rate__isnull=True).update( + point_rate=Decimal("0") + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0013_alter_payrollauditevent_period_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="employeepayrate", + name="point_rate", + field=models.DecimalField( + blank=True, + decimal_places=4, + max_digits=19, + null=True, + validators=[django.core.validators.MinValueValidator(Decimal("0"))], + verbose_name="Цена балла сверх нормы", + ), + ), + migrations.RunPython( + use_automatic_price_for_legacy_defaults, + restore_legacy_zero_price, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index a88a1a91..e915bf67 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -231,7 +231,8 @@ class EmployeePayRate(DraftVersionedModel): "Цена балла сверх нормы", max_digits=19, decimal_places=4, - default=Decimal("0"), + null=True, + blank=True, validators=[MinValueValidator(Decimal("0"))], ) currency = models.CharField( diff --git a/backend/finance/payroll/__init__.py b/backend/finance/payroll/__init__.py index c2c4d9bd..14ef6e96 100644 --- a/backend/finance/payroll/__init__.py +++ b/backend/finance/payroll/__init__.py @@ -7,6 +7,8 @@ from .services import ( approve_run, approve_work_record, calculate_period, + clear_component_input_lines, + bulk_set_target_points, close_period, publish_run, return_run_for_correction, @@ -20,6 +22,8 @@ __all__ = [ "approve_run", "approve_work_record", "calculate_period", + "clear_component_input_lines", + "bulk_set_target_points", "close_period", "publish_run", "return_run_for_correction", diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index 7b3b0b5c..e1d3d16f 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -2,7 +2,7 @@ from __future__ import annotations -from decimal import Decimal +from decimal import Context, Decimal, ROUND_HALF_UP, localcontext from payroll_core import ( ExpectedTotals, @@ -19,6 +19,8 @@ from finance.models import ( PayrollWorkRecord, ) +POINT_BASE_COMPONENT_CODES = frozenset({"BONUS"}) + def _core_period(period: PayrollPeriod) -> CorePayrollPeriod: return CorePayrollPeriod(start=period.date_from, end=period.date_to) @@ -65,22 +67,69 @@ def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine, return tuple(mapped_lines) +def _effective_point_rate( + rate: EmployeePayRate | None, + point_base_accrual: Decimal, + target_points: Decimal | None, + *, + rounding: str = ROUND_HALF_UP, +) -> Decimal: + """Resolve a nullable excess price without persisting the derived value.""" + + if rate is not None and rate.point_rate is not None: + return rate.point_rate + if target_points is None or target_points <= 0: + return Decimal("0") + with localcontext(Context(prec=80, rounding=rounding)): + return (point_base_accrual / target_points).quantize( + Decimal("0.0001"), + rounding=rounding, + ) + + +def _point_base_accrual( + rate: EmployeePayRate | None, + input_lines: list[PayrollInputLine], +) -> Decimal: + """Reproduce the Excel point basis: base salary plus bonus inputs.""" + + base_amount = rate.amount if rate is not None else Decimal("0") + return base_amount + sum( + ( + record.amount + for record in input_lines + if record.component.code in POINT_BASE_COMPONENT_CODES + ), + Decimal("0"), + ) + + def build_core_request( *, period: PayrollPeriod, rate: EmployeePayRate, work_record: PayrollWorkRecord | None, input_lines: list[PayrollInputLine], + rounding: str = ROUND_HALF_UP, ) -> PayrollRequest: + target_points = work_record.target_points if work_record else None + point_base_accrual = _point_base_accrual(rate, input_lines) return PayrollRequest( employee_ref=f"employee:{rate.employee_id}", 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), + point_base_accrual=point_base_accrual, + target_points=target_points, actual_points=(work_record.actual_points if work_record else None), - point_rate=rate.point_rate, + point_rate=_effective_point_rate( + rate, + point_base_accrual, + target_points, + rounding=rounding, + ), + point_rate_overridden=rate.point_rate is not None, lines=_mapped_input_lines(input_lines), expected_totals=( _expected_totals(work_record) if work_record is not None else None @@ -96,9 +145,11 @@ def build_core_preview_request( target_points: Decimal, actual_points: Decimal, input_lines: list[PayrollInputLine], + rounding: str = ROUND_HALF_UP, ) -> PayrollRequest: """Build a non-persisted calculation request from draft or partial inputs.""" + point_base_accrual = _point_base_accrual(rate, input_lines) return PayrollRequest( employee_ref=f"employee:{employee_id}", period=_core_period(period), @@ -109,9 +160,16 @@ def build_core_preview_request( if rate is not None else "preview:missing-pay-rate" ), + point_base_accrual=point_base_accrual, target_points=target_points, actual_points=actual_points, - point_rate=rate.point_rate if rate is not None else Decimal("0"), + point_rate=_effective_point_rate( + rate, + point_base_accrual, + target_points, + rounding=rounding, + ), + point_rate_overridden=bool(rate is not None and rate.point_rate is not None), lines=_mapped_input_lines(input_lines), expected_totals=None, ) diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index 61f67662..a4044ae9 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -30,7 +30,12 @@ from finance.enums import ( PayrollPeriodStatus, PayrollRunStatus, ) -from finance.models import PayrollAuditEvent, PayrollPeriod, PayrollWorkRecord +from finance.models import ( + PayrollAuditEvent, + PayrollPeriod, + PayrollWorkRecord, + PayrollWorkSettings, +) from .access import has_payroll_permission, has_simple_admin_access from .config import build_rules @@ -38,14 +43,15 @@ from .exceptions import PayrollOperationError, PayrollPermissionDenied Employee = get_user_model() -ATTENDANCE_WORK_POLICY_CODE = "hours_as_work_units_v1" +ATTENDANCE_WORK_POLICY_CODE = "attendance_to_daily_points_v2" ATTENDANCE_WORK_POLICY = { "code": ATTENDANCE_WORK_POLICY_CODE, - "label": "1 час = 1 единица выработки", + "label": "Посещаемость переводится в баллы дневной нормы", "description": ( - "Норма равна сумме плановых часов, факт — сумме отработанных часов " - "по проверенным рабочим дням. Импорт создаёт только черновики." + "Полностью отработанный день даёт дневную норму баллов. Неполный день " + "и переработка рассчитываются пропорционально плановым часам." ), + "formula": ("Баллы дня = дневная норма × отработанные часы ÷ плановые часы"), } ATTENDANCE_IMPORT_MODES = {"missing_only", "replace_existing"} ACTION_KEYS = ("create", "update", "revise", "unchanged", "skip", "blocked") @@ -88,6 +94,48 @@ def _is_explicit_absence(record: AttendanceRecord) -> bool: ) +def calculate_attendance_points_projection( + records: list[AttendanceRecord], + *, + daily_target_points: Decimal, +) -> Decimal: + """Calculate a read-only point total from the available attendance rows. + + This intentionally uses the same valid-day formula as the attendance import, + but does not require a complete period: the summary table can therefore show + the current projection while the month is still in progress. + """ + + actual_points = Decimal("0") + for record in records: + worked = _finite_decimal(record.work_hours) + expected = _finite_decimal(record.expected_hours) + if worked is not None and worked > 0 and not record.effective_is_workday: + continue + if not record.effective_is_workday or record.technical_issues: + continue + if record.personnel_status == ACTION_REMOTE and not ( + record.is_manually_edited + and "work_hours" in (record.manual_edit_payload or {}) + ): + continue + if bool(record.arrival_time) != bool(record.departure_time): + continue + if ( + expected is None + or worked is None + or expected <= 0 + or expected > 24 + or worked < 0 + or worked > 24 + ): + continue + if worked == 0 and not record.arrival_time and not _is_explicit_absence(record): + continue + actual_points += daily_target_points * worked / expected + return actual_points.quantize(QUANTUM) + + def _employee_payload(employee) -> dict: position = getattr(employee, "position", None) links = list(getattr(employee, "departments_links", []).all()) @@ -152,7 +200,12 @@ def _attendance_snapshot_hash(records: list[AttendanceRecord]) -> str: return hashlib.sha256(encoded.encode()).hexdigest() -def _analyze_attendance(period: PayrollPeriod, records: list[AttendanceRecord]): +def _analyze_attendance( + period: PayrollPeriod, + records: list[AttendanceRecord], + *, + daily_target_points: Decimal, +): blockers: list[dict[str, str]] = [] warnings: list[dict[str, str]] = [] expected_dates = set(_date_range(period)) @@ -173,8 +226,10 @@ def _analyze_attendance(period: PayrollPeriod, records: list[AttendanceRecord]): ) ) - target = Decimal("0") - actual = Decimal("0") + target_points = Decimal("0") + actual_points = Decimal("0") + expected_hours = Decimal("0") + worked_hours = Decimal("0") effective_workdays = 0 technical_issue_days = 0 absence_days = 0 @@ -254,8 +309,10 @@ def _analyze_attendance(period: PayrollPeriod, records: list[AttendanceRecord]): ) continue - target += expected - actual += worked + expected_hours += expected + worked_hours += worked + target_points += daily_target_points + actual_points += daily_target_points * worked / expected absence_days += int(explicit_absence) manual_days += int(record.is_manually_edited) overtime_days += int(record.is_overtime) @@ -267,11 +324,11 @@ def _analyze_attendance(period: PayrollPeriod, records: list[AttendanceRecord]): f"Технические проблемы обнаружены за {technical_issue_days} дн.", ) ) - if target <= 0: + if target_points <= 0: blockers.append( _issue( - "ZERO_TARGET_WORK_UNITS", - "По посещаемости не удалось получить положительную норму часов.", + "ZERO_TARGET_POINTS", + "По посещаемости не удалось получить положительную норму баллов.", ) ) if missing_schedule: @@ -304,10 +361,11 @@ def _analyze_attendance(period: PayrollPeriod, records: list[AttendanceRecord]): ) return { - "target_points": str(target.quantize(QUANTUM)), - "actual_points": str(actual.quantize(QUANTUM)), - "expected_hours": str(target.quantize(QUANTUM)), - "worked_hours": str(actual.quantize(QUANTUM)), + "target_points": str(target_points.quantize(QUANTUM)), + "actual_points": str(actual_points.quantize(QUANTUM)), + "daily_target_points": str(daily_target_points.quantize(QUANTUM)), + "expected_hours": str(expected_hours.quantize(QUANTUM)), + "worked_hours": str(worked_hours.quantize(QUANTUM)), "attendance_days": len(records), "effective_workdays": effective_workdays, "technical_issue_days": technical_issue_days, @@ -453,11 +511,16 @@ def build_attendance_work_preview( .select_related("position") .prefetch_related("departments_links__department") } + daily_target_points = PayrollWorkSettings.get_daily_target_points() items = [] for employee_id in employee_ids: records = attendance_by_employee[employee_id] - metrics = _analyze_attendance(period, records) + metrics = _analyze_attendance( + period, + records, + daily_target_points=daily_target_points, + ) if not records: metrics["blockers"].append( _issue( @@ -528,7 +591,10 @@ def build_attendance_work_preview( "period_id": period.pk, "generated_at": timezone.now().isoformat(), "preview_token": token, - "policy": ATTENDANCE_WORK_POLICY, + "policy": { + **ATTENDANCE_WORK_POLICY, + "daily_target_points": str(daily_target_points.quantize(QUANTUM)), + }, "summary": { "attendance_employees": len(attendance_by_employee), "existing": sum(item["existing_record"] is not None for item in items), @@ -666,7 +732,7 @@ def apply_attendance_work_preview( else prior_max + 1 ) source_ref = ( - f"attendance-hours-v1:{period.pk}:{employee_id}:r{revision}:" + f"attendance-points-v2:{period.pk}:{employee_id}:r{revision}:" f"{preview_token[:24]}" ) metrics = { @@ -683,6 +749,7 @@ def apply_attendance_work_preview( status=ApprovalStatus.DRAFT, source=InputSource.ATTENDANCE, source_ref=source_ref, + target_points_overridden=False, reason=mutation_reason, created_by=actor, **metrics, @@ -695,6 +762,7 @@ def apply_attendance_work_preview( record = existing record._expected_lock_version = record.lock_version record.target_points = metrics["target_points"] + record.target_points_overridden = False record.actual_points = metrics["actual_points"] record.source = InputSource.ATTENDANCE record.source_ref = source_ref @@ -712,6 +780,7 @@ def apply_attendance_work_preview( status=ApprovalStatus.DRAFT, source=InputSource.ATTENDANCE, source_ref=source_ref, + target_points_overridden=False, reason=mutation_reason, created_by=actor, expected_point_amount=existing.expected_point_amount, diff --git a/backend/finance/payroll/config.py b/backend/finance/payroll/config.py index 9a54f81b..216691bb 100644 --- a/backend/finance/payroll/config.py +++ b/backend/finance/payroll/config.py @@ -13,7 +13,7 @@ from payroll_core import PayrollRules, PointPolicy, RoundingPolicy DEFAULT_CONFIG = { "RULESET_ID": "eusrr-standard", - "RULESET_VERSION": "2026.07.2", + "RULESET_VERSION": "2026.07.4", "EFFECTIVE_FROM": "2026-01-01", "EFFECTIVE_TO": None, "POINT_POLICY": PointPolicy.PROPORTIONAL_WITH_EXCESS.value, diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 1a039d5e..2c146678 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -7,8 +7,13 @@ from dataclasses import replace from decimal import Decimal from django.contrib.auth import get_user_model +from django.db.models import Prefetch, Q from payroll_core import DeterministicPayrollCalculator +from attendance.models import AttendanceRecord +from employees.models import EmployeeAction +from requests_app.enums import RequestStatus + from finance.enums import ApprovalStatus from finance.models import ( EmployeePayRate, @@ -16,15 +21,21 @@ from finance.models import ( PayrollInputLine, PayrollPeriod, PayrollWorkRecord, + PayrollWorkSettings, ) -from .adapter import build_core_preview_request +from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request +from .attendance import calculate_attendance_points_projection from .config import base_rate_code, build_rules -from .work_norm import calculate_period_target_points +from .work_norm import ( + calculate_period_personnel_points, + calculate_period_target_points, + resolve_employee_schedule, +) Employee = get_user_model() -SYSTEM_COMPONENT_CODES = {"BASE", "POINT_EXCESS"} +SYSTEM_COMPONENT_CODES = {"BASE", "POINT_EXCESS", "POINT_ADJUSTMENT"} def _decimal_text(value): @@ -47,6 +58,29 @@ def _in_norm_point_rate(amount, target_points, *, rounding): ) +def _point_base_amount(rate_amount, component_amounts): + if rate_amount in (None, ""): + return None + return Decimal(str(rate_amount)) + sum( + ( + Decimal(str(component_amounts.get(code, "0"))) + for code in POINT_BASE_COMPONENT_CODES + ), + Decimal("0"), + ) + + +def _signed_point_amount(lines): + point_line = next( + (line for line in lines if line.code in {"POINT_EXCESS", "POINT_ADJUSTMENT"}), + None, + ) + if point_line is None: + return Decimal("0") + kind = getattr(point_line.kind, "value", point_line.kind) + return -point_line.amount if kind == "adjustment_debit" else point_line.amount + + def _employee_department(employee): links = getattr(employee, "_prefetched_objects_cache", {}).get( "departments_links", @@ -71,15 +105,12 @@ def _employee_payload(employee): 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") - ) + 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 { @@ -202,10 +233,29 @@ def build_payroll_period_table(period: PayrollPeriod): employees = list( Employee.objects.filter(id__in=employee_ids) .select_related("position") - .prefetch_related("departments_links__department") + .prefetch_related( + "departments_links__department", + Prefetch( + "actions", + queryset=EmployeeAction.objects.filter( + Q(source_request__isnull=True) + | Q(source_request__status=RequestStatus.APPROVED), + date__date__lte=period.date_to, + ).select_related("source_request"), + to_attr="payroll_personnel_actions", + ), + ) .order_by("last_name", "first_name", "id") ) + attendance_by_employee = defaultdict(list) + attendance_records = AttendanceRecord.objects.filter( + employee_id__in=employee_ids, + date__range=(period.date_from, period.date_to), + ).select_related("analysis_run") + for record in attendance_records: + attendance_by_employee[record.employee_id].append(record) + 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) @@ -225,7 +275,9 @@ def build_payroll_period_table(period: PayrollPeriod): if component.is_active and component.code not in SYSTEM_COMPONENT_CODES } calculator = DeterministicPayrollCalculator() - preview_rules = replace(build_rules(), allow_negative_payable=True) + active_rules = build_rules() + preview_rules = replace(active_rules, allow_negative_payable=True) + daily_target_points = PayrollWorkSettings.get_daily_target_points() rows = [] for employee in employees: @@ -233,20 +285,33 @@ def build_payroll_period_table(period: PayrollPeriod): 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) + schedule, target_points_source = resolve_employee_schedule(employee) + automatic_target_points, _, _ = calculate_period_target_points( + period, + employee=employee, + daily_target_points=daily_target_points, + schedule=schedule, + ) + attendance_records_for_employee = attendance_by_employee.get(employee.pk, []) + attendance_points = ( + calculate_attendance_points_projection( + attendance_records_for_employee, + daily_target_points=daily_target_points, + ) + if attendance_records_for_employee + else None + ) + personnel_points, _ = calculate_period_personnel_points( + period, + employee=employee, + actions=employee.payroll_personnel_actions, + daily_target_points=daily_target_points, + schedule=schedule, ) 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"), - ) + point_amount = _signed_point_amount(result_lines) component_amounts = _component_amounts( result_lines, component_definitions, @@ -263,7 +328,9 @@ def build_payroll_period_table(period: PayrollPeriod): "payable": str(statement.payable), } rate_amount = snapshot.get("base_accrual") - point_rate = snapshot.get("point_rate") + # Keep the configured value nullable in the editable projection. + # The snapshot contains the effective value used historically. + point_rate = _decimal_text(rate.point_rate) if rate else None target_points = snapshot.get("target_points") actual_points = snapshot.get("actual_points") point_delta = _decimal_text(statement.point_delta) @@ -300,16 +367,10 @@ def build_payroll_period_table(period: PayrollPeriod): target_points=preview_target_points, actual_points=preview_actual_points, input_lines=source_lines, + rounding=preview_rules.rounding.value, ) 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"), - ) + point_amount = _signed_point_amount(preview_result.lines) preview_totals = preview_result.totals totals = { "gross_before_adjustments": str( @@ -335,8 +396,9 @@ def build_payroll_period_table(period: PayrollPeriod): else: status = "ready" + point_base_amount = _point_base_amount(rate_amount, component_amounts) in_norm_point_rate = _in_norm_point_rate( - rate_amount, + point_base_amount, target_points, rounding=preview_rules.rounding.value, ) @@ -353,6 +415,8 @@ def build_payroll_period_table(period: PayrollPeriod): "target_points": target_points, "target_points_automatic": target_points_automatic, "target_points_source": target_points_source, + "attendance_points": _decimal_text(attendance_points), + "personnel_points": _decimal_text(personnel_points), "actual_points": actual_points, "point_delta": point_delta, "point_amount": _decimal_text(point_amount), @@ -373,6 +437,7 @@ def build_payroll_period_table(period: PayrollPeriod): return { "period_id": period.pk, "currency": period.currency, + "calculation_rules": active_rules.to_dict(), "run": ( { "id": current_run.pk, @@ -390,9 +455,7 @@ def build_payroll_period_table(period: PayrollPeriod): "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"] - ), + "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 465d8626..453291b1 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -11,7 +11,7 @@ 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, Sum +from django.db.models import F, Max, OuterRef, Q, Subquery, Sum from django.utils import timezone from payroll_core import ( @@ -177,6 +177,34 @@ def _resolve_rates( return sorted(selected, key=lambda rate: rate.employee_id) +def pay_rates_relevant_to_period( + period: PayrollPeriod, + *, + queryset=None, +): + """Return rate revisions shown as inputs for a payroll period.""" + + queryset = queryset if queryset is not None else EmployeePayRate.objects.all() + latest_effective_date = ( + EmployeePayRate.objects.filter( + employee_id=OuterRef("employee_id"), + rate_code=OuterRef("rate_code"), + effective_from__lte=period.date_from, + ) + .order_by("-effective_from") + .values("effective_from")[:1] + ) + return queryset.annotate( + _latest_effective_date=Subquery(latest_effective_date) + ).filter( + Q(effective_from=F("_latest_effective_date")) + | Q( + effective_from__gt=period.date_from, + effective_from__lte=period.date_to, + ) + ) + + def _current_work_records(period): return { record.employee_id: record @@ -260,6 +288,7 @@ def _calculate_current_period_inputs( rate=rate, work_record=work_record, input_lines=input_lines[rate.employee_id], + rounding=active_rules.rounding.value, ) try: result = active_calculator.calculate(request, active_rules) @@ -749,7 +778,7 @@ def bulk_set_point_rate( point_rate: Decimal | None = None, reason: str, ) -> dict[str, object]: - """Set a fixed excess price or copy each employee's in-norm point price.""" + """Set a fixed excess price or clear it to use the dynamic in-norm price.""" _require_permission(actor, "finance.manage_payroll_inputs") period = PayrollPeriod.objects.select_for_update().get(pk=period_id) @@ -771,29 +800,6 @@ def bulk_set_point_rate( 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, @@ -833,14 +839,10 @@ def bulk_set_point_rate( 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, - ) + resolved_point_rate = None else: resolved_point_rate = point_rate - if resolved_point_rate is None: + if mode == "fixed" and resolved_point_rate is None: _operation_error( "POINT_RATE_REQUIRED", "Не задана фиксированная цена балла сверх нормы.", @@ -878,8 +880,16 @@ def bulk_set_point_rate( instance=current, period=period, metadata={ - "previous_point_rate": str(previous_point_rate), - "point_rate": str(resolved_point_rate), + "previous_point_rate": ( + str(previous_point_rate) + if previous_point_rate is not None + else None + ), + "point_rate": ( + str(resolved_point_rate) + if resolved_point_rate is not None + else None + ), "point_rate_mode": mode, "reason": reason, }, @@ -935,8 +945,16 @@ def bulk_set_point_rate( period=period, metadata={ "replaces_id": current.pk, - "previous_point_rate": str(current.point_rate), - "point_rate": str(resolved_point_rate), + "previous_point_rate": ( + str(current.point_rate) + if current.point_rate is not None + else None + ), + "point_rate": ( + str(resolved_point_rate) + if resolved_point_rate is not None + else None + ), "point_rate_mode": mode, "reason": reason, }, @@ -1116,7 +1134,7 @@ def bulk_set_pay_rate( employee=employee, rate_code=base_rate_code(), amount=amount, - point_rate=(previous.point_rate if previous else Decimal("0")), + point_rate=(previous.point_rate if previous else None), currency=period.currency, effective_from=effective_from, revision=max((item.revision for item in exact_rates), default=0) + 1, @@ -1128,7 +1146,9 @@ def bulk_set_pay_rate( action = "payroll.rate_bulk_created" metadata = { "amount": str(amount), - "point_rate": str(rate.point_rate), + "point_rate": ( + str(rate.point_rate) if rate.point_rate is not None else None + ), "reason": reason, } summary_key = "created_drafts" @@ -1170,6 +1190,232 @@ def bulk_set_pay_rate( } +@transaction.atomic +def bulk_set_target_points( + period_id: int, + *, + actor, + employee_ids: list[int], + mode: str, + target_points: Decimal | None, + reason: str, +) -> dict[str, object]: + """Set one target for selected employees or restore their automatic target.""" + + _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)) + employees = { + employee.pk: employee + for employee in Employee.objects.filter( + pk__in=selected_employee_ids, + is_active=True, + ) + .select_related("position") + .order_by("pk") + } + records = list( + PayrollWorkRecord.objects.select_for_update(of=("self",)) + .filter(period=period, employee_id__in=employees) + .select_related("employee", "replaces") + .order_by("employee_id", "revision", "pk") + ) + records_by_employee: dict[int, list[PayrollWorkRecord]] = defaultdict(list) + for record in records: + records_by_employee[record.employee_id].append(record) + + summary = { + "selected_employees": len(selected_employee_ids), + "created_drafts": 0, + "updated_drafts": 0, + "created_revisions": 0, + "unchanged": 0, + "skipped": 0, + } + changed_records: list[PayrollWorkRecord] = [] + + for employee_id in selected_employee_ids: + employee = employees.get(employee_id) + if employee is None: + summary["skipped"] += 1 + continue + employee_records = records_by_employee.get(employee_id, []) + active_records = [ + record + for record in employee_records + if record.status != ApprovalStatus.VOIDED + ] + current = ( + max(active_records, key=lambda record: (record.revision, record.pk)) + if active_records + else None + ) + automatic = mode == "automatic" + resolved_target = ( + calculate_period_target_points(period, employee=employee)[0] + if automatic + else target_points + ) + if resolved_target is None or resolved_target <= 0: + _operation_error( + "TARGET_POINTS_REQUIRED", + "Не удалось определить положительную норму баллов.", + employee_id=employee_id, + ) + if current is None and automatic: + # The table already calculates this value dynamically when no work + # record exists, so an empty automatic record would only add noise. + summary["unchanged"] += 1 + continue + if ( + current is not None + and current.target_points == resolved_target + and current.target_points_overridden is not automatic + ): + 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_target = current.target_points + previous_automatic = not current.target_points_overridden + current.target_points = resolved_target + current.target_points_overridden = not automatic + current.expected_point_amount = None + current.expected_gross = None + current.expected_recalculated_gross = None + current.expected_payable = None + current.reason = reason + current.source = InputSource.MANUAL + current.source_ref = "" + 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.work_bulk_target_updated", + instance=current, + period=period, + metadata={ + "previous_target_points": str(previous_target), + "previous_automatic": previous_automatic, + "target_points": str(resolved_target), + "automatic": automatic, + "reason": reason, + }, + ) + summary["updated_drafts"] += 1 + changed_records.append(current) + continue + + if current is not None and current.status == ApprovalStatus.APPROVED: + if PayrollWorkRecord.objects.filter(replaces=current).exists(): + summary["skipped"] += 1 + continue + record = PayrollWorkRecord( + period=period, + employee=employee, + target_points=resolved_target, + target_points_overridden=not automatic, + actual_points=current.actual_points, + revision=current.revision + 1, + replaces=current, + reason=reason, + status=ApprovalStatus.DRAFT, + source=InputSource.MANUAL, + created_by=actor, + ) + action = "payroll.work_bulk_target_revision_created" + metadata = { + "replaces_id": current.pk, + "previous_target_points": str(current.target_points), + "target_points": str(resolved_target), + "automatic": automatic, + "reason": reason, + } + summary_key = "created_revisions" + elif current is None: + record = PayrollWorkRecord( + period=period, + employee=employee, + target_points=resolved_target, + target_points_overridden=not automatic, + actual_points=Decimal("0"), + revision=max( + (record.revision for record in employee_records), + default=0, + ) + + 1, + reason=reason, + status=ApprovalStatus.DRAFT, + source=InputSource.MANUAL, + created_by=actor, + ) + action = "payroll.work_bulk_target_created" + metadata = { + "target_points": str(resolved_target), + "automatic": automatic, + "reason": reason, + } + summary_key = "created_drafts" + else: + summary["skipped"] += 1 + continue + + 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_BULK_CONFLICT", + "Одна из записей выработки уже изменена; обновите экран и повторите.", + employee_id=employee_id, + ) + _audit( + actor=actor, + action=action, + instance=record, + period=period, + metadata=metadata, + ) + summary[summary_key] += 1 + changed_records.append(record) + + return { + "mode": mode, + "target_points": target_points, + "summary": summary, + "records": changed_records, + } + + def _sync_daily_work_record( *, period: PayrollPeriod, @@ -1603,6 +1849,202 @@ def _ensure_period_accepts_approvals(period: PayrollPeriod) -> None: ) +@transaction.atomic +def clear_component_input_lines( + period_id: int, + *, + employee_id: int, + component_id: int, + actor, +) -> dict[str, int]: + """Clear one spreadsheet-like component cell without losing its audit trail.""" + + _require_permission(actor, "finance.manage_payroll_inputs") + period = ( + PayrollPeriod.objects.select_for_update() + .filter(pk=period_id) + .first() + ) + if period is None: + _operation_error("PERIOD_NOT_FOUND", "Расчётный период не найден.") + _ensure_period_accepts_approvals(period) + if not Employee.objects.filter(pk=employee_id).exists(): + _operation_error("EMPLOYEE_NOT_FOUND", "Сотрудник не найден.") + if not PayrollComponent.objects.filter(pk=component_id).exists(): + _operation_error("COMPONENT_NOT_FOUND", "Вид операции не найден.") + + lines = list( + PayrollInputLine.objects.select_for_update() + .filter( + period=period, + employee_id=employee_id, + component_id=component_id, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + ) + .order_by("pk") + ) + now = timezone.now() + summary = {"draft": 0, "approved": 0, "total": len(lines)} + for line in lines: + previous_status = line.status + summary[previous_status] += 1 + line.status = ApprovalStatus.VOIDED + line.voided_by = actor + line.voided_at = now + line.void_reason = "Значение очищено в итоговой таблице" + line.save( + update_fields=[ + "status", + "voided_by", + "voided_at", + "void_reason", + ] + ) + _audit( + actor=actor, + action="payroll.input_line_voided_from_table", + instance=line, + period=period, + metadata={"previous_status": previous_status}, + ) + return summary + + +@transaction.atomic +def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: + """Approve every draft shown in a period queue as one transaction.""" + + _require_permission(actor, "finance.approve_payroll_inputs") + + # Keep the global lock order used by single-record rate approval: periods, + # active runs, rates, work records, components, then input lines. + periods = list(PayrollPeriod.objects.select_for_update().order_by("pk")) + period = next((item for item in periods if item.pk == period_id), None) + if period is None: + _operation_error("PERIOD_NOT_FOUND", "Расчётный период не найден.") + + open_periods = [ + item for item in periods if item.status != PayrollPeriodStatus.CLOSED + ] + current_run_ids = [ + item.current_run_id + for item in open_periods + if item.current_run_id is not None + ] + current_runs = list( + PayrollRun.objects.select_for_update() + .filter(pk__in=current_run_ids) + .order_by("pk") + ) + _ensure_period_accepts_approvals(period) + + rates = list( + pay_rates_relevant_to_period( + period, + queryset=EmployeePayRate.objects.select_for_update(), + ) + .filter(status=ApprovalStatus.DRAFT) + .order_by("pk") + ) + work_records = list( + PayrollWorkRecord.objects.select_for_update() + .select_related("period") + .filter(period=period, status=ApprovalStatus.DRAFT) + .order_by("pk") + ) + input_refs = list( + PayrollInputLine.objects.filter( + period=period, + status=ApprovalStatus.DRAFT, + ) + .order_by("pk") + .values_list("pk", "component_id") + ) + components = { + component.pk: component + for component in PayrollComponent.objects.select_for_update() + .filter(pk__in={component_id for _, component_id in input_refs}) + .order_by("pk") + } + input_lines = list( + PayrollInputLine.objects.select_for_update() + .select_related("period", "component") + .filter(pk__in=[line_id for line_id, _ in input_refs]) + .order_by("pk") + ) + + active_run_statuses = { + PayrollRunStatus.CALCULATED, + PayrollRunStatus.REVIEW, + PayrollRunStatus.APPROVED, + } + for rate in rates: + for run in current_runs: + if ( + run.status in active_run_statuses + and run.statements.filter(employee_id=rate.employee_id).exists() + ): + _operation_error( + "RATE_USED_BY_ACTIVE_RUN", + "Ставку нельзя утвердить, пока расчёт сотрудника согласуется.", + run_id=run.pk, + ) + + for record in work_records: + if period.status == PayrollPeriodStatus.PUBLISHED and not record.replaces_id: + _operation_error( + "PUBLISHED_WORK_REPLACEMENT_REQUIRED", + "После публикации показатели меняются только новой ревизией.", + ) + + for line in input_lines: + component = components[line.component_id] + if not component.is_active: + _operation_error( + "PAYROLL_COMPONENT_INACTIVE", + "Нельзя утвердить строку с неактивным компонентом.", + component_id=component.pk, + ) + line._state.fields_cache["component"] = component + + for rate in rates: + _approve_record( + rate, + actor=actor, + object_label="rate", + expected_lock_version=rate.lock_version, + ) + for record in work_records: + _approve_record( + record, + actor=actor, + object_label="work_record", + expected_lock_version=record.lock_version, + ) + for line in input_lines: + _approve_record( + line, + actor=actor, + object_label="input_line", + expected_lock_version=line.lock_version, + ) + + summary = { + "rates": len(rates), + "work_records": len(work_records), + "input_lines": len(input_lines), + } + summary["total"] = sum(summary.values()) + _audit( + actor=actor, + action="payroll.period_drafts_approved", + instance=period, + period=period, + metadata=summary, + ) + return summary + + @transaction.atomic def approve_pay_rate( rate_id: int, diff --git a/backend/finance/payroll/work_norm.py b/backend/finance/payroll/work_norm.py index d3d85380..a9b5e977 100644 --- a/backend/finance/payroll/work_norm.py +++ b/backend/finance/payroll/work_norm.py @@ -8,6 +8,7 @@ from attendance.services import ( get_standard_work_schedule, get_standard_work_schedule_payload, ) +from employees.services.personnel_state import resolve_employee_personnel_state from finance.models import PayrollPeriod, PayrollWorkSettings @@ -44,7 +45,7 @@ def _override_date(value) -> date | None: return None -def count_period_workdays(period: PayrollPeriod, schedule: dict) -> int: +def period_workdates(period: PayrollPeriod, schedule: dict) -> list[date]: workdays = set(schedule.get("workdays") or WEEKDAY_NAMES[:5]) overrides = {} for item in schedule.get("date_overrides") or []: @@ -54,15 +55,20 @@ def count_period_workdays(period: PayrollPeriod, schedule: dict) -> int: if override_date is not None and "is_workday" in item: overrides[override_date] = bool(item["is_workday"]) - total = 0 + dates = [] 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)) + if overrides.get(current, is_workday): + dates.append(current) current += timedelta(days=1) - return total + return dates + + +def count_period_workdays(period: PayrollPeriod, schedule: dict) -> int: + return len(period_workdates(period, schedule)) def calculate_period_target_points( @@ -84,3 +90,39 @@ def calculate_period_target_points( workdays_count = count_period_workdays(period, schedule) target_points = (daily_target * workdays_count).quantize(QUANTUM) return target_points, workdays_count, source + + +def calculate_period_personnel_points( + period: PayrollPeriod, + *, + employee, + actions, + daily_target_points: Decimal | None = None, + schedule: dict | None = None, +) -> tuple[Decimal, int]: + """Project points from the work calendar and official personnel events. + + A scheduled day is treated as attended unless the personnel state explicitly + says that attendance is not expected (leave, sick leave, day off, maternity + leave or dismissal). The result is a read-only projection and is not stored + in payroll models. + """ + + if schedule is None: + schedule, _ = resolve_employee_schedule(employee) + daily_target = ( + daily_target_points + if daily_target_points is not None + else PayrollWorkSettings.get_daily_target_points() + ) + attended_dates = [ + workdate + for workdate in period_workdates(period, schedule) + if resolve_employee_personnel_state( + employee, + workdate, + actions=actions, + ).expects_attendance + ] + points = (daily_target * len(attended_dates)).quantize(QUANTUM) + return points, len(attended_dates) diff --git a/backend/payroll_core/README.md b/backend/payroll_core/README.md index 006576a6..8ef5b950 100644 --- a/backend/payroll_core/README.md +++ b/backend/payroll_core/README.md @@ -44,6 +44,7 @@ request = PayrollRequest( currency="RUB", base_accrual=Decimal("80000"), base_source_ref="rate:42:revision:1", + point_base_accrual=Decimal("95000"), target_points=Decimal("110"), actual_points=Decimal("110"), point_rate=Decimal("100"), @@ -89,14 +90,19 @@ 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`. +`PROPORTIONAL_WITH_EXCESS` produces a signed point adjustment. Below target it +uses the complete point basis supplied by the host: +`point_base_accrual * actual / target - point_base_accrual`. Above target it +uses `(actual - target) * point_rate`. Keeping the basis separate from the base +accrual preserves the original input lines while making the adjustment visible. ## Host boundary The core knows an employee only as an opaque `employee_ref`. The host adapter is responsible for authorization, rate resolution, maker-checker approval, transactions, audit, publication, acknowledgements and notifications. +EUSRR maps the spreadsheet point basis to base accrual plus bonus inputs. It +keeps the configured excess point price nullable: when blank, the host supplies +the current in-norm display price and marks it as automatic. The core then uses +the unrounded `point_base_accrual / target_points` formula, without persisting +the derived value. diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py index 26fe7798..bb34d447 100644 --- a/backend/payroll_core/calculator.py +++ b/backend/payroll_core/calculator.py @@ -58,7 +58,7 @@ def _is_finite_decimal(value: object) -> bool: class DeterministicPayrollCalculator: """Pure calculator with explicit Decimal and rounding semantics.""" - CALCULATOR_VERSION = "1.0.0" + CALCULATOR_VERSION = "1.1.0" DECIMAL_PRECISION = 80 MAX_DECIMAL_DIGITS = 30 MAX_FRACTIONAL_PLACES = 12 @@ -79,20 +79,13 @@ 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(base_amount), + amount=quantize(request.base_accrual), source_ref=request.base_source_ref, calculated=True, ) @@ -100,10 +93,37 @@ class DeterministicPayrollCalculator: warnings: list[CalculationWarning] = [] point_delta: Decimal | None = None - if rules.point_policy in { - PointPolicy.EXCESS_ONLY, - PointPolicy.PROPORTIONAL_WITH_EXCESS, - }: + if rules.point_policy is PointPolicy.PROPORTIONAL_WITH_EXCESS: + point_delta = request.actual_points - request.target_points + point_base_accrual = ( + request.point_base_accrual + if request.point_base_accrual is not None + else request.base_accrual + ) + raw_point_amount = ( + point_delta * request.point_rate + if point_delta > 0 and request.point_rate_overridden + else point_base_accrual + * (request.actual_points / request.target_points) + - point_base_accrual + ) + point_kind = ( + LineKind.ADJUSTMENT_DEBIT + if raw_point_amount < 0 + else LineKind.ADJUSTMENT_CREDIT + ) + lines.append( + PayrollLine( + line_id="system:point-adjustment", + code="POINT_ADJUSTMENT", + label="Point-based recalculation", + kind=point_kind, + amount=quantize(abs(raw_point_amount)), + source_ref="calculation:point-policy", + calculated=True, + ) + ) + elif rules.point_policy is PointPolicy.EXCESS_ONLY: point_delta = request.actual_points - request.target_points eligible_points = max(point_delta, Decimal("0")) point_amount = quantize(eligible_points * request.point_rate) @@ -118,10 +138,7 @@ class DeterministicPayrollCalculator: calculated=True, ) ) - if ( - point_delta < 0 - and rules.point_policy is PointPolicy.EXCESS_ONLY - ): + if point_delta < 0: warnings.append( CalculationWarning( code="POINTS_BELOW_TARGET_NO_DEDUCTION", @@ -290,13 +307,29 @@ class DeterministicPayrollCalculator: if expected is None: return [] - point_amount = next( - (line.amount for line in lines if line.line_id == "system:point-excess"), - Decimal("0"), + point_line = next( + ( + line + for line in lines + if line.line_id + in {"system:point-excess", "system:point-adjustment"} + ), + None, + ) + point_amount = ( + -point_line.amount + if point_line is not None + and point_line.kind is LineKind.ADJUSTMENT_DEBIT + else point_line.amount + if point_line is not None + else Decimal("0") + ) + gross_before_point_recalculation = ( + totals.gross_after_adjustments - point_amount ) checks = ( ("point_amount", expected.point_amount, point_amount), - ("gross", expected.gross, totals.gross_after_adjustments), + ("gross", expected.gross, gross_before_point_recalculation), ( "recalculated_gross", expected.recalculated_gross, @@ -379,6 +412,13 @@ class DeterministicPayrollCalculator: issues, minimum=Decimal("0"), ) + self._validate_decimal( + request.point_base_accrual, + "point_base_accrual", + issues, + minimum=Decimal("0"), + allow_none=True, + ) if not self._is_nonempty_string(request.base_source_ref): issues.append( PayrollIssue( @@ -393,6 +433,14 @@ class DeterministicPayrollCalculator: issues, minimum=Decimal("0"), ) + if not isinstance(request.point_rate_overridden, bool): + issues.append( + PayrollIssue( + code="INVALID_POINT_RATE_MODE", + field="point_rate_overridden", + message="Point rate override marker must be boolean.", + ) + ) if not self._is_nonempty_string(rules.ruleset_id): issues.append( @@ -722,7 +770,8 @@ class DeterministicPayrollCalculator: issues, minimum=( None - if field_name == "payable" and rules.allow_negative_payable + if field_name == "point_amount" + or field_name == "payable" and rules.allow_negative_payable else Decimal("0") ), allow_none=True, diff --git a/backend/payroll_core/domain.py b/backend/payroll_core/domain.py index c06188a5..0117e058 100644 --- a/backend/payroll_core/domain.py +++ b/backend/payroll_core/domain.py @@ -141,9 +141,11 @@ class PayrollRequest: currency: str base_accrual: Decimal base_source_ref: str + point_base_accrual: Decimal | None = None target_points: Decimal | None = None actual_points: Decimal | None = None point_rate: Decimal = Decimal("0") + point_rate_overridden: bool = True lines: tuple[InputLine, ...] = dataclass_field(default_factory=tuple) expected_totals: ExpectedTotals | None = None @@ -155,6 +157,11 @@ class PayrollRequest: "currency": self.currency, "base_accrual": decimal_text(self.base_accrual), "base_source_ref": self.base_source_ref, + "point_base_accrual": ( + decimal_text(self.point_base_accrual) + if self.point_base_accrual is not None + else None + ), "target_points": ( decimal_text(self.target_points) if self.target_points is not None @@ -166,6 +173,7 @@ class PayrollRequest: else None ), "point_rate": decimal_text(self.point_rate), + "point_rate_overridden": self.point_rate_overridden, "lines": [line.to_dict() for line in ordered_lines], "expected_totals": ( self.expected_totals.to_dict() diff --git a/backend/payroll_core/tests/test_calculator.py b/backend/payroll_core/tests/test_calculator.py index 4485291a..9b534640 100644 --- a/backend/payroll_core/tests/test_calculator.py +++ b/backend/payroll_core/tests/test_calculator.py @@ -139,7 +139,7 @@ def test_points_below_target_do_not_silently_reduce_pay(period, rules): ] -def test_proportional_policy_reduces_base_below_target(period, rules): +def test_proportional_policy_records_a_signed_adjustment_below_target(period, rules): proportional_rules = replace( rules, point_policy=PointPolicy.PROPORTIONAL_WITH_EXCESS, @@ -157,9 +157,12 @@ def test_proportional_policy_reduces_base_below_target(period, 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") + point_line = next( + line for line in result.lines if line.code == "POINT_ADJUSTMENT" + ) + assert base_line.amount == Decimal("90000.00") + assert point_line.kind is LineKind.ADJUSTMENT_DEBIT + assert point_line.amount == Decimal("11739.13") assert result.point_delta == Decimal("-15") assert result.totals.payable == Decimal("78260.87") assert result.warnings == () @@ -184,12 +187,80 @@ def test_proportional_policy_keeps_full_base_and_pays_excess(period, 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") + point_line = next( + line for line in result.lines if line.code == "POINT_ADJUSTMENT" + ) assert base_line.amount == Decimal("90000.00") + assert point_line.kind is LineKind.ADJUSTMENT_CREDIT assert point_line.amount == Decimal("500.00") assert result.totals.payable == Decimal("90500.00") +def test_proportional_policy_matches_excel_bonus_basis(period, rules): + proportional_rules = replace( + rules, + point_policy=PointPolicy.PROPORTIONAL_WITH_EXCESS, + ) + request = make_request( + period, + base_accrual=Decimal("30000"), + point_base_accrual=Decimal("40000"), + target_points=Decimal("110"), + actual_points=Decimal("90"), + lines=(input_line("bonus:1", "BONUS", LineKind.EARNING, "10000"),), + expected_totals=ExpectedTotals( + point_amount=Decimal("-7272.73"), + gross=Decimal("40000"), + recalculated_gross=Decimal("32727.27"), + payable=Decimal("32727.27"), + ), + ) + + result = DeterministicPayrollCalculator().calculate( + request, + proportional_rules, + ) + + point_line = next( + line for line in result.lines if line.code == "POINT_ADJUSTMENT" + ) + assert point_line.kind is LineKind.ADJUSTMENT_DEBIT + assert point_line.amount == Decimal("7272.73") + assert result.totals.gross_before_adjustments == Decimal("40000.00") + assert result.totals.adjustment_total == Decimal("-7272.73") + assert result.totals.gross_after_adjustments == Decimal("32727.27") + assert result.totals.payable == Decimal("32727.27") + assert result.warnings == () + + +def test_automatic_excess_rate_keeps_unrounded_excel_formula(period, rules): + proportional_rules = replace( + rules, + point_policy=PointPolicy.PROPORTIONAL_WITH_EXCESS, + ) + request = make_request( + period, + base_accrual=Decimal("30000"), + point_base_accrual=Decimal("40000"), + target_points=Decimal("110"), + actual_points=Decimal("233"), + point_rate=Decimal("363.6364"), + point_rate_overridden=False, + lines=(input_line("bonus:1", "BONUS", LineKind.EARNING, "10000"),), + ) + + result = DeterministicPayrollCalculator().calculate( + request, + proportional_rules, + ) + + point_line = next( + line for line in result.lines if line.code == "POINT_ADJUSTMENT" + ) + assert point_line.amount == Decimal("44727.27") + assert result.totals.gross_after_adjustments == Decimal("84727.27") + + def test_debit_deduction_and_advance_have_distinct_effects(period, rules): request = make_request( period, diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index bda16bad..94bebac3 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -1,4 +1,5 @@ import uuid +from datetime import date from decimal import Decimal import pytest @@ -7,6 +8,8 @@ from django.db import IntegrityError, transaction from django.urls import reverse from django.utils import timezone +from attendance.models import AttendanceAnalysisRun, AttendanceRecord +from employees.models import EmployeeAction from finance.models import ( EmployeePayRate, PayrollAuditEvent, @@ -16,6 +19,8 @@ from finance.models import ( PayrollRun, PayrollWorkRecord, ) +from requests_app.enums import RequestStatus, RequestType +from requests_app.models import Request pytestmark = pytest.mark.django_db @@ -229,6 +234,9 @@ def test_period_table_lists_all_employees_and_requires_view_all( assert_no_store(response) body = response.json() assert body["period_id"] == period.pk + assert body["calculation_rules"]["point_policy"] == "proportional_with_excess" + assert body["calculation_rules"]["money_quantum"] == "0.01" + assert body["calculation_rules"]["rounding"] == "ROUND_HALF_UP" assert body["run"] is None assert body["summary"]["employee_count"] >= 4 assert {column["code"] for column in body["component_columns"]} == set( @@ -240,8 +248,10 @@ def test_period_table_lists_all_employees_and_requires_view_all( 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["in_norm_point_rate"] == "736.3636" assert row["target_points"] == "110.0000" + assert row["attendance_points"] is None + assert row["personnel_points"] == "110.0000" assert row["actual_points"] == "115.0000" assert row["point_delta"] == "5.0000" assert row["point_amount"] == "750.00" @@ -249,13 +259,9 @@ def test_period_table_lists_all_employees_and_requires_view_all( 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" - ] + 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 + 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 @@ -265,6 +271,161 @@ def test_period_table_lists_all_employees_and_requires_view_all( assert missing_row["payable"] == "0.00" +def test_period_table_projects_attendance_and_official_personnel_points( + user_factory, + auth_client_factory, +): + manager = user_factory(email="table.projections.manager@example.test") + employee = user_factory( + email="table.projections.employee@example.test", + first_name="Иван", + last_name="Проекционный", + ) + grant(manager, "view_all_payroll") + period = make_period(creator=manager) + attendance_run = AttendanceAnalysisRun.objects.create( + employee=employee, + period_start="2026-06-01", + period_end="2026-06-30", + status=AttendanceAnalysisRun.STATUS_SUCCESS, + schedule_payload={ + "workdays": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + }, + ) + AttendanceRecord.objects.create( + analysis_run=attendance_run, + employee=employee, + date="2026-06-01", + arrival_time="08:00", + departure_time="17:00", + work_hours=9, + expected_hours=9, + effective_is_workday=True, + ) + AttendanceRecord.objects.create( + analysis_run=attendance_run, + employee=employee, + date="2026-06-02", + arrival_time="08:00", + departure_time="12:30", + work_hours=4.5, + expected_hours=9, + effective_is_workday=True, + ) + + vacation = Request.objects.create( + employee=employee, + type=RequestType.VACATION, + date_from=date(2026, 6, 3), + date_to=date(2026, 6, 3), + status=RequestStatus.PENDING, + ) + vacation.status = RequestStatus.APPROVED + vacation.approver = manager + vacation.decided_at = timezone.now() + vacation.save(update_fields=["status", "approver", "decided_at", "updated_at"]) + + action = EmployeeAction.objects.get(source_request=vacation) + assert action.date.date() == date(2026, 6, 3) + + response = auth_client_factory(manager).get( + api_url("admin-period-table", pk=period.pk), + ) + + assert response.status_code == 200, response.content + row = next( + item + for item in response.json()["rows"] + if item["employee"]["id"] == employee.pk + ) + assert row["attendance_points"] == "7.5000" + assert row["personnel_points"] == "105.0000" + + +def test_component_cell_can_be_cleared_and_voids_all_active_lines( + user_factory, + auth_client_factory, +): + manager = user_factory(email="table.clear.manager@example.test") + employee = user_factory(email="table.clear.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + period = make_period(creator=manager) + bonus = PayrollComponent.objects.get(code="BONUS") + vacation = PayrollComponent.objects.get(code="VACATION_PAY") + draft = PayrollInputLine.objects.create( + period=period, + employee=employee, + component=bonus, + amount="3000", + reason="Черновая премия", + created_by=manager, + ) + approved = PayrollInputLine.objects.create( + period=period, + employee=employee, + component=bonus, + amount="7000", + reason="Утверждённая премия", + status="approved", + created_by=manager, + approved_by=manager, + approved_at=timezone.now(), + self_approval_overridden=True, + ) + unrelated = PayrollInputLine.objects.create( + period=period, + employee=employee, + component=vacation, + amount="500", + reason="Отпускные", + created_by=manager, + ) + client = auth_client_factory(manager) + + response = client.post( + api_url("admin-component-cell-clear", pk=period.pk), + {"employee_id": employee.pk, "component_id": bonus.pk}, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.json() == {"draft": 1, "approved": 1, "total": 2} + draft.refresh_from_db() + approved.refresh_from_db() + unrelated.refresh_from_db() + for line in (draft, approved): + assert line.status == "voided" + assert line.voided_by_id == manager.pk + assert line.voided_at is not None + assert line.void_reason == "Значение очищено в итоговой таблице" + assert unrelated.status == "draft" + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.input_line_voided_from_table", + object_id__in=[str(draft.pk), str(approved.pk)], + ).count() + == 2 + ) + + table_response = client.get(api_url("admin-period-table", pk=period.pk)) + assert table_response.status_code == 200, table_response.content + row = next( + item + for item in table_response.json()["rows"] + if item["employee"]["id"] == employee.pk + ) + assert "BONUS" not in row["component_amounts"] + assert row["component_amounts"] == {"VACATION_PAY": "500.00"} + + repeated = client.post( + api_url("admin-component-cell-clear", pk=period.pk), + {"employee_id": employee.pk, "component_id": bonus.pk}, + format="json", + ) + assert repeated.status_code == 200 + assert repeated.json() == {"draft": 0, "approved": 0, "total": 0} + + def test_period_table_includes_all_active_and_only_inactive_employees_with_work( user_factory, auth_client_factory, @@ -338,6 +499,55 @@ def test_period_table_includes_all_active_and_only_inactive_employees_with_work( assert inactive_with_zero_work.pk not in employee_ids +def test_period_table_preview_matches_excel_point_formula_with_bonus( + user_factory, + auth_client_factory, +): + manager = user_factory(email="table.excel.formula.manager@example.test") + employee = user_factory(email="table.excel.formula.employee@example.test") + grant(manager, "view_all_payroll") + period = make_period(creator=manager) + EmployeePayRate.objects.create( + employee=employee, + amount="30000", + point_rate=None, + effective_from=period.date_from, + created_by=manager, + ) + PayrollWorkRecord.objects.create( + period=period, + employee=employee, + target_points="110", + actual_points="90", + created_by=manager, + ) + PayrollInputLine.objects.create( + period=period, + employee=employee, + component=PayrollComponent.objects.get(code="BONUS"), + amount="10000", + created_by=manager, + ) + + response = auth_client_factory(manager).get( + api_url("admin-period-table", pk=period.pk), + ) + + assert response.status_code == 200, response.content + row = next( + item + for item in response.json()["rows"] + if item["employee"]["id"] == employee.pk + ) + assert row["in_norm_point_rate"] == "363.6364" + assert row["point_rate"] is None + assert row["point_amount"] == "-7272.73" + assert row["gross_before_adjustments"] == "40000.00" + assert row["adjustment_total"] == "-7272.73" + assert row["gross_total"] == "32727.27" + assert row["payable"] == "32727.27" + + def test_staff_admin_gets_full_pilot_access_and_can_manage_any_draft( user_factory, auth_client_factory, @@ -404,6 +614,102 @@ def test_staff_admin_gets_full_pilot_access_and_can_manage_any_draft( assert "self_approval_overridden" not in approved.json() +def test_staff_admin_can_approve_all_period_drafts_atomically( + user_factory, + auth_client_factory, +): + administrator = user_factory(email="bulk.approval.admin@example.test", staff=True) + employee = user_factory(email="bulk.approval.employee@example.test") + period = make_period(creator=administrator) + bonus = PayrollComponent.objects.get(code="BONUS") + client = auth_client_factory(administrator) + + rate = EmployeePayRate.objects.create( + employee=employee, + amount="80000.0000", + point_rate=None, + effective_from=period.date_from, + created_by=administrator, + ) + work_record = PayrollWorkRecord.objects.create( + period=period, + employee=employee, + target_points="110.0000", + actual_points="105.0000", + created_by=administrator, + ) + input_line = PayrollInputLine.objects.create( + period=period, + employee=employee, + component=bonus, + amount="5000.00", + created_by=administrator, + ) + + response = client.post( + api_url("admin-period-approve-drafts", pk=period.pk), + {}, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.json() == { + "period_id": period.pk, + "summary": { + "rates": 1, + "work_records": 1, + "input_lines": 1, + "total": 3, + }, + } + rate.refresh_from_db() + work_record.refresh_from_db() + input_line.refresh_from_db() + assert rate.status == "approved" + assert work_record.status == "approved" + assert input_line.status == "approved" + assert rate.approved_by_id == administrator.pk + assert work_record.approved_by_id == administrator.pk + assert input_line.approved_by_id == administrator.pk + assert_no_store(response) + + +def test_approve_all_period_drafts_rolls_back_when_one_draft_is_invalid( + user_factory, + auth_client_factory, +): + administrator = user_factory(email="bulk.rollback.admin@example.test", staff=True) + employee = user_factory(email="bulk.rollback.employee@example.test") + period = make_period(creator=administrator) + bonus = PayrollComponent.objects.get(code="BONUS") + rate = EmployeePayRate.objects.create( + employee=employee, + amount="80000.0000", + effective_from=period.date_from, + created_by=administrator, + ) + PayrollInputLine.objects.create( + period=period, + employee=employee, + component=bonus, + amount="5000.00", + created_by=administrator, + ) + bonus.is_active = False + bonus.save(update_fields=["is_active"]) + + response = auth_client_factory(administrator).post( + api_url("admin-period-approve-drafts", pk=period.pk), + {}, + format="json", + ) + + assert response.status_code == 409 + assert response.json()["code"] == "PAYROLL_COMPONENT_INACTIVE" + rate.refresh_from_db() + assert rate.status == "draft" + + def test_staff_without_finance_permissions_is_denied_when_simple_mode_is_disabled( settings, user_factory, @@ -474,7 +780,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates( "code": "RULESET_NOT_EFFECTIVE", "message": ( "Для выбранного периода нет действующих правил расчёта. " - "Набор eusrr-standard, версия 2026.07.2, применяется с 01.07.2026. " + "Набор eusrr-standard, версия 2026.07.4, применяется с 01.07.2026. " "Измените период или подключите историческую версию правил." ), "details": { @@ -484,7 +790,7 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates( }, "ruleset": { "id": "eusrr-standard", - "version": "2026.07.2", + "version": "2026.07.4", "effective_from": "2026-07-01", }, }, @@ -773,26 +1079,32 @@ def test_bulk_point_rate_updates_drafts_and_revises_approved_rates( 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 + 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( +def test_bulk_point_rate_uses_in_norm_price_without_persisting_derived_value( 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") + grant(manager, "manage_payroll_inputs", "view_all_payroll") period = make_period(creator=manager) first_rate = EmployeePayRate.objects.create( employee=first_employee, @@ -812,7 +1124,7 @@ def test_bulk_point_rate_can_copy_each_employees_in_norm_price( period=period, employee=first_employee, target_points="100", - actual_points="100", + actual_points="110", created_by=manager, ) PayrollWorkRecord.objects.create( @@ -847,11 +1159,27 @@ def test_bulk_point_rate_can_copy_each_employees_in_norm_price( } 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") + assert first_rate.point_rate is None + assert second_rate.point_rate is None + + table = auth_client_factory(manager).get( + api_url("admin-period-table", pk=period.pk), + ) + assert table.status_code == 200, table.content + first_row = next( + row + for row in table.json()["rows"] + if row["employee"]["id"] == first_employee.pk + ) + assert first_row["point_rate"] is None + assert first_row["in_norm_point_rate"] == "800.0000" + assert first_row["point_amount"] == "8000.00" + assert first_row["gross_total"] == "88000.00" + first_rate.refresh_from_db() + assert first_rate.point_rate is None -def test_bulk_point_rate_defaults_an_empty_fixed_price_to_zero( +def test_bulk_point_rate_defaults_an_omitted_price_to_automatic( user_factory, auth_client_factory, ): @@ -871,16 +1199,16 @@ def test_bulk_point_rate_defaults_an_empty_fixed_price_to_zero( api_url("admin-period-bulk-point-rate", pk=period.pk), { "employee_ids": [employee.pk], - "reason": "Убрать доплату сверх нормы", + "reason": "Использовать автоматическую цену сверх нормы", }, format="json", ) assert response.status_code == 200, response.content - assert response.json()["mode"] == "fixed" - assert response.json()["point_rate"] == "0.0000" + assert response.json()["mode"] == "in_norm" + assert response.json()["point_rate"] is None rate.refresh_from_db() - assert rate.point_rate == Decimal("0.0000") + assert rate.point_rate is None def test_bulk_point_rate_rejects_locked_period_without_partial_changes( @@ -918,6 +1246,144 @@ def test_bulk_point_rate_rejects_locked_period_without_partial_changes( assert rate.lock_version == 0 +def test_bulk_target_points_creates_updates_and_revises_work_records( + user_factory, + auth_client_factory, +): + manager = user_factory(email="bulk.target.manager@example.test") + approver = user_factory(email="bulk.target.approver@example.test") + draft_employee = user_factory(email="bulk.target.draft@example.test") + approved_employee = user_factory(email="bulk.target.approved@example.test") + missing_employee = user_factory(email="bulk.target.missing@example.test") + unchanged_employee = user_factory(email="bulk.target.unchanged@example.test") + grant(manager, "manage_payroll_inputs") + period = make_period(creator=manager) + draft = PayrollWorkRecord.objects.create( + period=period, + employee=draft_employee, + target_points="100", + actual_points="80", + created_by=manager, + ) + approved = PayrollWorkRecord.objects.create( + period=period, + employee=approved_employee, + target_points="120", + actual_points="90", + status="approved", + created_by=approver, + approved_by=manager, + approved_at=timezone.now(), + ) + unchanged = PayrollWorkRecord.objects.create( + period=period, + employee=unchanged_employee, + target_points="115", + actual_points="70", + created_by=manager, + ) + + response = auth_client_factory(manager).post( + api_url("admin-period-bulk-target-points", pk=period.pk), + { + "employee_ids": [ + draft_employee.pk, + approved_employee.pk, + missing_employee.pk, + unchanged_employee.pk, + ], + "mode": "fixed", + "target_points": "115.0000", + "reason": "Единая норма на период", + }, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.json() == { + "mode": "fixed", + "target_points": "115.0000", + "summary": { + "selected_employees": 4, + "created_drafts": 1, + "updated_drafts": 1, + "created_revisions": 1, + "unchanged": 1, + "skipped": 0, + }, + } + draft.refresh_from_db() + unchanged.refresh_from_db() + assert draft.target_points == Decimal("115") + assert draft.target_points_overridden is True + assert draft.actual_points == Decimal("80") + assert draft.lock_version == 1 + assert unchanged.lock_version == 0 + + revision = PayrollWorkRecord.objects.get(replaces=approved) + assert revision.target_points == Decimal("115") + assert revision.target_points_overridden is True + assert revision.actual_points == Decimal("90") + assert revision.status == "draft" + assert revision.reason == "Единая норма на период" + + created = PayrollWorkRecord.objects.get(employee=missing_employee, period=period) + assert created.target_points == Decimal("115") + assert created.target_points_overridden is True + assert created.actual_points == Decimal("0") + assert created.status == "draft" + + +def test_bulk_target_points_restores_automatic_norm_without_empty_records( + user_factory, + auth_client_factory, +): + manager = user_factory(email="bulk.target.auto.manager@example.test") + employee = user_factory(email="bulk.target.auto.employee@example.test") + missing_employee = user_factory(email="bulk.target.auto.missing@example.test") + grant(manager, "manage_payroll_inputs") + period = make_period(creator=manager) + record = PayrollWorkRecord.objects.create( + period=period, + employee=employee, + target_points="77", + actual_points="50", + created_by=manager, + ) + + response = auth_client_factory(manager).post( + api_url("admin-period-bulk-target-points", pk=period.pk), + { + "employee_ids": [employee.pk, missing_employee.pk], + "mode": "automatic", + "reason": "Вернуть норму по рабочему графику", + }, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.json() == { + "mode": "automatic", + "target_points": None, + "summary": { + "selected_employees": 2, + "created_drafts": 0, + "updated_drafts": 1, + "created_revisions": 0, + "unchanged": 1, + "skipped": 0, + }, + } + record.refresh_from_db() + assert record.target_points == Decimal("110") + assert record.target_points_overridden is False + assert record.actual_points == Decimal("50") + assert not PayrollWorkRecord.objects.filter( + employee=missing_employee, + period=period, + ).exists() + + def test_bulk_pay_rate_creates_updates_and_revises_selected_employee_rates( user_factory, auth_client_factory, @@ -1026,7 +1492,7 @@ def test_bulk_pay_rate_creates_updates_and_revises_selected_employee_rates( untouched.refresh_from_db() assert created.amount == Decimal("100000") - assert created.point_rate == Decimal("0") + assert created.point_rate is None assert created.status == "draft" assert inherited.amount == Decimal("100000") assert inherited.point_rate == Decimal("75") @@ -1043,20 +1509,29 @@ def test_bulk_pay_rate_creates_updates_and_revises_selected_employee_rates( 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 + 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( diff --git a/backend/tests/finance/test_payroll_attendance_import.py b/backend/tests/finance/test_payroll_attendance_import.py index ec4f1032..a215cab7 100644 --- a/backend/tests/finance/test_payroll_attendance_import.py +++ b/backend/tests/finance/test_payroll_attendance_import.py @@ -115,7 +115,19 @@ def test_attendance_preview_and_missing_only_create_audited_draft( assert preview_response.status_code == 200 preview = preview_response.json() - assert preview["policy"]["code"] == "hours_as_work_units_v1" + assert preview["policy"] == { + "code": "attendance_to_daily_points_v2", + "label": "Посещаемость переводится в баллы дневной нормы", + "description": ( + "Полностью отработанный день даёт дневную норму баллов. " + "Неполный день и переработка рассчитываются пропорционально " + "плановым часам." + ), + "formula": ( + "Баллы дня = дневная норма × отработанные часы ÷ плановые часы" + ), + "daily_target_points": "5.0000", + } assert preview["summary"] == { "attendance_employees": 1, "existing": 0, @@ -142,8 +154,11 @@ def test_attendance_preview_and_missing_only_create_audited_draft( }, } item = preview["items"][0] - assert item["target_points"] == "16.0000" - assert item["actual_points"] == "8.0000" + assert item["expected_hours"] == "16.0000" + assert item["worked_hours"] == "8.0000" + assert item["daily_target_points"] == "5.0000" + assert item["target_points"] == "10.0000" + assert item["actual_points"] == "5.0000" assert item["actions"] == { "missing_only": "create", "replace_existing": "create", @@ -168,8 +183,9 @@ def test_attendance_preview_and_missing_only_create_audited_draft( record = PayrollWorkRecord.objects.get(period=period, employee=employee) assert record.status == ApprovalStatus.DRAFT assert record.source == InputSource.ATTENDANCE - assert record.target_points == Decimal("16.0000") - assert record.actual_points == Decimal("8.0000") + assert record.target_points == Decimal("10.0000") + assert record.target_points_overridden is False + assert record.actual_points == Decimal("5.0000") assert record.created_by == manager assert record.approved_by is None assert PayrollAuditEvent.objects.filter( @@ -289,7 +305,9 @@ def test_replace_updates_only_own_draft_and_protects_foreign_draft( assert response.json()["summary"]["blocked"] == 1 own.refresh_from_db() foreign.refresh_from_db() - assert own.target_points == Decimal("16.0000") + assert own.target_points == Decimal("10.0000") + assert own.target_points_overridden is False + assert own.actual_points == Decimal("5.0000") assert own.lock_version == 1 assert own.source == InputSource.ATTENDANCE assert foreign.target_points == Decimal("1") @@ -338,7 +356,9 @@ def test_staff_admin_can_refresh_a_foreign_attendance_draft( assert response.status_code == 200 assert response.json()["summary"]["updated"] == 1 record.refresh_from_db() - assert record.target_points == Decimal("16.0000") + assert record.target_points == Decimal("10.0000") + assert record.target_points_overridden is False + assert record.actual_points == Decimal("5.0000") assert record.lock_version == 1 diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index e3be08c0..a0266241 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -209,7 +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", + "POINT_ADJUSTMENT", "BONUS", "CORRECTION_CREDIT", ] @@ -219,6 +219,66 @@ def test_excel_row_is_persisted_as_a_deterministic_statement( ).exists() +def test_calculation_uses_excel_point_basis_with_bonus( + june_period, + payroll_users, +): + operator = payroll_users["operator"] + approver = payroll_users["input_approver"] + employee = payroll_users["employee"] + rate = EmployeePayRate.objects.create( + employee=employee, + amount=Decimal("30000"), + point_rate=None, + currency="RUB", + effective_from=date(2026, 1, 1), + created_by=operator, + ) + work = PayrollWorkRecord.objects.create( + period=june_period, + employee=employee, + target_points=Decimal("110"), + actual_points=Decimal("90"), + expected_point_amount=Decimal("-7272.73"), + expected_gross=Decimal("40000"), + expected_recalculated_gross=Decimal("32727.27"), + expected_payable=Decimal("32727.27"), + created_by=operator, + ) + bonus = PayrollInputLine.objects.create( + period=june_period, + employee=employee, + component=PayrollComponent.objects.get(code="BONUS"), + amount=Decimal("10000"), + created_by=operator, + ) + for instance, approve in ( + (rate, approve_pay_rate), + (work, approve_work_record), + (bonus, approve_input_line), + ): + approve( + instance.pk, + actor=approver, + expected_lock_version=instance.lock_version, + ) + + run = calculate_period(june_period.pk, actor=operator) + + statement = run.statements.get(employee=employee) + point_line = statement.lines.get(code="POINT_ADJUSTMENT") + assert statement.input_snapshot["point_base_accrual"] == "40000" + assert statement.input_snapshot["point_rate"] == "363.6364" + assert statement.input_snapshot["point_rate_overridden"] is False + assert point_line.kind == "adjustment_debit" + assert point_line.amount == Decimal("7272.73") + assert statement.gross_before_adjustments == Decimal("40000.00") + assert statement.adjustment_total == Decimal("-7272.73") + assert statement.gross_total == Decimal("32727.27") + assert statement.payable == Decimal("32727.27") + assert statement.result_snapshot["warnings"] == [] + + def test_full_workflow_requires_a_second_person_and_employee_can_acknowledge( june_period, payroll_users, @@ -441,7 +501,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.2, применяется с 01.07.2026. " + "Набор eusrr-standard, версия 2026.07.4, применяется с 01.07.2026. " "Измените период или подключите историческую версию правил." ) assert error.value.details["employee_id"] == payroll_users["employee"].pk @@ -451,7 +511,7 @@ def test_ruleset_not_effective_keeps_domain_code_and_localized_message( } assert error.value.details["ruleset"] == { "id": "eusrr-standard", - "version": "2026.07.2", + "version": "2026.07.4", "effective_from": "2026-07-01", } assert {issue["code"] for issue in error.value.details["issues"]} == { diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx index 0d75695c..a921631e 100644 --- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -15,6 +15,9 @@ import type { PayrollBulkPointRateMode, PayrollBulkPointRateResult, PayrollBulkPointRateWrite, + PayrollBulkTargetPointsMode, + PayrollBulkTargetPointsResult, + PayrollBulkTargetPointsWrite, PayrollComponent, PayrollInputLineWrite, PayrollPayRateWrite, @@ -195,7 +198,7 @@ export function PayrollRateFormModal({ }) { const [employeeId, setEmployeeId] = useState(() => rate ? String(rate.employee.id) : ""); const [amount, setAmount] = useState(() => rate?.amount || ""); - const [pointRate, setPointRate] = useState(() => rate?.point_rate || "0"); + const [pointRate, setPointRate] = useState(() => rate?.point_rate ?? ""); const [effectiveFrom, setEffectiveFrom] = useState(() => rate?.effective_from || period.date_from); const [reason, setReason] = useState(() => rate?.reason || ""); const [busy, setBusy] = useState(false); @@ -207,7 +210,7 @@ export function PayrollRateFormModal({ employee_id: Number(employeeId), rate_code: "BASE", amount, - point_rate: pointRate || "0", + point_rate: pointRate.trim() || null, currency: period.currency, effective_from: effectiveFrom, reason, @@ -237,8 +240,8 @@ export function PayrollRateFormModal({ @@ -396,7 +401,7 @@ export function PayrollBulkPointRateModal({
@@ -496,6 +506,197 @@ export function PayrollBulkPointRateModal({ ); } +export function PayrollBulkTargetPointsModal({ + isOpen, + period, + employees, + records, + onClose, + onSubmit, + onSaved, + onStale, +}: { + isOpen: boolean; + period: PayrollAdminPeriod; + employees: PayrollAdminEmployee[]; + records: PayrollAdminWorkRecord[]; + onClose: () => void; + onSubmit: (payload: PayrollBulkTargetPointsWrite) => Promise; + onSaved: (result: PayrollBulkTargetPointsResult) => void | Promise; + onStale?: () => void | Promise; +}) { + const employeeOptions = useMemo(() => employees.map((employee) => { + const current = records + .filter((record) => record.employee.id === employee.id && record.status !== "voided") + .sort((left, right) => right.revision - left.revision || right.id - left.id)[0]; + return { employee, current }; + }).sort((left, right) => ( + left.employee.display_name.localeCompare(right.employee.display_name, "ru") + )), [employees, records]); + const [selectedEmployeeIds, setSelectedEmployeeIds] = useState>( + () => new Set(employeeOptions.map((option) => option.employee.id)), + ); + const [search, setSearch] = useState(""); + const [mode, setMode] = useState("fixed"); + const [targetPoints, setTargetPoints] = useState(""); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const filteredOptions = useMemo(() => { + const needle = search.trim().toLocaleLowerCase("ru-RU"); + if (!needle) return employeeOptions; + return employeeOptions.filter(({ employee }) => ( + `${employee.display_name} ${employee.position || ""} ${employee.department || ""}` + .toLocaleLowerCase("ru-RU") + .includes(needle) + )); + }, [employeeOptions, search]); + const allSelected = employeeOptions.length > 0 + && employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id)); + const numericTarget = Number(targetPoints); + const fixedValueValid = targetPoints.trim() !== "" + && Number.isFinite(numericTarget) + && numericTarget > 0; + const canSubmit = selectedEmployeeIds.size > 0 + && (mode === "automatic" || fixedValueValid) + && reason.trim().length > 0; + + const toggleEmployee = (employeeId: number) => { + setSelectedEmployeeIds((current) => { + const next = new Set(current); + if (next.has(employeeId)) next.delete(employeeId); + else next.add(employeeId); + return next; + }); + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + let result: PayrollBulkTargetPointsResult | null = null; + void submitModalForm(async () => { + result = await onSubmit({ + employee_ids: Array.from(selectedEmployeeIds), + mode, + target_points: mode === "fixed" ? targetPoints.trim() : null, + reason: reason.trim(), + }); + }, { + fallback: "Не удалось массово задать норму баллов.", + setBusy, + setError, + onSaved: async () => { + if (result) await onSaved(result); + }, + onStale, + }); + }; + + return ( + +
+
+

{period.name || period.code}

+

+ Черновики будут обновлены. Для утверждённой выработки система создаст новую черновую версию. +

+
+ +
+ Как заполнить норму баллов +
+ + +
+
+ +
+ + +
+ +
+
+ Сотрудники + +
+ +
+ {filteredOptions.length ? filteredOptions.map(({ employee, current }) => ( + + )) :

Сотрудники не найдены.

} +
+
+ +