feat(finance): add daily payroll point breakdown
This commit is contained in:
parent
54171f9dad
commit
f7ebc7b00f
@ -54,7 +54,10 @@ from finance.payroll.services import (
|
||||
return_run_for_correction,
|
||||
submit_run_for_review,
|
||||
)
|
||||
from finance.payroll.reporting import build_payroll_period_table
|
||||
from finance.payroll.reporting import (
|
||||
build_payroll_employee_point_breakdown,
|
||||
build_payroll_period_table,
|
||||
)
|
||||
from finance.payroll.attendance import (
|
||||
apply_attendance_work_preview,
|
||||
build_attendance_work_preview,
|
||||
@ -594,6 +597,23 @@ class PayrollPeriodTableView(PayrollAdminAPIView):
|
||||
return Response(build_payroll_period_table(period))
|
||||
|
||||
|
||||
class PayrollPeriodPointBreakdownView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
|
||||
|
||||
def get(self, request, pk, employee_id):
|
||||
period = get_object_or_404(
|
||||
PayrollPeriod.objects.select_related("current_run"),
|
||||
pk=pk,
|
||||
)
|
||||
employee = get_object_or_404(
|
||||
Employee.objects.select_related("position").prefetch_related(
|
||||
"departments_links__department"
|
||||
),
|
||||
pk=employee_id,
|
||||
)
|
||||
return Response(build_payroll_employee_point_breakdown(period, employee))
|
||||
|
||||
|
||||
class PayrollPeriodApproveDraftsView(PayrollAdminAPIView):
|
||||
allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],)
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ from .admin_views import (
|
||||
PayrollPeriodCalculateView,
|
||||
PayrollPeriodApproveDraftsView,
|
||||
PayrollPeriodCloseView,
|
||||
PayrollPeriodPointBreakdownView,
|
||||
PayrollPeriodTableView,
|
||||
PayrollRunApproveView,
|
||||
PayrollRunDetailView,
|
||||
@ -82,6 +83,11 @@ urlpatterns = [
|
||||
PayrollPeriodTableView.as_view(),
|
||||
name="admin-period-table",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/point-breakdown/",
|
||||
PayrollPeriodPointBreakdownView.as_view(),
|
||||
name="admin-period-point-breakdown",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/periods/<int:pk>/attendance-work-records/",
|
||||
PayrollAttendanceWorkRecordsView.as_view(),
|
||||
|
||||
@ -94,6 +94,52 @@ def _is_explicit_absence(record: AttendanceRecord) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _calculate_attendance_day_points(
|
||||
record: AttendanceRecord,
|
||||
*,
|
||||
daily_point_value: Decimal,
|
||||
) -> Decimal | None:
|
||||
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:
|
||||
return None
|
||||
if not record.effective_is_workday or record.technical_issues:
|
||||
return None
|
||||
if record.personnel_status == ACTION_REMOTE and not (
|
||||
record.is_manually_edited
|
||||
and "work_hours" in (record.manual_edit_payload or {})
|
||||
):
|
||||
return None
|
||||
if bool(record.arrival_time) != bool(record.departure_time):
|
||||
return None
|
||||
if (
|
||||
expected is None
|
||||
or worked is None
|
||||
or expected <= 0
|
||||
or expected > 24
|
||||
or worked < 0
|
||||
or worked > 24
|
||||
):
|
||||
return None
|
||||
if worked == 0 and not record.arrival_time and not _is_explicit_absence(record):
|
||||
return None
|
||||
return daily_point_value * worked / expected
|
||||
|
||||
|
||||
def calculate_attendance_day_points(
|
||||
record: AttendanceRecord,
|
||||
*,
|
||||
daily_point_value: Decimal,
|
||||
) -> Decimal | None:
|
||||
"""Return a comparable daily projection or ``None`` for unusable data."""
|
||||
|
||||
points = _calculate_attendance_day_points(
|
||||
record,
|
||||
daily_point_value=daily_point_value,
|
||||
)
|
||||
return points.quantize(QUANTUM) if points is not None else None
|
||||
|
||||
|
||||
def calculate_attendance_points_projection(
|
||||
records: list[AttendanceRecord],
|
||||
*,
|
||||
@ -108,31 +154,12 @@ def calculate_attendance_points_projection(
|
||||
|
||||
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
|
||||
day_points = _calculate_attendance_day_points(
|
||||
record,
|
||||
daily_point_value=daily_point_value,
|
||||
)
|
||||
if day_points is not None:
|
||||
actual_points += day_points
|
||||
return actual_points.quantize(QUANTUM)
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -18,6 +19,7 @@ from finance.enums import ApprovalStatus
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollComponent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
PayrollWorkRecord,
|
||||
@ -25,11 +27,16 @@ from finance.models import (
|
||||
)
|
||||
|
||||
from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request
|
||||
from .attendance import calculate_attendance_points_projection
|
||||
from .attendance import (
|
||||
calculate_attendance_day_points,
|
||||
calculate_attendance_points_projection,
|
||||
)
|
||||
from .config import base_rate_code, build_rules
|
||||
from .work_norm import (
|
||||
calculate_period_personnel_points,
|
||||
calculate_period_personnel_daily_points,
|
||||
calculate_period_target_points,
|
||||
period_workdates,
|
||||
resolve_employee_schedule,
|
||||
)
|
||||
|
||||
@ -240,7 +247,6 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
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",
|
||||
),
|
||||
@ -468,3 +474,119 @@ def build_payroll_period_table(period: PayrollPeriod):
|
||||
"payable_total": _row_money_total(rows, "payable"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_payroll_employee_point_breakdown(
|
||||
period: PayrollPeriod,
|
||||
employee,
|
||||
):
|
||||
"""Build a read-only day-by-day comparison of the three point sources."""
|
||||
|
||||
actions = list(
|
||||
EmployeeAction.objects.filter(employee=employee)
|
||||
.filter(
|
||||
Q(source_request__isnull=True)
|
||||
| Q(source_request__status=RequestStatus.APPROVED)
|
||||
)
|
||||
.select_related("source_request")
|
||||
.order_by("date", "id")
|
||||
)
|
||||
schedule, _ = resolve_employee_schedule(employee)
|
||||
workdates = period_workdates(period, schedule)
|
||||
workdate_set = set(workdates)
|
||||
automatic_target_points, _, _ = calculate_period_target_points(
|
||||
period,
|
||||
employee=employee,
|
||||
schedule=schedule,
|
||||
)
|
||||
work = _latest_work_by_employee(period, [employee.pk]).get(employee.pk)
|
||||
statement = None
|
||||
if period.current_run_id:
|
||||
statement = period.current_run.statements.filter(employee=employee).first()
|
||||
|
||||
if statement is not None:
|
||||
snapshot = statement.input_snapshot or {}
|
||||
target_points = Decimal(
|
||||
str(snapshot.get("target_points", automatic_target_points))
|
||||
)
|
||||
actual_points = Decimal(str(snapshot.get("actual_points", "0")))
|
||||
else:
|
||||
target_points = work.target_points if work else automatic_target_points
|
||||
actual_points = work.actual_points if work else None
|
||||
|
||||
daily_target_points = (
|
||||
target_points / Decimal(len(workdates)) if workdates else Decimal("0")
|
||||
)
|
||||
attendance_records = {
|
||||
record.date: record
|
||||
for record in AttendanceRecord.objects.filter(
|
||||
employee=employee,
|
||||
date__range=(period.date_from, period.date_to),
|
||||
).select_related("analysis_run")
|
||||
}
|
||||
personnel_points = calculate_period_personnel_daily_points(
|
||||
period,
|
||||
employee=employee,
|
||||
actions=actions,
|
||||
period_target_points=target_points,
|
||||
schedule=schedule,
|
||||
)
|
||||
work_entries = {
|
||||
entry.work_date: entry.actual_points
|
||||
for entry in PayrollDailyWorkEntry.objects.filter(
|
||||
period=period,
|
||||
employee=employee,
|
||||
)
|
||||
}
|
||||
dated_work_points_total = sum(work_entries.values(), Decimal("0"))
|
||||
undated_work_points = (
|
||||
(actual_points - dated_work_points_total).quantize(Decimal("0.0001"))
|
||||
if actual_points is not None
|
||||
else None
|
||||
)
|
||||
if undated_work_points == 0:
|
||||
undated_work_points = None
|
||||
if work_entries:
|
||||
work_points_mode = "daily_entries"
|
||||
work_points = work_entries
|
||||
else:
|
||||
work_points_mode = "unavailable"
|
||||
work_points = {}
|
||||
|
||||
dates = []
|
||||
current = period.date_from
|
||||
while current <= period.date_to:
|
||||
attendance_record = attendance_records.get(current)
|
||||
attendance_day_points = (
|
||||
calculate_attendance_day_points(
|
||||
attendance_record,
|
||||
daily_point_value=daily_target_points,
|
||||
)
|
||||
if attendance_record is not None
|
||||
else None
|
||||
)
|
||||
dates.append(
|
||||
{
|
||||
"date": current.isoformat(),
|
||||
"is_workday": current in workdate_set,
|
||||
"attendance_points": _decimal_text(attendance_day_points),
|
||||
"personnel_points": _decimal_text(personnel_points.get(current)),
|
||||
"work_points": _decimal_text(work_points.get(current)),
|
||||
}
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
|
||||
return {
|
||||
"period": {
|
||||
"id": period.pk,
|
||||
"name": period.name,
|
||||
"date_from": period.date_from.isoformat(),
|
||||
"date_to": period.date_to.isoformat(),
|
||||
},
|
||||
"employee": _employee_payload(employee),
|
||||
"target_points": _decimal_text(target_points),
|
||||
"actual_points": _decimal_text(actual_points),
|
||||
"undated_work_points": _decimal_text(undated_work_points),
|
||||
"work_points_mode": work_points_mode,
|
||||
"dates": dates,
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ from attendance.services import (
|
||||
get_standard_work_schedule,
|
||||
get_standard_work_schedule_payload,
|
||||
)
|
||||
from employees.constants import ACTIVATING_MARKER_ACTIONS
|
||||
from employees.services.personnel_state import resolve_employee_personnel_state
|
||||
|
||||
from finance.models import PayrollPeriod, PayrollWorkSettings
|
||||
@ -112,16 +113,26 @@ def calculate_period_personnel_points(
|
||||
|
||||
if schedule is None:
|
||||
schedule, _ = resolve_employee_schedule(employee)
|
||||
personnel_actions = list(actions)
|
||||
employment_start_dates = [
|
||||
action.date.date()
|
||||
for action in personnel_actions
|
||||
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
|
||||
]
|
||||
first_employment_date = (
|
||||
min(employment_start_dates) if employment_start_dates else None
|
||||
)
|
||||
workdates = period_workdates(period, schedule)
|
||||
today = timezone.localdate()
|
||||
completed_workdates = [workdate for workdate in workdates if workdate < today]
|
||||
attended_dates = [
|
||||
workdate
|
||||
for workdate in completed_workdates
|
||||
if resolve_employee_personnel_state(
|
||||
if (first_employment_date is None or workdate >= first_employment_date)
|
||||
and resolve_employee_personnel_state(
|
||||
employee,
|
||||
workdate,
|
||||
actions=actions,
|
||||
actions=personnel_actions,
|
||||
).expects_attendance
|
||||
]
|
||||
points = (
|
||||
@ -130,3 +141,52 @@ def calculate_period_personnel_points(
|
||||
else Decimal("0")
|
||||
).quantize(QUANTUM)
|
||||
return points, len(attended_dates)
|
||||
|
||||
|
||||
def calculate_period_personnel_daily_points(
|
||||
period: PayrollPeriod,
|
||||
*,
|
||||
employee,
|
||||
actions,
|
||||
period_target_points: Decimal,
|
||||
schedule: dict | None = None,
|
||||
) -> dict[date, Decimal | None]:
|
||||
"""Return daily personnel projections for scheduled dates in a period.
|
||||
|
||||
Future and current dates deliberately stay unknown. Completed scheduled
|
||||
dates receive their share of the period norm when attendance was expected,
|
||||
otherwise zero.
|
||||
"""
|
||||
|
||||
if schedule is None:
|
||||
schedule, _ = resolve_employee_schedule(employee)
|
||||
personnel_actions = list(actions)
|
||||
employment_start_dates = [
|
||||
action.date.date()
|
||||
for action in personnel_actions
|
||||
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
|
||||
]
|
||||
first_employment_date = (
|
||||
min(employment_start_dates) if employment_start_dates else None
|
||||
)
|
||||
workdates = period_workdates(period, schedule)
|
||||
if not workdates:
|
||||
return {}
|
||||
daily_point_value = period_target_points / Decimal(len(workdates))
|
||||
today = timezone.localdate()
|
||||
result = {}
|
||||
for workdate in workdates:
|
||||
if workdate >= today:
|
||||
result[workdate] = None
|
||||
continue
|
||||
expects_attendance = (
|
||||
first_employment_date is None or workdate >= first_employment_date
|
||||
) and resolve_employee_personnel_state(
|
||||
employee,
|
||||
workdate,
|
||||
actions=personnel_actions,
|
||||
).expects_attendance
|
||||
result[workdate] = (
|
||||
daily_point_value if expects_attendance else Decimal("0")
|
||||
).quantize(QUANTUM)
|
||||
return result
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@ -9,11 +9,13 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from employees.constants import ACTION_DISMISSED, ACTION_HIRED, ACTION_REHIRED
|
||||
from employees.models import EmployeeAction
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
PayrollAuditEvent,
|
||||
PayrollComponent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollInputLine,
|
||||
PayrollPeriod,
|
||||
PayrollRun,
|
||||
@ -251,7 +253,7 @@ def test_period_table_lists_all_employees_and_requires_view_all(
|
||||
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["personnel_points"] == "0.0000"
|
||||
assert row["actual_points"] == "115.0000"
|
||||
assert row["point_delta"] == "5.0000"
|
||||
assert row["point_amount"] == "750.00"
|
||||
@ -311,6 +313,11 @@ def test_period_table_projects_attendance_and_official_personnel_points(
|
||||
actual_points="0",
|
||||
created_by=manager,
|
||||
)
|
||||
EmployeeAction.objects.create(
|
||||
employee=employee,
|
||||
action=ACTION_HIRED,
|
||||
date=timezone.make_aware(datetime(2026, 6, 1)),
|
||||
)
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=attendance_run,
|
||||
employee=employee,
|
||||
@ -356,6 +363,185 @@ def test_period_table_projects_attendance_and_official_personnel_points(
|
||||
assert row["personnel_points"] == "20.0000"
|
||||
|
||||
|
||||
def test_period_table_personnel_points_respect_employment_boundaries(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
monkeypatch,
|
||||
):
|
||||
manager = user_factory(email="table.employment.manager@example.test")
|
||||
employee = user_factory(email="table.employment.employee@example.test")
|
||||
grant(manager, "view_all_payroll")
|
||||
period = make_period(creator=manager)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="220",
|
||||
target_points_overridden=True,
|
||||
actual_points="0",
|
||||
created_by=manager,
|
||||
)
|
||||
for action, action_date in (
|
||||
(ACTION_HIRED, datetime(2026, 6, 5)),
|
||||
(ACTION_DISMISSED, datetime(2026, 6, 10)),
|
||||
(ACTION_REHIRED, datetime(2026, 6, 15)),
|
||||
):
|
||||
EmployeeAction.objects.create(
|
||||
employee=employee,
|
||||
action=action,
|
||||
date=timezone.make_aware(action_date),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"finance.payroll.work_norm.timezone.localdate",
|
||||
lambda: date(2026, 7, 1),
|
||||
)
|
||||
|
||||
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["personnel_points"] == "150.0000"
|
||||
|
||||
|
||||
def test_period_point_breakdown_returns_daily_sources(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
monkeypatch,
|
||||
):
|
||||
viewer = user_factory(email="point.breakdown.viewer@example.test")
|
||||
employee = user_factory(
|
||||
email="point.breakdown.employee@example.test",
|
||||
first_name="Ирина",
|
||||
last_name="Подневная",
|
||||
)
|
||||
grant(viewer, "view_all_payroll")
|
||||
period = make_period(creator=viewer)
|
||||
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"]
|
||||
},
|
||||
)
|
||||
for day, worked in ((date(2026, 6, 1), 9), (date(2026, 6, 2), 4.5)):
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=attendance_run,
|
||||
employee=employee,
|
||||
date=day,
|
||||
arrival_time="08:00",
|
||||
departure_time="17:00" if worked == 9 else "12:30",
|
||||
work_hours=worked,
|
||||
expected_hours=9,
|
||||
effective_is_workday=True,
|
||||
)
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
target_points="220",
|
||||
target_points_overridden=True,
|
||||
actual_points="15",
|
||||
created_by=viewer,
|
||||
)
|
||||
PayrollDailyWorkEntry.objects.create(
|
||||
period=period,
|
||||
employee=employee,
|
||||
work_date=date(2026, 6, 1),
|
||||
target_points="10",
|
||||
actual_points="15",
|
||||
)
|
||||
EmployeeAction.objects.create(
|
||||
employee=employee,
|
||||
action=ACTION_HIRED,
|
||||
date=timezone.make_aware(datetime(2026, 6, 1)),
|
||||
)
|
||||
vacation = Request.objects.create(
|
||||
employee=employee,
|
||||
type=RequestType.VACATION,
|
||||
date_from=date(2026, 6, 2),
|
||||
date_to=date(2026, 6, 2),
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
vacation.status = RequestStatus.APPROVED
|
||||
vacation.approver = viewer
|
||||
vacation.decided_at = timezone.now()
|
||||
vacation.save(update_fields=["status", "approver", "decided_at", "updated_at"])
|
||||
assert EmployeeAction.objects.filter(source_request=vacation).exists()
|
||||
monkeypatch.setattr(
|
||||
"finance.payroll.work_norm.timezone.localdate",
|
||||
lambda: date(2026, 6, 3),
|
||||
)
|
||||
|
||||
response = auth_client_factory(viewer).get(
|
||||
api_url(
|
||||
"admin-period-point-breakdown",
|
||||
pk=period.pk,
|
||||
employee_id=employee.pk,
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert_no_store(response)
|
||||
body = response.json()
|
||||
assert body["employee"]["display_name"] == "Ирина Подневная"
|
||||
assert body["target_points"] == "220.0000"
|
||||
assert body["actual_points"] == "15.0000"
|
||||
assert body["undated_work_points"] is None
|
||||
assert body["work_points_mode"] == "daily_entries"
|
||||
june_first = next(item for item in body["dates"] if item["date"] == "2026-06-01")
|
||||
june_second = next(item for item in body["dates"] if item["date"] == "2026-06-02")
|
||||
weekend = next(item for item in body["dates"] if item["date"] == "2026-06-06")
|
||||
assert june_first == {
|
||||
"date": "2026-06-01",
|
||||
"is_workday": True,
|
||||
"attendance_points": "10.0000",
|
||||
"personnel_points": "10.0000",
|
||||
"work_points": "15.0000",
|
||||
}
|
||||
assert june_second == {
|
||||
"date": "2026-06-02",
|
||||
"is_workday": True,
|
||||
"attendance_points": "5.0000",
|
||||
"personnel_points": "0.0000",
|
||||
"work_points": None,
|
||||
}
|
||||
assert weekend == {
|
||||
"date": "2026-06-06",
|
||||
"is_workday": False,
|
||||
"attendance_points": None,
|
||||
"personnel_points": None,
|
||||
"work_points": None,
|
||||
}
|
||||
|
||||
undated_employee = user_factory(email="point.breakdown.undated@example.test")
|
||||
PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=undated_employee,
|
||||
target_points="220",
|
||||
target_points_overridden=True,
|
||||
actual_points="42",
|
||||
created_by=viewer,
|
||||
)
|
||||
undated_response = auth_client_factory(viewer).get(
|
||||
api_url(
|
||||
"admin-period-point-breakdown",
|
||||
pk=period.pk,
|
||||
employee_id=undated_employee.pk,
|
||||
)
|
||||
)
|
||||
assert undated_response.status_code == 200, undated_response.content
|
||||
undated_body = undated_response.json()
|
||||
assert undated_body["undated_work_points"] == "42.0000"
|
||||
assert undated_body["work_points_mode"] == "unavailable"
|
||||
assert all(item["work_points"] is None for item in undated_body["dates"])
|
||||
|
||||
|
||||
def test_component_cell_can_be_cleared_and_voids_all_active_lines(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
|
||||
@ -4,6 +4,7 @@ import { CircleHelp, Eye, EyeOff, Loader2, Pencil, Search, X } from "lucide-reac
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal";
|
||||
import type {
|
||||
PayrollPeriodTable,
|
||||
PayrollPeriodTableComponent,
|
||||
@ -12,6 +13,7 @@ import type {
|
||||
} from "@/lib/api/finance";
|
||||
import { formatPayrollMoney } from "@/lib/payroll";
|
||||
import { getPayrollAdminError } from "@/lib/payroll-admin";
|
||||
import { comparePayrollProjection, type PayrollProjectionDifference } from "@/lib/payroll-projection";
|
||||
|
||||
const statusMeta: Record<PayrollPeriodTableStatus, { label: string; className: string }> = {
|
||||
calculated: { label: "Рассчитано", className: "app-feedback-success" },
|
||||
@ -293,7 +295,7 @@ function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["
|
||||
},
|
||||
personnelPoints: {
|
||||
title: "Баллы по кадровым событиям",
|
||||
logic: "Это накопительный показатель на начало текущего дня. Он вычисляется на ходу от нормы баллов сотрудника, рабочего графика и официальных кадровых событий. Текущий и будущие дни не учитываются. Каждый завершённый плановый рабочий день считается отработанным полностью, пока одобренное заявление или кадровое событие не подтверждает отсутствие. Отпуск, больничный, отгул, декрет и увольнение дают 0 баллов; удалённая работа считается присутствием.",
|
||||
logic: "Это накопительный показатель на начало текущего дня. Он вычисляется на ходу от нормы баллов сотрудника, рабочего графика и официальных кадровых событий. До первого приёма, после увольнения и до повторного приёма баллы не начисляются; день приёма или восстановления уже учитывается. Текущий и будущие дни не входят в расчёт. Отпуск, больничный, отгул и декрет дают 0 баллов; удалённая работа считается присутствием.",
|
||||
formula: "Баллы по кадровым событиям = Норма баллов × Завершённые рабочие дни без подтверждённого отсутствия ÷ Все плановые рабочие дни периода",
|
||||
manual: "Нет. Значение меняется через норму баллов, заявление, кадровое событие или рабочий график.",
|
||||
},
|
||||
@ -400,6 +402,48 @@ function CalculatedMoney({ value, currency, preliminary }: { value: string | nul
|
||||
);
|
||||
}
|
||||
|
||||
const projectionDifferenceClasses: Record<PayrollProjectionDifference["color"], string> = {
|
||||
green: "text-emerald-300 font-medium",
|
||||
orange: "text-orange-400 font-medium",
|
||||
red: "text-red-400 font-medium",
|
||||
};
|
||||
|
||||
function ProjectionPointsCell({
|
||||
value,
|
||||
actualPoints,
|
||||
label,
|
||||
onOpen,
|
||||
}: {
|
||||
value: string | null;
|
||||
actualPoints: string | null;
|
||||
label: string;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const difference = comparePayrollProjection(value, actualPoints);
|
||||
const className = difference
|
||||
? projectionDifferenceClasses[difference.color]
|
||||
: "text-[var(--muted-foreground)]";
|
||||
const directionLabel = difference?.direction === "higher" ? "выше" : "ниже";
|
||||
const differenceLabel = difference?.percentage == null
|
||||
? "выработка равна нулю"
|
||||
: `расхождение ${difference.percentage.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
const title = difference
|
||||
? `${label}: значение ${directionLabel} выработки, ${differenceLabel}. Нажмите для детализации по дням.`
|
||||
: `${label}. Нажмите для детализации по дням.`;
|
||||
return (
|
||||
<td className={`${bodyCellClass} p-0`} title={title}>
|
||||
<button
|
||||
type="button"
|
||||
className={`min-h-10 w-full px-1.5 py-2 text-right text-[11px] tabular-nums transition-colors hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent-primary)] ${className}`}
|
||||
onClick={onOpen}
|
||||
aria-label={`${label}. Открыть детализацию по дням`}
|
||||
>
|
||||
{formatPoints(value)}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function editableNumber(value: string | null): string {
|
||||
if (value == null || value === "") return "";
|
||||
const numeric = Number(value);
|
||||
@ -526,6 +570,10 @@ export function PayrollPeriodTableView({
|
||||
));
|
||||
}, [data?.rows, search]);
|
||||
const [activeHelp, setActiveHelp] = useState<CalculationHelp | null>(null);
|
||||
const [breakdownEmployee, setBreakdownEmployee] = useState<{
|
||||
id: number;
|
||||
displayName: string;
|
||||
} | null>(null);
|
||||
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
|
||||
const columnsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenColumns, setHiddenColumns] = useHiddenPayrollColumns(storageUserId);
|
||||
@ -678,6 +726,9 @@ 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"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-300" aria-hidden="true" />Зелёный — значение выше выработки больше чем на 25%</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-orange-400" aria-hidden="true" />Оранжевый — ниже на 5–25%</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-red-400" aria-hidden="true" />Красный — ниже больше чем на 25%</span>
|
||||
<span className="inline-flex items-center gap-1.5"><CircleHelp className="app-accent-text" size={13} />Знак вопроса — логика и формула расчёта</span>
|
||||
</div>
|
||||
|
||||
@ -750,8 +801,8 @@ export function PayrollPeriodTableView({
|
||||
{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.attendancePoints) ? <ProjectionPointsCell value={row.attendance_points} actualPoints={row.actual_points} label={`Баллы по посещаемости: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} /> : null}
|
||||
{isVisible(columnIds.personnelPoints) ? <ProjectionPointsCell value={row.personnel_points} actualPoints={row.actual_points} label={`Баллы по кадровым событиям: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} /> : 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}
|
||||
@ -802,6 +853,14 @@ export function PayrollPeriodTableView({
|
||||
</table>
|
||||
</div>
|
||||
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
|
||||
{breakdownEmployee ? (
|
||||
<PayrollPointBreakdownModal
|
||||
periodId={data.period_id}
|
||||
employeeId={breakdownEmployee.id}
|
||||
employeeName={breakdownEmployee.displayName}
|
||||
onClose={() => setBreakdownEmployee(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarDays, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
PayrollPointBreakdown,
|
||||
PayrollPointBreakdownDay,
|
||||
} from "@/lib/api/finance";
|
||||
import { getPayrollAdminError } from "@/lib/payroll-admin";
|
||||
import {
|
||||
compareDailyPayrollProjection,
|
||||
type PayrollProjectionDifference,
|
||||
} from "@/lib/payroll-projection";
|
||||
|
||||
type PayrollPointBreakdownModalProps = {
|
||||
periodId: number;
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type PointRow = {
|
||||
id: "attendance_points" | "personnel_points" | "work_points";
|
||||
label: string;
|
||||
};
|
||||
|
||||
const pointRows: PointRow[] = [
|
||||
{ id: "attendance_points", label: "Баллы по посещаемости" },
|
||||
{ id: "personnel_points", label: "Баллы по кадровым событиям" },
|
||||
{ id: "work_points", label: "Баллы по выработке" },
|
||||
];
|
||||
|
||||
const differenceClasses: Record<PayrollProjectionDifference["color"], string> = {
|
||||
green: "bg-emerald-500/15 text-emerald-300",
|
||||
orange: "bg-orange-500/15 text-orange-300",
|
||||
red: "bg-red-500/15 text-red-300",
|
||||
};
|
||||
|
||||
function formatPoints(value: string | null): string {
|
||||
if (value == null || value === "") return "—";
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric)
|
||||
? numeric.toLocaleString("ru-RU", { maximumFractionDigits: 4 })
|
||||
: value;
|
||||
}
|
||||
|
||||
function dateParts(value: string): { date: string; weekday: string } {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return {
|
||||
date: parsed.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }),
|
||||
weekday: parsed.toLocaleDateString("ru-RU", { weekday: "short" }),
|
||||
};
|
||||
}
|
||||
|
||||
function differenceTitle(
|
||||
row: PointRow,
|
||||
day: PayrollPointBreakdownDay,
|
||||
difference: PayrollProjectionDifference | null,
|
||||
): string {
|
||||
if (!difference) return `${row.label}: ${formatPoints(day[row.id])}`;
|
||||
const direction = difference.direction === "higher" ? "выше" : "ниже";
|
||||
const amount = difference.percentage == null
|
||||
? "выработка равна нулю"
|
||||
: `на ${difference.percentage.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
return `${row.label}: ${direction} выработки ${amount}`;
|
||||
}
|
||||
|
||||
export function PayrollPointBreakdownModal({
|
||||
periodId,
|
||||
employeeId,
|
||||
employeeName,
|
||||
onClose,
|
||||
}: PayrollPointBreakdownModalProps) {
|
||||
const [data, setData] = useState<PayrollPointBreakdown | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await apiClient.getPayrollAdminPointBreakdown(periodId, employeeId);
|
||||
setData(payload);
|
||||
} catch (loadError) {
|
||||
setError(getPayrollAdminError(loadError, "Не удалось загрузить дневную детализацию баллов."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [employeeId, periodId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiClient
|
||||
.getPayrollAdminPointBreakdown(periodId, employeeId)
|
||||
.then((payload) => {
|
||||
if (active) setData(payload);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (active) {
|
||||
setError(getPayrollAdminError(loadError, "Не удалось загрузить дневную детализацию баллов."));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [employeeId, periodId]);
|
||||
|
||||
const tableWidth = useMemo(
|
||||
() => 190 + ((data?.dates.length || 0) + (data?.undated_work_points ? 1 : 0)) * 68,
|
||||
[data?.dates.length, data?.undated_work_points],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={`Детализация баллов · ${employeeName}`}
|
||||
size="full"
|
||||
closeOnClickOutside
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-72 items-center justify-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<Loader2 className="app-accent-text animate-spin" size={20} />
|
||||
Загружаем данные по дням…
|
||||
</div>
|
||||
) : error || !data ? (
|
||||
<div className="py-5">
|
||||
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
|
||||
{error || "Детализация недоступна."}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-secondary mt-3 inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
<RefreshCw size={15} /> Повторить
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{data.period.name}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{new Date(`${data.period.date_from}T00:00:00`).toLocaleDateString("ru-RU")} — {new Date(`${data.period.date_to}T00:00:00`).toLocaleDateString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted flex shrink-0 items-start gap-2 rounded-xl px-3 py-2.5 text-xs">
|
||||
<CalendarDays className="app-accent-text mt-0.5 shrink-0" size={15} />
|
||||
<div>
|
||||
<p className="text-[var(--foreground)]">
|
||||
Норма за период: <b>{formatPoints(data.target_points)}</b> · выработка: <b>{formatPoints(data.actual_points)}</b>
|
||||
</p>
|
||||
<p className="app-text-muted mt-1">
|
||||
{data.work_points_mode === "daily_entries"
|
||||
? "Выработка показана по фактическим ежедневным записям."
|
||||
: data.undated_work_points
|
||||
? "Выработка введена отдельной суммой без привязки к дате."
|
||||
: "Дневных записей выработки нет."}
|
||||
</p>
|
||||
{data.undated_work_points ? (
|
||||
<p className="app-text-muted mt-1">
|
||||
Не привязанная к дням сумма показана отдельной колонкой и не распределяется по периоду.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span>Подсвечивается любое дневное расхождение с выработкой:</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-400" />выше — зелёный</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-orange-400" />ниже до 25% — оранжевый</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-red-400" />ниже больше 25% — красный</span>
|
||||
</div>
|
||||
|
||||
<div className="app-table-scroll max-h-[min(65vh,620px)] w-full overflow-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
<table
|
||||
className="table-fixed border-separate border-spacing-0"
|
||||
style={{ width: `max(100%, ${tableWidth}px)` }}
|
||||
aria-label={`Дневная детализация баллов: ${data.employee.display_name}`}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: 190 }} />
|
||||
{data.dates.map((day) => <col key={day.date} style={{ width: 68 }} />)}
|
||||
{data.undated_work_points ? <col style={{ width: 68 }} /> : null}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr>
|
||||
<th className="app-surface-muted sticky left-0 z-30 border-b border-r border-[var(--border-subtle)] px-3 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]">
|
||||
Источник баллов
|
||||
</th>
|
||||
{data.dates.map((day) => {
|
||||
const parts = dateParts(day.date);
|
||||
return (
|
||||
<th
|
||||
key={day.date}
|
||||
className={`app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold ${day.is_workday ? "text-[var(--muted-foreground)]" : "opacity-55 text-[var(--muted-foreground)]"}`}
|
||||
title={day.is_workday ? "Плановый рабочий день" : "Нерабочий день"}
|
||||
>
|
||||
<span className="block tabular-nums">{parts.date}</span>
|
||||
<span className="mt-0.5 block font-normal uppercase">{parts.weekday}</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{data.undated_work_points ? (
|
||||
<th
|
||||
className="app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold text-[var(--muted-foreground)]"
|
||||
title="Выработка, введённая общей суммой без дневной записи внутри периода"
|
||||
>
|
||||
<span className="block">Вне</span>
|
||||
<span className="mt-0.5 block font-normal">периода</span>
|
||||
</th>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pointRows.map((row) => (
|
||||
<tr key={row.id} className="bg-[var(--surface-primary)]">
|
||||
<th className="sticky left-0 z-10 border-b border-r border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3 text-left text-xs font-semibold text-[var(--foreground)]">
|
||||
{row.label}
|
||||
</th>
|
||||
{data.dates.map((day) => {
|
||||
const difference = row.id === "work_points"
|
||||
? null
|
||||
: compareDailyPayrollProjection(day[row.id], day.work_points);
|
||||
return (
|
||||
<td
|
||||
key={day.date}
|
||||
className={`border-b border-r border-[var(--border-subtle)] px-1 py-3 text-center text-[11px] tabular-nums ${difference ? differenceClasses[difference.color] : day.is_workday ? "text-[var(--foreground)]" : "bg-[var(--surface-secondary)] text-[var(--muted-foreground)]"}`}
|
||||
title={differenceTitle(row, day, difference)}
|
||||
>
|
||||
{formatPoints(day[row.id])}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{data.undated_work_points ? (
|
||||
<td
|
||||
className="app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-3 text-center text-[11px] font-medium text-[var(--foreground)] tabular-nums"
|
||||
title={row.id === "work_points" ? "Выработка без привязки к дате внутри периода" : undefined}
|
||||
>
|
||||
{row.id === "work_points" ? formatPoints(data.undated_work_points) : "—"}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -286,6 +286,29 @@ export type PayrollPeriodTable = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PayrollPointBreakdownDay = {
|
||||
date: string;
|
||||
is_workday: boolean;
|
||||
attendance_points: string | null;
|
||||
personnel_points: string | null;
|
||||
work_points: string | null;
|
||||
};
|
||||
|
||||
export type PayrollPointBreakdown = {
|
||||
period: {
|
||||
id: number;
|
||||
name: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
};
|
||||
employee: PayrollAdminEmployee & { is_active: boolean };
|
||||
target_points: string | null;
|
||||
actual_points: string | null;
|
||||
undated_work_points: string | null;
|
||||
work_points_mode: "daily_entries" | "unavailable";
|
||||
dates: PayrollPointBreakdownDay[];
|
||||
};
|
||||
|
||||
export type PayrollComponent = {
|
||||
id: number;
|
||||
code: string;
|
||||
@ -617,6 +640,11 @@ export function createFinanceApi(request: RequestFn) {
|
||||
request(`${adminPrefix}/periods/`),
|
||||
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/table/`),
|
||||
getPayrollAdminPointBreakdown: (
|
||||
periodId: number,
|
||||
employeeId: number,
|
||||
): Promise<PayrollPointBreakdown> =>
|
||||
request(`${adminPrefix}/periods/${periodId}/employees/${employeeId}/point-breakdown/`),
|
||||
createPayrollAdminPeriod: (payload: PayrollPeriodWrite): Promise<PayrollAdminPeriod> =>
|
||||
request(`${adminPrefix}/periods/`, jsonOptions("POST", payload)),
|
||||
updatePayrollAdminPeriod: (
|
||||
|
||||
61
frontend/src/lib/payroll-projection.test.js
Normal file
61
frontend/src/lib/payroll-projection.test.js
Normal file
@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
compareDailyPayrollProjection,
|
||||
comparePayrollProjection,
|
||||
} from "./payroll-projection.ts";
|
||||
|
||||
test("payroll projection keeps small and moderate upward differences neutral", () => {
|
||||
assert.equal(comparePayrollProjection("105", "100"), null);
|
||||
assert.equal(comparePayrollProjection("95", "100"), null);
|
||||
assert.equal(comparePayrollProjection("125", "100"), null);
|
||||
});
|
||||
|
||||
test("payroll projection reports direction and traffic-light color", () => {
|
||||
assert.deepEqual(comparePayrollProjection("85", "100"), {
|
||||
direction: "lower",
|
||||
color: "orange",
|
||||
percentage: 15,
|
||||
});
|
||||
assert.deepEqual(comparePayrollProjection("130", "100"), {
|
||||
direction: "higher",
|
||||
color: "green",
|
||||
percentage: 30,
|
||||
});
|
||||
assert.deepEqual(comparePayrollProjection("70", "100"), {
|
||||
direction: "lower",
|
||||
color: "red",
|
||||
percentage: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test("positive projection against zero work is green", () => {
|
||||
assert.deepEqual(comparePayrollProjection("10", "0"), {
|
||||
direction: "higher",
|
||||
color: "green",
|
||||
percentage: null,
|
||||
});
|
||||
assert.equal(comparePayrollProjection("0", "0"), null);
|
||||
assert.equal(comparePayrollProjection(null, "100"), null);
|
||||
});
|
||||
|
||||
test("daily projection highlights every discrepancy", () => {
|
||||
assert.deepEqual(compareDailyPayrollProjection("101", "100"), {
|
||||
direction: "higher",
|
||||
color: "green",
|
||||
percentage: 1,
|
||||
});
|
||||
assert.deepEqual(compareDailyPayrollProjection("80", "100"), {
|
||||
direction: "lower",
|
||||
color: "orange",
|
||||
percentage: 20,
|
||||
});
|
||||
assert.deepEqual(compareDailyPayrollProjection("50", "100"), {
|
||||
direction: "lower",
|
||||
color: "red",
|
||||
percentage: 50,
|
||||
});
|
||||
assert.equal(compareDailyPayrollProjection("100", "100"), null);
|
||||
assert.equal(compareDailyPayrollProjection(null, "100"), null);
|
||||
});
|
||||
79
frontend/src/lib/payroll-projection.ts
Normal file
79
frontend/src/lib/payroll-projection.ts
Normal file
@ -0,0 +1,79 @@
|
||||
export type PayrollProjectionDifference = {
|
||||
direction: "higher" | "lower";
|
||||
color: "green" | "orange" | "red";
|
||||
percentage: number | null;
|
||||
};
|
||||
|
||||
const DIFFERENCE_THRESHOLD = 0.05;
|
||||
const RED_DIFFERENCE_THRESHOLD = 0.25;
|
||||
|
||||
function finiteNumber(value: string | null): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
export function comparePayrollProjection(
|
||||
projectedValue: string | null,
|
||||
actualValue: string | null,
|
||||
): PayrollProjectionDifference | null {
|
||||
const projected = finiteNumber(projectedValue);
|
||||
const actual = finiteNumber(actualValue);
|
||||
if (projected == null || actual == null) return null;
|
||||
|
||||
const difference = projected - actual;
|
||||
if (difference === 0) return null;
|
||||
if (actual === 0) {
|
||||
return {
|
||||
direction: difference > 0 ? "higher" : "lower",
|
||||
color: difference > 0 ? "green" : "red",
|
||||
percentage: null,
|
||||
};
|
||||
}
|
||||
|
||||
const ratio = Math.abs(difference) / Math.abs(actual);
|
||||
if (ratio <= DIFFERENCE_THRESHOLD) return null;
|
||||
const direction = difference > 0 ? "higher" : "lower";
|
||||
if (direction === "higher" && ratio <= RED_DIFFERENCE_THRESHOLD) return null;
|
||||
const color = direction === "higher"
|
||||
? "green"
|
||||
: ratio > RED_DIFFERENCE_THRESHOLD
|
||||
? "red"
|
||||
: "orange";
|
||||
return {
|
||||
direction,
|
||||
color,
|
||||
percentage: ratio * 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareDailyPayrollProjection(
|
||||
projectedValue: string | null,
|
||||
actualValue: string | null,
|
||||
): PayrollProjectionDifference | null {
|
||||
const projected = finiteNumber(projectedValue);
|
||||
const actual = finiteNumber(actualValue);
|
||||
if (projected == null || actual == null) return null;
|
||||
|
||||
const difference = projected - actual;
|
||||
if (difference === 0) return null;
|
||||
if (actual === 0) {
|
||||
return {
|
||||
direction: difference > 0 ? "higher" : "lower",
|
||||
color: difference > 0 ? "green" : "red",
|
||||
percentage: null,
|
||||
};
|
||||
}
|
||||
|
||||
const ratio = Math.abs(difference) / Math.abs(actual);
|
||||
const direction = difference > 0 ? "higher" : "lower";
|
||||
return {
|
||||
direction,
|
||||
color: direction === "higher"
|
||||
? "green"
|
||||
: ratio > RED_DIFFERENCE_THRESHOLD
|
||||
? "red"
|
||||
: "orange",
|
||||
percentage: ratio * 100,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user