feat(finance): expand payroll management and projections

This commit is contained in:
lizerk 2026-07-20 17:12:42 +03:00
parent e421a1b55c
commit d55657f7c3
29 changed files with 2652 additions and 344 deletions

View File

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

View File

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

View File

@ -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/<int:pk>/approve-drafts/",
PayrollPeriodApproveDraftsView.as_view(),
name="admin-period-approve-drafts",
),
path(
"payroll/admin/periods/<int:pk>/table/",
PayrollPeriodTableView.as_view(),
@ -89,6 +97,11 @@ urlpatterns = [
PayrollBulkPointRateView.as_view(),
name="admin-period-bulk-point-rate",
),
path(
"payroll/admin/periods/<int:pk>/bulk-target-points/",
PayrollBulkTargetPointsView.as_view(),
name="admin-period-bulk-target-points",
),
path(
"payroll/admin/periods/<int:pk>/close/",
PayrollPeriodCloseView.as_view(),
@ -149,6 +162,11 @@ urlpatterns = [
PayrollInputLineApproveView.as_view(),
name="admin-input-line-approve",
),
path(
"payroll/admin/periods/<int:pk>/component-cell/clear/",
PayrollComponentCellClearView.as_view(),
name="admin-component-cell-clear",
),
path(
"payroll/admin/runs/<int:pk>/",
PayrollRunDetailView.as_view(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"]} == {

View File

@ -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({
</label>
<label>
<span className="app-field-label">Цена балла сверх нормы</span>
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.01" value={pointRate} placeholder="0" onChange={(event) => setPointRate(event.target.value)} />
<span className="app-text-muted mt-1 block text-xs">Если оставить пустым, сверх нормы доплата не начисляется.</span>
<input type="number" className={inputClass} inputMode="decimal" min="0" step="0.01" value={pointRate} placeholder="Как в норме" onChange={(event) => setPointRate(event.target.value)} />
<span className="app-text-muted mt-1 block text-xs">Если оставить пустым, каждый балл сверх нормы оплачивается по цене балла в норме.</span>
</label>
<label>
<span className="app-field-label">Действует с</span>
@ -294,6 +297,7 @@ export function PayrollBulkPointRateModal({
return {
employee: current.employee,
pointRate: pointRates.size === 1 ? current.point_rate : null,
mixedPointRates: pointRates.size > 1,
hasDraft: records.some((record) => record.status === "draft"),
rateCount: new Set(records.map((record) => record.effective_from)).size,
};
@ -323,8 +327,9 @@ export function PayrollBulkPointRateModal({
const allSelected = employeeOptions.length > 0
&& employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id));
const numericPointRate = Number(pointRate);
const fixedValueValid = pointRate.trim() === ""
|| (Number.isFinite(numericPointRate) && numericPointRate >= 0);
const fixedValueValid = pointRate.trim() !== ""
&& Number.isFinite(numericPointRate)
&& numericPointRate >= 0;
const canSubmit = selectedEmployeeIds.size > 0
&& (mode === "in_norm" || fixedValueValid)
&& reason.trim().length > 0;
@ -346,7 +351,7 @@ export function PayrollBulkPointRateModal({
result = await onSubmit({
employee_ids: Array.from(selectedEmployeeIds),
mode,
point_rate: mode === "fixed" ? pointRate.trim() || "0" : null,
point_rate: mode === "fixed" ? pointRate.trim() : null,
reason: reason.trim(),
});
}, {
@ -387,7 +392,7 @@ export function PayrollBulkPointRateModal({
<input type="radio" name="point-rate-mode" value="in_norm" checked={mode === "in_norm"} onChange={() => setMode("in_norm")} />
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Как в пределах нормы</span>
<span className="app-text-muted mt-1 block text-xs">Для каждого сотрудника: его оклад ÷ его норма баллов.</span>
<span className="app-text-muted mt-1 block text-xs">Для каждого сотрудника: (оклад + премия) ÷ его норма баллов.</span>
</span>
</span>
</label>
@ -396,7 +401,7 @@ export function PayrollBulkPointRateModal({
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label>
<span className="app-field-label">Цена сверх нормы</span>
<span className="app-field-label">Цена сверх нормы {mode === "fixed" ? "*" : ""}</span>
<input
type="number"
className={inputClass}
@ -404,11 +409,12 @@ export function PayrollBulkPointRateModal({
min="0"
step="0.01"
value={pointRate}
required={mode === "fixed"}
disabled={mode === "in_norm"}
placeholder={mode === "in_norm" ? "Рассчитается отдельно" : "0"}
placeholder={mode === "in_norm" ? "Как цена в норме" : "0"}
onChange={(event) => setPointRate(event.target.value)}
/>
{mode === "fixed" ? <span className="app-text-muted mt-1 block text-xs">Пустое поле будет сохранено как 0.</span> : null}
{mode === "fixed" ? <span className="app-text-muted mt-1 block text-xs">Введите 0, если доплата сверх нормы не нужна.</span> : null}
</label>
<label>
<span className="app-field-label">Выбрано сотрудников</span>
@ -461,10 +467,14 @@ export function PayrollBulkPointRateModal({
</span>
<span className="shrink-0 text-right">
<span className="block text-xs font-medium text-[var(--foreground)]">
{option.pointRate === null ? `${option.rateCount} ставки` : `${option.pointRate} ${period.currency}`}
{option.mixedPointRates
? `${option.rateCount} ставки`
: option.pointRate === null
? "Автоматически"
: `${option.pointRate} ${period.currency}`}
</span>
<span className="app-text-muted mt-0.5 block text-[11px]">
{mode === "in_norm" ? "Станет как цена в норме" : option.hasDraft ? "Обновится черновик" : "Новая версия"}
{mode === "in_norm" ? "Будет считаться по цене в норме" : option.hasDraft ? "Обновится черновик" : "Новая версия"}
</span>
</span>
</label>
@ -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<PayrollBulkTargetPointsResult>;
onSaved: (result: PayrollBulkTargetPointsResult) => void | Promise<void>;
onStale?: () => void | Promise<void>;
}) {
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<Set<number>>(
() => new Set(employeeOptions.map((option) => option.employee.id)),
);
const [search, setSearch] = useState("");
const [mode, setMode] = useState<PayrollBulkTargetPointsMode>("fixed");
const [targetPoints, setTargetPoints] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const filteredOptions = useMemo(() => {
const needle = search.trim().toLocaleLowerCase("ru-RU");
if (!needle) return employeeOptions;
return employeeOptions.filter(({ employee }) => (
`${employee.display_name} ${employee.position || ""} ${employee.department || ""}`
.toLocaleLowerCase("ru-RU")
.includes(needle)
));
}, [employeeOptions, search]);
const allSelected = employeeOptions.length > 0
&& employeeOptions.every(({ employee }) => selectedEmployeeIds.has(employee.id));
const 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 (
<Modal isOpen={isOpen} onClose={onClose} title="Массовое заполнение нормы баллов" size="lg">
<form onSubmit={submit}>
<div className="app-selected rounded-xl px-4 py-3">
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
Черновики будут обновлены. Для утверждённой выработки система создаст новую черновую версию.
</p>
</div>
<fieldset className="mt-4">
<legend className="app-field-label">Как заполнить норму баллов</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${mode === "fixed" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2.5">
<input type="radio" name="target-points-mode" value="fixed" checked={mode === "fixed"} onChange={() => setMode("fixed")} />
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Указать одну норму</span>
<span className="app-text-muted mt-1 block text-xs">Одинаковое значение для выбранных сотрудников.</span>
</span>
</span>
</label>
<label className={`cursor-pointer rounded-xl border px-4 py-3 ${mode === "automatic" ? "app-selected" : "app-surface-muted border-[var(--border-subtle)]"}`}>
<span className="flex items-start gap-2.5">
<input type="radio" name="target-points-mode" value="automatic" checked={mode === "automatic"} onChange={() => setMode("automatic")} />
<span>
<span className="block text-sm font-semibold text-[var(--foreground)]">Вернуть автоматическую</span>
<span className="app-text-muted mt-1 block text-xs">По рабочему графику каждого сотрудника.</span>
</span>
</span>
</label>
</div>
</fieldset>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label>
<span className="app-field-label">Норма баллов {mode === "fixed" ? "*" : ""}</span>
<input
type="number"
className={inputClass}
inputMode="decimal"
min="0.0001"
step="0.0001"
value={targetPoints}
required={mode === "fixed"}
disabled={mode === "automatic"}
placeholder={mode === "automatic" ? "По рабочему графику" : "115"}
onChange={(event) => setTargetPoints(event.target.value)}
/>
</label>
<label>
<span className="app-field-label">Выбрано сотрудников</span>
<div className="app-input flex min-h-10 items-center rounded-lg px-3 text-sm font-medium">
{selectedEmployeeIds.size} из {employeeOptions.length}
</div>
</label>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="app-field-label mb-0">Сотрудники</span>
<button
type="button"
className="app-action-ghost rounded-md px-2 py-1 text-xs"
onClick={() => setSelectedEmployeeIds(
allSelected ? new Set() : new Set(employeeOptions.map((option) => option.employee.id)),
)}
>
{allSelected ? "Снять выбор" : "Выбрать всех"}
</button>
</div>
<label className="relative block">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотрудникам" onChange={(event) => setSearch(event.target.value)} />
</label>
<div className="app-surface-muted mt-2 max-h-64 overflow-y-auto rounded-xl border border-[var(--border-subtle)]">
{filteredOptions.length ? filteredOptions.map(({ employee, current }) => (
<label key={employee.id} className="flex cursor-pointer items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0 hover:bg-[var(--surface-secondary)]">
<input type="checkbox" checked={selectedEmployeeIds.has(employee.id)} onChange={() => toggleEmployee(employee.id)} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground)]">{employee.display_name}</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">{employee.position || employee.department || "Сотрудник"}</span>
</span>
<span className="shrink-0 text-right">
<span className="block text-xs font-medium text-[var(--foreground)]">{current ? current.target_points : "Автоматически"}</span>
<span className="app-text-muted mt-0.5 block text-[11px]">
{mode === "automatic" ? "Будет рассчитана по графику" : current?.status === "draft" ? "Обновится черновик" : current ? "Новая версия" : "Новый черновик"}
</span>
</span>
</label>
)) : <p className="app-text-muted px-4 py-8 text-center text-sm">Сотрудники не найдены.</p>}
</div>
</div>
<label className="mt-4 block">
<span className="app-field-label">Основание изменения *</span>
<textarea className={`${inputClass} min-h-20 resize-y`} value={reason} required placeholder="Укажите основание массового изменения" onChange={(event) => setReason(event.target.value)} />
</label>
<FormError message={error} />
<ModalActions busy={busy} disabled={!canSubmit} onClose={onClose} submitLabel={`Применить к ${selectedEmployeeIds.size}`} />
</form>
</Modal>
);
}
export function PayrollBulkPayRateModal({
isOpen,
period,
@ -683,7 +884,9 @@ export function PayrollBulkPayRateModal({
</span>
<span className="shrink-0 text-right">
<span className="block text-xs font-medium text-[var(--foreground)]">
{inherited ? `${inherited.point_rate} ${period.currency} / балл сверх нормы` : "Цена сверх нормы: 0"}
{inherited?.point_rate == null
? "Сверх нормы: автоматически"
: `${inherited.point_rate} ${period.currency} / балл сверх нормы`}
</span>
<span className="app-text-muted mt-0.5 block text-[11px]">{action}</span>
</span>
@ -694,7 +897,7 @@ export function PayrollBulkPayRateModal({
)}
</div>
<p className="app-text-muted mt-2 text-xs">
Цена балла сверх нормы наследуется из последней ставки сотрудника; если ставки ещё не было, будет установлено 0.
Цена балла сверх нормы наследуется из последней ставки сотрудника. Пустое значение означает автоматический расчёт по цене балла в норме.
</p>
</div>

View File

@ -137,7 +137,7 @@ export function PayrollRatesTable({
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] md:items-center">
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Оклад</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Цена сверх нормы</span><span className="text-sm text-[var(--foreground)]">{formatPayrollMoney(record.point_rate, record.currency)}</span></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Цена сверх нормы</span><span className={`text-sm ${record.point_rate === null ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"}`}>{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}</span></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">С</span><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
<RecordStatus status={record.status} />
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />

View File

@ -29,6 +29,7 @@ import {
PayrollConfirmModal,
PayrollBulkPayRateModal,
PayrollBulkPointRateModal,
PayrollBulkTargetPointsModal,
PayrollInputLineFormModal,
PayrollPeriodFormModal,
PayrollRateFormModal,
@ -55,6 +56,7 @@ import type {
PayrollApprovalStatus,
PayrollBulkPayRateResult,
PayrollBulkPointRateResult,
PayrollBulkTargetPointsResult,
PayrollInputLineWrite,
PayrollPayRateWrite,
PayrollPeriodTable,
@ -282,6 +284,7 @@ export function PayrollAdminWorkspace({
const [rateModalOpen, setRateModalOpen] = useState(false);
const [bulkPayRateModalOpen, setBulkPayRateModalOpen] = useState(false);
const [bulkPointRateModalOpen, setBulkPointRateModalOpen] = useState(false);
const [bulkTargetPointsModalOpen, setBulkTargetPointsModalOpen] = useState(false);
const [editingRate, setEditingRate] = useState<PayrollAdminPayRate | null>(null);
const [workModalOpen, setWorkModalOpen] = useState(false);
const [editingWork, setEditingWork] = useState<PayrollAdminWorkRecord | null>(null);
@ -428,7 +431,7 @@ export function PayrollAdminWorkspace({
}, [loadWorkspace, selectedPeriod?.id]);
const handleStale = useCallback(async () => {
setPeriodModalOpen(false); setRateModalOpen(false); setBulkPayRateModalOpen(false); setBulkPointRateModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null);
setPeriodModalOpen(false); setRateModalOpen(false); setBulkPayRateModalOpen(false); setBulkPointRateModalOpen(false); setBulkTargetPointsModalOpen(false); setWorkModalOpen(false); setAttendanceWorkModalOpen(false); setInputModalOpen(false); setPendingAction(null);
await refreshView("Данные изменились в другой сессии. Экран обновлён — повторите действие.");
}, [refreshView]);
@ -454,6 +457,21 @@ export function PayrollAdminWorkspace({
const openApproveInput = (record: PayrollAdminInputLine) => setPendingAction({
title: "Утвердить операцию", description: `${record.component.name} для ${record.employee.display_name}: ${formatPayrollMoney(record.amount, selectedPeriod?.currency)}.`, confirmLabel: "Утвердить", warning: "Утверждённая операция станет входом следующего расчёта.", successMessage: "Операция утверждена.", execute: () => apiClient.approvePayrollAdminInputLine(record.id, record.lock_version),
});
const openApproveAll = () => {
if (!selectedPeriod) return;
const rateCount = rates.filter((record) => record.status === "draft").length;
const workCount = workRecords.filter((record) => record.status === "draft").length;
const inputCount = inputLines.filter((record) => record.status === "draft").length;
const total = rateCount + workCount + inputCount;
setPendingAction({
title: "Утвердить все данные",
description: `Будут утверждены все черновики выбранного периода (${total}): ставки — ${rateCount}, выработка — ${workCount}, начисления и выплаты — ${inputCount}.`,
confirmLabel: "Утвердить всё",
warning: "Операция выполняется целиком: если один из черновиков не пройдёт проверку, остальные тоже не будут утверждены.",
successMessage: "Все черновики периода утверждены.",
execute: () => apiClient.approveAllPayrollAdminDrafts(selectedPeriod.id),
});
};
const openReviseRate = (record: PayrollAdminPayRate) => setPendingAction({
title: "Создать новую версию ставки", description: `Будет создан отдельный черновик ставки для ${record.employee.display_name}.`, confirmLabel: "Создать черновик", reasonLabel: "Основание изменения", reasonRequired: true, successMessage: "Новая версия ставки создана в черновиках.", execute: (reason) => apiClient.revisePayrollAdminPayRate(record.id, reason),
});
@ -512,6 +530,16 @@ export function PayrollAdminWorkspace({
);
const canManageSourceData = permissions.manage_inputs && !sourceDataLocked;
const canApproveSourceData = permissions.approve_inputs && !sourceDataLocked;
const draftApprovalRecords = [
...rates.filter((record) => record.status === "draft"),
...workRecords.filter((record) => record.status === "draft"),
...inputLines.filter((record) => record.status === "draft"),
];
const draftApprovalCount = draftApprovalRecords.length;
const hasOwnRestrictedDraft = !permissions.override_approval && draftApprovalRecords.some(
(record) => record.created_by.id === user?.id,
);
const canApproveAllDrafts = canApproveSourceData && !tableLoading && draftApprovalCount > 0 && !hasOwnRestrictedDraft;
const savePeriod = async (payload: PayrollPeriodWrite) => {
if (editingPeriod) await apiClient.updatePayrollAdminPeriod(editingPeriod.id, { ...payload, expected_lock_version: editingPeriod.lock_version });
@ -555,9 +583,9 @@ export function PayrollAdminWorkspace({
const saveRateCell = async (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => {
if (!canManageSourceData || !selectedPeriod) return;
const rawValue = value.trim().replace(",", ".");
const normalized = field === "point_rate" && rawValue === "" ? "0" : rawValue;
const numeric = Number(normalized);
if (!Number.isFinite(numeric) || numeric < 0 || (field === "amount" && numeric === 0)) {
const normalized = field === "point_rate" && rawValue === "" ? null : rawValue;
const numeric = normalized === null ? null : Number(normalized);
if ((numeric !== null && (!Number.isFinite(numeric) || numeric < 0)) || (field === "amount" && numeric === 0)) {
throw new Error(field === "amount" ? "Оклад должен быть больше нуля." : "Укажите корректную цену балла сверх нормы.");
}
const record = rates
@ -576,7 +604,7 @@ export function PayrollAdminWorkspace({
employee_id: row.employee.id,
rate_code: "BASE",
amount,
point_rate: field === "point_rate" ? normalized : row.point_rate || "0",
point_rate: field === "point_rate" ? normalized : row.point_rate,
currency: selectedPeriod.currency,
effective_from: selectedPeriod.date_from,
reason: "Введено в итоговой таблице",
@ -625,13 +653,23 @@ export function PayrollAdminWorkspace({
const saveComponentCell = async (row: PayrollPeriodTableRow, componentCode: string, value: string) => {
if (!canManageSourceData || !selectedPeriod) return;
const normalized = value.trim().replace(",", ".");
const desiredAmount = Number(normalized);
if (!Number.isFinite(desiredAmount) || desiredAmount <= 0) {
throw new Error("Сумма операции должна быть больше нуля.");
}
const component = workspace.components.find((item) => item.code === componentCode);
if (!component) throw new Error("Вид операции не найден.");
const matchingLines = inputLines.filter((item) => item.employee.id === row.employee.id && item.component.id === component.id && item.status !== "voided");
if (!normalized) {
if (matchingLines.length) {
await apiClient.clearPayrollAdminComponentCell(selectedPeriod.id, {
employee_id: row.employee.id,
component_id: component.id,
});
await refreshInlineTable("inputs");
}
return;
}
const desiredAmount = Number(normalized);
if (!Number.isFinite(desiredAmount) || desiredAmount <= 0) {
throw new Error("Сумма операции должна быть больше нуля или оставлена пустой.");
}
const draft = matchingLines
.filter((item) => item.status === "draft")
.sort((left, right) => right.id - left.id)[0];
@ -753,10 +791,10 @@ export function PayrollAdminWorkspace({
</section>
) : null}
{tab === "rates" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Ставки" description="Оклад и цена балла сверх нормы действуют с указанной даты. Цена балла в пределах нормы рассчитывается как оклад, разделённый на норму." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} onBulkPayRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
{tab === "rates" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Ставки" description="Оклад и цена балла сверх нормы действуют с указанной даты. Цена балла в норме рассчитывается по Excel: оклад плюс премия, разделённые на норму." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} onBulkPayRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
{tab === "work" ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<SectionIntro title="Выработка" description="Норму и факт можно ввести вручную в баллах или заполнить рабочими часами из посещаемости." />
<SectionIntro title="Выработка" description="Норму и факт можно ввести вручную в баллах или рассчитать по посещаемости пропорционально дневной норме баллов." />
{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}
<TableToolbar
search={search}
@ -774,12 +812,28 @@ export function PayrollAdminWorkspace({
{tab === "summary" ? (
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<SectionIntro title="Итоговая таблица" description="Сводные исходные данные, начисления, удержания и выплаты по всем сотрудникам за выбранный период." />
<PayrollPeriodTableView data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} canEdit={canManageSourceData} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} />
<PayrollPeriodTableView data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} />
</section>
) : null}
{tab === "approval" ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<SectionIntro title="Проверка данных" description="Здесь можно утвердить подготовленные черновики и продолжить расчёт." />
<div className="mb-4 flex flex-col items-start justify-between gap-3 sm:flex-row">
<div>
<h2 className="text-base font-semibold text-[var(--foreground)]">Проверка данных</h2>
<p className="app-text-muted mt-1 text-sm">Здесь можно утвердить подготовленные черновики и продолжить расчёт.</p>
</div>
{draftApprovalCount > 0 ? (
<button
type="button"
className="app-action-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
onClick={openApproveAll}
disabled={!canApproveAllDrafts}
title={hasOwnRestrictedDraft ? "В очереди есть ваши записи, для которых требуется другой утверждающий." : undefined}
>
<CheckCircle2 size={16} /> Утвердить всё
</button>
) : null}
</div>
{!canOpenInputTabs ? (
<div className="app-surface-muted rounded-xl p-4 text-sm">
<p className="font-semibold text-[var(--foreground)]">Очередь исходных данных недоступна для вашей роли</p>
@ -813,6 +867,7 @@ export function PayrollAdminWorkspace({
{selectedPeriod && rateModalOpen ? <PayrollRateFormModal isOpen period={selectedPeriod} employees={workspace.employees} rate={editingRate} onClose={() => setRateModalOpen(false)} onSubmit={saveRate} onSaved={async () => { setRateModalOpen(false); await refreshView("Черновик ставки сохранён."); }} onStale={handleStale} /> : null}
{selectedPeriod && bulkPayRateModalOpen ? <PayrollBulkPayRateModal isOpen period={selectedPeriod} employees={workspace.employees} rates={rates} onClose={() => setBulkPayRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkCreatePayrollAdminPayRates(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPayRateResult) => { setBulkPayRateModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Ставка сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
{selectedPeriod && bulkPointRateModalOpen ? <PayrollBulkPointRateModal isOpen period={selectedPeriod} rates={rates} onClose={() => setBulkPointRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminPointRate(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPointRateResult) => { setBulkPointRateModalOpen(false); const changed = result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Цена балла сверх нормы сохранена для ${changed} ставок${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
{selectedPeriod && bulkTargetPointsModalOpen ? <PayrollBulkTargetPointsModal isOpen period={selectedPeriod} employees={workspace.employees} records={workRecords} onClose={() => setBulkTargetPointsModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminTargetPoints(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkTargetPointsResult) => { setBulkTargetPointsModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Норма баллов сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
{selectedPeriod && workModalOpen ? <PayrollWorkRecordFormModal isOpen period={selectedPeriod} employees={workspace.employees} record={editingWork} onClose={() => setWorkModalOpen(false)} onSubmit={saveWork} onSaved={async () => { setWorkModalOpen(false); await refreshView("Черновик выработки сохранён."); }} onStale={handleStale} /> : null}
{selectedPeriod && attendanceWorkModalOpen ? (
<PayrollAttendanceWorkModal

View File

@ -144,8 +144,9 @@ function PreviewTable({
</div>
<div className="text-xs text-[var(--foreground)]">
<span className="app-text-muted mr-1 md:hidden">Выработка:</span>
<p>Норма: <b>{item.target_points} ч</b></p>
<p className="mt-1">Факт: <b>{item.actual_points} ч</b></p>
<p>Норма: <b>{item.target_points} балл.</b></p>
<p className="mt-1">Факт: <b>{item.actual_points} балл.</b></p>
<p className="app-text-muted mt-1 text-[11px]">Дневная норма: {item.daily_target_points}</p>
<span className="app-selected mt-2 inline-flex rounded-full px-2 py-1 text-[10px] font-semibold leading-none">
Из посещаемости
</span>
@ -302,13 +303,13 @@ export function PayrollAttendanceWorkModal({
<CalendarCheck2 className="app-accent-text mt-0.5 shrink-0" size={19} />
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
1 час = 1 единица выработки
Полный рабочий день = {preview.policy.daily_target_points} балл.
</p>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
{preview.policy.description || preview.policy.label}
</p>
<p className="mt-2 text-xs font-medium text-[var(--foreground)]">
Часы попадут в норму и факт выработки: до нормы они пропорционально формируют оклад, сверх нормы — доплату по заданной цене.
{preview.policy.formula}. Часы остаются исходными данными посещаемости, а в зарплатный расчёт попадут баллы.
</p>
</div>
</div>

View File

@ -1,10 +1,12 @@
"use client";
import { Loader2, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { CircleHelp, Eye, EyeOff, Loader2, Pencil, Search, X } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import type {
PayrollPeriodTable,
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
PayrollPeriodTableStatus,
} from "@/lib/api/finance";
@ -28,6 +30,351 @@ const kindLabels = {
const headerCellClass = "app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]";
const bodyCellClass = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums";
const HIDDEN_COLUMNS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-columns";
const HIDDEN_COLUMNS_CHANGE_EVENT = "payroll-period-table-hidden-columns-change";
const columnIds = {
rateAmount: "rate_amount",
inNormPointRate: "in_norm_point_rate",
excessPointRate: "point_rate",
targetPoints: "target_points",
attendancePoints: "attendance_points",
personnelPoints: "personnel_points",
actualPoints: "actual_points",
pointDelta: "point_delta",
pointAmount: "point_amount",
grossBeforeAdjustments: "gross_before_adjustments",
adjustments: "adjustment_total",
gross: "gross_total",
deductions: "deduction_total",
net: "net_pay",
payments: "payment_total",
payable: "payable",
status: "status",
} as const;
type ColumnDefinition = {
id: string;
label: string;
group: string;
width: number;
};
const salaryColumns: ColumnDefinition[] = [
{ id: columnIds.rateAmount, label: "Оклад", group: "Оклад и баллы", width: 75 },
{ id: columnIds.inNormPointRate, label: "Цена балла в норме", group: "Оклад и баллы", width: 75 },
{ id: columnIds.excessPointRate, label: "Цена балла сверх нормы", group: "Оклад и баллы", width: 75 },
];
const workColumns: ColumnDefinition[] = [
{ id: columnIds.targetPoints, label: "Норма", group: "Выработка", width: 55 },
{ id: columnIds.attendancePoints, label: "Баллы по посещаемости", group: "Выработка", width: 75 },
{ id: columnIds.personnelPoints, label: "Баллы по кадровым событиям", group: "Выработка", width: 82 },
{ id: columnIds.actualPoints, label: "Баллы", group: "Выработка", width: 55 },
{ id: columnIds.pointDelta, label: "Отклонение", group: "Выработка", width: 55 },
{ id: columnIds.pointAmount, label: "По баллам", group: "Выработка", width: 75 },
];
const calculationColumns: ColumnDefinition[] = [
{ id: columnIds.grossBeforeAdjustments, label: "До корректировок", group: "Расчёт", width: 80 },
{ id: columnIds.adjustments, label: "Корректировки", group: "Расчёт", width: 80 },
{ id: columnIds.gross, label: "Начислено", group: "Расчёт", width: 80 },
{ id: columnIds.deductions, label: "Удержано", group: "Расчёт", width: 80 },
{ id: columnIds.net, label: "После удержаний", group: "Расчёт", width: 80 },
{ id: columnIds.payments, label: "Выплачено", group: "Расчёт", width: 80 },
{ id: columnIds.payable, label: "К выплате", group: "Расчёт", width: 80 },
];
const statusColumn: ColumnDefinition = {
id: columnIds.status,
label: "Состояние",
group: "Состояние",
width: 90,
};
function componentColumnId(code: string): string {
return `component:${code}`;
}
function parseHiddenColumns(value: string): Set<string> {
try {
const parsed: unknown = JSON.parse(value);
return new Set(Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []);
} catch {
return new Set();
}
}
function useHiddenPayrollColumns(userId?: number | null) {
const storageKey = `${HIDDEN_COLUMNS_STORAGE_PREFIX}.${userId || "default"}`;
const subscribe = useCallback((notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(HIDDEN_COLUMNS_CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(HIDDEN_COLUMNS_CHANGE_EVENT, notify);
};
}, [storageKey]);
const getSnapshot = useCallback(
() => window.localStorage.getItem(storageKey) || "[]",
[storageKey],
);
const serialized = useSyncExternalStore(subscribe, getSnapshot, () => "[]");
const hiddenColumns = useMemo(() => parseHiddenColumns(serialized), [serialized]);
const setHiddenColumns = useCallback((next: Set<string>) => {
window.localStorage.setItem(storageKey, JSON.stringify([...next].sort()));
window.dispatchEvent(new Event(HIDDEN_COLUMNS_CHANGE_EVENT));
}, [storageKey]);
return [hiddenColumns, setHiddenColumns] as const;
}
type CalculationHelp = {
title: string;
logic: string;
formula: string;
manual: string;
};
function ColumnHeader({
children,
label,
help,
onOpen,
onBulkEdit,
onHide,
}: {
children: ReactNode;
label: string;
help?: CalculationHelp;
onOpen: (help: CalculationHelp) => void;
onBulkEdit?: () => void;
onHide: () => void;
}) {
return (
<span className="min-w-0 whitespace-normal break-words">
{children}{" "}
<span className="inline-flex translate-y-0.5 items-center gap-0.5 whitespace-nowrap align-baseline">
{help ? (
<button
type="button"
className="app-accent-text inline-flex h-4 w-4 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={() => onOpen(help)}
aria-label={`Как рассчитывается «${help.title}»`}
title="Показать логику расчёта"
>
<CircleHelp size={13} strokeWidth={2} />
</button>
) : null}
{onBulkEdit ? (
<button
type="button"
className="app-text-muted inline-flex h-4 w-4 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={onBulkEdit}
aria-label={`Массово заполнить колонку «${label}»`}
title="Массовое заполнение"
>
<Pencil size={12} strokeWidth={2} />
</button>
) : null}
<button
type="button"
className="app-text-muted inline-flex h-4 w-4 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={onHide}
aria-label={`Скрыть колонку «${label}»`}
title="Скрыть колонку"
>
<Eye size={12} strokeWidth={2} />
</button>
</span>
</span>
);
}
function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp; rules: PayrollPeriodTable["calculation_rules"]; onClose: () => void }) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
closeButtonRef.current?.focus();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
previouslyFocused?.focus();
};
}, [onClose]);
if (typeof document === "undefined") return null;
const roundingLabel = rules.rounding === "ROUND_HALF_UP"
? "математическое"
: rules.rounding === "ROUND_HALF_EVEN"
? "банковское"
: "вниз";
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" role="presentation">
<button type="button" className="absolute inset-0 bg-black/60" onClick={onClose} aria-label="Закрыть пояснение" />
<section className="app-surface relative z-10 w-full max-w-lg rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="payroll-formula-title">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="app-text-muted text-xs uppercase tracking-wide">Логика расчёта</p>
<h3 id="payroll-formula-title" className="mt-1 text-lg font-semibold text-[var(--foreground)]">{help.title}</h3>
</div>
<button ref={closeButtonRef} type="button" className="app-action-ghost inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" onClick={onClose} aria-label="Закрыть"><X size={17} /></button>
</div>
<div className="mt-5 space-y-4 text-sm">
<div>
<p className="font-semibold text-[var(--foreground)]">Как формируется</p>
<p className="app-text-muted mt-1 leading-relaxed">{help.logic}</p>
</div>
<div>
<p className="font-semibold text-[var(--foreground)]">Формула</p>
<div className="app-surface-muted mt-2 whitespace-pre-wrap rounded-xl px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--foreground)]">{help.formula}</div>
</div>
<div>
<p className="font-semibold text-[var(--foreground)]">Можно изменить вручную?</p>
<p className="app-text-muted mt-1 leading-relaxed">{help.manual}</p>
</div>
</div>
<p className="app-text-muted mt-5 border-t border-[var(--border-subtle)] pt-3 text-xs leading-relaxed">Правила {rules.ruleset_id}, версия {rules.version}. Денежные строки округляются до {rules.money_quantum} ({roundingLabel} округление).</p>
</section>
</div>,
document.body,
);
}
function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"]): Record<string, CalculationHelp> {
const pointRecalculationLogic = pointPolicy === "proportional_with_excess"
? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный."
: pointPolicy === "excess_only"
? "Недовыполнение нормы не уменьшает начисление; оплачивается только превышение."
: "Перерасчёт по баллам отключён текущими правилами.";
const pointRecalculationFormula = pointPolicy === "proportional_with_excess"
? "Если цена сверх нормы пустая:\nПо баллам = ((Оклад + Премия) ÷ Норма × Баллы) − (Оклад + Премия)\n\nЕсли цена задана и Баллы > Норма:\nПо баллам = (Баллы − Норма) × Цена сверх нормы"
: pointPolicy === "excess_only"
? "По баллам = max(Баллы − Норма, 0) × Цена балла сверх нормы"
: "По баллам = 0";
return {
inNormPointRate: {
title: "Цена балла в норме",
logic: "Расчётная стоимость одного балла в исходной Excel-формуле. В балльную базу входят оклад и премия.",
formula: "Цена балла в норме = (Оклад + Премия) ÷ Норма",
manual: "Нет. Значение меняется через оклад, премию или норму баллов.",
},
excessPointRate: {
title: "Цена балла сверх нормы",
logic: pointPolicy === "disabled" ? "При текущем правиле балльная доплата отключена, поэтому цена не участвует в расчёте." : "Если отдельная цена не заполнена, система автоматически использует цену балла в норме. Можно задать индивидуальную цену.",
formula: "Цена сверх нормы = указанная цена\nесли пусто: (Оклад + Премия) ÷ Норма",
manual: "Да. Пустая ячейка возвращает автоматический расчёт по цене балла в норме.",
},
targetPoints: {
title: "Норма",
logic: "По умолчанию норма берётся из рабочего графика. Посещаемость определяет фактические рабочие дни, но часы не становятся баллами напрямую.",
formula: "Норма = Дневная норма баллов × Рабочие дни",
manual: "Да. Ручное значение заменяет автоматическую норму; очистка ячейки возвращает расчёт по графику.",
},
actualPoints: {
title: "Баллы",
logic: "Фактическую выработку можно ввести вручную или рассчитать из посещаемости. Полная смена даёт дневную норму баллов; неполная смена и переработка учитываются пропорционально.",
formula: "Баллы дня = Дневная норма × Отработанные часы ÷ Плановые часы\nБаллы периода = Σ баллов рабочих дней",
manual: "Да. Значение можно изменить в таблице или пересчитать из посещаемости.",
},
attendancePoints: {
title: "Баллы по посещаемости",
logic: "Вычисляются на ходу по доступным корректным записям посещаемости и не сохраняются в зарплате. Полная смена даёт дневную норму, неполная смена и переработка учитываются пропорционально. Если данных посещаемости нет, показывается прочерк.",
formula: "Баллы дня = Дневная норма × Отработанные часы ÷ Плановые часы\nБаллы по посещаемости = Σ баллов доступных рабочих дней",
manual: "Нет. Для изменения нужно исправить исходные данные посещаемости.",
},
personnelPoints: {
title: "Баллы по кадровым событиям",
logic: "Вычисляются на ходу по рабочему графику и официальным кадровым событиям. Каждый плановый рабочий день считается отработанным полностью, пока одобренное заявление или кадровое событие не подтверждает отсутствие. Отпуск, больничный, отгул, декрет и увольнение дают 0 баллов за затронутые рабочие дни; удалённая работа считается присутствием.",
formula: "Баллы по кадровым событиям = Дневная норма × Рабочие дни без подтверждённого отсутствия",
manual: "Нет. Для изменения нужно исправить заявление, его статус, кадровое событие или рабочий график.",
},
pointDelta: {
title: "Отклонение",
logic: "Показывает, насколько фактическая выработка выше или ниже нормы.",
formula: "Отклонение = Баллы − Норма",
manual: "Нет. Изменяется автоматически после изменения нормы или баллов.",
},
pointAmount: {
title: "Перерасчёт по баллам",
logic: pointRecalculationLogic,
formula: pointRecalculationFormula,
manual: "Нет. Меняется через оклад, премию, норму и баллы; при перевыполнении также через цену сверх нормы.",
},
grossBeforeAdjustments: {
title: "До корректировок",
logic: "Сумма исходных начислений до положительных, отрицательных и балльной корректировок.",
formula: "До корректировок = Оклад + Отпускные + Премия + Разовые + Σ прочих начислений",
manual: "Нет. Итог пересчитывается из исходных ячеек и строк начислений.",
},
adjustments: {
title: "Корректировки",
logic: "Объединяет ручные корректировки и подписанный перерасчёт по баллам. Положительное значение увеличивает начисление, отрицательное — уменьшает.",
formula: "Корректировки = Σ ручных положительных − Σ ручных отрицательных + Перерасчёт по баллам",
manual: "Нет. Итог меняется через соответствующие строки корректировок.",
},
gross: {
title: "Начислено",
logic: "Общая сумма начисления до удержаний и учёта уже выплаченных сумм.",
formula: "Начислено = До корректировок + Корректировки",
manual: "Нет. Пересчитывается автоматически из начислений и корректировок.",
},
deductions: {
title: "Удержано",
logic: "Сумма всех действующих строк с типом «Удержание» за выбранный период.",
formula: "Удержано = Σ удержаний",
manual: "Нет. Итог меняется через строки удержаний.",
},
net: {
title: "После удержаний",
logic: "Сумма, оставшаяся после вычета удержаний, но до учёта уже записанных выплат.",
formula: "После удержаний = Начислено − Удержано",
manual: "Нет. Пересчитывается автоматически.",
},
payments: {
title: "Выплачено",
logic: "Сумма уже произведённых выплат за период, например авансов.",
formula: "Выплачено = Σ строк с типом «Выплата»",
manual: "Нет. Итог меняется через строки выплат.",
},
payable: {
title: "К выплате",
logic: "Остаток, который ещё нужно выплатить сотруднику.",
formula: "К выплате = После удержаний − Выплачено",
manual: "Нет. Пересчитывается автоматически из связанных сумм.",
},
status: {
title: "Состояние",
logic: "Система показывает готовность строки по наличию ставки, выработки, черновиков и сохранённого результата расчёта.",
formula: "Нет ставки или выработки → Не заполнено\nЕсть черновики → Есть черновики\nВсе входы утверждены → Готово\nЕсть результат расчёта → Рассчитано",
manual: "Нет. Состояние меняется автоматически вместе с данными и этапом расчёта.",
},
};
}
function componentCalculationHelp(component: PayrollPeriodTableComponent): CalculationHelp {
const effect = {
earning: "входит в сумму «До корректировок»",
adjustment_credit: "увеличивает сумму «Корректировки»",
adjustment_debit: "уменьшает сумму «Корректировки»",
deduction: "входит в сумму «Удержано»",
payment: "входит в сумму «Выплачено»",
}[component.kind];
return {
title: component.label,
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Этот вид операции ${effect}.`,
formula: `${component.label} = Σ операций этого вида за период`,
manual: "Да. Ввод нового итога в ячейку создаёт или обновляет черновик операции на разницу. Утверждённые суммы исправляются новой операцией.",
};
}
function formatPoints(value: string | null): string {
if (value == null || value === "") return "—";
@ -146,7 +493,11 @@ export function PayrollPeriodTableView({
error,
search,
onSearch,
storageUserId,
canEdit = false,
onBulkEditRate,
onBulkEditPointRate,
onBulkEditTargetPoints,
onSaveRate,
onSaveWork,
onSaveComponent,
@ -156,7 +507,11 @@ export function PayrollPeriodTableView({
error: string | null;
search: string;
onSearch: (value: string) => void;
storageUserId?: number | null;
canEdit?: boolean;
onBulkEditRate?: () => void;
onBulkEditPointRate?: () => void;
onBulkEditTargetPoints?: () => void;
onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise<void>;
onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise<void>;
onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise<void>;
@ -170,6 +525,58 @@ export function PayrollPeriodTableView({
.includes(needle)
));
}, [data?.rows, search]);
const [activeHelp, setActiveHelp] = useState<CalculationHelp | null>(null);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const columnsMenuRef = useRef<HTMLDivElement>(null);
const [hiddenColumns, setHiddenColumns] = useHiddenPayrollColumns(storageUserId);
const componentColumns = useMemo(() => data?.component_columns || [], [data?.component_columns]);
const componentDefinitions = useMemo<ColumnDefinition[]>(() => (
componentColumns.map((component) => ({
id: componentColumnId(component.code),
label: component.label,
group: "Начисления и выплаты",
width: 75,
}))
), [componentColumns]);
const allColumns = useMemo(
() => [...salaryColumns, ...workColumns, ...componentDefinitions, ...calculationColumns, statusColumn],
[componentDefinitions],
);
const hiddenCurrentColumns = allColumns.filter((column) => hiddenColumns.has(column.id));
const isVisible = (columnId: string) => !hiddenColumns.has(columnId);
const visibleSalaryColumns = salaryColumns.filter((column) => isVisible(column.id));
const visibleWorkColumns = workColumns.filter((column) => isVisible(column.id));
const visibleComponentColumns = componentColumns.filter((component) => isVisible(componentColumnId(component.code)));
const visibleCalculationColumns = calculationColumns.filter((column) => isVisible(column.id));
const visibleColumnCount = 1 + visibleSalaryColumns.length + visibleWorkColumns.length + visibleComponentColumns.length + visibleCalculationColumns.length + (isVisible(columnIds.status) ? 1 : 0);
const hideColumn = (columnId: string) => {
const next = new Set(hiddenColumns);
next.add(columnId);
setHiddenColumns(next);
};
const showColumn = (columnId: string) => {
const next = new Set(hiddenColumns);
next.delete(columnId);
setHiddenColumns(next);
if (hiddenCurrentColumns.length === 1) setColumnsMenuOpen(false);
};
useEffect(() => {
if (!columnsMenuOpen) return;
const closeOnOutsideClick = (event: MouseEvent) => {
if (!columnsMenuRef.current?.contains(event.target as Node)) setColumnsMenuOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setColumnsMenuOpen(false);
};
document.addEventListener("mousedown", closeOnOutsideClick);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutsideClick);
document.removeEventListener("keydown", closeOnEscape);
};
}, [columnsMenuOpen]);
if (loading && !data) {
return <div className="flex min-h-56 items-center justify-center"><Loader2 className="app-accent-text animate-spin" size={24} /></div>;
@ -177,7 +584,11 @@ export function PayrollPeriodTableView({
if (error) return <div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</div>;
if (!data) return null;
const tableWidth = 1265 + data.component_columns.length * 75;
const tableWidth = 150 + allColumns.reduce(
(width, column) => width + (isVisible(column.id) ? column.width : 0),
0,
);
const helps = calculationHelps(data.calculation_rules.point_policy);
const total = (field: keyof PayrollPeriodTableRow) => sumRows(rows, (row) => {
const value = row[field];
return typeof value === "string" ? value : null;
@ -195,15 +606,67 @@ export function PayrollPeriodTableView({
{data.summary.preliminary_count ? <span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Предварительно <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.preliminary_count}</span></span> : null}
{data.run ? <span className="app-selected inline-flex items-center rounded-full px-3 py-1.5 text-xs">Ревизия {data.run.revision}</span> : null}
</div>
<label className="relative block min-w-0 lg:w-72">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
value={search}
placeholder="Поиск по сотрудникам"
onChange={(event) => onSearch(event.target.value)}
/>
</label>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative block min-w-0 sm:w-72">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
value={search}
placeholder="Поиск по сотрудникам"
onChange={(event) => onSearch(event.target.value)}
/>
</label>
<div ref={columnsMenuRef} className="relative shrink-0">
<button
type="button"
className="app-action-secondary inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium disabled:cursor-default disabled:opacity-60 sm:w-auto"
onClick={() => setColumnsMenuOpen((open) => !open)}
disabled={!hiddenCurrentColumns.length}
aria-expanded={columnsMenuOpen}
aria-haspopup="menu"
>
<Eye size={15} />
Показать колонки
{hiddenCurrentColumns.length ? <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{hiddenCurrentColumns.length}</span> : null}
</button>
{columnsMenuOpen && hiddenCurrentColumns.length ? (
<div className="app-surface absolute right-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-[var(--border-subtle)] p-2 shadow-2xl" role="menu" aria-label="Скрытые колонки">
<p className="app-text-muted px-2 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-wide">Скрытые колонки</p>
<div className="max-h-72 overflow-y-auto">
{hiddenCurrentColumns.map((column) => (
<button
key={column.id}
type="button"
className="app-action-ghost flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left"
onClick={() => showColumn(column.id)}
role="menuitem"
title={`Показать колонку «${column.label}»`}
>
<EyeOff className="app-text-muted shrink-0" size={15} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-[var(--foreground)]">{column.label}</span>
<span className="app-text-muted block truncate text-[10px]">{column.group}</span>
</span>
<span className="app-accent-text text-[10px]">Показать</span>
</button>
))}
</div>
{hiddenCurrentColumns.length > 1 ? (
<button
type="button"
className="app-action-secondary mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-medium"
onClick={() => {
setHiddenColumns(new Set());
setColumnsMenuOpen(false);
}}
>
<Eye size={14} /> Показать все
</button>
) : null}
</div>
) : null}
</div>
</div>
</div>
{data.summary.preliminary_count ? (
@ -215,58 +678,63 @@ export function PayrollPeriodTableView({
<div className="app-text-muted mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
{canEdit ? <span>Enter или переход в другую ячейку — сохранить, Esc — отменить.</span> : null}
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[var(--muted-foreground)]" aria-hidden="true" />Серый текст — заполнено автоматически</span>
<span className="inline-flex items-center gap-1.5"><CircleHelp className="app-accent-text" size={13} />Знак вопроса — логика и формула расчёта</span>
</div>
<div className="app-table-scroll max-h-[min(70vh,760px)] w-full max-w-full overflow-auto overscroll-contain rounded-xl border border-[var(--border-subtle)]">
<table className="table-fixed border-separate border-spacing-0" style={{ width: `max(100%, ${tableWidth}px)` }} aria-label="Итоговая таблица расчётного периода">
<colgroup>
<col style={{ width: 150 }} />
<col style={{ width: 75 }} />
<col style={{ width: 75 }} />
<col style={{ width: 75 }} />
<col style={{ width: 55 }} />
<col style={{ width: 55 }} />
<col style={{ width: 55 }} />
<col style={{ width: 75 }} />
{data.component_columns.map((component) => <col key={component.code} style={{ width: 75 }} />)}
{Array.from({ length: 7 }, (_, index) => <col key={`total-${index}`} style={{ width: 80 }} />)}
<col style={{ width: 90 }} />
{[...visibleSalaryColumns, ...visibleWorkColumns].map((column) => <col key={column.id} style={{ width: column.width }} />)}
{visibleComponentColumns.map((component) => <col key={component.code} style={{ width: 75 }} />)}
{visibleCalculationColumns.map((column) => <col key={column.id} style={{ width: column.width }} />)}
{isVisible(columnIds.status) ? <col style={{ width: statusColumn.width }} /> : null}
</colgroup>
<thead className="sticky top-0 z-20">
<tr>
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-30 align-middle`}>Сотрудник</th>
<th colSpan={3} className={`${headerCellClass} text-center uppercase tracking-wide`}>Оклад и баллы</th>
<th colSpan={4} className={`${headerCellClass} text-center uppercase tracking-wide`}>Выработка</th>
{data.component_columns.length ? <th colSpan={data.component_columns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>Начисления и выплаты</th> : null}
<th colSpan={7} className={`${headerCellClass} text-center uppercase tracking-wide`}>Расчёт</th>
<th rowSpan={2} className={`${headerCellClass} align-middle`}>Состояние</th>
{visibleSalaryColumns.length ? <th colSpan={visibleSalaryColumns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>Оклад и баллы</th> : null}
{visibleWorkColumns.length ? <th colSpan={visibleWorkColumns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>Выработка</th> : null}
{visibleComponentColumns.length ? <th colSpan={visibleComponentColumns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>Начисления и выплаты</th> : null}
{visibleCalculationColumns.length ? <th colSpan={visibleCalculationColumns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>Расчёт</th> : null}
{isVisible(columnIds.status) ? (
<th rowSpan={2} className={`${headerCellClass} align-middle`}>
<ColumnHeader label="Состояние" help={helps.status} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.status)}>Состояние</ColumnHeader>
</th>
) : null}
</tr>
<tr>
<th className={`${headerCellClass} text-right`}>Оклад</th>
<th className={`${headerCellClass} whitespace-normal text-right`} title="Цена одного балла в пределах нормы: оклад, разделённый на норму баллов">
<span className="block">Цена балла</span>
<span className="block">в норме</span>
</th>
<th className={`${headerCellClass} whitespace-normal text-right`} title="Цена каждого балла сверх нормы">
<span className="block">Цена балла</span>
<span className="block">сверх нормы</span>
</th>
<th className={`${headerCellClass} text-right`}>Норма</th>
<th className={`${headerCellClass} text-right`}>Баллы</th>
<th className={`${headerCellClass} text-right`}>Отклонение</th>
<th className={`${headerCellClass} text-right`} title="Доплата за баллы сверх нормы">Доплата</th>
{data.component_columns.map((component) => (
{isVisible(columnIds.rateAmount) ? (
<th className={`${headerCellClass} text-right`}><ColumnHeader label="Оклад" onOpen={setActiveHelp} onBulkEdit={onBulkEditRate} onHide={() => hideColumn(columnIds.rateAmount)}>Оклад</ColumnHeader></th>
) : null}
{isVisible(columnIds.inNormPointRate) ? (
<th className={`${headerCellClass} whitespace-normal text-right`}>
<ColumnHeader label="Цена балла в норме" help={helps.inNormPointRate} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.inNormPointRate)}>Цена балла в норме</ColumnHeader>
</th>
) : null}
{isVisible(columnIds.excessPointRate) ? (
<th className={`${headerCellClass} whitespace-normal text-right`}>
<ColumnHeader label="Цена балла сверх нормы" help={helps.excessPointRate} onOpen={setActiveHelp} onBulkEdit={onBulkEditPointRate} onHide={() => hideColumn(columnIds.excessPointRate)}>Цена балла сверх нормы</ColumnHeader>
</th>
) : null}
{isVisible(columnIds.targetPoints) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Норма баллов" help={helps.targetPoints} onOpen={setActiveHelp} onBulkEdit={onBulkEditTargetPoints} onHide={() => hideColumn(columnIds.targetPoints)}>Норма</ColumnHeader></th> : null}
{isVisible(columnIds.attendancePoints) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Баллы по посещаемости" help={helps.attendancePoints} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.attendancePoints)}>Баллы по посещаемости</ColumnHeader></th> : null}
{isVisible(columnIds.personnelPoints) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Баллы по кадровым событиям" help={helps.personnelPoints} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.personnelPoints)}>Баллы по кадровым событиям</ColumnHeader></th> : null}
{isVisible(columnIds.actualPoints) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Баллы" help={helps.actualPoints} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.actualPoints)}>Баллы</ColumnHeader></th> : null}
{isVisible(columnIds.pointDelta) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Отклонение" help={helps.pointDelta} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.pointDelta)}>Отклонение</ColumnHeader></th> : null}
{isVisible(columnIds.pointAmount) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="По баллам" help={helps.pointAmount} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.pointAmount)}>По баллам</ColumnHeader></th> : null}
{visibleComponentColumns.map((component) => (
<th key={component.code} className={`${headerCellClass} text-right whitespace-normal break-words`} title={`${component.label} · ${kindLabels[component.kind]}`}>
{component.label}
<ColumnHeader label={component.label} help={componentCalculationHelp(component)} onOpen={setActiveHelp} onHide={() => hideColumn(componentColumnId(component.code))}>{component.label}</ColumnHeader>
</th>
))}
<th className={`${headerCellClass} text-right`} title="До корректировок">До коррект.</th>
<th className={`${headerCellClass} text-right`}>Корректировки</th>
<th className={`${headerCellClass} text-right`}>Начислено</th>
<th className={`${headerCellClass} text-right`}>Удержано</th>
<th className={`${headerCellClass} text-right`} title="После удержаний">После удерж.</th>
<th className={`${headerCellClass} text-right`}>Выплачено</th>
<th className={`${headerCellClass} text-right`}>К выплате</th>
{isVisible(columnIds.grossBeforeAdjustments) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="До корректировок" help={helps.grossBeforeAdjustments} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.grossBeforeAdjustments)}>До коррект.</ColumnHeader></th> : null}
{isVisible(columnIds.adjustments) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Корректировки" help={helps.adjustments} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.adjustments)}>Корректировки</ColumnHeader></th> : null}
{isVisible(columnIds.gross) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Начислено" help={helps.gross} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.gross)}>Начислено</ColumnHeader></th> : null}
{isVisible(columnIds.deductions) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Удержано" help={helps.deductions} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.deductions)}>Удержано</ColumnHeader></th> : null}
{isVisible(columnIds.net) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="После удержаний" help={helps.net} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.net)}>После удерж.</ColumnHeader></th> : null}
{isVisible(columnIds.payments) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="Выплачено" help={helps.payments} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.payments)}>Выплачено</ColumnHeader></th> : null}
{isVisible(columnIds.payable) ? <th className={`${headerCellClass} text-right`}><ColumnHeader label="К выплате" help={helps.payable} onOpen={setActiveHelp} onHide={() => hideColumn(columnIds.payable)}>К выплате</ColumnHeader></th> : null}
</tr>
</thead>
<tbody>
@ -278,57 +746,62 @@ export function PayrollPeriodTableView({
<p className="max-w-full truncate text-xs font-semibold text-[var(--foreground)]" title={row.employee.display_name}>{row.employee.display_name}</p>
<p className="app-text-muted mt-0.5 max-w-full truncate text-[10px]">{row.employee.position || row.employee.department || "Сотрудник"}{row.employee.is_active ? "" : " · неактивен"}</p>
</td>
<InlineEditableCell value={row.rate_amount} formattedValue={formatMoney(row.rate_amount, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "amount", value) : undefined} label={`Оклад: ${row.employee.display_name}`} />
<InlineEditableCell value={row.in_norm_point_rate} formattedValue={formatMoney(row.in_norm_point_rate, data.currency)} label={`Цена балла в пределах нормы: ${row.employee.display_name}`} automatic />
<InlineEditableCell value={row.point_rate} formattedValue={formatMoney(row.point_rate, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} />
<InlineEditableCell value={row.target_points} formattedValue={formatPoints(row.target_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "target_points", value) : undefined} label={`Норма: ${row.employee.display_name}`} automatic={row.target_points_automatic} />
<InlineEditableCell value={row.actual_points} formattedValue={formatPoints(row.actual_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "actual_points", value) : undefined} label={`Баллы: ${row.employee.display_name}`} />
<td className={bodyCellClass}>{formatPoints(row.point_delta)}</td>
<td className={bodyCellClass}><CalculatedMoney value={row.point_amount} currency={data.currency} preliminary={row.totals_preliminary} /></td>
{data.component_columns.map((component) => (
{isVisible(columnIds.rateAmount) ? <InlineEditableCell value={row.rate_amount} formattedValue={formatMoney(row.rate_amount, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "amount", value) : undefined} label={`Оклад: ${row.employee.display_name}`} /> : null}
{isVisible(columnIds.inNormPointRate) ? <InlineEditableCell value={row.in_norm_point_rate} formattedValue={formatMoney(row.in_norm_point_rate, data.currency)} label={`Цена балла в пределах нормы: ${row.employee.display_name}`} automatic /> : null}
{isVisible(columnIds.excessPointRate) ? <InlineEditableCell value={row.point_rate ?? row.in_norm_point_rate} formattedValue={formatMoney(row.point_rate ?? row.in_norm_point_rate, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} automatic={row.point_rate === null} /> : null}
{isVisible(columnIds.targetPoints) ? <InlineEditableCell value={row.target_points} formattedValue={formatPoints(row.target_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "target_points", value) : undefined} label={`Норма: ${row.employee.display_name}`} automatic={row.target_points_automatic} /> : null}
{isVisible(columnIds.attendancePoints) ? <InlineEditableCell value={row.attendance_points} formattedValue={formatPoints(row.attendance_points)} label={`Баллы по посещаемости: ${row.employee.display_name}`} automatic /> : null}
{isVisible(columnIds.personnelPoints) ? <InlineEditableCell value={row.personnel_points} formattedValue={formatPoints(row.personnel_points)} label={`Баллы по кадровым событиям: ${row.employee.display_name}`} automatic /> : null}
{isVisible(columnIds.actualPoints) ? <InlineEditableCell value={row.actual_points} formattedValue={formatPoints(row.actual_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "actual_points", value) : undefined} label={`Баллы: ${row.employee.display_name}`} /> : null}
{isVisible(columnIds.pointDelta) ? <td className={bodyCellClass}>{formatPoints(row.point_delta)}</td> : null}
{isVisible(columnIds.pointAmount) ? <td className={bodyCellClass}><CalculatedMoney value={row.point_amount} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{visibleComponentColumns.map((component) => (
<InlineEditableCell key={component.code} value={row.component_amounts[component.code] || null} formattedValue={formatMoney(row.component_amounts[component.code] || null, data.currency)} onSave={canEdit && onSaveComponent ? (value) => onSaveComponent(row, component.code, value) : undefined} label={`${component.label}: ${row.employee.display_name}`} />
))}
<td className={bodyCellClass}><CalculatedMoney value={row.gross_before_adjustments} currency={data.currency} preliminary={row.totals_preliminary} /></td>
<td className={bodyCellClass}><CalculatedMoney value={row.adjustment_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
<td className={`${bodyCellClass} font-semibold`}><CalculatedMoney value={row.gross_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
<td className={bodyCellClass}><CalculatedMoney value={row.deduction_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
<td className={bodyCellClass}><CalculatedMoney value={row.net_pay} currency={data.currency} preliminary={row.totals_preliminary} /></td>
<td className={bodyCellClass}><CalculatedMoney value={row.payment_total} currency={data.currency} preliminary={row.totals_preliminary} /></td>
<td className={`${bodyCellClass} font-semibold`}><CalculatedMoney value={row.payable} currency={data.currency} preliminary={row.totals_preliminary} /></td>
<td className="overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center"><span className={`app-status-pill px-2 text-[10px] ${meta.className}`}>{meta.label}</span></td>
{isVisible(columnIds.grossBeforeAdjustments) ? <td className={bodyCellClass}><CalculatedMoney value={row.gross_before_adjustments} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{isVisible(columnIds.adjustments) ? <td className={bodyCellClass}><CalculatedMoney value={row.adjustment_total} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{isVisible(columnIds.gross) ? <td className={`${bodyCellClass} font-semibold`}><CalculatedMoney value={row.gross_total} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{isVisible(columnIds.deductions) ? <td className={bodyCellClass}><CalculatedMoney value={row.deduction_total} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{isVisible(columnIds.net) ? <td className={bodyCellClass}><CalculatedMoney value={row.net_pay} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{isVisible(columnIds.payments) ? <td className={bodyCellClass}><CalculatedMoney value={row.payment_total} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{isVisible(columnIds.payable) ? <td className={`${bodyCellClass} font-semibold`}><CalculatedMoney value={row.payable} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
{isVisible(columnIds.status) ? <td className="overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center"><span className={`app-status-pill px-2 text-[10px] ${meta.className}`}>{meta.label}</span></td> : null}
</tr>
);
}) : (
<tr><td colSpan={16 + data.component_columns.length} className="app-text-muted px-4 py-10 text-center text-sm">Сотрудники не найдены.</td></tr>
<tr><td colSpan={visibleColumnCount} className="app-text-muted px-4 py-10 text-center text-sm">Сотрудники не найдены.</td></tr>
)}
</tbody>
{rows.length ? (
<tfoot>
<tr className="app-surface-muted font-semibold">
<td className="sticky left-0 z-10 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 text-xs text-[var(--foreground)]">Итого · {rows.length}</td>
<td className={bodyCellClass}>{formatMoney(total("rate_amount"), data.currency)}</td>
<td className={bodyCellClass}>—</td>
<td className={bodyCellClass}>—</td>
<td className={bodyCellClass}>{formatPoints(total("target_points"))}</td>
<td className={bodyCellClass}>{formatPoints(total("actual_points"))}</td>
<td className={bodyCellClass}>{formatPoints(total("point_delta"))}</td>
<td className={bodyCellClass}>{formatMoney(total("point_amount"), data.currency)}</td>
{data.component_columns.map((component) => (
{isVisible(columnIds.rateAmount) ? <td className={bodyCellClass}>{formatMoney(total("rate_amount"), data.currency)}</td> : null}
{isVisible(columnIds.inNormPointRate) ? <td className={bodyCellClass}>—</td> : null}
{isVisible(columnIds.excessPointRate) ? <td className={bodyCellClass}>—</td> : null}
{isVisible(columnIds.targetPoints) ? <td className={bodyCellClass}>{formatPoints(total("target_points"))}</td> : null}
{isVisible(columnIds.attendancePoints) ? <td className={bodyCellClass}>{formatPoints(total("attendance_points"))}</td> : null}
{isVisible(columnIds.personnelPoints) ? <td className={bodyCellClass}>{formatPoints(total("personnel_points"))}</td> : null}
{isVisible(columnIds.actualPoints) ? <td className={bodyCellClass}>{formatPoints(total("actual_points"))}</td> : null}
{isVisible(columnIds.pointDelta) ? <td className={bodyCellClass}>{formatPoints(total("point_delta"))}</td> : null}
{isVisible(columnIds.pointAmount) ? <td className={bodyCellClass}>{formatMoney(total("point_amount"), data.currency)}</td> : null}
{visibleComponentColumns.map((component) => (
<td key={component.code} className={bodyCellClass}>{formatMoney(sumRows(rows, (row) => row.component_amounts[component.code] || null), data.currency)}</td>
))}
<td className={bodyCellClass}>{formatMoney(total("gross_before_adjustments"), data.currency)}</td>
<td className={bodyCellClass}>{formatMoney(total("adjustment_total"), data.currency)}</td>
<td className={bodyCellClass}>{formatMoney(total("gross_total"), data.currency)}</td>
<td className={bodyCellClass}>{formatMoney(total("deduction_total"), data.currency)}</td>
<td className={bodyCellClass}>{formatMoney(total("net_pay"), data.currency)}</td>
<td className={bodyCellClass}>{formatMoney(total("payment_total"), data.currency)}</td>
<td className={bodyCellClass}>{formatMoney(total("payable"), data.currency)}</td>
<td className="border-r border-[var(--border-subtle)] px-3 py-3" />
{isVisible(columnIds.grossBeforeAdjustments) ? <td className={bodyCellClass}>{formatMoney(total("gross_before_adjustments"), data.currency)}</td> : null}
{isVisible(columnIds.adjustments) ? <td className={bodyCellClass}>{formatMoney(total("adjustment_total"), data.currency)}</td> : null}
{isVisible(columnIds.gross) ? <td className={bodyCellClass}>{formatMoney(total("gross_total"), data.currency)}</td> : null}
{isVisible(columnIds.deductions) ? <td className={bodyCellClass}>{formatMoney(total("deduction_total"), data.currency)}</td> : null}
{isVisible(columnIds.net) ? <td className={bodyCellClass}>{formatMoney(total("net_pay"), data.currency)}</td> : null}
{isVisible(columnIds.payments) ? <td className={bodyCellClass}>{formatMoney(total("payment_total"), data.currency)}</td> : null}
{isVisible(columnIds.payable) ? <td className={bodyCellClass}>{formatMoney(total("payable"), data.currency)}</td> : null}
{isVisible(columnIds.status) ? <td className="border-r border-[var(--border-subtle)] px-3 py-3" /> : null}
</tr>
</tfoot>
) : null}
</table>
</div>
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
</div>
);
}

View File

@ -241,6 +241,8 @@ export type PayrollPeriodTableRow = {
target_points: string | null;
target_points_automatic: boolean;
target_points_source: string;
attendance_points: string | null;
personnel_points: string;
actual_points: string | null;
point_delta: string | null;
point_amount: string | null;
@ -258,6 +260,16 @@ export type PayrollPeriodTableRow = {
export type PayrollPeriodTable = {
period_id: number;
currency: string;
calculation_rules: {
ruleset_id: string;
version: string;
effective_from: string;
effective_to: string | null;
point_policy: "disabled" | "excess_only" | "proportional_with_excess";
money_quantum: string;
rounding: "ROUND_HALF_UP" | "ROUND_HALF_EVEN" | "ROUND_DOWN";
allow_negative_payable: boolean;
};
run: Pick<PayrollAdminRun, "id" | "revision" | "status"> | null;
component_columns: PayrollPeriodTableComponent[];
rows: PayrollPeriodTableRow[];
@ -290,7 +302,7 @@ export type PayrollAdminPayRate = {
employee: PayrollAdminEmployee;
rate_code: string;
amount: string;
point_rate: string;
point_rate: string | null;
currency: string;
effective_from: string;
revision: number;
@ -366,6 +378,7 @@ export type PayrollAttendanceWorkPreviewItem = {
employee: PayrollAdminEmployee;
target_points: string;
actual_points: string;
daily_target_points: string;
expected_hours: string;
worked_hours: string;
attendance_days: number;
@ -385,6 +398,8 @@ export type PayrollAttendanceWorkPreview = {
code: string;
label: string;
description: string;
formula: string;
daily_target_points: string;
};
summary: {
attendance_employees: number;
@ -455,7 +470,7 @@ export type PayrollPayRateWrite = {
employee_id: number;
rate_code: string;
amount: string;
point_rate: string;
point_rate: string | null;
currency: string;
effective_from: string;
reason: string;
@ -502,6 +517,38 @@ export type PayrollBulkPayRateResult = {
};
};
export type PayrollBulkTargetPointsMode = "fixed" | "automatic";
export type PayrollBulkTargetPointsWrite = {
employee_ids: number[];
mode: PayrollBulkTargetPointsMode;
target_points?: string | null;
reason: string;
};
export type PayrollBulkTargetPointsResult = {
mode: PayrollBulkTargetPointsMode;
target_points: string | null;
summary: {
selected_employees: number;
created_drafts: number;
updated_drafts: number;
created_revisions: number;
unchanged: number;
skipped: number;
};
};
export type PayrollApproveDraftsResult = {
period_id: number;
summary: {
rates: number;
work_records: number;
input_lines: number;
total: number;
};
};
export type PayrollWorkRecordWrite = {
period_id: number;
employee_id: number;
@ -600,6 +647,13 @@ export function createFinanceApi(request: RequestFn) {
payload: PayrollBulkPointRateWrite,
): Promise<PayrollBulkPointRateResult> =>
request(`${adminPrefix}/periods/${periodId}/bulk-point-rate/`, jsonOptions("POST", payload)),
bulkSetPayrollAdminTargetPoints: (
periodId: number,
payload: PayrollBulkTargetPointsWrite,
): Promise<PayrollBulkTargetPointsResult> =>
request(`${adminPrefix}/periods/${periodId}/bulk-target-points/`, jsonOptions("POST", payload)),
approveAllPayrollAdminDrafts: (periodId: number): Promise<PayrollApproveDraftsResult> =>
request(`${adminPrefix}/periods/${periodId}/approve-drafts/`, jsonOptions("POST")),
getPayrollAdminWorkRecords: (params: PayrollAdminListParams = {}): Promise<PayrollAdminListPayload<PayrollAdminWorkRecord>> =>
request(`${adminPrefix}/work-records/${buildQuery({ ...params, page_size: 200 })}`),
createPayrollAdminWorkRecord: (payload: PayrollWorkRecordWrite): Promise<PayrollAdminWorkRecord> =>
@ -632,6 +686,11 @@ export function createFinanceApi(request: RequestFn) {
payload: Partial<Omit<PayrollInputLineWrite, "period_id" | "employee_id" | "component_id">> & { expected_lock_version: number },
): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/input-lines/${lineId}/`, jsonOptions("PATCH", payload)),
clearPayrollAdminComponentCell: (
periodId: number,
payload: { employee_id: number; component_id: number },
): Promise<{ draft: number; approved: number; total: number }> =>
request(`${adminPrefix}/periods/${periodId}/component-cell/clear/`, jsonOptions("POST", payload)),
approvePayrollAdminInputLine: (lineId: number, expectedLockVersion: number): Promise<PayrollAdminInputLine> =>
request(`${adminPrefix}/input-lines/${lineId}/approve/`, jsonOptions("POST", { expected_lock_version: expectedLockVersion })),
calculatePayrollAdminPeriod: (

View File

@ -15,10 +15,10 @@ test("attendance warnings use the correct employee form", () => {
assert.equal(formatPayrollAttendanceEmployeeCount(11), "11 сотрудников");
});
test("attendance work metrics are explicitly displayed as hours", () => {
test("attendance work metrics are explicitly displayed as points", () => {
assert.equal(isAttendancePayrollWorkRecord("attendance"), true);
assert.equal(isAttendancePayrollWorkRecord("manual"), false);
assert.equal(formatPayrollWorkMetric("152.5000", "attendance"), "152.5000 ч");
assert.equal(formatPayrollWorkMetric("95.3125", "attendance"), "95.3125 балл.");
assert.equal(formatPayrollWorkMetric("110.0000", "manual"), "110.0000");
});

View File

@ -35,7 +35,7 @@ export function isAttendancePayrollWorkRecord(source?: string | null): boolean {
}
export function formatPayrollWorkMetric(value: string, source?: string | null): string {
return `${value}${isAttendancePayrollWorkRecord(source) ? " ч" : ""}`;
return `${value}${isAttendancePayrollWorkRecord(source) ? " балл." : ""}`;
}
function pluralize(count: number, one: string, few: string, many: string): string {