feat(statement): предупреждения и нарушения у строк, ячеек, колонок и в списке документа (#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:25:51 +04:00
parent 34d523afc7
commit 4ae8643d78
9 changed files with 427 additions and 28 deletions

View File

@ -6,20 +6,33 @@ import { formatAmount } from '../../utils/money';
export const failureText = (f: FailedCheckOut) => export const failureText = (f: FailedCheckOut) =>
`Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`; `Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`;
type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise<void> }; type Props = {
checks: ChecksOut;
editable: boolean;
dirty: boolean;
onAccept: (comment: string) => Promise<void>;
onShow?: (key: string) => void;
};
// soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления; // soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления;
// подтверждение они не блокируют // подтверждение они не блокируют
// длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран // длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран
const failureList = (list: FailedCheckOut[]) => ( const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) => (
<div style={{ maxHeight: 120, overflowY: 'auto' }}> <div style={{ maxHeight: 120, overflowY: 'auto' }}>
{list.map((f) => ( {list.map((f) => (
<div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div> <div key={`${f.column_index}-${f.relation}`}>
{failureText(f)}
{onShow && f.line_ids.length > 0 && (
<Button type='link' size='small' onClick={() => onShow(f.line_ids[0])}>
Показать
</Button>
)}
</div>
))} ))}
</div> </div>
); );
export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) { export function ChecksPanel({ checks, editable, dirty, onAccept, onShow }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -44,14 +57,14 @@ export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
<Alert type='success' showIcon title='Проверки пройдены' /> <Alert type='success' showIcon title='Проверки пройдены' />
)} )}
{checks.failed.length > 0 && ( {checks.failed.length > 0 && (
<Alert type='error' showIcon title={`Нарушения (${checks.failed.length})`} description={failureList(checks.failed)} /> <Alert type='error' showIcon title={`Нарушения (${checks.failed.length})`} description={failureList(checks.failed, onShow)} />
)} )}
{checks.soft_failed.length > 0 && ( {checks.soft_failed.length > 0 && (
<Alert <Alert
type='warning' type='warning'
showIcon showIcon
title={`Предупреждения проверок (${checks.soft_failed.length})`} title={`Предупреждения проверок (${checks.soft_failed.length})`}
description={failureList(checks.soft_failed)} description={failureList(checks.soft_failed, onShow)}
/> />
)} )}
{checks.cross_form && ( {checks.cross_form && (

View File

@ -0,0 +1,43 @@
import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Alert, Button, Tag } from 'antd';
import type { Severity } from '../../types/api';
import { severityLabel } from '../../utils/labels';
import type { IssueListItem } from './issuePlaces';
/** Значок уровня (P6-15): error — красный, info — синий. */
export function SeverityIcon({ severity, style }: { severity: Severity; style?: React.CSSProperties }) {
return severity === 'error' ? (
<ExclamationCircleOutlined style={{ color: '#cf1322', ...style }} />
) : (
<InfoCircleOutlined style={{ color: '#1677ff', ...style }} />
);
}
type Props = { items: IssueListItem[]; onShow?: (key: string) => void };
/** Список предупреждений документа; пустой — не показывается. Длинный список прокручивается, как список нарушений. */
export function IssuesPanel({ items, onShow }: Props) {
if (items.length === 0) return null;
return (
<Alert
type={items.some((i) => i.severity === 'error') ? 'error' : 'info'}
showIcon
title={`Предупреждения (${items.length})`}
description={
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
{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 && (
<Button type='link' size='small' onClick={() => onShow(item.targetKey as string)}>
Показать
</Button>
)}
</div>
))}
</div>
}
/>
);
}

View File

