feat(#190): метрики качества первички (AURORA-1759) #229
@ -18,6 +18,7 @@ import {
|
||||
Mono,
|
||||
} from './layouts/';
|
||||
import { ConsolidationReportPage } from './layouts/ConsolidationReportPage';
|
||||
import { QualityMetrics } from './layouts/QualityMetrics';
|
||||
import { store } from './store/store';
|
||||
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
||||
|
||||
@ -44,6 +45,7 @@ function App() {
|
||||
<Route path='/catalogue/jur_l' element={<CatalogueJur />} />
|
||||
<Route path='/docstorage' element={<DocStorage />} />
|
||||
<Route path='/documents/:document_id' element={<DocumentPage />} />
|
||||
<Route path='/quality-metrics' element={<QualityMetrics />} />
|
||||
<Route path='/logging' element={<AuditLogging />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер:
|
||||
// проверки на клиенте только прячут то, что пользователю всё равно не разрешат.
|
||||
export const USERS_ADMIN = 'USERS_ADMIN';
|
||||
// Экран «Качество первички» (метрики качества распознавания).
|
||||
export const QUALITY_METRICS_VIEW = 'QUALITY_METRICS_VIEW';
|
||||
|
||||
13
web/src/layouts/QualityMetrics.tsx
Normal file
13
web/src/layouts/QualityMetrics.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Result } from 'antd';
|
||||
import { useCurrentUser } from '../auth/MeContext';
|
||||
import { QUALITY_METRICS_VIEW } from '../auth/permissions';
|
||||
import { QualityMetricsPage } from '../widgets/QualityMetrics/QualityMetricsPage';
|
||||
|
||||
/** Маршрут `/quality-metrics`: без права `QUALITY_METRICS_VIEW` страница не создаётся, запросов к API нет. */
|
||||
export function QualityMetrics() {
|
||||
const me = useCurrentUser();
|
||||
if (!me.permissions.includes(QUALITY_METRICS_VIEW)) {
|
||||
return <Result status='403' title='Нет доступа' subTitle='Раздел доступен пользователям с правом просмотра метрик качества' />;
|
||||
}
|
||||
return <QualityMetricsPage />;
|
||||
}
|
||||
13
web/src/services/api/qualityMetrics.test.ts
Normal file
13
web/src/services/api/qualityMetrics.test.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { queryString } from './qualityMetrics';
|
||||
|
||||
describe('queryString', () => {
|
||||
it('пропускает пустые поля, булево пишет как true/false', () => {
|
||||
expect(queryString({ date_from: '2026-01-01', pipeline: undefined, is_scan: false, format: 'csv' })).toBe(
|
||||
'?date_from=2026-01-01&is_scan=false&format=csv',
|
||||
);
|
||||
});
|
||||
it('без параметров — пустая строка', () => {
|
||||
expect(queryString({})).toBe('');
|
||||
});
|
||||
});
|
||||
45
web/src/services/api/qualityMetrics.ts
Normal file
45
web/src/services/api/qualityMetrics.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { api, apiFetchBlob } from '../../api/client';
|
||||
import type { Page } from '../../api/types';
|
||||
import type {
|
||||
ExportFormat,
|
||||
QualityCause,
|
||||
QualityCell,
|
||||
QualityCellDocumentOut,
|
||||
QualityFilters,
|
||||
QualityMatrixOut,
|
||||
QualityMonthlySeriesOut,
|
||||
} from '../../types/qualityMetrics';
|
||||
|
||||
const BASE = '/quality-metrics';
|
||||
|
||||
export function getMatrix(filters: QualityFilters): Promise<QualityMatrixOut> {
|
||||
return api.get<QualityMatrixOut>(`${BASE}/matrix`, filters);
|
||||
}
|
||||
|
||||
export function getSeries(filters: QualityFilters, cause: QualityCause): Promise<QualityMonthlySeriesOut> {
|
||||
return api.get<QualityMonthlySeriesOut>(`${BASE}/series`, { ...filters, cause });
|
||||
}
|
||||
|
||||
export function getCellDocuments(
|
||||
filters: QualityFilters,
|
||||
cell: QualityCell,
|
||||
paging: { limit: number; offset: number },
|
||||
): Promise<Page<QualityCellDocumentOut>> {
|
||||
return api.get<Page<QualityCellDocumentOut>>(`${BASE}/cells/${cell}/documents`, { ...filters, ...paging });
|
||||
}
|
||||
|
||||
/** Выгрузка фактов за период; скачивание идёт через fetch, потому что нужен заголовок Authorization. */
|
||||
export function exportFacts(filters: QualityFilters, format: ExportFormat): Promise<{ blob: Blob; filename: string | null }> {
|
||||
return apiFetchBlob(`${BASE}/export${queryString({ ...filters, format })}`);
|
||||
}
|
||||
|
||||
/** Строка запроса для выгрузки (пустые поля пропускаются) — вынесено отдельно ради теста. */
|
||||
export function queryString(params: object): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
search.append(key, String(value));
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
98
web/src/types/qualityMetrics.ts
Normal file
98
web/src/types/qualityMetrics.ts
Normal file
@ -0,0 +1,98 @@
|
||||
// Типы экрана «Качество первички» 1:1 со схемами API (api/src/schemas/quality_metrics.py).
|
||||
// Доли — числа 0..1; даты — строки ISO (`2026-11-01`), момент времени — ISO с часовым поясом.
|
||||
|
||||
import type { DocumentType } from './api';
|
||||
|
||||
/** Исход документа (`QualityOutcome`). */
|
||||
export type QualityOutcome =
|
||||
| 'AUTO_CONFIRMED'
|
||||
| 'CONFIRMED'
|
||||
| 'DELETED'
|
||||
| 'SUPERSEDED'
|
||||
| 'DELETED_AFTER_CONFIRM'
|
||||
| 'SUPERSEDED_AFTER_CONFIRM';
|
||||
|
||||
/** Причина документа (`QualityCause`). */
|
||||
export type QualityCause = 'SERVICE_ERROR' | 'OUR_ERROR' | 'CLIENT_DOCUMENT' | 'WITHOUT_PARTICIPATION' | 'AS_IS';
|
||||
|
||||
/** Ячейка матрицы, из которой раскрывается список документов (`QualityCell`). */
|
||||
export type QualityCell = QualityCause | 'REMOVED_BEFORE_CONFIRM' | 'REMOVED_AFTER_CONFIRM' | 'IN_PROGRESS';
|
||||
|
||||
/** Источник: image = PDF/скан, tabular = Excel (`RecognitionPipeline`). */
|
||||
export type RecognitionPipeline = 'image' | 'tabular';
|
||||
|
||||
export type ExportFormat = 'csv' | 'json';
|
||||
|
||||
/** Фильтры (`QualityFilters`); пустые поля в запрос не попадают. */
|
||||
export interface QualityFilters {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
group_id?: string;
|
||||
legal_entity_id?: string;
|
||||
document_type?: DocumentType;
|
||||
pipeline?: RecognitionPipeline;
|
||||
is_scan?: boolean;
|
||||
}
|
||||
|
||||
export interface TechnicalFailuresOut {
|
||||
failed_files_count: number;
|
||||
total_files_count: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface QualityMatrixOut {
|
||||
counts: Record<QualityCause, number>;
|
||||
shares: Record<QualityCause, number>;
|
||||
denominator: number;
|
||||
removed_before_confirm: number;
|
||||
removed_after_confirm: number;
|
||||
deleted_after_confirm_count: number;
|
||||
superseded_after_confirm_count: number;
|
||||
in_progress_count: number;
|
||||
unique_reports_count: number;
|
||||
technical_failures: TechnicalFailuresOut;
|
||||
}
|
||||
|
||||
export interface MonthPointOut {
|
||||
month: string;
|
||||
count: number;
|
||||
denominator: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface QualityMonthlySeriesOut {
|
||||
cause: QualityCause;
|
||||
points: MonthPointOut[];
|
||||
}
|
||||
|
||||
export interface QualityFixesOut {
|
||||
value_service: number;
|
||||
value_ours: number;
|
||||
code_service: number;
|
||||
code_ours: number;
|
||||
added_row_service: number;
|
||||
added_row_ours: number;
|
||||
header_service: number;
|
||||
header_ours: number;
|
||||
osv_period_account: number;
|
||||
}
|
||||
|
||||
export interface QualityCellDocumentOut {
|
||||
document_id: string;
|
||||
document_type: DocumentType;
|
||||
legal_entity_id: string;
|
||||
legal_entity_name: string;
|
||||
group_name: string | null;
|
||||
received_at: string;
|
||||
outcome: QualityOutcome | null;
|
||||
outcome_at: string | null;
|
||||
previous_outcome: QualityOutcome | null;
|
||||
primary_cause: QualityCause | null;
|
||||
report_date: string | null;
|
||||
fixes: QualityFixesOut | null;
|
||||
checks_accepted: boolean | null;
|
||||
issue_count_at_outcome: number | null;
|
||||
confirmed_by_id: string | null;
|
||||
confirmed_by_name: string | null;
|
||||
document_link: string | null;
|
||||
}
|
||||
72
web/src/widgets/QualityMetrics/DocumentDrillList.tsx
Normal file
72
web/src/widgets/QualityMetrics/DocumentDrillList.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Alert, Table } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getCellDocuments } from '../../services/api/qualityMetrics';
|
||||
import type { QualityCell, QualityCellDocumentOut, QualityFilters } from '../../types/qualityMetrics';
|
||||
import { documentTypeLabel } from '../../utils/labels';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** Документы одной ячейки матрицы; постранично, имя документа ведёт на карточку документа. */
|
||||
export function DocumentDrillList({ filters, cell }: { filters: QualityFilters; cell: QualityCell }) {
|
||||
const [items, setItems] = useState<QualityCellDocumentOut[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// смена фильтров или ячейки — снова с первой страницы
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: сброс страницы по смене входных данных
|
||||
useEffect(() => setPage(1), [filters, cell]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getCellDocuments(filters, cell, { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE })
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setItems(r.items);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Не удалось загрузить документы');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filters, cell, page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Alert type='error' message={error} showIcon style={{ marginBottom: 8 }} />}
|
||||
<Table<QualityCellDocumentOut>
|
||||
rowKey='document_id'
|
||||
size='small'
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
locale={{ emptyText: 'Нет документов' }}
|
||||
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, onChange: setPage }}
|
||||
columns={[
|
||||
{
|
||||
title: 'Документ',
|
||||
render: (_: unknown, d) =>
|
||||
d.document_link ? (
|
||||
<Link to={`/documents/${d.document_id}`}>{documentTypeLabel(d.document_type, null)}</Link>
|
||||
) : (
|
||||
<span title='Документ удалён'>{documentTypeLabel(d.document_type, null)} (удалён)</span>
|
||||
),
|
||||
},
|
||||
{ title: 'Юрлицо', dataIndex: 'legal_entity_name' },
|
||||
{ title: 'ГК', dataIndex: 'group_name', render: (g: string | null) => g ?? '—' },
|
||||
{ title: 'Отчётная дата', dataIndex: 'report_date', render: (v: string | null) => v ?? '—' },
|
||||
{ title: 'Принят', dataIndex: 'received_at', render: (v: string) => v.slice(0, 10) },
|
||||
{ title: 'Подтвердил', dataIndex: 'confirmed_by_name', render: (v: string | null) => v ?? '—' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
web/src/widgets/QualityMetrics/MatrixTable.tsx
Normal file
48
web/src/widgets/QualityMetrics/MatrixTable.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { Button, Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { QualityCell, QualityMatrixOut } from '../../types/qualityMetrics';
|
||||
import { type MatrixRow, buildMatrixRows } from './qualityView';
|
||||
|
||||
interface Props {
|
||||
data: QualityMatrixOut;
|
||||
/** Клик по счётчику ячейки — открыть список документов. */
|
||||
onOpenCell?: (cell: QualityCell, label: string) => void;
|
||||
}
|
||||
|
||||
/** Матрица причин: строка — причина или служебная группа, счётчик раскрывается в список документов. */
|
||||
export function MatrixTable({ data, onOpenCell }: Props) {
|
||||
const columns: ColumnsType<MatrixRow> = [
|
||||
{
|
||||
title: 'Причина',
|
||||
dataIndex: 'label',
|
||||
render: (label: string, row) => (
|
||||
<div>
|
||||
<div>{label}</div>
|
||||
{row.note && <div style={{ color: '#888', fontSize: 12 }}>{row.note}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Документов',
|
||||
dataIndex: 'count',
|
||||
align: 'right',
|
||||
width: 140,
|
||||
render: (count: number, row) =>
|
||||
onOpenCell && count > 0 ? (
|
||||
<Button type='link' size='small' onClick={() => onOpenCell(row.key, row.label)}>
|
||||
{count}
|
||||
</Button>
|
||||
) : (
|
||||
count
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Доля от подтверждённых',
|
||||
dataIndex: 'share',
|
||||
align: 'right',
|
||||
width: 200,
|
||||
render: (share: string | null) => share ?? '—',
|
||||
},
|
||||
];
|
||||
return <Table<MatrixRow> rowKey='key' columns={columns} dataSource={buildMatrixRows(data)} pagination={false} size='small' />;
|
||||
}
|
||||
58
web/src/widgets/QualityMetrics/MonthlyTrend.tsx
Normal file
58
web/src/widgets/QualityMetrics/MonthlyTrend.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Alert, Select, Space, Table } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSeries } from '../../services/api/qualityMetrics';
|
||||
import type { MonthPointOut, QualityCause, QualityFilters } from '../../types/qualityMetrics';
|
||||
import { CAUSE_ORDER, causeLabel, formatShare, monthLabel } from './qualityView';
|
||||
|
||||
/** Динамика по месяцам даты приёма для выбранной причины (таблицей: библиотеки графиков в проекте нет). */
|
||||
export function MonthlyTrend({ filters }: { filters: QualityFilters }) {
|
||||
const [cause, setCause] = useState<QualityCause>('WITHOUT_PARTICIPATION');
|
||||
const [points, setPoints] = useState<MonthPointOut[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getSeries(filters, cause)
|
||||
.then((r) => {
|
||||
if (!cancelled) setPoints(r.points);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Не удалось загрузить динамику');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filters, cause]);
|
||||
|
||||
return (
|
||||
<Space direction='vertical' style={{ width: '100%' }}>
|
||||
<Select<QualityCause>
|
||||
value={cause}
|
||||
onChange={setCause}
|
||||
style={{ width: 360 }}
|
||||
options={CAUSE_ORDER.map((c) => ({ value: c, label: causeLabel(c) }))}
|
||||
/>
|
||||
{error && <Alert type='error' message={error} showIcon />}
|
||||
<Table<MonthPointOut>
|
||||
rowKey='month'
|
||||
size='small'
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
dataSource={points}
|
||||
locale={{ emptyText: 'Нет данных за период' }}
|
||||
columns={[
|
||||
{ title: 'Месяц приёма', dataIndex: 'month', render: monthLabel },
|
||||
{ title: 'Документов', dataIndex: 'count', align: 'right' },
|
||||
{ title: 'Подтверждено всего', dataIndex: 'denominator', align: 'right' },
|
||||
{ title: 'Доля', dataIndex: 'share', align: 'right', render: (_: number, p) => formatShare(p.share, p.denominator) },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
146
web/src/widgets/QualityMetrics/QualityMetricsPage.tsx
Normal file
146
web/src/widgets/QualityMetrics/QualityMetricsPage.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { exportFacts, getMatrix } from '../../services/api/qualityMetrics';
|
||||
import type { DocumentType } from '../../types/api';
|
||||
import type { ExportFormat, QualityCell, QualityFilters, QualityMatrixOut, RecognitionPipeline } from '../../types/qualityMetrics';
|
||||
import { saveBlob } from '../../utils/download';
|
||||
import { DocumentDrillList } from './DocumentDrillList';
|
||||
import { MatrixTable } from './MatrixTable';
|
||||
import { MonthlyTrend } from './MonthlyTrend';
|
||||
import { formatShare } from './qualityView';
|
||||
|
||||
const DOC_TYPES: { value: DocumentType; label: string }[] = [
|
||||
{ value: 'BALANCE', label: 'Бухгалтерский баланс' },
|
||||
{ value: 'INCOME', label: 'ОФР' },
|
||||
{ value: 'OSV', label: 'ОСВ' },
|
||||
];
|
||||
|
||||
const PIPELINES: { value: RecognitionPipeline; label: string }[] = [
|
||||
{ value: 'image', label: 'PDF (распознавание)' },
|
||||
{ value: 'tabular', label: 'Excel' },
|
||||
];
|
||||
|
||||
const SCAN_OPTIONS = [
|
||||
{ value: 'true', label: 'Скан' },
|
||||
{ value: 'false', label: 'Цифровой PDF' },
|
||||
];
|
||||
|
||||
const iso = (d: Dayjs): string => d.format('YYYY-MM-DD');
|
||||
|
||||
/** Экран «Качество первички»: фильтры, матрица причин, технические отказы, динамика по месяцам, выгрузка. */
|
||||
export function QualityMetricsPage() {
|
||||
const [filters, setFilters] = useState<QualityFilters>({});
|
||||
const [matrix, setMatrix] = useState<QualityMatrixOut | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<ApiError | Error | null>(null);
|
||||
const [drill, setDrill] = useState<{ cell: QualityCell; label: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getMatrix(filters)
|
||||
.then((r) => {
|
||||
if (!cancelled) setMatrix(r);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (cancelled) return;
|
||||
setMatrix(null);
|
||||
setError(e instanceof Error ? e : new Error('Не удалось загрузить метрики'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filters]);
|
||||
|
||||
const patch = (changes: Partial<QualityFilters>) => setFilters((f) => ({ ...f, ...changes }));
|
||||
|
||||
const download = async (format: ExportFormat) => {
|
||||
try {
|
||||
const { blob, filename } = await exportFacts(filters, format);
|
||||
saveBlob(blob, filename ?? `quality-facts.${format}`);
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : 'Не удалось выгрузить данные');
|
||||
}
|
||||
};
|
||||
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
return <Result status='403' title='Нет доступа' subTitle='Раздел доступен пользователям с правом просмотра метрик качества' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: 10, padding: 16, background: 'white', borderRadius: 10, height: 'calc(100% - 20px)', overflow: 'auto' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Качество первички</h2>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<DatePicker.RangePicker
|
||||
format='DD.MM.YYYY'
|
||||
placeholder={['Принят с', 'Принят по']}
|
||||
onChange={(range) =>
|
||||
patch({ date_from: range?.[0] ? iso(range[0]) : undefined, date_to: range?.[1] ? iso(range[1]) : undefined })
|
||||
}
|
||||
/>
|
||||
<Select<DocumentType>
|
||||
allowClear
|
||||
placeholder='Тип документа'
|
||||
style={{ width: 200 }}
|
||||
options={DOC_TYPES}
|
||||
onChange={(v) => patch({ document_type: v })}
|
||||
/>
|
||||
<Select<RecognitionPipeline>
|
||||
allowClear
|
||||
placeholder='Источник'
|
||||
style={{ width: 200 }}
|
||||
options={PIPELINES}
|
||||
onChange={(v) => patch({ pipeline: v })}
|
||||
/>
|
||||
<Select<string>
|
||||
allowClear
|
||||
placeholder='Скан / цифровой'
|
||||
style={{ width: 170 }}
|
||||
options={SCAN_OPTIONS}
|
||||
onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })}
|
||||
/>
|
||||
<Button onClick={() => void download('csv')}>Выгрузить CSV</Button>
|
||||
<Button onClick={() => void download('json')}>Выгрузить JSON</Button>
|
||||
</Space>
|
||||
|
||||
{error && <Alert type='error' showIcon message={error.message} style={{ marginBottom: 16 }} />}
|
||||
<Spin spinning={loading}>
|
||||
{matrix && matrix.denominator === 0 && matrix.in_progress_count === 0 && (
|
||||
<Alert type='info' showIcon message='Нет данных за выбранный период' style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
{matrix && (
|
||||
<>
|
||||
<Descriptions size='small' bordered column={{ xs: 1, md: 3 }} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label='Подтверждённых документов'>{matrix.denominator}</Descriptions.Item>
|
||||
<Descriptions.Item label='Уникальных отчётов'>{matrix.unique_reports_count}</Descriptions.Item>
|
||||
<Descriptions.Item label='Технические отказы (по файлам)'>
|
||||
{matrix.technical_failures.failed_files_count} из {matrix.technical_failures.total_files_count} (
|
||||
{formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)})
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'matrix',
|
||||
label: 'Причины',
|
||||
children: <MatrixTable data={matrix} onOpenCell={(cell, label) => setDrill({ cell, label })} />,
|
||||
},
|
||||
{ key: 'trend', label: 'Динамика по месяцам', children: <MonthlyTrend filters={filters} /> },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Drawer title={drill?.label} width={900} open={drill !== null} onClose={() => setDrill(null)} destroyOnClose>
|
||||
{drill && <DocumentDrillList filters={filters} cell={drill.cell} />}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
web/src/widgets/QualityMetrics/qualityView.test.ts
Normal file
53
web/src/widgets/QualityMetrics/qualityView.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { QualityMatrixOut } from '../../types/qualityMetrics';
|
||||
import { filterByPermission } from '../Sidebar/visibility';
|
||||
import { buildMatrixRows, formatShare, monthLabel } from './qualityView';
|
||||
|
||||
const matrixFixture: QualityMatrixOut = {
|
||||
counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 },
|
||||
shares: { SERVICE_ERROR: 0.13, OUR_ERROR: 0.087, CLIENT_DOCUMENT: 0.043, WITHOUT_PARTICIPATION: 0.522, AS_IS: 0.217 },
|
||||
denominator: 23,
|
||||
removed_before_confirm: 4,
|
||||
removed_after_confirm: 3,
|
||||
deleted_after_confirm_count: 1,
|
||||
superseded_after_confirm_count: 2,
|
||||
in_progress_count: 6,
|
||||
unique_reports_count: 20,
|
||||
technical_failures: { failed_files_count: 1, total_files_count: 30, share: 0.033 },
|
||||
};
|
||||
|
||||
describe('пункт меню по праву', () => {
|
||||
const items = [{ link: '/a' }, { link: '/q', permission: 'QUALITY_METRICS_VIEW' }];
|
||||
it('скрыт без права', () => {
|
||||
expect(filterByPermission(items, ['USERS_ADMIN']).map((i) => i.link)).toEqual(['/a']);
|
||||
});
|
||||
it('виден с правом', () => {
|
||||
expect(filterByPermission(items, ['QUALITY_METRICS_VIEW']).map((i) => i.link)).toEqual(['/a', '/q']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMatrixRows', () => {
|
||||
const rows = buildMatrixRows(matrixFixture);
|
||||
it('счётчики из ответа API', () => {
|
||||
expect(rows.find((r) => r.key === 'WITHOUT_PARTICIPATION')?.count).toBe(12);
|
||||
expect(rows.find((r) => r.key === 'REMOVED_BEFORE_CONFIRM')?.count).toBe(4);
|
||||
expect(rows.find((r) => r.key === 'IN_PROGRESS')?.count).toBe(6);
|
||||
});
|
||||
it('доли только у причин; разбивка удалённых после подтверждения', () => {
|
||||
expect(rows.find((r) => r.key === 'WITHOUT_PARTICIPATION')?.share).toBe('52,2%');
|
||||
expect(rows.find((r) => r.key === 'IN_PROGRESS')?.share).toBeNull();
|
||||
expect(rows.find((r) => r.key === 'REMOVED_AFTER_CONFIRM')?.note).toBe('удалено: 1, заменено: 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShare', () => {
|
||||
it('нет знаменателя — «нет данных», не 0%', () => {
|
||||
expect(formatShare(0, 0)).toBe('нет данных');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthLabel', () => {
|
||||
it('месяц.год', () => {
|
||||
expect(monthLabel('2026-03-01')).toBe('03.2026');
|
||||
});
|
||||
});
|
||||
59
web/src/widgets/QualityMetrics/qualityView.ts
Normal file
59
web/src/widgets/QualityMetrics/qualityView.ts
Normal file
@ -0,0 +1,59 @@
|
||||
// Чистые функции экрана «Качество первички»: подписи и строки матрицы. Отдельно от React — чтобы тестировать.
|
||||
|
||||
import type { QualityCause, QualityCell, QualityMatrixOut } from '../../types/qualityMetrics';
|
||||
|
||||
export const CAUSE_ORDER: readonly QualityCause[] = ['WITHOUT_PARTICIPATION', 'AS_IS', 'CLIENT_DOCUMENT', 'OUR_ERROR', 'SERVICE_ERROR'];
|
||||
|
||||
const CAUSE_LABEL: Record<QualityCause, string> = {
|
||||
WITHOUT_PARTICIPATION: 'Без участия аналитика (автоподтверждён)',
|
||||
AS_IS: 'Подтверждён как есть',
|
||||
CLIENT_DOCUMENT: 'Ошибка в документе клиента',
|
||||
OUR_ERROR: 'Наша ошибка разбора',
|
||||
SERVICE_ERROR: 'Ошибка сервиса распознавания',
|
||||
};
|
||||
|
||||
export const causeLabel = (cause: QualityCause): string => CAUSE_LABEL[cause];
|
||||
|
||||
/** Доля 0..1 → «12,3%»; нет знаменателя — «нет данных», а не «0%». */
|
||||
export function formatShare(share: number, denominator: number): string {
|
||||
if (denominator <= 0) return 'нет данных';
|
||||
return `${(share * 100).toFixed(1).replace('.', ',')}%`;
|
||||
}
|
||||
|
||||
export interface MatrixRow {
|
||||
key: QualityCell;
|
||||
label: string;
|
||||
count: number;
|
||||
/** Подпись доли; null — строка в доли не входит (удалённые, заменённые, «в работе»). */
|
||||
share: string | null;
|
||||
/** Пояснение под названием (разбивка «удалён / заменён»). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Строки матрицы: пять причин (с долями от подтверждённых) и три служебные строки без долей. */
|
||||
export function buildMatrixRows(data: QualityMatrixOut): MatrixRow[] {
|
||||
const causeRows: MatrixRow[] = CAUSE_ORDER.map((cause) => ({
|
||||
key: cause,
|
||||
label: causeLabel(cause),
|
||||
count: data.counts[cause] ?? 0,
|
||||
share: formatShare(data.shares[cause] ?? 0, data.denominator),
|
||||
}));
|
||||
return [
|
||||
...causeRows,
|
||||
{ key: 'REMOVED_BEFORE_CONFIRM', label: 'Удалены или заменены до подтверждения', count: data.removed_before_confirm, share: null },
|
||||
{
|
||||
key: 'REMOVED_AFTER_CONFIRM',
|
||||
label: 'Удалены или заменены после подтверждения',
|
||||
count: data.removed_after_confirm,
|
||||
share: null,
|
||||
note: `удалено: ${data.deleted_after_confirm_count}, заменено: ${data.superseded_after_confirm_count}`,
|
||||
},
|
||||
{ key: 'IN_PROGRESS', label: 'В работе (исхода ещё нет)', count: data.in_progress_count, share: null },
|
||||
];
|
||||
}
|
||||
|
||||
/** Месяц `2026-03-01` → «03.2026». */
|
||||
export function monthLabel(iso: string): string {
|
||||
const [year, month] = iso.split('-');
|
||||
return month && year ? `${month}.${year}` : iso;
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { Button, Divider, Layout, Tooltip, message } from 'antd';
|
||||
import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
|
||||
import { Archive, Building2, ClipboardCheck, ScrollText, UserCog2, Users2 } from 'lucide-react';
|
||||
import { type ReactNode, memo, useState } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { USERS_ADMIN } from '../../auth/permissions';
|
||||
import { useCurrentUser } from '../../auth/MeContext';
|
||||
import { QUALITY_METRICS_VIEW, USERS_ADMIN } from '../../auth/permissions';
|
||||
import { createConsolidation } from '../../services/api/consolidations';
|
||||
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
|
||||
import { useAppDispatch } from '../../store/store';
|
||||
@ -11,6 +12,7 @@ import { quarterLabel } from '../../utils/labels';
|
||||
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
|
||||
import { UserMenu } from '../UserMenu/UserMenu';
|
||||
import styles from './styles.module.css';
|
||||
import { filterByPermission } from './visibility';
|
||||
|
||||
type CollapsedIconProps = {
|
||||
icon: ReactNode;
|
||||
@ -104,6 +106,12 @@ const config: Group[] = [
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Журнал действий',
|
||||
},
|
||||
{
|
||||
link: '/quality-metrics',
|
||||
icon: <ClipboardCheck size={14} />,
|
||||
label: 'Качество первички',
|
||||
permission: QUALITY_METRICS_VIEW,
|
||||
},
|
||||
{
|
||||
link: '/admin',
|
||||
icon: <UserCog2 size={14} />,
|
||||
@ -127,11 +135,14 @@ function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
|
||||
const Section = memo(function Section({ isCollapsed, group, permissions }: { isCollapsed: boolean; group: Group; permissions: string[] }) {
|
||||
// пункты без нужного права не показываем; пустую секцию не рисуем совсем
|
||||
const children = filterByPermission(group.children, permissions);
|
||||
if (children.length === 0) return null;
|
||||
return !isCollapsed ? (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{group.title}</div>
|
||||
{group.children.map((i) => (
|
||||
{children.map((i) => (
|
||||
<NavLinkStyled key={i.link} to={i.link}>
|
||||
{i.icon}
|
||||
{i.label}
|
||||
@ -141,7 +152,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{group.children.map((i) => (
|
||||
{children.map((i) => (
|
||||
<CollapsedIcon key={i.link} icon={i.icon} to={i.link} title={i.label} />
|
||||
))}
|
||||
</>
|
||||
@ -149,6 +160,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const me = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@ -194,7 +206,7 @@ export function Sidebar() {
|
||||
</>
|
||||
)}
|
||||
{config.map((g) => (
|
||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} permissions={me.permissions} />
|
||||
))}
|
||||
</div>
|
||||
<NewConsolidationModal
|
||||
|
||||
10
web/src/widgets/Sidebar/visibility.ts
Normal file
10
web/src/widgets/Sidebar/visibility.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// Видимость пунктов меню по правам из `/me`. Прячет лишнее для удобства; доступ всё равно решает сервер.
|
||||
|
||||
export interface PermissionItem {
|
||||
/** Право, без которого пункт не показывается; нет — пункт виден всем. */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
export function filterByPermission<T extends PermissionItem>(items: readonly T[], permissions: readonly string[]): T[] {
|
||||
return items.filter((item) => !item.permission || permissions.includes(item.permission));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user