feat(files): предупреждения файла структурами и предупреждения документа в панели файлов и у ОСВ (#43)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:45:27 +04:00
parent 5b11f2d00a
commit fdf9358b40
9 changed files with 305 additions and 10 deletions

View File

@ -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>

View File

@ -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'

View File

@ -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 }}>

View 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>
);
}

View File

@ -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) {

View File

@ -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();
});
});

View File

@ -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,
};
});
});

View 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');
});
});

View 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);