Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44

Merged
aep merged 22 commits from feat/43-statements-v2 into test 2026-10-18 19:09:18 +03:00
6 changed files with 313 additions and 65 deletions
Showing only changes of commit 34d523afc7 - Show all commits

View File

@ -0,0 +1,20 @@
import { Tooltip, Typography } from 'antd';
import { codeCellView } from './cellView';
import type { GridRow } from './statementGrid';
/** Ячейка кода (просмотр): код, у нераспознанного — серый сырой текст; подсказка — по `codeCellView`. */
export function CodeCell({ row, fontWeight }: { row: GridRow; fontWeight?: number }) {
const view = codeCellView(row);
const content = view.muted ? (
<Typography.Text type='secondary'>{view.text}</Typography.Text>
) : (
<span style={{ fontWeight }}>{view.text}</span>
);
if (!view.tip) return content;
return (
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{view.tip}</span>}>
{/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */}
<span style={{ display: 'inline-block', minWidth: 16 }}>{content}</span>
</Tooltip>
);
}

View File

@ -11,6 +11,7 @@ import { SourcePreview } from './SourcePreview';
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError';
import { detailCount, filterLayout, formLayout } from './formLayout';
import { reviewActions } from './reviewView';
import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid';
@ -26,6 +27,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
const [showEmptySublines, setShowEmptySublines] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const load = useCallback(async () => {
try {
@ -43,10 +45,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set<string>()), [grid, draft]);
const shownRows = useMemo(() => {
if (!grid) return [];
return showEmptySublines ? grid.rows : grid.rows.filter((row) => !emptySublines.has(row.key));
}, [grid, showEmptySublines, emptySublines]);
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
const layout = useMemo(
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
[fullLayout, showDetails, showEmptySublines, emptySublines],
);
const details = grid ? detailCount(grid.rows) : 0;
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
@ -206,10 +210,17 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
</label>
</span>
)}
{details > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Switch id='show-details' size='small' checked={showDetails} onChange={setShowDetails} />
<label htmlFor='show-details' style={{ cursor: 'pointer' }}>
Показать расшифровки ({details})
</label>
</span>
)}
<StatementTable
documentType={statement.document.document_type}
columns={grid.columns}
rows={shownRows}
layout={layout}
editable={editing && actions.canSave}
draft={draft}
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}

View File

@ -1,51 +1,33 @@
import { Input, Table, Tooltip } from 'antd';
import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut, DocumentType, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money';
import { type LayoutRow, formLayout } from './formLayout';
import type { ColumnOut } from '../../types/api';
import { CodeCell } from './CodeCell';
import { ValueCell } from './ValueCell';
import { COLORS, rowBackground, valueBackground } from './cellView';
import type { LayoutRow } from './formLayout';
import { columnTitle } from './reviewView';
import { type GridRow, cellFlags, cellKey } from './statementGrid';
import { cellKey } from './statementGrid';
type Props = {
documentType: DocumentType;
columns: ColumnOut[];
rows: GridRow[];
layout: LayoutRow[];
editable: boolean;
draft: Record<string, string>;
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
};
const ATTENTION_BG = '#fff7e6';
const MODIFIED_BG = '#e6f4ff';
function cellBackground(value: ValueOut | undefined): string | undefined {
const flags = cellFlags(value);
if (flags.modified) {
return MODIFIED_BG;
}
return flags.attention ? ATTENTION_BG : undefined;
}
function cellTip(value: ValueOut): string | undefined {
if (value.is_modified) {
return `Исходное: ${formatAmount(value.original_amount, { fraction: 'auto', empty: '-' })}`;
}
return value.confidence ? `Уверенность: ${value.confidence}%` : undefined;
}
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
const SECTION_BG = '#f2f4f7';
const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание)
function lineWeight(r: LayoutRow): number | undefined {
if (r.kind !== 'line') return undefined;
if (r.grand) return 700;
return r.total ? 600 : undefined;
}
const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_BG : undefined);
const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== 'ok' && rowBackground(r.row) !== undefined;
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) {
const layout = formLayout(documentType, rows);
export function StatementTable({ columns, layout, editable, draft, onDraftChange }: Props) {
const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
@ -57,14 +39,19 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
...sectionSpan(r, true),
style:
r.kind === 'section'
? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 }
: { background: lineBackground(r) },
? { background: COLORS.section, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 }
: { background: labelBackground(r) },
}),
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return r.title;
const text = (
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}>
{r.displayName || '—'}
{r.secondaryName && (
<Typography.Text type='secondary' style={{ display: 'block', fontWeight: 400, fontSize: 12 }}>
{r.secondaryName}
</Typography.Text>
)}
</span>
);
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
@ -75,8 +62,8 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
title: 'Код',
width: 60,
align: 'center' as const,
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }),
render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? <span style={{ fontWeight: lineWeight(r) }}>{r.row.code}</span> : null),
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }),
render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? <CodeCell row={r.row} fontWeight={lineWeight(r)} /> : null),
},
...columns.map((col) => ({
key: col.id,
@ -86,34 +73,32 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
onCell: (r: LayoutRow) =>
r.kind === 'section'
? sectionSpan(r, false)
: { style: { background: cellBackground(r.row.cells[col.id]) ?? lineBackground(r), fontWeight: lineWeight(r) } },
: {
style: {
background: isUnresolved(r)
? rowBackground(r.row)
: (valueBackground(r.row.cells[col.id]) ?? (r.grand ? COLORS.grand : undefined)),
fontWeight: lineWeight(r),
},
},
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return null;
const row = r.row;
const value = row.cells[col.id];
if (!value) {
return null;
}
const key = cellKey(row.key, col.id);
const tip = cellTip(value);
if (!editable) {
return <Tooltip title={tip}>{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}</Tooltip>;
}
const text = draft[key] ?? value.amount ?? '';
const invalid = key in draft && !parseAmountInput(text).ok;
return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<Input
size='small'
status={invalid ? 'error' : undefined}
value={text}
onChange={(e) => onDraftChange(key, e.target.value, value.amount)}
style={{ textAlign: 'right' }}
/>
</Tooltip>
);
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 <Table<LayoutRow> size='small' bordered rowKey='key' columns={tableColumns} dataSource={layout} pagination={false} />;
return (
<Table<LayoutRow>
size='small'
bordered
rowKey='key'
columns={tableColumns}
dataSource={layout}
pagination={false}
onRow={(r) => (r.kind === 'line' ? { id: `row-${r.key}` } : {})}
/>
);
}

View File

@ -0,0 +1,42 @@
import { CheckOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import type { ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money';
import { cellTipLines } from './cellView';
type Props = {
value: ValueOut;
editable: boolean;
/** ключ ячейки в черновике и сам черновик (текст, пока аналитик правит) */
draftKey: string;
draft: Record<string, string>;
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
};
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: Props) {
const lines = cellTipLines(value);
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: '-' })}
</Tooltip>
);
}
const text = draft[draftKey] ?? value.amount ?? '';
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' }}
/>
</Tooltip>
);
}

