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/`), поля не придумываем. повторяют схемы бэка (`../api/src/schemas/`), поля не придумываем.
2. Пользовательский путь — `../../docs/context/10-user-journey.md`; макет — `../../docs/mockup/` (по одному экрану через 2. Пользовательский путь — `../../docs/context/10-user-journey.md`; макет — `../../docs/mockup/` (по одному экрану через
`../../docs/mockup/INDEX.md`). `../../docs/mockup/INDEX.md`).
3. Права — данные: доступ решает сервер (403). Меню пока не скрывается по правам (`Sidebar` не применяет permission); 3. Права — данные: доступ решает сервер (403). Пункты меню с `permission` скрываются без права (`widgets/Sidebar/visibility.ts`);
коды прав из `/me` лежат в `src/auth/permissions.ts`. коды прав из `/me` лежат в `src/auth/permissions.ts`.
- Требования к экранам: действующие — `../openspec/specs/*-ui/spec.md` (перенесены из arm-web@de4e477), новые и - Требования к экранам: действующие — `../openspec/specs/*-ui/spec.md` (перенесены из arm-web@de4e477), новые и
изменённые — `../openspec/changes/` этого репозитория. изменённые — `../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); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
function buildQuery(params?: object): string { export function buildQuery(params?: object): string {
if (!params) return ''; if (!params) return '';
const search = new URLSearchParams(); const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {

View File

@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { queryString } from './qualityMetrics'; import { buildQuery } from '../../api/client';
describe('queryString', () => { describe('buildQuery', () => {
it('пропускает пустые поля, булево пишет как true/false', () => { it('пропускает неуказанные поля, булево пишет как true/false', () => {
expect(queryString({ date_from: '2026-01-01', pipeline: undefined, is_scan: false, format: 'csv' })).toBe( 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', '?date_from=2026-01-01&is_scan=false&format=csv',
); );
}); });
it('без параметров — пустая строка', () => { 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 { Page } from '../../api/types';
import type { import type {
ExportFormat, ExportFormat,
@ -30,16 +30,5 @@ export function getCellDocuments(
/** Выгрузка фактов за период; скачивание идёт через fetch, потому что нужен заголовок Authorization. */ /** Выгрузка фактов за период; скачивание идёт через fetch, потому что нужен заголовок Authorization. */
export function exportFacts(filters: QualityFilters, format: ExportFormat): Promise<{ blob: Blob; filename: string | null }> { export function exportFacts(filters: QualityFilters, format: ExportFormat): Promise<{ blob: Blob; filename: string | null }> {
return apiFetchBlob(`${BASE}/export${queryString({ ...filters, format })}`); return apiFetchBlob(`${BASE}/export${buildQuery({ ...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}` : '';
} }

View File

@ -2,8 +2,17 @@ import { Alert, Table } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getCellDocuments } from '../../services/api/qualityMetrics'; 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 { documentTypeLabel } from '../../utils/labels';
import { causeLabel, formatReportDate, outcomeLabel, sumFixes } from './qualityView';
const PAGE_SIZE = 20; 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 }} />} {error && <Alert type='error' message={error} showIcon style={{ marginBottom: 8 }} />}
<Table<QualityCellDocumentOut> <Table<QualityCellDocumentOut>
scroll={{ x: 'max-content' }}
rowKey='document_id' rowKey='document_id'
size='small' size='small'
loading={loading} loading={loading}
@ -62,8 +72,24 @@ export function DocumentDrillList({ filters, cell }: { filters: QualityFilters;
}, },
{ title: 'Юрлицо', dataIndex: 'legal_entity_name' }, { title: 'Юрлицо', dataIndex: 'legal_entity_name' },
{ title: 'ГК', dataIndex: 'group_name', render: (g: string | null) => g ?? '—' }, { title: 'ГК', dataIndex: 'group_name', render: (g: string | null) => g ?? '—' },
{ title: 'Отчётная дата', dataIndex: 'report_date', render: (v: string | null) => v ?? '—' }, { title: 'Отчётная дата', dataIndex: 'report_date', render: (v: string | null) => formatReportDate(v) },
{ title: 'Принят', dataIndex: 'received_at', render: (v: string) => v.slice(0, 10) }, { 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 ?? '—' }, { title: 'Подтвердил', dataIndex: 'confirmed_by_name', render: (v: string | null) => v ?? '—' },
]} ]}
/> />

View File

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

View File

