fix(#190): доработки экрана качества по ревью Task 16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-07 02:56:27 +04:00
parent 717dec69cf
commit 7ded34fec5
10 changed files with 288 additions and 80 deletions

View File

@ -17,7 +17,7 @@ arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), сп
повторяют схемы бэка (`../api/src/schemas/`), поля не придумываем.
2. Пользовательский путь — `../../docs/context/10-user-journey.md`; макет — `../../docs/mockup/` (по одному экрану через
`../../docs/mockup/INDEX.md`).
3. Права — данные: доступ решает сервер (403). Меню пока не скрывается по правам (`Sidebar` не применяет permission);
3. Права — данные: доступ решает сервер (403). Пункты меню с `permission` скрываются без права (`widgets/Sidebar/visibility.ts`);
коды прав из `/me` лежат в `src/auth/permissions.ts`.
- Требования к экранам: действующие — `../openspec/specs/*-ui/spec.md` (перенесены из arm-web@de4e477), новые и
изменённые — `../openspec/changes/` этого репозитория.

View File

@ -28,7 +28,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function buildQuery(params?: object): string {
export function buildQuery(params?: object): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {

View File

@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest';
import { queryString } from './qualityMetrics';
import { buildQuery } from '../../api/client';
describe('queryString', () => {
it('пропускает пустые поля, булево пишет как true/false', () => {
expect(queryString({ date_from: '2026-01-01', pipeline: undefined, is_scan: false, format: 'csv' })).toBe(
describe('buildQuery', () => {
it('пропускает неуказанные поля, булево пишет как true/false', () => {
expect(buildQuery({ 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('');
expect(buildQuery({})).toBe('');
});
});

View File

@ -1,4 +1,4 @@
import { api, apiFetchBlob } from '../../api/client';
import { api, apiFetchBlob, buildQuery } from '../../api/client';
import type { Page } from '../../api/types';
import type {
ExportFormat,
@ -30,16 +30,5 @@ export function getCellDocuments(
/** Выгрузка фактов за период; скачивание идёт через 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}` : '';
return apiFetchBlob(`${BASE}/export${buildQuery({ ...filters, format })}`);
}

View File

@ -2,8 +2,17 @@ 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 type {
QualityCause,
QualityCell,
QualityCellDocumentOut,
QualityFilters,
QualityFixesOut,
QualityOutcome,
} from '../../types/qualityMetrics';
import { formatMoscowDate } from '../../utils/dates';
import { documentTypeLabel } from '../../utils/labels';
import { causeLabel, formatReportDate, outcomeLabel, sumFixes } from './qualityView';
const PAGE_SIZE = 20;
@ -44,6 +53,7 @@ export function DocumentDrillList({ filters, cell }: { filters: QualityFilters;
<>
{error && <Alert type='error' message={error} showIcon style={{ marginBottom: 8 }} />}
<Table<QualityCellDocumentOut>
scroll={{ x: 'max-content' }}
rowKey='document_id'
size='small'
loading={loading}
@ -62,8 +72,24 @@ export function DocumentDrillList({ filters, cell }: { filters: QualityFilters;
},
{ 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: 'report_date', render: (v: string | null) => formatReportDate(v) },
{ title: 'Принят', dataIndex: 'received_at', render: (v: string) => formatMoscowDate(v) ?? '—' },
{ title: 'Исход', dataIndex: 'outcome', render: (v: QualityOutcome | null) => outcomeLabel(v) },
{
title: 'Прежний исход',
dataIndex: 'previous_outcome',
// прежний исход осмыслен только для ячейки «после подтверждения»
render: (v: QualityOutcome | null) => (cell === 'REMOVED_AFTER_CONFIRM' ? outcomeLabel(v) : '—'),
},
{ title: 'Причина', dataIndex: 'primary_cause', render: (v: QualityCause | null) => (v ? causeLabel(v) : '—') },
{
title: 'Правки: сервис / наш разбор',
dataIndex: 'fixes',
render: (f: QualityFixesOut | null) => {
const sum = sumFixes(f);
return sum ? `${sum.service} / ${sum.ours}` : '—';
},
},
{ title: 'Подтвердил', dataIndex: 'confirmed_by_name', render: (v: string | null) => v ?? '—' },
]}
/>

View File

@ -1,48 +1,64 @@
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';
import type { QualityCell, QualityMatrixOut, RecognitionPipeline } from '../../types/qualityMetrics';
import { type SourceColumn, type SourceRow, buildSourceRows } from './qualityView';
export interface MatrixColumnData {
column: SourceColumn;
data: QualityMatrixOut;
}
interface Props {
data: QualityMatrixOut;
/** Клик по счётчику ячейки — открыть список документов. */
onOpenCell?: (cell: QualityCell, label: string) => void;
/** По одной матрице на колонку источника («PDF», «Excel», «Всего») или одна, если источник выбран в фильтре. */
columns: MatrixColumnData[];
/** Клик по счётчику ячейки — открыть список документов (с источником колонки). */
onOpenCell?: (cell: QualityCell, label: string, pipeline: RecognitionPipeline | undefined) => 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 ?? '—',
},
/** Матрица «причина × источник»: в каждой колонке счётчик (раскрывается в список документов) и доля. */
export function MatrixTable({ columns, onOpenCell }: Props) {
const rows = buildSourceRows(columns.map((c) => ({ key: c.column.key, data: c.data })));
const tableColumns: ColumnsType<SourceRow> = [
{ title: 'Причина', dataIndex: 'label' },
...columns.map(({ column }) => ({
title: column.label,
align: 'right' as const,
width: 220,
children: [
{
title: 'Документов',
key: `${column.key}-count`,
align: 'right' as const,
render: (_: unknown, row: SourceRow) => {
const cell = row.cells[column.key];
if (!cell) return '—';
return (
<div>
{onOpenCell && cell.count > 0 ? (
<Button
type='link'
size='small'
aria-label={`${row.label}, ${column.label}: ${cell.count} — открыть список документов`}
onClick={() => onOpenCell(row.key, `${row.label} — ${column.label}`, column.pipeline)}>
{cell.count}
</Button>
) : (
cell.count
)}
{cell.note && <div style={{ color: '#595959', fontSize: 12 }}>{cell.note}</div>}
</div>
);
},
},
{
title: 'Доля',
key: `${column.key}-share`,
align: 'right' as const,
render: (_: unknown, row: SourceRow) => row.cells[column.key]?.share ?? '—',
},
],
})),
];
return <Table<MatrixRow> rowKey='key' columns={columns} dataSource={buildMatrixRows(data)} pagination={false} size='small' />;
return <Table<SourceRow> rowKey='key' columns={tableColumns} dataSource={rows} pagination={false} size='small' bordered />;
}

View File

@ -33,6 +33,7 @@ export function MonthlyTrend({ filters }: { filters: QualityFilters }) {
return (
<Space direction='vertical' style={{ width: '100%' }}>
<Select<QualityCause>
aria-label='Причина'
value={cause}
onChange={setCause}
style={{ width: 360 }}

View File

@ -1,15 +1,17 @@
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 { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { exportFacts, getMatrix } from '../../services/api/qualityMetrics';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { DocumentType } from '../../types/api';
import type { ExportFormat, QualityCell, QualityFilters, QualityMatrixOut, RecognitionPipeline } from '../../types/qualityMetrics';
import type { ExportFormat, QualityCell, QualityFilters, RecognitionPipeline } from '../../types/qualityMetrics';
import { saveBlob } from '../../utils/download';
import { DocumentDrillList } from './DocumentDrillList';
import { MatrixTable } from './MatrixTable';
import { type MatrixColumnData, MatrixTable } from './MatrixTable';
import { MonthlyTrend } from './MonthlyTrend';
import { formatShare } from './qualityView';
import { formatShare, sourceColumns } from './qualityView';
const DOC_TYPES: { value: DocumentType; label: string }[] = [
{ value: 'BALANCE', label: 'Бухгалтерский баланс' },
@ -32,22 +34,42 @@ 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 [matrices, setMatrices] = useState<MatrixColumnData[]>([]);
const [exporting, setExporting] = useState<ExportFormat | null>(null);
const dispatch = useAppDispatch();
const groups = useAppSelector((state) => state.company_groups.raw_rows);
const groupsLoading = useAppSelector((state) => state.company_groups.loading);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | Error | null>(null);
const [drill, setDrill] = useState<{ cell: QualityCell; label: string } | null>(null);
const [drill, setDrill] = useState<{ cell: QualityCell; label: string; pipeline: RecognitionPipeline | undefined } | null>(null);
// фильтры списка документов: общие + источник колонки, по которой кликнули
const drillFilters = useMemo<QualityFilters>(() => ({ ...filters, pipeline: drill?.pipeline ?? filters.pipeline }), [filters, drill]);
// biome-ignore lint/nursery/useAtIndex: в lib проекта (tsconfig) нет Array.prototype.at
const matrix = matrices[matrices.length - 1]?.data ?? null;
// список ГК для фильтра: загружаем, если ещё не загружен
useEffect(() => {
if (groups.length === 0 && !groupsLoading) dispatch(getCompanyGroupsThunk());
}, [groups.length, groupsLoading, dispatch]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getMatrix(filters)
// источник не выбран — три запроса: PDF, Excel и без фильтра («Всего»); выбран — один
Promise.all(
sourceColumns(filters.pipeline).map(async (column) => ({
column,
data: await getMatrix(column.pipeline ? { ...filters, pipeline: column.pipeline } : { ...filters, pipeline: undefined }),
})),
)
.then((r) => {
if (!cancelled) setMatrix(r);
if (!cancelled) setMatrices(r);
})
.catch((e: unknown) => {
if (cancelled) return;
setMatrix(null);
setMatrices([]);
setError(e instanceof Error ? e : new Error('Не удалось загрузить метрики'));
})
.finally(() => {
@ -61,11 +83,15 @@ export function QualityMetricsPage() {
const patch = (changes: Partial<QualityFilters>) => setFilters((f) => ({ ...f, ...changes }));
const download = async (format: ExportFormat) => {
if (exporting) return;
setExporting(format);
try {
const { blob, filename } = await exportFacts(filters, format);
saveBlob(blob, filename ?? `quality-facts.${format}`);
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : 'Не удалось выгрузить данные');
} finally {
setExporting(null);
}
};
@ -84,8 +110,20 @@ export function QualityMetricsPage() {
patch({ date_from: range?.[0] ? iso(range[0]) : undefined, date_to: range?.[1] ? iso(range[1]) : undefined })
}
/>
<Select<string>
allowClear
showSearch
aria-label='Группа компаний'
placeholder='Группа компаний'
style={{ width: 240 }}
loading={groupsLoading}
optionFilterProp='label'
options={groups.map((g) => ({ value: g.id, label: g.name }))}
onChange={(v) => patch({ group_id: v })}
/>
<Select<DocumentType>
allowClear
aria-label='Тип документа'
placeholder='Тип документа'
style={{ width: 200 }}
options={DOC_TYPES}
@ -93,6 +131,7 @@ export function QualityMetricsPage() {
/>
<Select<RecognitionPipeline>
allowClear
aria-label='Источник'
placeholder='Источник'
style={{ width: 200 }}
options={PIPELINES}
@ -100,13 +139,18 @@ export function QualityMetricsPage() {
/>
<Select<string>
allowClear
aria-label='Скан или цифровой PDF'
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>
<Button loading={exporting === 'csv'} disabled={exporting !== null} onClick={() => void download('csv')}>
Выгрузить CSV
</Button>
<Button loading={exporting === 'json'} disabled={exporting !== null} onClick={() => void download('json')}>
Выгрузить JSON
</Button>
</Space>
{error && <Alert type='error' showIcon message={error.message} style={{ marginBottom: 16 }} />}
@ -119,7 +163,7 @@ export function QualityMetricsPage() {
<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='Технические отказы (по файлам)'>
<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>
@ -129,7 +173,7 @@ export function QualityMetricsPage() {
{
key: 'matrix',
label: 'Причины',
children: <MatrixTable data={matrix} onOpenCell={(cell, label) => setDrill({ cell, label })} />,
children: <MatrixTable columns={matrices} onOpenCell={(cell, label, pipeline) => setDrill({ cell, label, pipeline })} />,
},
{ key: 'trend', label: 'Динамика по месяцам', children: <MonthlyTrend filters={filters} /> },
]}
@ -138,8 +182,8 @@ export function QualityMetricsPage() {
)}
</Spin>
<Drawer title={drill?.label} width={900} open={drill !== null} onClose={() => setDrill(null)} destroyOnClose>
{drill && <DocumentDrillList filters={filters} cell={drill.cell} />}
<Drawer title={drill?.label} width={900} open={drill !== null} onClose={() => setDrill(null)} destroyOnHidden>
{drill && <DocumentDrillList filters={drillFilters} cell={drill.cell} />}
</Drawer>
</div>
);

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { QualityMatrixOut } from '../../types/qualityMetrics';
import { filterByPermission } from '../Sidebar/visibility';
import { buildMatrixRows, formatShare, monthLabel } from './qualityView';
import { buildMatrixRows, buildSourceRows, formatReportDate, formatShare, monthLabel, sourceColumns, sumFixes } from './qualityView';
const matrixFixture: QualityMatrixOut = {
counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 },
@ -51,3 +51,55 @@ describe('monthLabel', () => {
expect(monthLabel('2026-03-01')).toBe('03.2026');
});
});
describe('sourceColumns', () => {
it('источник не выбран — PDF, Excel, Всего', () => {
expect(sourceColumns(undefined).map((c) => c.key)).toEqual(['image', 'tabular', 'all']);
});
it('выбран источник — одна колонка', () => {
expect(sourceColumns('tabular').map((c) => c.label)).toEqual(['Excel']);
});
});
describe('buildSourceRows', () => {
it('счётчики по колонкам источника', () => {
const excel = { ...matrixFixture, counts: { ...matrixFixture.counts, AS_IS: 1 } };
const rows = buildSourceRows([
{ key: 'image', data: matrixFixture },
{ key: 'tabular', data: excel },
]);
const asIs = rows.find((r) => r.key === 'AS_IS');
expect(asIs?.cells.image?.count).toBe(5);
expect(asIs?.cells.tabular?.count).toBe(1);
});
it('пустой список колонок — нет строк', () => {
expect(buildSourceRows([])).toEqual([]);
});
});
describe('sumFixes', () => {
it('суммирует по сервису и по нашему разбору, правку ОСВ не считает', () => {
const fixes = {
value_service: 1,
value_ours: 2,
code_service: 3,
code_ours: 4,
added_row_service: 5,
added_row_ours: 6,
header_service: 7,
header_ours: 8,
osv_period_account: 9,
};
expect(sumFixes(fixes)).toEqual({ service: 16, ours: 20 });
});
it('нет правок — null', () => {
expect(sumFixes(null)).toBeNull();
});
});
describe('formatReportDate', () => {
it('ДД.ММ.ГГГГ', () => {
expect(formatReportDate('2026-03-31')).toBe('31.03.2026');
expect(formatReportDate(null)).toBe('—');
});
});

View File

@ -1,6 +1,13 @@
// Чистые функции экрана «Качество первички»: подписи и строки матрицы. Отдельно от React — чтобы тестировать.
import type { QualityCause, QualityCell, QualityMatrixOut } from '../../types/qualityMetrics';
import type {
QualityCause,
QualityCell,
QualityFixesOut,
QualityMatrixOut,
QualityOutcome,
RecognitionPipeline,
} from '../../types/qualityMetrics';
export const CAUSE_ORDER: readonly QualityCause[] = ['WITHOUT_PARTICIPATION', 'AS_IS', 'CLIENT_DOCUMENT', 'OUR_ERROR', 'SERVICE_ERROR'];
@ -57,3 +64,76 @@ export function monthLabel(iso: string): string {
const [year, month] = iso.split('-');
return month && year ? `${month}.${year}` : iso;
}
const OUTCOME_LABEL: Record<QualityOutcome, string> = {
AUTO_CONFIRMED: 'Автоподтверждён',
CONFIRMED: 'Подтверждён аналитиком',
DELETED: 'Удалён до подтверждения',
SUPERSEDED: 'Заменён до подтверждения',
DELETED_AFTER_CONFIRM: 'Удалён после подтверждения',
SUPERSEDED_AFTER_CONFIRM: 'Заменён после подтверждения',
};
export const outcomeLabel = (outcome: QualityOutcome | null): string => (outcome ? OUTCOME_LABEL[outcome] : '—');
/** Сумма правок по источнику: сколько исправил сервис распознавания и сколько — наш разбор (без правки периода/счёта ОСВ). */
export function sumFixes(fixes: QualityFixesOut | null): { service: number; ours: number } | null {
if (!fixes) return null;
return {
service: fixes.value_service + fixes.code_service + fixes.added_row_service + fixes.header_service,
ours: fixes.value_ours + fixes.code_ours + fixes.added_row_ours + fixes.header_ours,
};
}
/** Отчётная дата `2026-03-31` → «31.03.2026» (без часовых поясов: это календарная дата, не момент времени). */
export function formatReportDate(iso: string | null): string {
if (!iso) return '—';
const [year, month, day] = iso.split('-');
return year && month && day ? `${day}.${month}.${year}` : iso;
}
/** Колонка матрицы: источник. `pipeline` не задан — «Всего» (запрос без фильтра по источнику). */
export interface SourceColumn {
key: 'image' | 'tabular' | 'all';
label: string;
pipeline?: RecognitionPipeline;
}
const COL_IMAGE: SourceColumn = { key: 'image', label: 'PDF', pipeline: 'image' };
const COL_TABULAR: SourceColumn = { key: 'tabular', label: 'Excel', pipeline: 'tabular' };
const COL_ALL: SourceColumn = { key: 'all', label: 'Всего' };
/** Колонки матрицы: источник не выбран — «PDF | Excel | Всего», выбран — одна колонка этого источника. */
export function sourceColumns(selected: RecognitionPipeline | undefined): SourceColumn[] {
if (selected === 'image') return [COL_IMAGE];
if (selected === 'tabular') return [COL_TABULAR];
return [COL_IMAGE, COL_TABULAR, COL_ALL];
}
export interface SourceCell {
count: number;
share: string | null;
note?: string;
}
export interface SourceRow {
key: QualityCell;
label: string;
/** Ячейки по ключам колонок (`SourceColumn.key`). */
cells: Record<string, SourceCell>;
}
/** Строки матрицы «причина × источник»: подписи берём из первой матрицы, счётчики — по каждой колонке. */
export function buildSourceRows(columns: { key: string; data: QualityMatrixOut }[]): SourceRow[] {
const first = columns[0];
if (!first) return [];
const perColumn = columns.map((c) => ({ key: c.key, rows: buildMatrixRows(c.data) }));
return buildMatrixRows(first.data).map((row, i) => {
const cells: Record<string, SourceCell> = {};
for (const c of perColumn) {
const r = c.rows[i];
if (r) cells[c.key] = { count: r.count, share: r.share, note: r.note };
}
return { key: row.key, label: row.label, cells };
});
}