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

История изменений

+

Все действия с записями, новые события сверху

+
+ {!loading && !error ? {events.length} : null} +
+ + {loading ? ( +
+ Загрузка истории… +
+ ) : error ? ( +
{error}
+ ) : events.length === 0 ? ( +
История пока пуста.
+ ) : ( + <> +
+ {visibleEvents.map((event) => ( +
+ +
+

+ {event.action_label} + · {event.subject} +

+

{event.employee.display_name}

+
+
+

{formatPayrollDateTime(event.created_at)}

+

{event.actor?.display_name || "Система"}

+
+
+ ))} +
+ {events.length > 5 ? ( + + ) : null} + + )} +
+ ); +} + function ActionButton({ label, icon, @@ -195,6 +264,7 @@ export function PayrollRatesTable({ {record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)} {formatPayrollDate(record.effective_from)} + {formatPayrollDateTime(record.created_at)}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> @@ -204,17 +274,18 @@ export function PayrollRatesTable({
-
-
- СотрудникОкладЦена сверх нормыДействует сСтатусДействия +
+
+ СотрудникОкладЦена сверх нормыДействует сДобавленоСтатусДействия
{records.map((record) => ( -
+

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

{formatPayrollMoney(record.amount, record.currency)}
{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
{formatPayrollDate(record.effective_from)}
+
{formatPayrollDateTime(record.created_at)}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
@@ -281,6 +352,7 @@ export function PayrollWorkRecordsTable({ {formatPayrollWorkMetric(record.actual_points, record.source)} {record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"} {fromAttendance ? "Посещаемость" : "Введено вручную"} + {formatPayrollDateTime(record.created_at)}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> @@ -291,15 +363,15 @@ export function PayrollWorkRecordsTable({
-
-
- СотрудникНормаФактКонтрольСтатусДействия +
+
+ СотрудникНормаФактКонтрольДобавленоСтатусДействия
{records.map((record) => { const fromAttendance = isAttendancePayrollWorkRecord(record.source); return ( -
+

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

@@ -312,6 +384,7 @@ export function PayrollWorkRecordsTable({
{formatPayrollWorkMetric(record.target_points, record.source)}
{formatPayrollWorkMetric(record.actual_points, record.source)}
{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}
+
{formatPayrollDateTime(record.created_at)}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
@@ -375,6 +448,7 @@ export function PayrollInputLinesTable({ ) : null} {record.reason ? {record.reason} : null} + {formatPayrollDateTime(record.created_at)}
onEdit(record)} onApprove={() => onApprove(record)} /> @@ -384,13 +458,13 @@ export function PayrollInputLinesTable({
-
-
- СотрудникОперацияСуммаСтатусДействия +
+
+ СотрудникОперацияСуммаДобавленоСтатусДействия
{records.map((record) => ( -
+

{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}
{formatPayrollMoney(record.amount)}
+
{formatPayrollDateTime(record.created_at)}
onEdit(record)} onApprove={() => onApprove(record)} />
diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 9c615e48..b54e0509 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -3,6 +3,7 @@ import { AlertCircle, ArrowLeft, + ArrowUpDown, Banknote, CalendarCheck2, Calculator, @@ -43,6 +44,7 @@ import { PayrollManagementAccessModal } from "@/components/finance/admin/Payroll import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal"; import { PayrollApprovalQueue, + PayrollInputHistory, PayrollInputLinesTable, PayrollRatesTable, PayrollWorkRecordsTable, @@ -64,6 +66,8 @@ import type { PayrollBulkTargetPointsResult, PayrollCalculationBlocker, PayrollInputLineWrite, + PayrollInputHistoryEvent, + PayrollInputHistoryKind, PayrollPayRateWrite, PayrollPeriodTable, PayrollPeriodTableRow, @@ -97,6 +101,27 @@ type PendingAction = { execute: (reason: string) => Promise; }; +type PayrollInputSort = "newest" | "oldest" | "employee_asc" | "employee_desc"; + +function sortPayrollInputRecords< + T extends { id: number; created_at: string; employee: { display_name: string } }, +>(records: T[], sort: PayrollInputSort): T[] { + return [...records].sort((left, right) => { + if (sort === "employee_asc" || sort === "employee_desc") { + const direction = sort === "employee_asc" ? 1 : -1; + const byEmployee = left.employee.display_name.localeCompare( + right.employee.display_name, + "ru-RU", + { sensitivity: "base" }, + ); + return byEmployee !== 0 ? byEmployee * direction : right.id - left.id; + } + const direction = sort === "newest" ? -1 : 1; + const byCreatedAt = new Date(left.created_at).getTime() - new Date(right.created_at).getTime(); + return byCreatedAt !== 0 ? byCreatedAt * direction : (left.id - right.id) * direction; + }); +} + function StatusPill({ period }: { period: PayrollAdminPeriod }) { const meta = periodStatusMeta[period.status]; return {meta.label}; @@ -217,8 +242,10 @@ function ReadinessBlockers({ function TableToolbar({ search, status, + sort, onSearch, onStatus, + onSort, onAdd, onFillFromAttendance, onImportWorkbook, @@ -228,8 +255,10 @@ function TableToolbar({ }: { search: string; status: "" | PayrollApprovalStatus; + sort: PayrollInputSort; onSearch: (value: string) => void; onStatus: (value: "" | PayrollApprovalStatus) => void; + onSort: (value: PayrollInputSort) => void; onAdd?: () => void; onFillFromAttendance?: () => void; onImportWorkbook?: () => void; @@ -274,6 +303,15 @@ function TableToolbar({ + {onAdd ? (