feat(#190): экран «Качество первички» (web)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3589f53c3c
commit
717dec69cf
@ -18,6 +18,7 @@ import {
|
|||||||
Mono,
|
Mono,
|
||||||
} from './layouts/';
|
} from './layouts/';
|
||||||
import { ConsolidationReportPage } from './layouts/ConsolidationReportPage';
|
import { ConsolidationReportPage } from './layouts/ConsolidationReportPage';
|
||||||
|
import { QualityMetrics } from './layouts/QualityMetrics';
|
||||||
import { store } from './store/store';
|
import { store } from './store/store';
|
||||||
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
||||||
|
|
||||||
@ -44,6 +45,7 @@ function App() {
|
|||||||
<Route path='/catalogue/jur_l' element={<CatalogueJur />} />
|
<Route path='/catalogue/jur_l' element={<CatalogueJur />} />
|
||||||
<Route path='/docstorage' element={<DocStorage />} />
|
<Route path='/docstorage' element={<DocStorage />} />
|
||||||
<Route path='/documents/:document_id' element={<DocumentPage />} />
|
<Route path='/documents/:document_id' element={<DocumentPage />} />
|
||||||
|
<Route path='/quality-metrics' element={<QualityMetrics />} />
|
||||||
<Route path='/logging' element={<AuditLogging />} />
|
<Route path='/logging' element={<AuditLogging />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер:
|
// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер:
|
||||||
// проверки на клиенте только прячут то, что пользователю всё равно не разрешат.
|
// проверки на клиенте только прячут то, что пользователю всё равно не разрешат.
|
||||||
export const USERS_ADMIN = 'USERS_ADMIN';
|
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 { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import { Button, Divider, Layout, Tooltip, message } from 'antd';
|
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 { type ReactNode, memo, useState } from 'react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
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 { createConsolidation } from '../../services/api/consolidations';
|
||||||
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
|
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
|
||||||
import { useAppDispatch } from '../../store/store';
|
import { useAppDispatch } from '../../store/store';
|
||||||
@ -11,6 +12,7 @@ import { quarterLabel } from '../../utils/labels';
|
|||||||
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
|
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
|
||||||
import { UserMenu } from '../UserMenu/UserMenu';
|
import { UserMenu } from '../UserMenu/UserMenu';
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
import { filterByPermission } from './visibility';
|
||||||
|
|
||||||
type CollapsedIconProps = {
|
type CollapsedIconProps = {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
@ -104,6 +106,12 @@ const config: Group[] = [
|
|||||||
icon: <ScrollText size={14} />,
|
icon: <ScrollText size={14} />,
|
||||||
label: 'Журнал действий',
|
label: 'Журнал действий',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
link: '/quality-metrics',
|
||||||
|
icon: <ClipboardCheck size={14} />,
|
||||||
|
label: 'Качество первички',
|
||||||
|
permission: QUALITY_METRICS_VIEW,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
link: '/admin',
|
link: '/admin',
|
||||||
icon: <UserCog2 size={14} />,
|
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 ? (
|
return !isCollapsed ? (
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>{group.title}</div>
|
<div className={styles.sectionTitle}>{group.title}</div>
|
||||||
{group.children.map((i) => (
|
{children.map((i) => (
|
||||||
<NavLinkStyled key={i.link} to={i.link}>
|
<NavLinkStyled key={i.link} to={i.link}>
|
||||||
{i.icon}
|
{i.icon}
|
||||||
{i.label}
|
{i.label}
|
||||||
@ -141,7 +152,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{group.children.map((i) => (
|
{children.map((i) => (
|
||||||
<CollapsedIcon key={i.link} icon={i.icon} to={i.link} title={i.label} />
|
<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() {
|
export function Sidebar() {
|
||||||
|
const me = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@ -194,7 +206,7 @@ export function Sidebar() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.map((g) => (
|
{config.map((g) => (
|
||||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
<Section key={g.key} group={g} isCollapsed={isCollapsed} permissions={me.permissions} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<NewConsolidationModal
|
<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