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:
parent
34d523afc7
commit
4ae8643d78
@ -6,20 +6,33 @@ import { formatAmount } from '../../utils/money';
|
||||
export const failureText = (f: FailedCheckOut) =>
|
||||
`Колонка ${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, «сумма подстрок = родитель») и жёсткие в пределах округления;
|
||||
// подтверждение они не блокируют
|
||||
// длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран
|
||||
const failureList = (list: FailedCheckOut[]) => (
|
||||
const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) => (
|
||||
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
|
||||
{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>
|
||||
);
|
||||
|
||||
export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
|
||||
export function ChecksPanel({ checks, editable, dirty, onAccept, onShow }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@ -44,14 +57,14 @@ export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
|
||||
<Alert type='success' showIcon title='Проверки пройдены' />
|
||||
)}
|
||||
{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 && (
|
||||
<Alert
|
||||
type='warning'
|
||||
showIcon
|
||||
title={`Предупреждения проверок (${checks.soft_failed.length})`}
|
||||
description={failureList(checks.soft_failed)}
|
||||
description={failureList(checks.soft_failed, onShow)}
|
||||
/>
|
||||
)}
|
||||
{checks.cross_form && (
|
||||
|
||||
43
src/widgets/DocumentReview/IssuesPanel.tsx
Normal file
43
src/widgets/DocumentReview/IssuesPanel.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,20 @@
|
||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||||
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 { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
|
||||
import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api';
|
||||
import { parseAmountInput } from '../../utils/money';
|
||||
import { ChecksPanel, failureText } from './ChecksPanel';
|
||||
import { IssuesPanel } from './IssuesPanel';
|
||||
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
|
||||
import { SourcePreview } from './SourcePreview';
|
||||
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
|
||||
import { StatementTable } from './StatementTable';
|
||||
import { confirmErrorInfo } from './confirmError';
|
||||
import { switchesFor } from './focusTarget';
|
||||
import { detailCount, filterLayout, formLayout } from './formLayout';
|
||||
import { issueListItems, issuePlaces } from './issuePlaces';
|
||||
import { reviewActions } from './reviewView';
|
||||
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 [showEmptySublines, setShowEmptySublines] = 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 () => {
|
||||
try {
|
||||
@ -50,6 +56,28 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : 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 actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
|
||||
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
|
||||
@ -138,7 +166,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
if (loadError) {
|
||||
return <Alert type='error' showIcon title={loadError} action={<Button onClick={() => void load()}>Повторить</Button>} />;
|
||||
}
|
||||
if (!statement || !grid) {
|
||||
if (!statement || !grid || !places) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -189,7 +217,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
draft={header}
|
||||
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 && (
|
||||
<Alert
|
||||
type='error'
|
||||
@ -221,6 +250,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
<StatementTable
|
||||
columns={grid.columns}
|
||||
layout={layout}
|
||||
places={places}
|
||||
highlightKey={highlightKey}
|
||||
editable={editing && actions.canSave}
|
||||
draft={draft}
|
||||
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Table, Tooltip, Typography } from 'antd';
|
||||
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 { SeverityIcon } from './IssuesPanel';
|
||||
import { ValueCell } from './ValueCell';
|
||||
import { COLORS, rowBackground, valueBackground } from './cellView';
|
||||
import type { LayoutRow } from './formLayout';
|
||||
import { type IssuePlaces, worstSeverity } from './issuePlaces';
|
||||
import { columnTitle } from './reviewView';
|
||||
import { cellKey } from './statementGrid';
|
||||
|
||||
@ -14,6 +16,10 @@ type Props = {
|
||||
editable: boolean;
|
||||
draft: Record<string, string>;
|
||||
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
||||
/** предупреждения и нарушения у мест таблицы */
|
||||
places: IssuePlaces;
|
||||
/** строка, которую нужно подсветить рамкой после перехода «Показать» */
|
||||
highlightKey?: string | null;
|
||||
};
|
||||
|
||||
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
|
||||
@ -27,7 +33,23 @@ const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !==
|
||||
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
|
||||
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;
|
||||
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
|
||||
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) => {
|
||||
if (r.kind === 'section') return r.title;
|
||||
const rowIssues = places.byRow.get(r.key) ?? [];
|
||||
const rowWorst = worstSeverity(rowIssues);
|
||||
const text = (
|
||||
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}>
|
||||
{r.displayName || '—'}
|
||||
@ -54,7 +78,21 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
|
||||
)}
|
||||
</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) => ({
|
||||
key: col.id,
|
||||
title: columnTitle(col),
|
||||
title: <ColumnTitle col={col} issues={places.byColumn.get(col.id) ?? []} />,
|
||||
width: 112,
|
||||
align: 'right' as const,
|
||||
onCell: (r: LayoutRow) =>
|
||||
@ -86,7 +124,16 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
|
||||
const value = r.row.cells[col.id];
|
||||
if (!value) return null;
|
||||
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}
|
||||
dataSource={layout}
|
||||
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 }
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
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 { SeverityIcon } from './IssuesPanel';
|
||||
import { cellTipLines } from './cellView';
|
||||
import { worstSeverity } from './issuePlaces';
|
||||
|
||||
type Props = {
|
||||
value: ValueOut;
|
||||
@ -11,17 +13,25 @@ type Props = {
|
||||
draftKey: string;
|
||||
draft: Record<string, string>;
|
||||
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
||||
/** записи issues, привязанные к этой ячейке */
|
||||
issues?: IssueOut[];
|
||||
};
|
||||
|
||||
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
|
||||
export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: Props) {
|
||||
const lines = cellTipLines(value);
|
||||
export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [] }: Props) {
|
||||
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;
|
||||
if (!editable) {
|
||||
return (
|
||||
<Tooltip title={tip}>
|
||||
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c', marginRight: 4 }} />}
|
||||
{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}
|
||||
<span>
|
||||
{corner}
|
||||
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c', marginRight: 4 }} />}
|
||||
{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@ -29,14 +39,17 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: P
|
||||
const invalid = draftKey in draft && !parseAmountInput(text).ok;
|
||||
return (
|
||||
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
|
||||
<Input
|
||||
size='small'
|
||||
status={invalid ? 'error' : undefined}
|
||||
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
|
||||
value={text}
|
||||
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
|
||||
style={{ textAlign: 'right' }}
|
||||
/>
|
||||
<span>
|
||||
{corner}
|
||||
<Input
|
||||
size='small'
|
||||
status={invalid ? 'error' : undefined}
|
||||
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
|
||||
value={text}
|
||||
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
|
||||
style={{ textAlign: 'right' }}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/widgets/DocumentReview/focusTarget.test.ts
Normal file
24
src/widgets/DocumentReview/focusTarget.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
21
src/widgets/DocumentReview/focusTarget.ts
Normal file
21
src/widgets/DocumentReview/focusTarget.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
84
src/widgets/DocumentReview/issuePlaces.test.ts
Normal file
84
src/widgets/DocumentReview/issuePlaces.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
119
src/widgets/DocumentReview/issuePlaces.ts
Normal file
119
src/widgets/DocumentReview/issuePlaces.ts
Normal 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';
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user