@ -1,15 +1,17 @@
import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd'; import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { exportFacts, getMatrix } from '../../services/api/qualityMetrics'; 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 { 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 { saveBlob } from '../../utils/download';
import { DocumentDrillList } from './DocumentDrillList'; import { DocumentDrillList } from './DocumentDrillList';
import { MatrixTable } from './MatrixTable'; import { type MatrixColumnData, MatrixTable } from './MatrixTable';
import { MonthlyTrend } from './MonthlyTrend'; import { MonthlyTrend } from './MonthlyTrend';
import { formatShare } from './qualityView'; import { formatShare, sourceColumns } from './qualityView';
const DOC_TYPES: { value: DocumentType; label: string }[] = [ const DOC_TYPES: { value: DocumentType; label: string }[] = [
{ value: 'BALANCE', label: 'Бухгалтерский баланс' }, { value: 'BALANCE', label: 'Бухгалтерский баланс' },
@ -32,22 +34,42 @@ const iso = (d: Dayjs): string => d.format('YYYY-MM-DD');
/** Экран «Качество первички»: фильтры, матрица причин, технические отказы, динамика по месяцам, выгрузка. */ /** Экран «Качество первички»: фильтры, матрица причин, технические отказы, динамика по месяцам, выгрузка. */
export function QualityMetricsPage() { export function QualityMetricsPage() {
const [filters, setFilters] = useState<QualityFilters>({}); 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 [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | Error | null>(null); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(null); 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) => { .then((r) => {
if (!cancelled) setMatrix(r); if (!cancelled) setMatrices(r);
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
if (cancelled) return; if (cancelled) return;
setMatrix(null); setMatrices([]);
setError(e instanceof Error ? e : new Error('Не удалось загрузить метрики')); setError(e instanceof Error ? e : new Error('Не удалось загрузить метрики'));
}) })
.finally(() => { .finally(() => {
@ -61,11 +83,15 @@ export function QualityMetricsPage() {
const patch = (changes: Partial<QualityFilters>) => setFilters((f) => ({ ...f, ...changes })); const patch = (changes: Partial<QualityFilters>) => setFilters((f) => ({ ...f, ...changes }));
const download = async (format: ExportFormat) => { const download = async (format: ExportFormat) => {
if (exporting) return;
setExporting(format);
try { try {
const { blob, filename } = await exportFacts(filters, format); const { blob, filename } = await exportFacts(filters, format);
saveBlob(blob, filename ?? `quality-facts.${format}`); saveBlob(blob, filename ?? `quality-facts.${format}`);
} catch (e: unknown) { } catch (e: unknown) {
message.error(e instanceof Error ? e.message : 'Не удалось выгрузить данные'); 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 }) 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> <Select<DocumentType>
allowClear allowClear
aria-label='Тип документа'
placeholder='Тип документа' placeholder='Тип документа'
style={{ width: 200 }} style={{ width: 200 }}
options={DOC_TYPES} options={DOC_TYPES}
@ -93,6 +131,7 @@ export function QualityMetricsPage() {
/> />
<Select<RecognitionPipeline> <Select<RecognitionPipeline>
allowClear allowClear
aria-label='Источник'
placeholder='Источник' placeholder='Источник'
style={{ width: 200 }} style={{ width: 200 }}
options={PIPELINES} options={PIPELINES}
@ -100,13 +139,18 @@ export function QualityMetricsPage() {
/> />
<Select<string> <Select<string>
allowClear allowClear
aria-label='Скан или цифровой PDF'
placeholder='Скан / цифровой' placeholder='Скан / цифровой'
style={{ width: 170 }} style={{ width: 170 }}
options={SCAN_OPTIONS} options={SCAN_OPTIONS}
onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })} onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })}
/> />
<Button onClick={() => void download('csv')}>Выгрузить CSV</Button> <Button loading={exporting === 'csv'} disabled={exporting !== null} onClick={() => void download('csv')}>
<Button onClick={() => void download('json')}>Выгрузить JSON</Button> Выгрузить CSV
</Button>
<Button loading={exporting === 'json'} disabled={exporting !== null} onClick={() => void download('json')}>
Выгрузить JSON
</Button>
</Space> </Space>
{error && <Alert type='error' showIcon message={error.message} style={{ marginBottom: 16 }} />} {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 size='small' bordered column={{ xs: 1, md: 3 }} style={{ marginBottom: 16 }}>
<Descriptions.Item label='Подтверждённых документов'>{matrix.denominator}</Descriptions.Item> <Descriptions.Item label='Подтверждённых документов'>{matrix.denominator}</Descriptions.Item>
<Descriptions.Item label='Уникальных отчётов'>{matrix.unique_reports_count}</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} ( {matrix.technical_failures.failed_files_count} из {matrix.technical_failures.total_files_count} (
{formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)}) {formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)})
</Descriptions.Item> </Descriptions.Item>
@ -129,7 +173,7 @@ export function QualityMetricsPage() {
{ {
key: 'matrix', key: 'matrix',
label: 'Причины', 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} /> }, { key: 'trend', label: 'Динамика по месяцам', children: <MonthlyTrend filters={filters} /> },
]} ]}
@ -138,8 +182,8 @@ export function QualityMetricsPage() {
)} )}
</Spin> </Spin>
<Drawer title={drill?.label} width={900} open={drill !== null} onClose={() => setDrill(null)} destroyOnClose> <Drawer title={drill?.label} width={900} open={drill !== null} onClose={() => setDrill(null)} destroyOnHidden>
{drill && <DocumentDrillList filters={filters} cell={drill.cell} />} {drill && <DocumentDrillList filters={drillFilters} cell={drill.cell} />}
</Drawer> </Drawer>
</div> </div>
); );

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { QualityMatrixOut } from '../../types/qualityMetrics'; import type { QualityMatrixOut } from '../../types/qualityMetrics';
import { filterByPermission } from '../Sidebar/visibility'; import { filterByPermission } from '../Sidebar/visibility';
import { buildMatrixRows, formatShare, monthLabel } from './qualityView'; import { buildMatrixRows, buildSourceRows, formatReportDate, formatShare, monthLabel, sourceColumns, sumFixes } from './qualityView';
const matrixFixture: QualityMatrixOut = { const matrixFixture: QualityMatrixOut = {
counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 }, 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'); 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 — чтобы тестировать. // Чистые функции экрана «Качество первички»: подписи и строки матрицы. Отдельно от 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']; 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('-'); const [year, month] = iso.split('-');
return month && year ? `${month}.${year}` : iso; 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 };
});
}