fix(#190): доработки экрана качества по ревью Task 16
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
717dec69cf
commit
7ded34fec5
@ -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/` этого репозитория.
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 })}`);
|
||||
}
|
||||
|
||||
@ -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 ?? '—' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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('—');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user