@ -1,17 +1,20 @@
import { CheckOutlined, EditOutlined } from '@ant-design/icons'; import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Splitter, Switch, message } from 'antd'; import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents'; import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api'; import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api';
import { parseAmountInput } from '../../utils/money'; import { parseAmountInput } from '../../utils/money';
import { ChecksPanel, failureText } from './ChecksPanel'; import { ChecksPanel, failureText } from './ChecksPanel';
import { IssuesPanel } from './IssuesPanel';
import { ReviewCard, confirmButtonStyle } from './ReviewCard'; import { ReviewCard, confirmButtonStyle } from './ReviewCard';
import { SourcePreview } from './SourcePreview'; import { SourcePreview } from './SourcePreview';
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock'; import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable'; import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError'; import { confirmErrorInfo } from './confirmError';
import { switchesFor } from './focusTarget';
import { detailCount, filterLayout, formLayout } from './formLayout'; import { detailCount, filterLayout, formLayout } from './formLayout';
import { issueListItems, issuePlaces } from './issuePlaces';
import { reviewActions } from './reviewView'; import { reviewActions } from './reviewView';
import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid'; import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid';
@ -28,6 +31,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const [confirmMessage, setConfirmMessage] = useState<string | null>(null); const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
const [showEmptySublines, setShowEmptySublines] = useState(false); const [showEmptySublines, setShowEmptySublines] = useState(false);
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
const [highlightKey, setHighlightKey] = useState<string | null>(null);
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
const highlightTimer = useRef<number | undefined>(undefined);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -50,6 +56,28 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }), () => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
[fullLayout, showDetails, showEmptySublines, emptySublines], [fullLayout, showDetails, showEmptySublines, emptySublines],
); );
const places = useMemo(() => (grid && statement ? issuePlaces(statement.issues, grid.rows, grid.columns) : null), [grid, statement]);
const issueItems = useMemo(
() => (grid && statement && places ? issueListItems(statement.issues, places, grid.rows, grid.columns) : []),
[grid, statement, places],
);
// «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM
const focusRow = (key: string) => {
if (!grid) return;
const need = switchesFor(key, grid.rows, { showDetails, showEmptySublines, emptySublines });
if (need.details) setShowDetails(true);
if (need.emptySublines) setShowEmptySublines(true);
setPendingFocus(key);
};
useEffect(() => {
if (pendingFocus === null) return;
window.document.getElementById(`row-${pendingFocus}`)?.scrollIntoView({ block: 'center' });
setHighlightKey(pendingFocus);
setPendingFocus(null);
window.clearTimeout(highlightTimer.current);
highlightTimer.current = window.setTimeout(() => setHighlightKey(null), 2000);
}, [pendingFocus]);
useEffect(() => () => window.clearTimeout(highlightTimer.current), []);
const details = grid ? detailCount(grid.rows) : 0; const details = grid ? detailCount(grid.rows) : 0;
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing); const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён // ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
@ -138,7 +166,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
if (loadError) { if (loadError) {
return <Alert type='error' showIcon title={loadError} action={<Button onClick={() => void load()}>Повторить</Button>} />; return <Alert type='error' showIcon title={loadError} action={<Button onClick={() => void load()}>Повторить</Button>} />;
} }
if (!statement || !grid) { if (!statement || !grid || !places) {
return null; return null;
} }
@ -189,7 +217,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
draft={header} draft={header}
onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))} onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))}
/> />
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} /> <ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
<IssuesPanel items={issueItems} onShow={focusRow} />
{confirmMessage && ( {confirmMessage && (
<Alert <Alert
type='error' type='error'
@ -221,6 +250,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
<StatementTable <StatementTable
columns={grid.columns} columns={grid.columns}
layout={layout} layout={layout}
places={places}
highlightKey={highlightKey}
editable={editing && actions.canSave} editable={editing && actions.canSave}
draft={draft} draft={draft}
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))} onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}

View File

