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:28:41 +04:00
parent 4ae8643d78
commit 8ab116fdba
4 changed files with 123 additions and 44 deletions

View File

@ -1,6 +1,8 @@
import { Input, Tooltip } from 'antd'; import { Button, Input, Tooltip } from 'antd';
import type { DocumentType, StatementHeaderOut } from '../../types/api'; import type { DocumentType, IssueOut, StatementHeaderOut } from '../../types/api';
import { SeverityIcon } from './IssuesPanel';
import { type HeaderFieldKey, headerFields } from './headerFields'; import { type HeaderFieldKey, headerFields } from './headerFields';
import { worstSeverity } from './issuePlaces';
import { formTitle, formatReportDate } from './reviewView'; import { formTitle, formatReportDate } from './reviewView';
export type HeaderDraft = Partial<Record<HeaderFieldKey, string>>; export type HeaderDraft = Partial<Record<HeaderFieldKey, string>>;
@ -11,11 +13,20 @@ type Props = {
editing: boolean; editing: boolean;
draft: HeaderDraft; draft: HeaderDraft;
onDraftChange: (field: HeaderFieldKey, text: string) => void; onDraftChange: (field: HeaderFieldKey, text: string) => void;
/** предупреждения по полям шапки (IssuePlaces.byField) */
byField?: Map<string, IssueOut[]>;
/** «Подтвердить дату»; черновик подтверждения подключает T6 */
onConfirmDate?: () => void;
}; };
const ATTENTION_BG = '#fff7e6'; const ATTENTION_BG = '#fff7e6';
const MODIFIED_BG = '#e6f4ff'; const MODIFIED_BG = '#e6f4ff';
const PLACEHOLDER: Record<HeaderFieldKey, string> = { inn: 'ИНН', report_date: 'ГГГГ-ММ-ДД', units: 'тыс. руб.' }; const PLACEHOLDER: Record<HeaderFieldKey, string> = {
organization_name: 'Наименование организации',
inn: 'ИНН',
report_date: 'ГГГГ-ММ-ДД',
units: 'тыс. руб.',
};
const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' }; const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' };
function fieldBackground(modified: boolean, attention: boolean): string | undefined { function fieldBackground(modified: boolean, attention: boolean): string | undefined {
@ -28,29 +39,43 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41] * ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем. * (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
*/ */
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange }: Props) { export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, onConfirmDate }: Props) {
const reportDate = formatReportDate(header.report_date.value); const reportDate = formatReportDate(header.report_date.value);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<table style={{ alignSelf: 'flex-end', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ alignSelf: 'flex-end', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody> <tbody>
{headerFields(header).map((r) => ( {headerFields(header, byField).map((r) => (
<tr key={r.field}> <tr key={r.field}>
<td style={{ ...cell, color: '#475467' }}>{r.label}</td> <td style={{ ...cell, color: '#475467' }}>{r.label}</td>
<td <td
style={{ style={{
...cell, ...cell,
fontWeight: 600, fontWeight: 600,
minWidth: 160, minWidth: r.field === 'organization_name' ? 320 : 160,
background: fieldBackground(r.modified, r.attention), background: fieldBackground(r.modified, r.attention),
}}> }}>
{r.issues.length > 0 && (
<Tooltip title={r.issues.map((i) => i.message).join('; ')}>
<span>
<SeverityIcon severity={worstSeverity(r.issues) ?? 'info'} style={{ marginRight: 4 }} />
</span>
</Tooltip>
)}
{editing ? ( {editing ? (
<Input <>
size='small' <Input
placeholder={PLACEHOLDER[r.field]} size='small'
value={draft[r.field] ?? r.raw ?? ''} placeholder={PLACEHOLDER[r.field]}
onChange={(e) => onDraftChange(r.field, e.target.value)} value={draft[r.field] ?? r.raw ?? ''}
/> onChange={(e) => onDraftChange(r.field, e.target.value)}
/>
{r.canConfirm && onConfirmDate && (
<Button size='small' style={{ marginTop: 4 }} onClick={onConfirmDate}>
Подтвердить дату
</Button>
)}
</>
) : ( ) : (
<Tooltip title={r.tip}>{r.text}</Tooltip> <Tooltip title={r.tip}>{r.text}</Tooltip>
)} )}

View File

@ -99,7 +99,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
body.values = draftToPatch(values, grid?.rows ?? []); body.values = draftToPatch(values, grid?.rows ?? []);
} }
const headerPatch: HeaderDraft = {}; const headerPatch: HeaderDraft = {};
for (const field of ['inn', 'report_date', 'units'] as const) { for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) {
const text = header[field]?.trim(); const text = header[field]?.trim();
if (text) { if (text) {
headerPatch[field] = text; headerPatch[field] = text;
@ -215,6 +215,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
documentType={statement.document.document_type} documentType={statement.document.document_type}
editing={editing} editing={editing}
draft={header} draft={header}
byField={places.byField}
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} onShow={focusRow} /> <ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; import type { HeaderFieldOut, IssueOut, StatementHeaderOut, StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { headerFields } from './headerFields'; import { headerFields } from './headerFields';
const f = (value: string | null, extra: Partial<HeaderFieldOut> = {}): HeaderFieldOut => ({ const f = (value: string | null, extra: Partial<HeaderFieldOut> = {}): HeaderFieldOut => ({
@ -15,7 +16,11 @@ const header = (over: Partial<StatementHeaderOut> = {}) =>
inn: f('3528104476'), inn: f('3528104476'),
report_date: f('2024-12-31'), report_date: f('2024-12-31'),
units: f('тыс. руб.'), units: f('тыс. руб.'),
organization_name: 'мусор', organization_name: 'ООО Ромашка',
organization_name_modified: false,
okei: null,
needs_review_fields: [],
source: {},
okud: null, okud: null,
okpo: null, okpo: null,
okopf: null, okopf: null,
@ -24,24 +29,53 @@ const header = (over: Partial<StatementHeaderOut> = {}) =>
...over, ...over,
}) as StatementHeaderOut; }) as StatementHeaderOut;
describe('headerFields — шапка как в старом АРМ: только надёжно извлекаемые поля', () => { const fixtureHeader = (statementV2Json as unknown as StatementOut).header;
it('три поля по порядку, дата — dd.mm.yyyy', () => { const fixtureIssue = (statementV2Json as unknown as StatementOut).issues.find((i) => i.id === 'i3') as IssueOut;
expect(headerFields(header()).map((r) => [r.field, r.label, r.text])).toEqual([
['inn', 'ИНН', '3528104476'], describe('headerFields — шапка: организация, поля на проверку, подсказки источника', () => {
['report_date', 'Дата отчёта', '31.12.2024'], it('четыре поля по порядку', () => {
['units', 'Единица измерения', 'тыс. руб.'], const rows = headerFields(fixtureHeader);
expect(rows.map((r) => [r.field, r.label])).toEqual([
['organization_name', 'Организация'],
['inn', 'ИНН'],
['report_date', 'Дата отчёта'],
['units', 'Единица измерения'],
]); ]);
expect(rows[2].text).toBe('30.09.2025');
}); });
it('низкая уверенность — внимание; правка — исходное значение в подсказке; пусто — прочерк', () => { it('внимание только по needs_review_fields', () => {
const rows = headerFields( const rows = headerFields(fixtureHeader);
header({ expect(rows[2]).toMatchObject({ attention: true, canConfirm: true });
inn: f('312120035', { confidence: '61.54' }), expect(rows[0]).toMatchObject({ attention: false, canConfirm: false });
units: f('руб.', { modified: true, original_value: 'тыс. руб.' }), const low = headerFields(header({ inn: f('312120035', { confidence: '50' }) }));
report_date: f(null), expect(low[1].attention).toBe(false);
}), const empty = headerFields(header({ inn: f(null) }));
); expect(empty[1]).toMatchObject({ attention: false, text: '—' });
expect(rows[0]).toMatchObject({ attention: true, modified: false, tip: 'Уверенность: 61.54%' }); });
expect(rows[1]).toMatchObject({ text: '—', attention: true }); it('подтвердить можно только дату с известным значением', () => {
expect(rows[2]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' }); const rows = headerFields(header({ needs_review_fields: ['report_date'], report_date: f(null) }));
expect(rows[2].canConfirm).toBe(false);
});
it('подсказка источника', () => {
expect(headerFields(fixtureHeader)[0].tip).toBe('Распознано: «ООО «Ромашкa»» · уверенность 67%');
const h = header({ source: { organization_name: { text: 'ООО Р', confidence: '67.00' } } });
expect(headerFields(h)[0].tip).toBe('Распознано: «ООО Р» · уверенность 67%');
expect(headerFields(header())[0].tip).toBeUndefined();
});
it('исправленное поле — исходное в подсказке', () => {
const rows = headerFields(header({ units: f('руб.', { modified: true, original_value: 'тыс. руб.' }) }));
expect(rows[3]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' });
});
it('исправленная организация', () => {
const h = header({
organization_name_modified: true,
source: { organization_name: { text: 'ООО Р', confidence: '67.00' } },
});
expect(headerFields(h)[0]).toMatchObject({ modified: true, tip: 'Распознано: «ООО Р»' });
});
it('предупреждения поля', () => {
const rows = headerFields(fixtureHeader, new Map([['organization_name', [fixtureIssue]]]));
expect(rows[0].issues).toEqual([fixtureIssue]);
expect(rows[1].issues).toEqual([]);
}); });
}); });

View File

@ -1,7 +1,7 @@
import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; import type { HeaderFieldOut, IssueOut, StatementHeaderOut } from '../../types/api';
import { formatReportDate } from './reviewView'; import { formatReportDate } from './reviewView';
export type HeaderFieldKey = 'inn' | 'report_date' | 'units'; export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units';
export type HeaderFieldRow = { export type HeaderFieldRow = {
field: HeaderFieldKey; field: HeaderFieldKey;
@ -11,27 +11,46 @@ export type HeaderFieldRow = {
attention: boolean; attention: boolean;
modified: boolean; modified: boolean;
tip: string | undefined; tip: string | undefined;
issues: IssueOut[];
canConfirm: boolean;
}; };
const FIELDS: [HeaderFieldKey, string][] = [ const FIELDS: [HeaderFieldKey, string][] = [
['organization_name', 'Организация'],
['inn', 'ИНН'], ['inn', 'ИНН'],
['report_date', 'Дата отчёта'], ['report_date', 'Дата отчёта'],
['units', 'Единица измерения'], ['units', 'Единица измерения'],
]; ];
/** /**
* Шапка формы — только поля, которые распознавание извлекает надёжно (как в старом АРМ, PDFResultViewer): * Шапка формы: организация, ИНН, дата отчёта, единица измерения. «Требует внимания» — только поля из
* ИНН, дата отчёта, единица измерения. Пустое или с уверенностью < 100 — «требует внимания», правка — исходное в подсказке. * `needs_review_fields` (решает ядро), подсказка — что именно распознано в файле (`source`) или исходное до правки.
*/ */
export function headerFields(header: StatementHeaderOut): HeaderFieldRow[] { export function headerFields(header: StatementHeaderOut, byField?: Map<string, IssueOut[]>): HeaderFieldRow[] {
return FIELDS.map(([field, label]) => { return FIELDS.map(([field, label]) => {
const h: HeaderFieldOut = header[field]; const isOrg = field === 'organization_name';
const text = (field === 'report_date' ? formatReportDate(h.value) : h.value) ?? '—'; const h: HeaderFieldOut | null = isOrg ? null : header[field];
const lowConfidence = h.confidence !== null && h.confidence !== undefined && Number(h.confidence) < 100; const raw = isOrg ? header.organization_name : (h?.value ?? null);
const modified = h.modified === true; const text = (field === 'report_date' ? formatReportDate(raw) : raw) ?? '—';
const modified = isOrg ? header.organization_name_modified : h?.modified === true;
const attention = header.needs_review_fields.includes(field);
const src = header.source[field];
const recognized = src?.text != null ? `Распознано: «${src.text}»` : undefined;
let tip: string | undefined; let tip: string | undefined;
if (modified) tip = `Исходное: ${h.original_value ?? '—'}`; if (modified) tip = isOrg ? recognized : `Исходное: ${h?.original_value ?? '—'}`;
else if (lowConfidence) tip = `Уверенность: ${h.confidence}%`; else if (recognized) {
return { field, label, text, raw: h.value, attention: !modified && (h.value === null || lowConfidence), modified, tip }; tip = src?.confidence != null ? `${recognized} · уверенность ${Number(src.confidence)}%` : recognized;
}
return {
field,
label,
text,
raw,
attention,
modified,
tip,
issues: byField?.get(field) ?? [],
canConfirm: field === 'report_date' && attention && raw !== null,
};
}); });
} }