View File

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import type { ValueOut } from '../../types/api';
import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground } from './cellView';
import type { GridRow } from './statementGrid';
const val = (extra: Partial<ValueOut> = {}): ValueOut => ({
id: 'v1',
column_id: 'c1',
column_index: 0,
amount: '1.00',
string_value: null,
text: null,
confidence: null,
is_recognized: true,
is_modified: false,
original_amount: null,
needs_review: false,
is_confirmed: false,
...extra,
});
const row = (extra: Partial<GridRow> = {}): GridRow => ({
key: 'l1',
lineId: 'l1',
code: '1240',
name: 'Финансовые вложения',
referenceName: null,
origin: 'source',
parentCode: null,
lineStatus: 'ok',
rowIndex: 1,
parentRowIndex: null,
codeText: '1240',
codeConfidence: '100',
needsReview: false,
isConfirmed: false,
codeModified: false,
cells: {},
...extra,
});
describe('cellTipLines', () => {
it('распознанный текст и уверенность', () => {
expect(cellTipLines(val({ text: '42831 1', confidence: '100', is_recognized: false }))).toEqual([
'Распознано: «42831 1»',
'Уверенность: 100%',
]);
});
it('исправленное: первой строкой исходное значение', () => {
const lines = cellTipLines(val({ is_modified: true, original_amount: '293709' }));
expect(lines[0]).toMatch(/^Исходное: /);
expect(lines[0]).toContain('293');
});
it('подтверждённое', () => {
expect(cellTipLines(val({ is_confirmed: true }))).toContain('Подтверждено аналитиком');
});
it('нет текста и уверенности — пусто', () => {
expect(cellTipLines(val({ text: null, confidence: null }))).toEqual([]);
});
it('расшифровка без needs_review тоже получает подсказку', () => {
expect(cellTipLines(val({ needs_review: false, text: '70045', confidence: '67' }))).toEqual([
'Распознано: «70045»',
'Уверенность: 67%',
]);
});
});
describe('codeCellView', () => {
it('ok: код и уверенность', () => {
expect(codeCellView(row({ codeConfidence: '50' }))).toEqual({ text: '1240', muted: false, tip: 'Уверенность кода: 50%' });
});
it('ok: уверенность 100 — без подсказки', () => {
expect(codeCellView(row()).tip).toBeUndefined();
});
it('ok: назначен аналитиком — уверенности нет', () => {
expect(codeCellView(row({ codeModified: true, codeConfidence: '50' })).tip).toBe('Код назначен аналитиком');
});
it('ok: подтверждён', () => {
expect(codeCellView(row({ isConfirmed: true })).tip).toBe('Код подтверждён');
});
it('unreadable', () => {
expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: 'ИЧ,250' }))).toEqual({
text: 'ИЧ,250',
muted: true,
tip: 'Код не распознан: «ИЧ,250»',
});
});
it('unreadable без текста — тире', () => {
expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: null })).text).toBe('—');
});
it('missing', () => {
expect(codeCellView(row({ lineStatus: 'missing', code: null, codeText: null }))).toEqual({
text: '—',
muted: true,
tip: 'Кода в строке нет',
});
});
it('detail', () => {
expect(codeCellView(row({ lineStatus: 'detail', code: null }))).toEqual({
text: '',
muted: true,
tip: 'Расшифровка — в суммы формы не входит',
});
});
});
describe('фон', () => {
it('needs_review — внимание', () => {
expect(valueBackground(val({ needs_review: true }))).toBe(COLORS.attention);
});
it('исправлено перекрывает внимание', () => {
expect(valueBackground(val({ needs_review: true, is_modified: true }))).toBe(COLORS.modified);
});
it('низкая уверенность без needs_review — без фона', () => {
expect(valueBackground(val({ confidence: '50' }))).toBeUndefined();
expect(valueBackground(undefined)).toBeUndefined();
});
it('строка unreadable/missing — красноватая', () => {
expect(rowBackground(row({ lineStatus: 'unreadable' }))).toBe(COLORS.unresolved);
expect(rowBackground(row({ lineStatus: 'missing' }))).toBe(COLORS.unresolved);
});
it('ok с needsReview — внимание; обычная — без фона', () => {
expect(rowBackground(row({ needsReview: true }))).toBe(COLORS.attention);
expect(rowBackground(row())).toBeUndefined();
});
});

