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
4ae8643d78
commit
8ab116fdba
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user