diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py
index 540c2fd1..1268df9f 100644
--- a/backend/api/v1/finance/admin_views.py
+++ b/backend/api/v1/finance/admin_views.py
@@ -115,6 +115,7 @@ from .admin_serializers import (
PayrollPeriodSerializer,
PayrollPeriodWriteSerializer,
PayrollRunSerializer,
+ PayrollUserRefSerializer,
PayrollWorkRecordRevisionSerializer,
PayrollWorkRecordSerializer,
PayrollWorkRecordWriteSerializer,
@@ -215,6 +216,20 @@ def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None):
)
+def _payroll_history_action_label(action):
+ if "voided" in action or "cancelled" in action:
+ return "Запись аннулирована"
+ if action.endswith("_approved"):
+ return "Запись утверждена"
+ if "revision" in action and action.endswith("_created"):
+ return "Создана новая версия"
+ if action.endswith("_created"):
+ return "Запись добавлена"
+ if action.endswith("_updated") or action.endswith("_replaced"):
+ return "Запись изменена"
+ return "Состояние записи изменено"
+
+
def _validate_payroll_table_column(column_id):
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
return column_id
@@ -519,9 +534,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
)
draft_input_count = draft_inputs.count()
draft_input_employee_ids = list(
- draft_inputs.order_by()
- .values_list("employee_id", flat=True)
- .distinct()
+ draft_inputs.order_by().values_list("employee_id", flat=True).distinct()
)
pending_rates = EmployeePayRate.objects.filter(
employee_id__in=roster_employee_ids,
@@ -1161,6 +1174,91 @@ class DraftCollectionView(PayrollAdminAPIView):
)
+class PayrollInputHistoryView(PayrollAdminAPIView):
+ allowed_permissions = INPUT_VIEW_PERMISSIONS
+
+ def get(self, request):
+ period_id = _optional_int(request, "period_id")
+ if period_id is None:
+ raise ValidationError({"period_id": "Укажите расчётный период."})
+ period = get_object_or_404(PayrollPeriod, pk=period_id)
+ kind = request.query_params.get("kind", "").strip()
+
+ if kind == "rates":
+ records = pay_rates_relevant_to_period(
+ period,
+ queryset=EmployeePayRate.objects.select_related(
+ "employee",
+ "employee__position",
+ ).prefetch_related("employee__departments_links__department"),
+ )
+ object_type = EmployeePayRate._meta.label_lower
+ subjects = {record.pk: "Ставка" for record in records}
+ elif kind == "work":
+ records = (
+ PayrollWorkRecord.objects.filter(period=period)
+ .select_related(
+ "employee",
+ "employee__position",
+ )
+ .prefetch_related("employee__departments_links__department")
+ )
+ object_type = PayrollWorkRecord._meta.label_lower
+ subjects = {record.pk: "Выработка" for record in records}
+ elif kind == "inputs":
+ records = (
+ PayrollInputLine.objects.filter(period=period)
+ .select_related(
+ "employee",
+ "employee__position",
+ "component",
+ )
+ .prefetch_related("employee__departments_links__department")
+ )
+ object_type = PayrollInputLine._meta.label_lower
+ subjects = {record.pk: record.component.name for record in records}
+ else:
+ raise ValidationError(
+ {"kind": "Допустимые значения: rates, work или inputs."}
+ )
+
+ records_by_id = {record.pk: record for record in records}
+ events = (
+ PayrollAuditEvent.objects.filter(
+ object_type=object_type,
+ object_id__in=[str(record_id) for record_id in records_by_id],
+ )
+ .select_related("actor")
+ .order_by("-created_at", "-id")
+ )
+ result = []
+ for event in events:
+ try:
+ record_id = int(event.object_id)
+ except (TypeError, ValueError):
+ continue
+ record = records_by_id.get(record_id)
+ if record is None:
+ continue
+ result.append(
+ {
+ "id": event.pk,
+ "record_id": record_id,
+ "action": event.action,
+ "action_label": _payroll_history_action_label(event.action),
+ "subject": subjects[record_id],
+ "employee": PayrollEmployeeMiniSerializer(record.employee).data,
+ "actor": (
+ PayrollUserRefSerializer(event.actor).data
+ if event.actor is not None
+ else None
+ ),
+ "created_at": event.created_at,
+ }
+ )
+ return Response(result)
+
+
class DraftDetailView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
model = None
@@ -1256,12 +1354,16 @@ class EmployeePayRateListCreateView(DraftCollectionView):
audit_label = "rate"
def get_queryset(self):
- return EmployeePayRate.objects.select_related(
- "employee",
- "employee__position",
- "created_by",
- "approved_by",
- ).prefetch_related("employee__departments_links__department")
+ return (
+ EmployeePayRate.objects.select_related(
+ "employee",
+ "employee__position",
+ "created_by",
+ "approved_by",
+ )
+ .prefetch_related("employee__departments_links__department")
+ .order_by("-created_at", "-id")
+ )
def filter_queryset(self, request, queryset):
queryset = super().filter_queryset(request, queryset)
@@ -1375,13 +1477,17 @@ class PayrollWorkRecordListCreateView(DraftCollectionView):
)
def get_queryset(self):
- return PayrollWorkRecord.objects.select_related(
- "period",
- "employee",
- "employee__position",
- "created_by",
- "approved_by",
- ).prefetch_related("employee__departments_links__department")
+ return (
+ PayrollWorkRecord.objects.select_related(
+ "period",
+ "employee",
+ "employee__position",
+ "created_by",
+ "approved_by",
+ )
+ .prefetch_related("employee__departments_links__department")
+ .order_by("-created_at", "-id")
+ )
def filter_queryset(self, request, queryset):
queryset = super().filter_queryset(request, queryset)
@@ -1422,15 +1528,19 @@ class PayrollInputLineListCreateView(DraftCollectionView):
period_scoped = True
def get_queryset(self):
- return PayrollInputLine.objects.select_related(
- "period",
- "employee",
- "employee__position",
- "component",
- "relates_to_period",
- "created_by",
- "approved_by",
- ).prefetch_related("employee__departments_links__department")
+ return (
+ PayrollInputLine.objects.select_related(
+ "period",
+ "employee",
+ "employee__position",
+ "component",
+ "relates_to_period",
+ "created_by",
+ "approved_by",
+ )
+ .prefetch_related("employee__departments_links__department")
+ .order_by("-created_at", "-id")
+ )
def filter_queryset(self, request, queryset):
queryset = super().filter_queryset(request, queryset)
diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py
index 57f0c992..08d07ef9 100644
--- a/backend/api/v1/finance/urls.py
+++ b/backend/api/v1/finance/urls.py
@@ -28,6 +28,7 @@ from .admin_views import (
PayrollInputLineApproveView,
PayrollInputLineDetailView,
PayrollInputLineListCreateView,
+ PayrollInputHistoryView,
PayrollPeriodCalculateView,
PayrollPeriodCarryoverListCreateView,
PayrollPeriodCompleteWorkflowView,
@@ -161,6 +162,11 @@ urlpatterns = [
PayrollPeriodCloseView.as_view(),
name="admin-period-close",
),
+ path(
+ "payroll/admin/history/",
+ PayrollInputHistoryView.as_view(),
+ name="admin-input-history",
+ ),
path(
"payroll/admin/pay-rates/",
EmployeePayRateListCreateView.as_view(),
diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py
index fc02ef8e..9470eca7 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, datetime
+from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
@@ -92,6 +92,170 @@ def make_approved_period_inputs(
)
+def test_admin_input_lists_show_newest_records_first(
+ user_factory,
+ auth_client_factory,
+):
+ manager = user_factory(email="chronology.manager@example.test")
+ older_employee = user_factory(email="chronology.older@example.test")
+ newer_employee = user_factory(email="chronology.newer@example.test")
+ grant(manager, "manage_payroll_inputs")
+ period = make_period(creator=manager)
+ component = PayrollComponent.objects.get(code="BONUS")
+
+ older_rate = EmployeePayRate.objects.create(
+ employee=older_employee,
+ amount="80000.0000",
+ effective_from="2026-06-01",
+ created_by=manager,
+ )
+ newer_rate = EmployeePayRate.objects.create(
+ employee=newer_employee,
+ amount="90000.0000",
+ effective_from="2026-06-01",
+ created_by=manager,
+ )
+ older_work = PayrollWorkRecord.objects.create(
+ period=period,
+ employee=older_employee,
+ target_points="100.0000",
+ actual_points="90.0000",
+ created_by=manager,
+ )
+ newer_work = PayrollWorkRecord.objects.create(
+ period=period,
+ employee=newer_employee,
+ target_points="100.0000",
+ actual_points="95.0000",
+ created_by=manager,
+ )
+ older_input = PayrollInputLine.objects.create(
+ period=period,
+ employee=older_employee,
+ component=component,
+ amount="1000.00",
+ created_by=manager,
+ )
+ newer_input = PayrollInputLine.objects.create(
+ period=period,
+ employee=newer_employee,
+ component=component,
+ amount="2000.00",
+ created_by=manager,
+ )
+
+ older_created_at = timezone.now() - timedelta(days=1)
+ for model, instance in (
+ (EmployeePayRate, older_rate),
+ (PayrollWorkRecord, older_work),
+ (PayrollInputLine, older_input),
+ ):
+ model.objects.filter(pk=instance.pk).update(created_at=older_created_at)
+
+ client = auth_client_factory(manager)
+ cases = (
+ ("admin-pay-rate-list", newer_rate.pk, older_rate.pk),
+ ("admin-work-record-list", newer_work.pk, older_work.pk),
+ ("admin-input-line-list", newer_input.pk, older_input.pk),
+ )
+ for route_name, newer_id, older_id in cases:
+ response = client.get(api_url(route_name), {"period_id": period.pk})
+ assert response.status_code == 200, response.content
+ assert [item["id"] for item in response.json()] == [newer_id, older_id]
+
+
+def test_admin_input_history_returns_all_record_events_in_reverse_chronology(
+ user_factory,
+ auth_client_factory,
+):
+ manager = user_factory(
+ email="history.manager@example.test",
+ first_name="Мария",
+ last_name="Историк",
+ )
+ employee = user_factory(
+ email="history.employee@example.test",
+ first_name="Иван",
+ last_name="Хронологов",
+ )
+ grant(manager, "manage_payroll_inputs")
+ period = make_period(creator=manager)
+ client = auth_client_factory(manager)
+
+ rate = client.post(
+ api_url("admin-pay-rate-list"),
+ {
+ "employee_id": employee.pk,
+ "rate_code": "BASE",
+ "amount": "80000.0000",
+ "currency": "RUB",
+ "effective_from": "2026-06-01",
+ "reason": "Первая ставка",
+ },
+ format="json",
+ )
+ assert rate.status_code == 201, rate.content
+ changed_rate = client.patch(
+ api_url("admin-pay-rate-detail", pk=rate.json()["id"]),
+ {
+ "amount": "85000.0000",
+ "expected_lock_version": rate.json()["lock_version"],
+ },
+ format="json",
+ )
+ assert changed_rate.status_code == 200, changed_rate.content
+
+ work = client.post(
+ api_url("admin-work-record-list"),
+ {
+ "period_id": period.pk,
+ "employee_id": employee.pk,
+ "target_points": "100.0000",
+ "actual_points": "90.0000",
+ "reason": "Ручной ввод",
+ },
+ format="json",
+ )
+ assert work.status_code == 201, work.content
+ component = PayrollComponent.objects.get(code="BONUS")
+ input_line = client.post(
+ api_url("admin-input-line-list"),
+ {
+ "period_id": period.pk,
+ "employee_id": employee.pk,
+ "component_id": component.pk,
+ "amount": "1000.00",
+ "reason": "Премия",
+ },
+ format="json",
+ )
+ assert input_line.status_code == 201, input_line.content
+
+ rate_history = client.get(
+ api_url("admin-input-history"),
+ {"period_id": period.pk, "kind": "rates"},
+ )
+ assert rate_history.status_code == 200, rate_history.content
+ assert [item["action"] for item in rate_history.json()] == [
+ "payroll.rate_draft_updated",
+ "payroll.rate_draft_created",
+ ]
+ assert rate_history.json()[0]["action_label"] == "Запись изменена"
+ assert rate_history.json()[0]["employee"]["id"] == employee.pk
+ assert rate_history.json()[0]["actor"]["id"] == manager.pk
+
+ for kind, expected_action in (
+ ("work", "payroll.work_record_draft_created"),
+ ("inputs", "payroll.input_line_draft_created"),
+ ):
+ response = client.get(
+ api_url("admin-input-history"),
+ {"period_id": period.pk, "kind": kind},
+ )
+ assert response.status_code == 200, response.content
+ assert [item["action"] for item in response.json()] == [expected_action]
+
+
def test_work_record_target_is_optional_and_can_return_to_automatic(
user_factory,
auth_client_factory,
@@ -450,11 +614,14 @@ def test_period_table_cell_comments_use_shared_discussion_flow(
assert created.status_code == 201, created.content
assert created.json()["text"] == "Проверить итоговое начисление"
assert created.json()["author"]["id"] == author.pk
- assert PayrollCellCommentThread.objects.filter(
- period=period,
- subject_employee=employee,
- column_id="gross_total",
- ).count() == 1
+ assert (
+ PayrollCellCommentThread.objects.filter(
+ period=period,
+ subject_employee=employee,
+ column_id="gross_total",
+ ).count()
+ == 1
+ )
listed = auth_client_factory(colleague).get(comments_url)
assert listed.status_code == 200
@@ -486,9 +653,12 @@ def test_period_table_cell_comments_use_shared_discussion_flow(
deleted = author_client.delete(detail_url)
assert deleted.status_code == 204
assert author_client.get(comments_url).json() == []
- assert auth_client_factory(colleague).get(
- api_url("admin-period-table", pk=period.pk)
- ).json()["cell_comments"] == []
+ assert (
+ auth_client_factory(colleague)
+ .get(api_url("admin-period-table", pk=period.pk))
+ .json()["cell_comments"]
+ == []
+ )
def test_cell_comments_reject_columns_not_present_in_table(
@@ -756,35 +926,44 @@ def test_period_point_breakdown_returns_daily_sources(
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",
- )} == {
+ 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["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",
- )} == {
+ 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",
@@ -796,13 +975,16 @@ def test_period_point_breakdown_returns_daily_sources(
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",
- )} == {
+ 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,
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index d14c6c42..fcfd455a 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -644,6 +644,7 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
.payroll-admin-toolbar-search,
.payroll-admin-toolbar-status,
+.payroll-admin-toolbar-sort,
.payroll-admin-toolbar-actions {
width: 100%;
}
@@ -662,6 +663,10 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
width: 11rem;
}
+ .payroll-admin-toolbar-sort {
+ width: 12rem;
+ }
+
.payroll-admin-toolbar-actions {
width: auto;
flex-shrink: 0;
diff --git a/frontend/src/components/finance/admin/PayrollAdminTables.tsx b/frontend/src/components/finance/admin/PayrollAdminTables.tsx
index 8dfaa5a1..67000b72 100644
--- a/frontend/src/components/finance/admin/PayrollAdminTables.tsx
+++ b/frontend/src/components/finance/admin/PayrollAdminTables.tsx
@@ -1,15 +1,16 @@
"use client";
-import { CalendarCheck2, Check, ChevronDown, Loader2, Pencil, RotateCcw } from "lucide-react";
+import { CalendarCheck2, Check, ChevronDown, History, Loader2, Pencil, RotateCcw } from "lucide-react";
import { type ReactNode, useState } from "react";
import type {
PayrollAdminInputLine,
+ PayrollInputHistoryEvent,
PayrollAdminPayRate,
PayrollAdminWorkRecord,
PayrollApprovalStatus,
} from "@/lib/api/finance";
-import { formatPayrollDate, formatPayrollMoney } from "@/lib/payroll";
+import { formatPayrollDate, formatPayrollDateTime, formatPayrollMoney } from "@/lib/payroll";
import { formatPayrollWorkMetric, isAttendancePayrollWorkRecord } from "@/lib/payroll-attendance";
import {
approvalStatusMeta,
@@ -85,6 +86,74 @@ function ListState({ loading, error, empty }: { loading: boolean; error: string
return null;
}
+export function PayrollInputHistory({
+ events,
+ loading,
+ error,
+}: {
+ events: PayrollInputHistoryEvent[];
+ loading: boolean;
+ error: string | null;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const visibleEvents = expanded ? events : events.slice(0, 5);
+
+ return (
+ Все действия с записями, новые события сверху
+ {event.action_label}
+ · {event.subject}
+ {event.employee.display_name} {formatPayrollDateTime(event.created_at)} {event.actor?.display_name || "Система"}История изменений
+
{record.employee.display_name}
{record.employee.position || record.employee.department || "Сотрудник"}
{record.employee.display_name}
{record.employee.position || record.employee.department || "Сотрудник"}
@@ -312,6 +384,7 @@ export function PayrollWorkRecordsTable({{record.employee.display_name}
{record.employee.position || record.employee.department || "Сотрудник"}
{record.component.name}
@@ -405,6 +479,7 @@ export function PayrollInputLinesTable({ {record.reason ?{record.reason}
: null}