View File

@ -0,0 +1,64 @@
// Вид ячеек и строк таблицы формы: цвета, подсказки, ячейка кода. Только показ, данные не меняются.
import type { IssueOut, ValueOut } from '../../types/api';
import { formatAmount } from '../../utils/money';
import { type GridRow, cellFlags } from './statementGrid';
export const COLORS = {
attention: '#fff7e6', // требует внимания (needs_review)
modified: '#e6f4ff', // исправлено аналитиком
unresolved: '#fff1f0', // строка без разобранного кода (unreadable / missing)
section: '#f2f4f7', // заголовок раздела
grand: '#f9f0ff', // «БАЛАНС»
} as const;
/** Фон ячейки: исправлено перекрывает «внимание»; сама по себе низкая уверенность не подсвечивается. */
export function valueBackground(value: ValueOut | undefined): string | undefined {
const flags = cellFlags(value);
if (flags.modified) return COLORS.modified;
return flags.attention ? COLORS.attention : undefined;
}
/** Фон строки: без кода — вся строка; ok с needsReview — ячейки наименования и кода (суммы красит valueBackground). */
export function rowBackground(row: GridRow): string | undefined {
if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') return COLORS.unresolved;
return row.lineStatus === 'ok' && row.needsReview ? COLORS.attention : undefined;
}
const percent = (confidence: string): string => `${Math.round(Number(confidence))}%`;
/** Строки подсказки ячейки суммы; подсказка есть и без подсветки (Р-23). */
export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[] {
const lines: string[] = [];
if (value.is_modified) {
lines.push(`Исходное: ${formatAmount(value.original_amount, { fraction: 'auto', empty: '-' })}`);
}
if (value.text !== null) lines.push(`Распознано: «${value.text}»`);
if (value.confidence !== null) lines.push(`Уверенность: ${percent(value.confidence)}`);
if (value.is_confirmed) lines.push('Подтверждено аналитиком');
for (const issue of issues) lines.push(issue.message);
return lines;
}
/** Ячейка кода (P6-16): текст, серый ли он, подсказка. */
export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined } {
switch (row.lineStatus) {
case 'unreadable': {
const raw = row.codeText?.trim() ?? '';
return { text: raw || '—', muted: true, tip: `Код не распознан: «${raw}»` };
}
case 'missing':
return { text: '—', muted: true, tip: 'Кода в строке нет' };
case 'detail':
return { text: '', muted: true, tip: 'Расшифровка — в суммы формы не входит' };
default: {
const tips: string[] = [];
if (row.codeModified) {
tips.push('Код назначен аналитиком');
} else if (row.codeConfidence !== null && Number(row.codeConfidence) < 100) {
tips.push(`Уверенность кода: ${percent(row.codeConfidence)}`);
}
if (row.isConfirmed) tips.push('Код подтверждён');
return { text: row.code ?? '', muted: false, tip: tips.length > 0 ? tips.join('\n') : undefined };
}
}
}