diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py index 80f7cb2e..d3428ff1 100644 --- a/backend/api/v1/attendance/views.py +++ b/backend/api/v1/attendance/views.py @@ -87,6 +87,19 @@ def _records_visible_to(user): return queryset.filter(employee_id=user.id) +def _day_event_records_visible_to(user): + """Allow payroll viewers to inspect evidence used by point projections.""" + + queryset = AttendanceRecord.objects.all() + if ( + user.is_staff + or user.is_superuser + or user.has_perm("finance.view_all_payroll") + ): + return queryset + return queryset.filter(employee_id=user.id) + + def _check_staff_access(user) -> None: if not (user and user.is_authenticated and (user.is_staff or user.is_superuser)): raise PermissionDenied("You don't have permission to manage attendance") @@ -662,7 +675,7 @@ class AttendanceRecordDayEventsAPIView(APIView): def get_record(self, request, record_id): return get_object_or_404( - _records_visible_to(request.user).select_related("employee"), + _day_event_records_visible_to(request.user).select_related("employee"), pk=record_id, ) @@ -708,7 +721,7 @@ class AttendanceRecordDayEventPhotoAPIView(APIView): def get_record(self, request, record_id): return get_object_or_404( - _records_visible_to(request.user).select_related("employee"), + _day_event_records_visible_to(request.user).select_related("employee"), pk=record_id, ) diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index c44a8e1b..38685628 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -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"],) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 9dc983c5..94b34c19 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -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//employees//point-breakdown/", + PayrollPeriodPointBreakdownView.as_view(), + name="admin-period-point-breakdown", + ), path( "payroll/admin/periods//attendance-work-records/", PayrollAttendanceWorkRecordsView.as_view(), diff --git a/backend/employees/services/personnel_state.py b/backend/employees/services/personnel_state.py index e68e62bb..cb67cbd2 100644 --- a/backend/employees/services/personnel_state.py +++ b/backend/employees/services/personnel_state.py @@ -55,7 +55,7 @@ def resolve_employee_personnel_state( relevant_actions = [ action for action in employee_actions - if action.date and action.date.date() <= target_date + if _action_date(action) is not None and _action_date(action) <= target_date ] if not relevant_actions: return EmployeePersonnelState() @@ -66,7 +66,7 @@ def resolve_employee_personnel_state( status=ACTION_DISMISSED, label=ACTION_LABELS[ACTION_DISMISSED], action_id=permanent_action.id, - date_from=permanent_action.date.date(), + date_from=_action_date(permanent_action), expects_attendance=False, ) @@ -77,7 +77,7 @@ def resolve_employee_personnel_state( status=action.action, label=action.get_action_display(), action_id=action.id, - date_from=action.date.date(), + date_from=_action_date(action), date_to=date_to, expects_attendance=False, ) @@ -88,7 +88,7 @@ def resolve_employee_personnel_state( status=marker_action.action, label=marker_action.get_action_display(), action_id=marker_action.id, - date_from=marker_action.date.date(), + date_from=_action_date(marker_action), expects_attendance=True, ) @@ -97,7 +97,7 @@ def resolve_employee_personnel_state( status=permanent_action.action, label=permanent_action.get_action_display(), action_id=permanent_action.id, - date_from=permanent_action.date.date(), + date_from=_action_date(permanent_action), ) return EmployeePersonnelState( @@ -209,7 +209,7 @@ def _active_marker_action( for action in actions if action.action in MARKER_ACTIONS and action.date - and action.date.date() == target_date + and _action_date(action) == target_date ] return marker_actions[-1] if marker_actions else None @@ -223,7 +223,9 @@ def _active_temporary_actions( if action.action not in TEMPORARY_START_ACTIONS or not action.date: continue - date_from = action.date.date() + date_from = _action_date(action) + if date_from is None: + continue if date_from > target_date: continue @@ -247,7 +249,7 @@ def _resolve_temporary_action_date_to(action: EmployeeAction) -> date | None: if action.date_to: return action.date_to if action.date: - return action.date.date() + return _action_date(action) return None diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index 66c3aadb..ca3caf29 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -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) diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 3857ccce..873cea30 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -4,20 +4,26 @@ 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 from django.db.models import Prefetch, Q +from django.utils import timezone from payroll_core import DeterministicPayrollCalculator from attendance.models import AttendanceRecord +from employees.constants import ACTIVATING_MARKER_ACTIONS from employees.models import EmployeeAction +from employees.services.personnel_state import resolve_employee_personnel_state from requests_app.enums import RequestStatus +from requests_app.models import Request from finance.enums import ApprovalStatus from finance.models import ( EmployeePayRate, PayrollComponent, + PayrollDailyWorkEntry, PayrollInputLine, PayrollPeriod, PayrollWorkRecord, @@ -25,11 +31,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 +251,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 +478,288 @@ 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") + ) + employment_start_dates = [ + timezone.localtime(action.date).date() + for action in actions + if action.action in ACTIVATING_MARKER_ACTIONS and action.date + ] + first_employment_date = ( + min(employment_start_dates) if employment_start_dates else None + ) + requests = list( + Request.objects.filter(employee=employee) + .filter( + Q( + date_from__lte=period.date_to, + date_to__gte=period.date_from, + ) + | Q( + date_from__range=(period.date_from, period.date_to), + date_to__isnull=True, + ) + | Q( + date_from__isnull=True, + created_at__date__range=(period.date_from, period.date_to), + ) + ) + .select_related("approver") + .order_by("date_from", "created_at", "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 + for entry in PayrollDailyWorkEntry.objects.filter( + period=period, + employee=employee, + ) + } + dated_work_points_total = sum( + (entry.actual_points for entry in 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 = { + workdate: entry.actual_points + for workdate, entry in work_entries.items() + } + 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 + ) + personnel_state = resolve_employee_personnel_state( + employee, + current, + actions=actions, + ) + related_actions = [] + for action in actions: + action_date = ( + timezone.localtime(action.date).date() if action.date else None + ) + if action_date is None: + continue + action_is_related = action_date == current or ( + action.date_to is not None + and action_date <= current <= action.date_to + ) + if personnel_state.action_id == action.pk: + action_is_related = True + if not action_is_related: + continue + related_actions.append( + { + "id": action.pk, + "action": action.action, + "label": action.get_action_display(), + "date": action_date.isoformat(), + "date_to": action.date_to.isoformat() if action.date_to else None, + "comment": action.comment, + "source_request_id": action.source_request_id, + } + ) + related_requests = [] + for personnel_request in requests: + request_date_from = personnel_request.date_from + request_date_to = personnel_request.date_to or request_date_from + request_is_related = bool( + request_date_from + and request_date_to + and request_date_from <= current <= request_date_to + ) + if request_date_from is None: + request_is_related = ( + timezone.localtime(personnel_request.created_at).date() == current + ) + if not request_is_related: + continue + related_requests.append( + { + "id": personnel_request.pk, + "type": personnel_request.type, + "type_label": personnel_request.get_type_display(), + "title": personnel_request.display_title, + "status": personnel_request.status, + "status_label": personnel_request.get_status_display(), + "date_from": ( + personnel_request.date_from.isoformat() + if personnel_request.date_from + else None + ), + "date_to": ( + personnel_request.date_to.isoformat() + if personnel_request.date_to + else None + ), + "comment": personnel_request.comment, + } + ) + attendance_payload = None + if attendance_record is not None: + attendance_payload = { + "id": attendance_record.pk, + "arrival_time": attendance_record.arrival_time, + "departure_time": attendance_record.departure_time, + "work_hours": attendance_record.work_hours, + "expected_hours": attendance_record.expected_hours, + "status_label": ( + attendance_record.personnel_status_label + or ("Отсутствие" if attendance_record.is_absent else "Рабочий день") + ), + "issues": list( + dict.fromkeys( + str(item) + for item in [ + *(attendance_record.statuses or []), + *(attendance_record.employee_issues or []), + *(attendance_record.technical_issues or []), + ] + if str(item).strip() + ) + ), + "is_manually_edited": attendance_record.is_manually_edited, + } + work_entry = work_entries.get(current) + 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)), + "attendance_record": attendance_payload, + "personnel_detail": { + "state": ( + { + "status": "before_employment", + "label": "До приёма на работу", + "expects_attendance": False, + } + if first_employment_date is not None + and current < first_employment_date + else { + "status": personnel_state.status, + "label": personnel_state.label + or "Обычный рабочий день", + "expects_attendance": personnel_state.expects_attendance, + } + ), + "actions": related_actions, + "requests": related_requests, + }, + "work_entry": ( + { + "id": work_entry.pk, + "target_points": _decimal_text(work_entry.target_points), + "actual_points": _decimal_text(work_entry.actual_points), + "note": work_entry.note, + "created_at": work_entry.created_at.isoformat(), + "updated_at": work_entry.updated_at.isoformat(), + } + if work_entry is not None + else None + ), + } + ) + 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), + "undated_work_record": ( + { + "id": work.pk, + "actual_points": _decimal_text(undated_work_points), + "note": work.reason, + "created_at": work.created_at.isoformat(), + "updated_at": work.updated_at.isoformat(), + } + if undated_work_points is not None and work is not None + else None + ), + "work_points_mode": work_points_mode, + "dates": dates, + } diff --git a/backend/finance/payroll/work_norm.py b/backend/finance/payroll/work_norm.py index 5d468c07..181d78a3 100644 --- a/backend/finance/payroll/work_norm.py +++ b/backend/finance/payroll/work_norm.py @@ -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 = [ + timezone.localtime(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 = [ + timezone.localtime(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 diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 09b4d173..bb17a343 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -3,6 +3,7 @@ from io import BytesIO from datetime import date, datetime, timedelta import pytest +from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -994,6 +995,39 @@ def test_user_cannot_load_other_employee_attendance_day_events( assert response.status_code == 404 +def test_payroll_viewer_can_load_source_attendance_day_events( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + payroll_viewer = user_factory(staff=False) + employee = user_factory() + permission = Permission.objects.get( + content_type__app_label="finance", + codename="view_all_payroll", + ) + payroll_viewer.user_permissions.add(permission) + staff_client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + staff_client.post(_analyze_url(), _payload(employee.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + with patch( + "api.v1.attendance.views.LogStormClient.get_attendance_day_events", + return_value=[], + ): + response = auth_client_factory(payroll_viewer).get( + _record_day_events_url(record.id) + ) + + assert response.status_code == 200 + assert response.json() == [] + + def test_attendance_day_events_logstorm_error_returns_502( auth_client_factory, user_factory, diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 7246d0bd..b88387ee 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -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,218 @@ 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 {key: june_first[key] for key in ( + "date", + "is_workday", + "attendance_points", + "personnel_points", + "work_points", + )} == { + "date": "2026-06-01", + "is_workday": True, + "attendance_points": "10.0000", + "personnel_points": "10.0000", + "work_points": "15.0000", + } + assert june_first["attendance_record"]["id"] == AttendanceRecord.objects.get( + employee=employee, + date="2026-06-01", + ).pk + assert june_first["work_entry"]["actual_points"] == "15.0000" + assert june_first["work_entry"]["note"] == "" + assert [item["action"] for item in june_first["personnel_detail"]["actions"]] == [ + ACTION_HIRED + ] + assert {key: june_second[key] for key in ( + "date", + "is_workday", + "attendance_points", + "personnel_points", + "work_points", + )} == { + "date": "2026-06-02", + "is_workday": True, + "attendance_points": "5.0000", + "personnel_points": "0.0000", + "work_points": None, + } + assert june_second["work_entry"] is None + assert [item["id"] for item in june_second["personnel_detail"]["requests"]] == [ + vacation.pk + ] + assert june_second["personnel_detail"]["requests"][0]["status"] == "approved" + assert {key: weekend[key] for key in ( + "date", + "is_workday", + "attendance_points", + "personnel_points", + "work_points", + )} == { + "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["undated_work_record"]["actual_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, diff --git a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx index 1efa3f99..c1475abc 100644 --- a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx +++ b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx @@ -25,6 +25,7 @@ type AttendanceDayEventsModalProps = { onClose: () => void; onOpenComments?: (record: AttendanceDayEventsPreview) => void; record: AttendanceDayEventsPreview | null; + stackLevel?: number; }; function getErrorMessage(error: unknown, fallback: string) { @@ -81,6 +82,7 @@ export function AttendanceDayEventsModal({ onClose, onOpenComments, record, + stackLevel = 0, }: AttendanceDayEventsModalProps) { const [events, setEvents] = useState([]); const [photoUrls, setPhotoUrls] = useState>({}); @@ -171,6 +173,7 @@ export function AttendanceDayEventsModal({ onClose={close} title="События посещения" size="xl" + stackLevel={stackLevel} > {record ? (
diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index ac1999cb..827f2eb3 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -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 = { 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 = { + 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 ( + + + + ); +} + 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(null); + const [breakdownEmployee, setBreakdownEmployee] = useState<{ + id: number; + displayName: string; + } | null>(null); const [columnsMenuOpen, setColumnsMenuOpen] = useState(false); const columnsMenuRef = useRef(null); const [hiddenColumns, setHiddenColumns] = useHiddenPayrollColumns(storageUserId); @@ -678,6 +726,9 @@ export function PayrollPeriodTableView({
{canEdit ? Enter или переход в другую ячейку — сохранить, Esc — отменить. : null} + + + Знак вопроса — логика и формула расчёта
@@ -750,8 +801,8 @@ export function PayrollPeriodTableView({ {isVisible(columnIds.inNormPointRate) ? : null} {isVisible(columnIds.excessPointRate) ? onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} automatic={row.point_rate === null} /> : null} {isVisible(columnIds.targetPoints) ? onSaveWork(row, "target_points", value) : undefined} label={`Норма: ${row.employee.display_name}`} automatic={row.target_points_automatic} /> : null} - {isVisible(columnIds.attendancePoints) ? : null} - {isVisible(columnIds.personnelPoints) ? : null} + {isVisible(columnIds.attendancePoints) ? setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} /> : null} + {isVisible(columnIds.personnelPoints) ? setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} /> : null} {isVisible(columnIds.actualPoints) ? onSaveWork(row, "actual_points", value) : undefined} label={`Баллы: ${row.employee.display_name}`} /> : null} {isVisible(columnIds.pointDelta) ? {formatPoints(row.point_delta)} : null} {isVisible(columnIds.pointAmount) ? : null} @@ -802,6 +853,14 @@ export function PayrollPeriodTableView({
{activeHelp ? setActiveHelp(null)} /> : null} + {breakdownEmployee ? ( + setBreakdownEmployee(null)} + /> + ) : null} ); } diff --git a/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx new file mode 100644 index 00000000..77049d68 --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { CalendarDays, Loader2, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +import { + AttendanceDayEventsModal, + type AttendanceDayEventsPreview, +} from "@/components/attendance/AttendanceDayEventsModal"; +import { + PayrollPersonnelDayModal, + PayrollWorkEntryDayModal, +} from "@/components/finance/admin/PayrollPointSourceDetailModals"; +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 = { + 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 formatDate(value: string): string { + return new Date(`${value}T00:00:00`).toLocaleDateString("ru-RU"); +} + +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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [attendanceDay, setAttendanceDay] = useState(null); + const [personnelDay, setPersonnelDay] = useState(null); + const [workDay, setWorkDay] = useState(null); + const [undatedWorkOpen, setUndatedWorkOpen] = useState(false); + + 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], + ); + const attendancePreview = useMemo(() => { + const record = attendanceDay?.attendance_record; + if (!attendanceDay || !record) return null; + const time = (value: string | null) => value || "—"; + return { + recordId: record.id, + employeeName, + date: formatDate(attendanceDay.date), + statusLabel: record.status_label, + displayText: `Приход ${time(record.arrival_time)} · уход ${time(record.departure_time)}`, + detailLines: [ + `Отработано: ${record.work_hours ?? "—"} ч.`, + `План: ${record.expected_hours ?? "—"} ч.`, + `Баллы: ${formatPoints(attendanceDay.attendance_points)}`, + ], + issues: record.issues, + isManuallyEdited: record.is_manually_edited, + }; + }, [attendanceDay, employeeName]); + + const openDayDetail = (row: PointRow, day: PayrollPointBreakdownDay) => { + if (row.id === "attendance_points" && day.attendance_record) { + setAttendanceDay(day); + } else if (row.id === "personnel_points") { + setPersonnelDay(day); + } else if (row.id === "work_points" && day.work_entry) { + setWorkDay(day); + } + }; + + return ( + <> + + {loading ? ( +
+ + Загружаем данные по дням… +
+ ) : error || !data ? ( +
+
+ {error || "Детализация недоступна."} +
+ +
+ ) : ( +
+
+
+

{data.period.name}

+

+ {new Date(`${data.period.date_from}T00:00:00`).toLocaleDateString("ru-RU")} — {new Date(`${data.period.date_to}T00:00:00`).toLocaleDateString("ru-RU")} +

+
+
+ +
+

+ Норма за период: {formatPoints(data.target_points)} · выработка: {formatPoints(data.actual_points)} +

+

+ {data.work_points_mode === "daily_entries" + ? "Выработка показана по фактическим ежедневным записям." + : data.undated_work_points + ? "Выработка введена отдельной суммой без привязки к дате." + : "Дневных записей выработки нет."} +

+ {data.undated_work_points ? ( +

+ Не привязанная к дням сумма показана отдельной колонкой и не распределяется по периоду. +

+ ) : null} +
+
+
+ +
+ Нажмите на значение, чтобы открыть исходные данные дня. + Подсвечивается любое дневное расхождение с выработкой: + выше — зелёный + ниже до 25% — оранжевый + ниже больше 25% — красный +
+ +
+ + + + {data.dates.map((day) => )} + {data.undated_work_points ? : null} + + + + + {data.dates.map((day) => { + const parts = dateParts(day.date); + return ( + + ); + })} + {data.undated_work_points ? ( + + ) : null} + + + + {pointRows.map((row) => ( + + + {data.dates.map((day) => { + const difference = row.id === "work_points" + ? null + : compareDailyPayrollProjection(day[row.id], day.work_points); + const canOpen = row.id === "attendance_points" + ? Boolean(day.attendance_record) + : row.id === "personnel_points" + ? day.personnel_points != null || day.personnel_detail.actions.length > 0 || day.personnel_detail.requests.length > 0 + : Boolean(day.work_entry); + return ( + + ); + })} + {data.undated_work_points ? ( + + ) : null} + + ))} + +
+ Источник баллов + + {parts.date} + {parts.weekday} + + Вне + периода +
+ {row.label} + + {canOpen ? ( + + ) : ( + {formatPoints(day[row.id])} + )} + + {row.id === "work_points" && data.undated_work_record ? ( + + ) : —} +
+
+ +
+ )} +
+ {typeof document !== "undefined" && data ? createPortal( + <> + setAttendanceDay(null)} + record={attendancePreview} + stackLevel={1} + /> + {personnelDay ? ( + setPersonnelDay(null)} + /> + ) : null} + {workDay?.work_entry ? ( + setWorkDay(null)} + /> + ) : null} + {undatedWorkOpen && data.undated_work_record ? ( + setUndatedWorkOpen(false)} + /> + ) : null} + , + document.body, + ) : null} + + ); +} diff --git a/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx b/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx new file mode 100644 index 00000000..459f7ade --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { BriefcaseBusiness, CalendarDays, FileText, UserRoundCog } from "lucide-react"; + +import { Modal } from "@/components/ui"; +import type { PayrollPointBreakdownDay } from "@/lib/api/finance"; + +function formatDate(value: string | null): string { + if (!value) return "—"; + return new Date(`${value}T00:00:00`).toLocaleDateString("ru-RU"); +} + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatPoints(value: string | null): string { + if (value == null || value === "") return "—"; + const number = Number(value); + return Number.isFinite(number) + ? number.toLocaleString("ru-RU", { maximumFractionDigits: 4 }) + : value; +} + +function requestStatusClass(status: string): string { + if (status === "approved") return "app-feedback-success"; + if (status === "rejected" || status === "cancelled") return "app-feedback-danger"; + if (status === "pending") return "app-feedback-warning"; + return "app-surface-muted"; +} + +export function PayrollPersonnelDayModal({ + day, + employeeName, + onClose, +}: { + day: PayrollPointBreakdownDay; + employeeName: string; + onClose: () => void; +}) { + const detail = day.personnel_detail; + + return ( + +
+
+
+
+

Состояние на {formatDate(day.date)}

+

+ {detail.state.label} +

+
+ + {detail.state.expects_attendance ? "Явка ожидается" : "Явка не ожидается"} + +
+
+ +
+
+ +

+ Кадровые события ({detail.actions.length}) +

+
+ {detail.actions.length ? ( +
+ {detail.actions.map((action) => ( +
+
+

{action.label}

+ #{action.id} +
+

+ {formatDate(action.date)}{action.date_to ? ` — ${formatDate(action.date_to)}` : ""} +

+ {action.comment ?

{action.comment}

: null} + {action.source_request_id ? ( +

Создано по заявлению #{action.source_request_id}

+ ) : null} +
+ ))} +
+ ) : ( +
+ Отдельных кадровых событий для этой даты нет. +
+ )} +
+ +
+
+ +

+ Заявления ({detail.requests.length}) +

+
+ {detail.requests.length ? ( +
+ {detail.requests.map((request) => ( +
+
+
+

{request.title || request.type_label}

+

{request.type_label} · заявление #{request.id}

+
+ {request.status_label} +
+

+ {formatDate(request.date_from)}{request.date_to ? ` — ${formatDate(request.date_to)}` : ""} +

+ {request.comment ?

{request.comment}

: null} +
+ ))} +
+ ) : ( +
+ Заявлений, относящихся к этой дате, нет. +
+ )} +
+
+
+ ); +} + +type WorkEntryDetail = { + id: number; + target_points?: string | null; + actual_points: string | null; + note: string; + created_at: string; + updated_at: string; +}; + +export function PayrollWorkEntryDayModal({ + entry, + date, + employeeName, + undated = false, + onClose, +}: { + entry: WorkEntryDetail; + date: string | null; + employeeName: string; + undated?: boolean; + onClose: () => void; +}) { + return ( + +
+
+ {undated ? : } +
+

{undated ? "Без привязки к дате" : formatDate(date)}

+

+ {formatPoints(entry.actual_points)} балл. +

+ {entry.target_points != null ? ( +

Дневная норма: {formatPoints(entry.target_points)}

+ ) : null} +
+
+ +
+

Комментарий

+

+ {entry.note || "Комментарий не указан."} +

+
+ +
+

Создано: {formatDateTime(entry.created_at)}

+

Изменено: {formatDateTime(entry.updated_at)}

+
+
+
+ ); +} diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index 75144039..cdd3cb16 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -286,6 +286,81 @@ export type PayrollPeriodTable = { }; }; +export type PayrollPointBreakdownDay = { + date: string; + is_workday: boolean; + attendance_points: string | null; + personnel_points: string | null; + work_points: string | null; + attendance_record: { + id: number; + arrival_time: string | null; + departure_time: string | null; + work_hours: number | null; + expected_hours: number | null; + status_label: string; + issues: string[]; + is_manually_edited: boolean; + } | null; + personnel_detail: { + state: { + status: string; + label: string; + expects_attendance: boolean; + }; + actions: Array<{ + id: number; + action: string; + label: string; + date: string; + date_to: string | null; + comment: string; + source_request_id: number | null; + }>; + requests: Array<{ + id: number; + type: string; + type_label: string; + title: string; + status: string; + status_label: string; + date_from: string | null; + date_to: string | null; + comment: string; + }>; + }; + work_entry: { + id: number; + target_points: string | null; + actual_points: string | null; + note: string; + created_at: string; + updated_at: 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; + undated_work_record: { + id: number; + actual_points: string | null; + note: string; + created_at: string; + updated_at: string; + } | null; + work_points_mode: "daily_entries" | "unavailable"; + dates: PayrollPointBreakdownDay[]; +}; + export type PayrollComponent = { id: number; code: string; @@ -617,6 +692,11 @@ export function createFinanceApi(request: RequestFn) { request(`${adminPrefix}/periods/`), getPayrollAdminPeriodTable: (periodId: number): Promise => request(`${adminPrefix}/periods/${periodId}/table/`), + getPayrollAdminPointBreakdown: ( + periodId: number, + employeeId: number, + ): Promise => + request(`${adminPrefix}/periods/${periodId}/employees/${employeeId}/point-breakdown/`), createPayrollAdminPeriod: (payload: PayrollPeriodWrite): Promise => request(`${adminPrefix}/periods/`, jsonOptions("POST", payload)), updatePayrollAdminPeriod: ( diff --git a/frontend/src/lib/payroll-projection.test.js b/frontend/src/lib/payroll-projection.test.js new file mode 100644 index 00000000..48436737 --- /dev/null +++ b/frontend/src/lib/payroll-projection.test.js @@ -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); +}); diff --git a/frontend/src/lib/payroll-projection.ts b/frontend/src/lib/payroll-projection.ts new file mode 100644 index 00000000..a8f5ddfb --- /dev/null +++ b/frontend/src/lib/payroll-projection.ts @@ -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, + }; +}