@ -1,10 +1,12 @@
import { Table, Tooltip, Typography } from 'antd'; import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut } from '../../types/api'; import type { ColumnOut, IssueOut } from '../../types/api';
import { CodeCell } from './CodeCell'; import { CodeCell } from './CodeCell';
import { SeverityIcon } from './IssuesPanel';
import { ValueCell } from './ValueCell'; import { ValueCell } from './ValueCell';
import { COLORS, rowBackground, valueBackground } from './cellView'; import { COLORS, rowBackground, valueBackground } from './cellView';
import type { LayoutRow } from './formLayout'; import type { LayoutRow } from './formLayout';
import { type IssuePlaces, worstSeverity } from './issuePlaces';
import { columnTitle } from './reviewView'; import { columnTitle } from './reviewView';
import { cellKey } from './statementGrid'; import { cellKey } from './statementGrid';
@ -14,6 +16,10 @@ type Props = {
editable: boolean; editable: boolean;
draft: Record<string, string>; draft: Record<string, string>;
onDraftChange: (key: string, text: string, originalAmount: string | null) => void; onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
/** предупреждения и нарушения у мест таблицы */
places: IssuePlaces;
/** строка, которую нужно подсветить рамкой после перехода «Показать» */
highlightKey?: string | null;
}; };
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
@ -27,7 +33,23 @@ const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !==
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС») // наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined); const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
export function StatementTable({ columns, layout, editable, draft, onDraftChange }: Props) { function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) {
const worst = worstSeverity(issues);
if (!worst) return <>{columnTitle(col)}</>;
return (
<Tooltip
title={issues.map((i) => (
<div key={i.id}>{i.message}</div>
))}>
<span>
<SeverityIcon severity={worst} style={{ marginRight: 4 }} />
{columnTitle(col)}
</span>
</Tooltip>
);
}
export function StatementTable({ columns, layout, editable, draft, onDraftChange, places, highlightKey = null }: Props) {
const span = columns.length + 2; const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты // строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {}); const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
@ -44,6 +66,8 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
}), }),
render: (_: unknown, r: LayoutRow) => { render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return r.title; if (r.kind === 'section') return r.title;
const rowIssues = places.byRow.get(r.key) ?? [];
const rowWorst = worstSeverity(rowIssues);
const text = ( const text = (
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}> <span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}>
{r.displayName || '—'} {r.displayName || '—'}
@ -54,7 +78,21 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
)} )}
</span> </span>
); );
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text; const named = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
if (!rowWorst) return named;
return (
<>
<Tooltip
title={rowIssues.map((i) => (
<div key={i.id}>{i.message}</div>
))}>
<span>
<SeverityIcon severity={rowWorst} style={{ marginRight: 4 }} />
</span>
</Tooltip>
{named}
</>
);
}, },
}, },
{ {
@ -67,7 +105,7 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
}, },
...columns.map((col) => ({ ...columns.map((col) => ({
key: col.id, key: col.id,
title: columnTitle(col), title: <ColumnTitle col={col} issues={places.byColumn.get(col.id) ?? []} />,
width: 112, width: 112,
align: 'right' as const, align: 'right' as const,
onCell: (r: LayoutRow) => onCell: (r: LayoutRow) =>
@ -86,7 +124,16 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
const value = r.row.cells[col.id]; const value = r.row.cells[col.id];
if (!value) return null; if (!value) return null;
const draftKey = cellKey(r.row.key, col.id); const draftKey = cellKey(r.row.key, col.id);
return <ValueCell value={value} editable={editable} draftKey={draftKey} draft={draft} onDraftChange={onDraftChange} />; return (
<ValueCell
value={value}
editable={editable}
draftKey={draftKey}
draft={draft}
onDraftChange={onDraftChange}
issues={places.byCell.get(draftKey)}
/>
);
}, },
})), })),
]; ];
@ -98,7 +145,11 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
columns={tableColumns} columns={tableColumns}
dataSource={layout} dataSource={layout}
pagination={false} pagination={false}
onRow={(r) => (r.kind === 'line' ? { id: `row-${r.key}` } : {})} onRow={(r) =>
r.kind === 'line'
? { id: `row-${r.key}`, style: r.key === highlightKey ? { outline: '2px solid #1677ff', outlineOffset: -2 } : undefined }
: {}
}
/> />
); );
} }

View File

