Merge branch 'develop'
This commit is contained in:
commit
52aabd2ed3
@ -178,7 +178,7 @@ def create_qr_login_token(
|
||||
def exchange_qr_login_token(*, raw_token: str, request) -> tuple[str, str]:
|
||||
token_hash = hash_qr_login_token(raw_token)
|
||||
qr_token = (
|
||||
QrLoginToken.objects.select_for_update()
|
||||
QrLoginToken.objects.select_for_update(of=("self",))
|
||||
.select_related("user")
|
||||
.filter(token_hash=token_hash)
|
||||
.first()
|
||||
@ -330,7 +330,7 @@ def poll_qr_login_request(
|
||||
request,
|
||||
) -> dict[str, str | None]:
|
||||
qr_request = (
|
||||
QrLoginRequest.objects.select_for_update()
|
||||
QrLoginRequest.objects.select_for_update(of=("self",))
|
||||
.select_related("approved_by")
|
||||
.filter(
|
||||
client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
@ -150,7 +156,9 @@ def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None):
|
||||
|
||||
def _lock_period_accepting_draft_edits(period_id):
|
||||
period = get_object_or_404(
|
||||
PayrollPeriod.objects.select_for_update().select_related("current_run"),
|
||||
PayrollPeriod.objects.select_for_update(of=("self",)).select_related(
|
||||
"current_run"
|
||||
),
|
||||
pk=period_id,
|
||||
)
|
||||
if period.status == PayrollPeriodStatus.CLOSED:
|
||||
@ -586,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
|
||||
@ -699,7 +717,12 @@ class DraftDetailView(PayrollAdminAPIView):
|
||||
period = _lock_period_accepting_draft_edits(reference.period_id)
|
||||
else:
|
||||
period = None
|
||||
instance = get_object_or_404(scoped_queryset.select_for_update())
|
||||
# Read serializers join nullable audit/revision relations. PostgreSQL
|
||||
# cannot apply FOR UPDATE to the nullable side of those outer joins,
|
||||
# and only the draft itself needs to be locked here.
|
||||
instance = get_object_or_404(
|
||||
scoped_queryset.select_for_update(of=("self",))
|
||||
)
|
||||
if period is not None:
|
||||
instance._state.fields_cache["period"] = period
|
||||
self.validate_period_update(instance, period)
|
||||
@ -759,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):
|
||||
@ -841,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
|
||||
@ -945,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
|
||||
@ -1001,7 +1047,9 @@ class EmployeePayRateReviseView(PayrollAdminAPIView):
|
||||
def post(self, request, pk):
|
||||
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
|
||||
source = get_object_or_404(
|
||||
EmployeePayRate.objects.select_for_update().select_related("employee"),
|
||||
EmployeePayRate.objects.select_for_update(of=("self",)).select_related(
|
||||
"employee"
|
||||
),
|
||||
pk=pk,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
)
|
||||
@ -1071,7 +1119,7 @@ class PayrollWorkRecordReviseView(PayrollAdminAPIView):
|
||||
)
|
||||
period = _lock_period_accepting_draft_edits(reference.period_id)
|
||||
source = get_object_or_404(
|
||||
PayrollWorkRecord.objects.select_for_update().select_related(
|
||||
PayrollWorkRecord.objects.select_for_update(of=("self",)).select_related(
|
||||
"period", "employee"
|
||||
),
|
||||
pk=pk,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -600,7 +600,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
visible_task = self.get_object()
|
||||
with transaction.atomic():
|
||||
task = (
|
||||
Task.objects.select_for_update()
|
||||
Task.objects.select_for_update(of=("self",))
|
||||
.select_related("board", "column", "assignee")
|
||||
.get(id=visible_task.id)
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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_point_value: 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_point_value * 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,
|
||||
@ -433,8 +491,10 @@ def build_attendance_work_preview(
|
||||
"employee", "employee__position", "created_by"
|
||||
)
|
||||
if lock:
|
||||
attendance = attendance.select_for_update()
|
||||
work = work.select_for_update()
|
||||
# Both projections contain nullable outer joins (analysis run and
|
||||
# employee position). Lock only the source rows being imported.
|
||||
attendance = attendance.select_for_update(of=("self",))
|
||||
work = work.select_for_update(of=("self",))
|
||||
attendance_records = list(attendance.order_by("employee_id", "date", "id"))
|
||||
work_records = list(work.order_by("employee_id", "revision", "id"))
|
||||
|
||||
@ -451,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(
|
||||
@ -526,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),
|
||||
@ -594,7 +662,7 @@ def apply_attendance_work_preview(
|
||||
)
|
||||
|
||||
period = (
|
||||
PayrollPeriod.objects.select_for_update()
|
||||
PayrollPeriod.objects.select_for_update(of=("self",))
|
||||
.select_related("current_run")
|
||||
.get(pk=period_id)
|
||||
)
|
||||
@ -664,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 = {
|
||||
@ -681,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,
|
||||
@ -693,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
|
||||
@ -710,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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,17 @@ 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, scheduled_workdays, _ = calculate_period_target_points(
|
||||
period,
|
||||
employee=employee,
|
||||
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 +312,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 +351,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 +380,32 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
else:
|
||||
status = "ready"
|
||||
|
||||
projection_target_points = Decimal(str(target_points or "0"))
|
||||
daily_point_value = (
|
||||
projection_target_points / Decimal(scheduled_workdays)
|
||||
if scheduled_workdays
|
||||
else Decimal("0")
|
||||
)
|
||||
attendance_records_for_employee = attendance_by_employee.get(employee.pk, [])
|
||||
attendance_points = (
|
||||
calculate_attendance_points_projection(
|
||||
attendance_records_for_employee,
|
||||
daily_point_value=daily_point_value,
|
||||
)
|
||||
if attendance_records_for_employee
|
||||
else None
|
||||
)
|
||||
personnel_points, _ = calculate_period_personnel_points(
|
||||
period,
|
||||
employee=employee,
|
||||
actions=employee.payroll_personnel_actions,
|
||||
period_target_points=projection_target_points,
|
||||
schedule=schedule,
|
||||
)
|
||||
|
||||
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 +422,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 +444,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 +462,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"),
|
||||
|
||||
@ -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)
|
||||
@ -757,7 +786,7 @@ def bulk_set_point_rate(
|
||||
|
||||
selected_employee_ids = sorted(set(employee_ids))
|
||||
rates = list(
|
||||
EmployeePayRate.objects.select_for_update()
|
||||
EmployeePayRate.objects.select_for_update(of=("self",))
|
||||
.filter(
|
||||
employee_id__in=selected_employee_ids,
|
||||
rate_code=base_rate_code(),
|
||||
@ -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,
|
||||
},
|
||||
@ -984,7 +1002,7 @@ def bulk_set_pay_rate(
|
||||
).order_by("pk")
|
||||
}
|
||||
rates = list(
|
||||
EmployeePayRate.objects.select_for_update()
|
||||
EmployeePayRate.objects.select_for_update(of=("self",))
|
||||
.filter(
|
||||
employee_id__in=employees,
|
||||
rate_code=base_rate_code(),
|
||||
@ -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,
|
||||
@ -1374,7 +1620,7 @@ def save_own_daily_work_entry(
|
||||
"Для сохранения выработки необходимо войти в систему.",
|
||||
)
|
||||
period = (
|
||||
PayrollPeriod.objects.select_for_update()
|
||||
PayrollPeriod.objects.select_for_update(of=("self",))
|
||||
.select_related("current_run")
|
||||
.get(pk=period_id)
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
period_target_points: Decimal,
|
||||
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)
|
||||
workdates = period_workdates(period, schedule)
|
||||
attended_dates = [
|
||||
workdate
|
||||
for workdate in workdates
|
||||
if resolve_employee_personnel_state(
|
||||
employee,
|
||||
workdate,
|
||||
actions=actions,
|
||||
).expects_attendance
|
||||
]
|
||||
points = (
|
||||
period_target_points * Decimal(len(attended_dates)) / Decimal(len(workdates))
|
||||
if workdates
|
||||
else Decimal("0")
|
||||
).quantize(QUANTUM)
|
||||
return points, len(attended_dates)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,170 @@ 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,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="220",
|
||||
target_points_overridden=True,
|
||||
actual_points="0",
|
||||
created_by=manager,
|
||||
)
|
||||
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["target_points"] == "220.0000"
|
||||
assert row["attendance_points"] == "15.0000"
|
||||
assert row["personnel_points"] == "210.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 +508,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 +623,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 +789,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 +799,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 +1088,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 +1133,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 +1168,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 +1208,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 +1255,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 +1501,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 +1518,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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"]} == {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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)} />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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Баллы дня = Дневная доля нормы × Отработанные часы ÷ Плановые часы\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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: (
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user