diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 8309ab3..217c090 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -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/` этого репозитория. diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f4d6c15..c13ec2c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -28,7 +28,7 @@ function isRecord(value: unknown): value is Record { 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)) { diff --git a/web/src/services/api/qualityMetrics.test.ts b/web/src/services/api/qualityMetrics.test.ts index 361f439..034a8ca 100644 --- a/web/src/services/api/qualityMetrics.test.ts +++ b/web/src/services/api/qualityMetrics.test.ts @@ -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(''); }); }); diff --git a/web/src/services/api/qualityMetrics.ts b/web/src/services/api/qualityMetrics.ts index f9d2f37..033b456 100644 --- a/web/src/services/api/qualityMetrics.ts +++ b/web/src/services/api/qualityMetrics.ts @@ -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 })}`); } diff --git a/web/src/widgets/QualityMetrics/DocumentDrillList.tsx b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx index dc7676e..ac13de1 100644 --- a/web/src/widgets/QualityMetrics/DocumentDrillList.tsx +++ b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx @@ -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 && } + 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 ?? '—' }, ]} /> diff --git a/web/src/widgets/QualityMetrics/MatrixTable.tsx b/web/src/widgets/QualityMetrics/MatrixTable.tsx index 9ab35c3..d8dcbfb 100644 --- a/web/src/widgets/QualityMetrics/MatrixTable.tsx +++ b/web/src/widgets/QualityMetrics/MatrixTable.tsx @@ -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 = [ - { - title: 'Причина', - dataIndex: 'label', - render: (label: string, row) => ( -
-
{label}
- {row.note &&
{row.note}
} -
- ), - }, - { - title: 'Документов', - dataIndex: 'count', - align: 'right', - width: 140, - render: (count: number, row) => - onOpenCell && count > 0 ? ( - - ) : ( - 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 = [ + { 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 ( +
+ {onOpenCell && cell.count > 0 ? ( + + ) : ( + cell.count + )} + {cell.note &&
{cell.note}
} +
+ ); + }, + }, + { + title: 'Доля', + key: `${column.key}-share`, + align: 'right' as const, + render: (_: unknown, row: SourceRow) => row.cells[column.key]?.share ?? '—', + }, + ], + })), ]; - return rowKey='key' columns={columns} dataSource={buildMatrixRows(data)} pagination={false} size='small' />; + return rowKey='key' columns={tableColumns} dataSource={rows} pagination={false} size='small' bordered />; } diff --git a/web/src/widgets/QualityMetrics/MonthlyTrend.tsx b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx index 3492d22..16e0325 100644 --- a/web/src/widgets/QualityMetrics/MonthlyTrend.tsx +++ b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx @@ -33,6 +33,7 @@ export function MonthlyTrend({ filters }: { filters: QualityFilters }) { return ( + aria-label='Причина' value={cause} onChange={setCause} style={{ width: 360 }} diff --git a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx index 72619b2..e8b6e05 100644 --- a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx +++ b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx @@ -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({}); - const [matrix, setMatrix] = useState(null); + // матрицы по колонкам источника; «Всего» (или единственная) — последняя, из неё берём итоги над таблицей + const [matrices, setMatrices] = useState([]); + const [exporting, setExporting] = useState(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(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(() => ({ ...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) => 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 }) } /> + + 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 })} + /> allowClear + aria-label='Тип документа' placeholder='Тип документа' style={{ width: 200 }} options={DOC_TYPES} @@ -93,6 +131,7 @@ export function QualityMetricsPage() { /> allowClear + aria-label='Источник' placeholder='Источник' style={{ width: 200 }} options={PIPELINES} @@ -100,13 +139,18 @@ export function QualityMetricsPage() { /> allowClear + aria-label='Скан или цифровой PDF' placeholder='Скан / цифровой' style={{ width: 170 }} options={SCAN_OPTIONS} onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })} /> - - + + {error && } @@ -119,7 +163,7 @@ export function QualityMetricsPage() { {matrix.denominator} {matrix.unique_reports_count} - + {matrix.technical_failures.failed_files_count} из {matrix.technical_failures.total_files_count} ( {formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)}) @@ -129,7 +173,7 @@ export function QualityMetricsPage() { { key: 'matrix', label: 'Причины', - children: setDrill({ cell, label })} />, + children: setDrill({ cell, label, pipeline })} />, }, { key: 'trend', label: 'Динамика по месяцам', children: }, ]} @@ -138,8 +182,8 @@ export function QualityMetricsPage() { )} - setDrill(null)} destroyOnClose> - {drill && } + setDrill(null)} destroyOnHidden> + {drill && } ); diff --git a/web/src/widgets/QualityMetrics/qualityView.test.ts b/web/src/widgets/QualityMetrics/qualityView.test.ts index c7f8cc4..7c9c2d3 100644 --- a/web/src/widgets/QualityMetrics/qualityView.test.ts +++ b/web/src/widgets/QualityMetrics/qualityView.test.ts @@ -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('—'); + }); +}); diff --git a/web/src/widgets/QualityMetrics/qualityView.ts b/web/src/widgets/QualityMetrics/qualityView.ts index 73feae8..d480d30 100644 --- a/web/src/widgets/QualityMetrics/qualityView.ts +++ b/web/src/widgets/QualityMetrics/qualityView.ts @@ -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 = { + 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; +} + +/** Строки матрицы «причина × источник»: подписи берём из первой матрицы, счётчики — по каждой колонке. */ +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 = {}; + 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 }; + }); +}