@ -1,8 +1,10 @@
import { CheckOutlined } from '@ant-design/icons'; import { CheckOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd'; import { Input, Tooltip } from 'antd';
import type { ValueOut } from '../../types/api'; import type { IssueOut, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money'; import { formatAmount, parseAmountInput } from '../../utils/money';
import { SeverityIcon } from './IssuesPanel';
import { cellTipLines } from './cellView'; import { cellTipLines } from './cellView';
import { worstSeverity } from './issuePlaces';
type Props = { type Props = {
value: ValueOut; value: ValueOut;
@ -11,17 +13,25 @@ type Props = {
draftKey: string; draftKey: string;
draft: Record<string, string>; draft: Record<string, string>;
onDraftChange: (key: string, text: string, originalAmount: string | null) => void; onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
/** записи issues, привязанные к этой ячейке */
issues?: IssueOut[];
}; };
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */ /** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: Props) { export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [] }: Props) {
const lines = cellTipLines(value); const lines = cellTipLines(value, issues);
const worst = worstSeverity(issues);
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы
const corner = worst && <SeverityIcon severity={worst} style={{ position: 'absolute', top: 1, left: 2, fontSize: 10 }} />;
const tip = lines.length > 0 ? lines.map((line, i) => <div key={`${i}-${line}`}>{line}</div>) : undefined; const tip = lines.length > 0 ? lines.map((line, i) => <div key={`${i}-${line}`}>{line}</div>) : undefined;
if (!editable) { if (!editable) {
return ( return (
<Tooltip title={tip}> <Tooltip title={tip}>
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c', marginRight: 4 }} />} <span>
{formatAmount(value.amount, { fraction: 'auto', empty: '-' })} {corner}
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c', marginRight: 4 }} />}
{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}
</span>
</Tooltip> </Tooltip>
); );
} }
@ -29,14 +39,17 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: P
const invalid = draftKey in draft && !parseAmountInput(text).ok; const invalid = draftKey in draft && !parseAmountInput(text).ok;
return ( return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}> <Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<Input <span>
size='small' {corner}
status={invalid ? 'error' : undefined} <Input
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined} size='small'
value={text} status={invalid ? 'error' : undefined}
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
style={{ textAlign: 'right' }} value={text}
/> onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
style={{ textAlign: 'right' }}
/>
</span>
</Tooltip> </Tooltip>
); );
} }

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import type { StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { switchesFor } from './focusTarget';
import { buildStatementGrid } from './statementGrid';
const { rows } = buildStatementGrid(statementV2Json as unknown as StatementOut);
const off = { showDetails: false, showEmptySublines: false, emptySublines: new Set<string>() };
describe('switchesFor', () => {
it('скрытая расшифровка — включить расшифровки', () => {
expect(switchesFor('l-d13', rows, off)).toEqual({ details: true, emptySublines: false });
expect(switchesFor('l-d13', rows, { ...off, showDetails: true })).toEqual({ details: false, emptySublines: false });
});
it('скрытая пустая подстрока — включить пустые строки', () => {
const state = { ...off, emptySublines: new Set(['l-1210']) };
expect(switchesFor('l-1210', rows, state)).toEqual({ details: false, emptySublines: true });
expect(switchesFor('l-1210', rows, { ...state, showEmptySublines: true })).toEqual({ details: false, emptySublines: false });
});
it('обычная и неизвестная строка — ничего', () => {
expect(switchesFor('l-1110', rows, off)).toEqual({ details: false, emptySublines: false });
expect(switchesFor('нет-такой', rows, off)).toEqual({ details: false, emptySublines: false });
});
});

View File

@ -0,0 +1,21 @@
// Что нужно включить, чтобы строку с ключом key можно было показать на экране (переход «Показать»).
import type { GridRow } from './statementGrid';
export interface FocusSwitches {
details: boolean;
emptySublines: boolean;
}
/** Какие переключатели нужно включить для строки key; уже включённые и неизвестный ключ — false. */
export function switchesFor(
key: string,
rows: GridRow[],
state: { showDetails: boolean; showEmptySublines: boolean; emptySublines: ReadonlySet<string> },
): FocusSwitches {
const row = rows.find((r) => r.key === key);
if (!row) return { details: false, emptySublines: false };
return {
details: row.lineStatus === 'detail' && !state.showDetails,
emptySublines: state.emptySublines.has(key) && !state.showEmptySublines,
};
}

View File

@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import type { IssueOut, StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { issueListItems, issuePlaces, worstSeverity } from './issuePlaces';
import { buildStatementGrid, cellKey } from './statementGrid';
const statement = statementV2Json as unknown as StatementOut;
const { rows, columns } = buildStatementGrid(statement);
const ids = (list: IssueOut[] | undefined) => (list ?? []).map((i) => i.id);
const issue = (patch: Partial<IssueOut>): IssueOut => ({
id: 'x',
kind: 'warning',
code: 'X',
severity: 'info',
message: 'm',
line_ids: [],
column_id: null,
column_index: null,
field: null,
source: 'service',
relation: null,
codes: [],
expected: null,
actual: null,
...patch,
});
describe('issuePlaces', () => {
it('привязка фикстуры', () => {
const p = issuePlaces(statement.issues, rows, columns);
expect(ids(p.byRow.get('l-1370a'))).toContain('i1');
expect(ids(p.byRow.get('l-1370b'))).toContain('i1');
for (const k of ['l-1200', 'l-1210', 'l-1230', 'l-1240']) expect(ids(p.byCell.get(cellKey(k, 'c1')))).toEqual(['i6']);
expect(ids(p.byColumn.get('c2'))).toEqual(['i2']);
expect(ids(p.byField.get('organization_name'))).toEqual(['i3']);
expect(ids(p.byField.get('report_date'))).toEqual(['i5']);
expect(ids(p.document)).toEqual(['i4']);
expect(ids(p.byRow.get('l-1230'))).toEqual(['i7']);
});
it('неизвестные строки и поля — у документа', () => {
const gone = issue({ id: 'g', line_ids: ['l-gone'] });
const mixed = issue({ id: 'm', line_ids: ['l-gone', 'l-1210'] });
const okpo = issue({ id: 'o', field: 'okpo' });
const p = issuePlaces([gone, mixed, okpo], rows, columns);
expect(ids(p.document)).toEqual(['g', 'o']);
expect(ids(p.byRow.get('l-1210'))).toEqual(['m']);
expect(p.byRow.size).toBe(1);
});
});
describe('issueListItems', () => {
const p = issuePlaces(statement.issues, rows, columns);
const items = issueListItems(statement.issues, p, rows, columns);
it('6 пунктов без relation, error первыми', () => {
expect(items).toHaveLength(6);
expect(items.slice(0, 2).map((i) => i.id)).toEqual(['i2', 'i5']);
expect(items.every((i) => i.id !== 'i6')).toBe(true);
});
it('места и цели', () => {
const by = (id: string) => items.find((i) => i.id === id);
expect(by('i4')).toMatchObject({ place: 'столбец 6 вне колонок формы', targetKey: null });
expect(by('i1')).toMatchObject({ place: 'строка 1370', targetKey: 'l-1370a' });
expect(by('i3')?.place).toBe('поле «Организация»');
expect(by('i2')?.place).toMatch(/^колонка «/);
});
it('расшифровка', () => {
const d = issue({ id: 'd', line_ids: ['l-d13'] });
const list = issueListItems([d], issuePlaces([d], rows, columns), rows, columns);
expect(list[0]).toMatchObject({ place: 'расшифровка «справочно»', targetKey: 'l-d13' });
});
it('строка без кода', () => {
const u = issue({ id: 'u', line_ids: ['l-u7'] });
const m = issue({ id: 'mm', line_ids: ['l-m8'] });
const list = issueListItems([u, m], issuePlaces([u, m], rows, columns), rows, columns);
expect(list.map((i) => i.place)).toEqual(['строка без кода «ИЧ,250»', 'строка без кода']);
});
});
describe('worstSeverity', () => {
it('уровни', () => {
expect(worstSeverity([issue({ severity: 'info' }), issue({ severity: 'error' })])).toBe('error');
expect(worstSeverity([])).toBeNull();
});
});

View File

@ -0,0 +1,119 @@
// Куда в интерфейсе положить каждую запись issues (P6-5): у строки, ячейки, колонки, поля шапки или у документа.
// Только показ: данные не меняются.
import type { ColumnOut, IssueOut, Severity } from '../../types/api';
import { columnTitle } from './reviewView';
import { type GridRow, cellKey } from './statementGrid';
export const HEADER_BLOCK_FIELDS = ['organization_name', 'inn', 'report_date', 'units'] as const;
export interface IssuePlaces {
byRow: Map<string, IssueOut[]>;
/** ключ — cellKey(rowKey, columnId) */
byCell: Map<string, IssueOut[]>;
byColumn: Map<string, IssueOut[]>;
byField: Map<string, IssueOut[]>;
document: IssueOut[];
}
export interface IssueListItem {
id: string;
severity: Severity;
message: string;
place: string;
targetKey: string | null;
}
const push = (map: Map<string, IssueOut[]>, key: string, issue: IssueOut) => {
const list = map.get(key);
if (list) list.push(issue);
else map.set(key, [issue]);
};
export function issuePlaces(
issues: IssueOut[],
rows: GridRow[],
columns: ColumnOut[],
fields: readonly string[] = HEADER_BLOCK_FIELDS,
): IssuePlaces {
const places: IssuePlaces = { byRow: new Map(), byCell: new Map(), byColumn: new Map(), byField: new Map(), document: [] };
const rowByLineId = new Map<string, GridRow>();
for (const r of rows) if (r.lineId !== null) rowByLineId.set(r.lineId, r);
const columnIds = new Set(columns.map((c) => c.id));
for (const issue of issues) {
if (issue.field !== null) {
if (fields.includes(issue.field)) push(places.byField, issue.field, issue);
else places.document.push(issue);
continue;
}
const known = issue.line_ids.map((id) => rowByLineId.get(id)).filter((r): r is GridRow => r !== undefined);
const columnId = issue.column_id !== null && columnIds.has(issue.column_id) ? issue.column_id : null;
if (issue.line_ids.length > 0 && known.length > 0) {
for (const r of known) {
if (columnId !== null) push(places.byCell, cellKey(r.key, columnId), issue);
else push(places.byRow, r.key, issue);
}
} else if (issue.line_ids.length === 0 && columnId !== null) {
push(places.byColumn, columnId, issue);
} else {
places.document.push(issue);
}
}
return places;
}
const FIELD_LABELS: Record<string, string> = {
organization_name: 'Организация',
inn: 'ИНН',
report_date: 'Дата отчёта',
units: 'Единица измерения',
};
const rowTitle = (row: GridRow): string => {
switch (row.lineStatus) {
case 'ok':
return `строка ${row.code ?? ''}`.trim();
case 'detail':
return `расшифровка «${row.name ?? ''}»`;
default: {
const text = row.codeText?.trim();
return text ? `строка без кода «${text}»` : 'строка без кода';
}
}
};
const SEVERITY_ORDER: Record<Severity, number> = { error: 0, info: 1 };
/** Список предупреждений документа: только kind = warning, сначала error, затем info, внутри — как пришло. */
export function issueListItems(issues: IssueOut[], places: IssuePlaces, rows: GridRow[], columns: ColumnOut[]): IssueListItem[] {
const rowByLineId = new Map<string, GridRow>();
for (const r of rows) if (r.lineId !== null) rowByLineId.set(r.lineId, r);
const columnById = new Map(columns.map((c) => [c.id, c]));
const inDocument = new Set(places.document);
const items = issues
.filter((issue) => issue.kind === 'warning')
.map((issue): IssueListItem => {
const first = issue.line_ids.map((id) => rowByLineId.get(id)).find((r) => r !== undefined);
const column = issue.column_id !== null ? columnById.get(issue.column_id) : undefined;
let place: string;
let targetKey: string | null = null;
if (inDocument.has(issue)) {
place =
issue.column_index !== null && issue.column_id === null ? `столбец ${issue.column_index + 1} вне колонок формы` : 'документ';
} else if (issue.field !== null) {
place = `поле «${FIELD_LABELS[issue.field] ?? issue.field}»`;
} else if (first) {
targetKey = first.key;
place = column ? `${rowTitle(first)}, колонка «${columnTitle(column)}»` : rowTitle(first);
} else {
place = column ? `колонка «${columnTitle(column)}»` : 'документ';
}
return { id: issue.id, severity: issue.severity, message: issue.message, place, targetKey };
});
// сортировка устойчивая: порядок issues внутри уровня сохраняется
return items.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
}
export function worstSeverity(issues: IssueOut[]): Severity | null {
if (issues.length === 0) return null;
return issues.some((i) => i.severity === 'error') ? 'error' : 'info';
}