Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44
@ -13,10 +13,14 @@ export function SeverityIcon({ severity, style }: { severity: Severity; style?:
|
||||
);
|
||||
}
|
||||
|
||||
type Props = { items: IssueListItem[]; onShow?: (key: string) => void };
|
||||
type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?: (key: string) => void };
|
||||
|
||||
/** Список предупреждений документа; пустой — не показывается. Длинный список прокручивается, как список нарушений. */
|
||||
export function IssuesPanel({ items, onShow }: Props) {
|
||||
/**
|
||||
* Список предупреждений; пустой — не показывается. Длинный список прокручивается, как список нарушений.
|
||||
* `statement` — форма ББ/ОФР: место записи и ссылка «Показать»; `document` (ОСВ, P6-19) — простые пункты без места и ссылок.
|
||||
*/
|
||||
export function IssuesPanel({ items, mode, onShow }: Props) {
|
||||
const plain = mode === 'document';
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<Alert
|
||||
@ -28,8 +32,9 @@ export function IssuesPanel({ items, onShow }: Props) {
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<Tag color={item.severity === 'error' ? 'error' : 'processing'}>{severityLabel(item.severity)}</Tag>
|
||||
{item.message} ({item.place})
|
||||
{item.targetKey !== null && onShow && (
|
||||
{item.message}
|
||||
{!plain && ` (${item.place})`}
|
||||
{!plain && item.targetKey !== null && onShow && (
|
||||
<Button type='link' size='small' onClick={() => onShow(item.targetKey as string)}>
|
||||
Показать
|
||||
</Button>
|
||||
|
||||
@ -6,6 +6,7 @@ import { ApiError } from '../../api/client';
|
||||
import { confirmDocument, getAllEntries } from '../../services/api/documents';
|
||||
import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
import { IssuesPanel } from './IssuesPanel';
|
||||
import { confirmButtonStyle } from './ReviewCard';
|
||||
import { confirmErrorInfo } from './confirmError';
|
||||
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
|
||||
@ -116,6 +117,11 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
||||
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
|
||||
// колонки до сумм: Субсчёт, [Подразделение], аналитика, [ИНН] — под ними подпись «Итого по документу»
|
||||
const labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0);
|
||||
// предупреждения документа (P6-19): все записи issues, без привязки к строкам
|
||||
const issueItems = useMemo(
|
||||
() => document.issues.map((i) => ({ id: i.id, severity: i.severity, message: i.message, place: '', targetKey: null })),
|
||||
[document.issues],
|
||||
);
|
||||
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
|
||||
|
||||
const confirm = async () => {
|
||||
@ -165,6 +171,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
||||
/>
|
||||
<span style={{ marginLeft: 'auto', color: '#667085' }}>Строк: {rows.length}</span>
|
||||
</div>
|
||||
<IssuesPanel mode='document' items={issueItems} />
|
||||
{problem && (
|
||||
<Alert
|
||||
type='error'
|
||||
|
||||
@ -264,7 +264,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
onConfirmDate={() => setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))}
|
||||
/>
|
||||
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
|
||||
<IssuesPanel items={issueItems} onShow={focusRow} />
|
||||
<IssuesPanel mode='statement' items={issueItems} onShow={focusRow} />
|
||||
{confirmMessage && <Alert type='error' showIcon title={confirmMessage} description={confirmDescription} />}
|
||||
{emptySublines.size > 0 && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
|
||||
106
src/widgets/FilesDrawer/FileWarningsPopover.tsx
Normal file
106
src/widgets/FilesDrawer/FileWarningsPopover.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Popover, Spin, Tag } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { getDocument } from '../../services/api/documents';
|
||||
import type { IssueOut, Severity } from '../../types/api';
|
||||
import { severityLabel } from '../../utils/labels';
|
||||
import { SeverityIcon } from '../DocumentReview/IssuesPanel';
|
||||
import type { DocumentRow } from './documentRows';
|
||||
import { warningText } from './fileWarnings';
|
||||
|
||||
type IssuesState = { status: 'loading' } | { status: 'error' } | { status: 'done'; issues: IssueOut[] };
|
||||
|
||||
const tagColor = (s: Severity) => (s === 'error' ? 'error' : 'processing');
|
||||
|
||||
function DocumentIssues({ state }: { state: IssuesState | null }) {
|
||||
if (state === null || state.status === 'loading') return <Spin size='small' />;
|
||||
if (state.status === 'error') return <span style={{ color: '#cf1322' }}>Не удалось загрузить</span>;
|
||||
if (state.issues.length === 0) return <span style={{ color: '#8c8c8c' }}>Нет</span>;
|
||||
return (
|
||||
<>
|
||||
{state.issues.map((i) => (
|
||||
<div key={i.id}>
|
||||
<Tag color={tagColor(i.severity)}>{severityLabel(i.severity)}</Tag>
|
||||
{i.message}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Значок с числом предупреждений под плашкой статуса; по клику — подробности (файл, обработка, документ). */
|
||||
export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { count: number; level: Severity } | null }) {
|
||||
const [issues, setIssues] = useState<IssuesState | null>(null);
|
||||
|
||||
if (!badge) return null;
|
||||
|
||||
// предупреждения документа грузим один раз за открытие: при закрытии сбрасываем, повторное открытие запрашивает заново
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setIssues(null);
|
||||
return;
|
||||
}
|
||||
if (!row.documentId) return;
|
||||
setIssues({ status: 'loading' });
|
||||
getDocument(row.documentId)
|
||||
.then((d) => setIssues({ status: 'done', issues: d.issues }))
|
||||
.catch(() => setIssues({ status: 'error' }));
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div style={{ maxWidth: 420, maxHeight: 320, overflowY: 'auto' }}>
|
||||
{row.fileWarnings.length > 0 && (
|
||||
<Section title='Предупреждения файла'>
|
||||
{row.fileWarnings.map((w, i) => (
|
||||
<div key={`${w.code}-${i}`}>
|
||||
<Tag color={tagColor(w.severity)}>{severityLabel(w.severity)}</Tag>
|
||||
{warningText(w)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{row.mappingWarnings.length > 0 && (
|
||||
<Section title='Предупреждения обработки'>
|
||||
{row.mappingWarnings.map((w) => (
|
||||
<div key={w}>
|
||||
<Tag color={tagColor('info')}>{severityLabel('info')}</Tag>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{row.documentId && (
|
||||
<Section title='Предупреждения документа'>
|
||||
<DocumentIssues state={issues} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover trigger='click' title='Предупреждения' content={content} onOpenChange={onOpenChange}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'nowrap',
|
||||
color: badge.level === 'error' ? '#cf1322' : '#1677ff',
|
||||
}}>
|
||||
<SeverityIcon severity={badge.level} /> {badge.count}
|
||||
</button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,8 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { CheckResult, FileOut, FileType } from '../../types/api';
|
||||
import { type DocumentRow, type RowStatus, buildDocumentRows } from './documentRows';
|
||||
import { FileWarningsPopover } from './FileWarningsPopover';
|
||||
import { type DocumentRow, type RowStatus, buildDocumentRows, rowBadge } from './documentRows';
|
||||
|
||||
const STATUS_STYLE: Record<RowStatus, { color: string; background: string; border: string }> = {
|
||||
success: { color: '#237804', background: '#f6ffed', border: '#b7eb8f' },
|
||||
@ -94,7 +95,17 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
|
||||
</>
|
||||
) : null,
|
||||
},
|
||||
{ title: head('Статус'), key: 'status', width: 160, render: (_, r) => <StatusPill row={r} /> },
|
||||
{
|
||||
title: head('Статус'),
|
||||
key: 'status',
|
||||
width: 160,
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'center' }}>
|
||||
<StatusPill row={r} />
|
||||
<FileWarningsPopover row={r} badge={rowBadge(r)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DocumentBrief, FileOut } from '../../types/api';
|
||||
import { buildDocumentRows } from './documentRows';
|
||||
import { buildDocumentRows, rowBadge } from './documentRows';
|
||||
|
||||
const doc = (over: Partial<DocumentBrief> = {}): DocumentBrief => ({
|
||||
id: 'd1',
|
||||
@ -116,3 +116,61 @@ describe('buildDocumentRows', () => {
|
||||
expect(rows[1].subtitle.endsWith(' · вне консолидации')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const jsonFailed = { code: 'JSON_FAILED', severity: 'error' as const, message: 'm', locator: 'file:x_ofr.json' };
|
||||
const job = (over: Partial<NonNullable<FileOut['job']>> = {}): FileOut['job'] => ({
|
||||
id: 'j',
|
||||
status: 'DONE',
|
||||
error_code: null,
|
||||
error_message: null,
|
||||
mapping_error: null,
|
||||
warnings: [],
|
||||
mapping_warnings: [],
|
||||
finished_at: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('buildDocumentRows: предупреждения', () => {
|
||||
it('предупреждения файла попадают в обе строки файла', () => {
|
||||
const rows = buildDocumentRows([
|
||||
file({ job: job({ warnings: [jsonFailed], mapping_warnings: ['w'] }), documents: [doc({ id: 'a' }), doc({ id: 'b' })] }),
|
||||
]);
|
||||
expect(rows.map((r) => r.fileWarnings.length)).toEqual([1, 1]);
|
||||
expect(rows[0].mappingWarnings).toEqual(['w']);
|
||||
});
|
||||
|
||||
it('файл ERROR без документов', () => {
|
||||
const rows = buildDocumentRows([file({ status: 'ERROR', job: job({ warnings: [jsonFailed] }) })]);
|
||||
expect(rows[0].fileWarnings).toEqual([jsonFailed]);
|
||||
expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 });
|
||||
});
|
||||
|
||||
it('job null — пустые списки; старый формат строк не ломает', () => {
|
||||
expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] });
|
||||
const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]);
|
||||
expect(legacy[0].fileWarnings[0]).toMatchObject({ code: 'LEGACY_WARNING', message: 'старое' });
|
||||
});
|
||||
|
||||
it('issue_counts документа переносятся в issueCounts', () => {
|
||||
const rows = buildDocumentRows([file({ documents: [doc({ issue_counts: { error: 1, info: 3 } })] })]);
|
||||
expect(rows[0].issueCounts).toEqual({ error: 1, info: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowBadge', () => {
|
||||
const row = (fileWarnings: unknown[], counts: { error: number; info: number }) =>
|
||||
buildDocumentRows([file({ job: job({ warnings: fileWarnings as never }), documents: [doc({ issue_counts: counts })] })])[0];
|
||||
|
||||
it('только счётчики info', () => {
|
||||
expect(rowBadge(row([], { error: 0, info: 2 }))).toEqual({ count: 2, level: 'info' });
|
||||
});
|
||||
it('ошибка файла + info документа', () => {
|
||||
expect(rowBadge(row([jsonFailed], { error: 0, info: 1 }))).toEqual({ count: 2, level: 'error' });
|
||||
});
|
||||
it('error документа без файла', () => {
|
||||
expect(rowBadge(row([], { error: 1, info: 0 }))).toMatchObject({ level: 'error' });
|
||||
});
|
||||
it('нули — null', () => {
|
||||
expect(rowBadge(row([], { error: 0, info: 0 }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { CheckResult, DocumentStatus, FileOut, FileType } from '../../types/api';
|
||||
import type { CheckResult, DocumentStatus, FileOut, FileType, FileWarningOut, IssueCountsOut, Severity } from '../../types/api';
|
||||
import { formatMoscowDate } from '../../utils/dates';
|
||||
import { documentStatusLabel, documentTypeLabel, periodLabel } from '../../utils/labels';
|
||||
|
||||
import { toFileWarnings, warningsLevel } from './fileWarnings';
|
||||
|
||||
export type RowStatus = 'success' | 'warning' | 'processing' | 'error';
|
||||
|
||||
export interface DocumentRow {
|
||||
@ -18,6 +20,19 @@ export interface DocumentRow {
|
||||
statusLabel: string;
|
||||
errorReason: string | null;
|
||||
linkable: boolean;
|
||||
/** предупреждения разбора файла (общие для всех документов файла) */
|
||||
fileWarnings: FileWarningOut[];
|
||||
mappingWarnings: string[];
|
||||
/** счётчики issues документа; у строки файла без документов — нули */
|
||||
issueCounts: IssueCountsOut;
|
||||
}
|
||||
|
||||
/** Значок у статуса (P6-12): число предупреждений и цвет уровня; ничего нет — null. */
|
||||
export function rowBadge(row: DocumentRow): { count: number; level: Severity } | null {
|
||||
const count = row.fileWarnings.length + row.issueCounts.error + row.issueCounts.info;
|
||||
if (count === 0) return null;
|
||||
const level = warningsLevel(row.fileWarnings) === 'error' || row.issueCounts.error > 0 ? 'error' : 'info';
|
||||
return { count, level };
|
||||
}
|
||||
|
||||
const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']);
|
||||
@ -41,6 +56,8 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
||||
const extra = labelFor?.(f) ?? null;
|
||||
const name = f.original_name ?? 'без имени';
|
||||
const date = formatMoscowDate(f.uploaded_at);
|
||||
const fileWarnings = toFileWarnings(f.job?.warnings);
|
||||
const mappingWarnings = f.job?.mapping_warnings ?? [];
|
||||
if (f.documents.length === 0) {
|
||||
const failed = f.status === 'ERROR';
|
||||
return [
|
||||
@ -55,6 +72,9 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
||||
statusLabel: failed ? 'Ошибка' : 'Обработка',
|
||||
errorReason: errorReason(f),
|
||||
linkable: false,
|
||||
fileWarnings,
|
||||
mappingWarnings,
|
||||
issueCounts: { error: 0, info: 0 },
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -77,6 +97,9 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
||||
statusLabel: documentStatusLabel(d.status),
|
||||
errorReason: null,
|
||||
linkable: LINKABLE.has(d.document_type),
|
||||
fileWarnings,
|
||||
mappingWarnings,
|
||||
issueCounts: d.issue_counts,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
51
src/widgets/FilesDrawer/fileWarnings.test.ts
Normal file
51
src/widgets/FilesDrawer/fileWarnings.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toFileWarnings, warningText, warningsLevel } from './fileWarnings';
|
||||
|
||||
describe('toFileWarnings', () => {
|
||||
it('старый формат: строка становится LEGACY_WARNING', () => {
|
||||
expect(toFileWarnings(['sheet=ОСВ 60 строки без родителя'])).toEqual([
|
||||
{ code: 'LEGACY_WARNING', severity: 'info', message: 'sheet=ОСВ 60 строки без родителя', locator: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('смесь: мусор отбрасывается, объект без locator получает null', () => {
|
||||
const res = toFileWarnings([
|
||||
'a',
|
||||
{ code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' },
|
||||
5,
|
||||
{ code: 'X' },
|
||||
{ code: 'Y', severity: 'warn', message: 'z' },
|
||||
]);
|
||||
expect(res).toEqual([
|
||||
{ code: 'LEGACY_WARNING', severity: 'info', message: 'a', locator: null },
|
||||
{ code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('объект без locator — locator null', () => {
|
||||
expect(toFileWarnings([{ code: 'C', severity: 'info', message: 'm' }])[0].locator).toBeNull();
|
||||
});
|
||||
|
||||
it('не массив — пустой список', () => {
|
||||
expect(toFileWarnings('строка')).toEqual([]);
|
||||
expect(toFileWarnings(null)).toEqual([]);
|
||||
expect(toFileWarnings(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningsLevel', () => {
|
||||
it('error важнее info', () => {
|
||||
expect(warningsLevel([{ severity: 'info' }, { severity: 'error' }])).toBe('error');
|
||||
expect(warningsLevel([{ severity: 'info' }])).toBe('info');
|
||||
});
|
||||
it('пусто — null', () => {
|
||||
expect(warningsLevel([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningText', () => {
|
||||
it('с locator и без', () => {
|
||||
expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: 'file:x_ofr.json' })).toBe('m — file:x_ofr.json');
|
||||
expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: null })).toBe('m');
|
||||
});
|
||||
});
|
||||
34
src/widgets/FilesDrawer/fileWarnings.ts
Normal file
34
src/widgets/FilesDrawer/fileWarnings.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { FileWarningOut, Severity } from '../../types/api';
|
||||
|
||||
/**
|
||||
* Приводит `job.warnings` к структурам (P6-13): старые строки становятся `LEGACY_WARNING` уровня info,
|
||||
* записи без code/message/корректного severity и любые прочие значения отбрасываются.
|
||||
*/
|
||||
export function toFileWarnings(value: unknown): FileWarningOut[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const result: FileWarningOut[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
result.push({ code: 'LEGACY_WARNING', severity: 'info', message: item, locator: null });
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
const w = item as Record<string, unknown>;
|
||||
if (typeof w.code === 'string' && typeof w.message === 'string' && (w.severity === 'error' || w.severity === 'info')) {
|
||||
result.push({
|
||||
code: w.code,
|
||||
severity: w.severity,
|
||||
message: w.message,
|
||||
locator: typeof w.locator === 'string' ? w.locator : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Высший уровень списка: error, если есть хоть одно; пустой список — null. */
|
||||
export function warningsLevel(list: readonly { severity: Severity }[]): Severity | null {
|
||||
if (list.length === 0) return null;
|
||||
return list.some((w) => w.severity === 'error') ? 'error' : 'info';
|
||||
}
|
||||
|
||||
export const warningText = (w: FileWarningOut): string => (w.locator ? `${w.message} — ${w.locator}` : w.message);
|
||||
Loading…
x
Reference in New Issue
Block a user