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 type { DocumentType, StatementHeaderOut } from '../../types/api';
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import type { DocumentType, IssueOut, StatementHeaderOut } from '../../types/api';
|
||||
import { SeverityIcon } from './IssuesPanel';
|
||||
import { type HeaderFieldKey, headerFields } from './headerFields';
|
||||
import { worstSeverity } from './issuePlaces';
|
||||
import { formTitle, formatReportDate } from './reviewView';
|
||||
|
||||
export type HeaderDraft = Partial<Record<HeaderFieldKey, string>>;
|
||||
@ -11,11 +13,20 @@ type Props = {
|
||||
editing: boolean;
|
||||
draft: HeaderDraft;
|
||||
onDraftChange: (field: HeaderFieldKey, text: string) => void;
|
||||
/** предупреждения по полям шапки (IssuePlaces.byField) */
|
||||
byField?: Map<string, IssueOut[]>;
|
||||
/** «Подтвердить дату»; черновик подтверждения подключает T6 */
|
||||
onConfirmDate?: () => void;
|
||||
};
|
||||
|
||||
const ATTENTION_BG = '#fff7e6';
|
||||
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' };
|
||||
|
||||
function fieldBackground(modified: boolean, attention: boolean): string | undefined {
|
||||
@ -28,29 +39,43 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi
|
||||
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
||||
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (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);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<table style={{ alignSelf: 'flex-end', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<tbody>
|
||||
{headerFields(header).map((r) => (
|
||||
{headerFields(header, byField).map((r) => (
|
||||
<tr key={r.field}>
|
||||
<td style={{ ...cell, color: '#475467' }}>{r.label}</td>
|
||||
<td
|
||||
style={{
|
||||
...cell,
|
||||
fontWeight: 600,
|
||||
minWidth: 160,
|
||||
minWidth: r.field === 'organization_name' ? 320 : 160,
|
||||
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 ? (
|
||||
<Input
|
||||
size='small'
|
||||
placeholder={PLACEHOLDER[r.field]}
|
||||
value={draft[r.field] ?? r.raw ?? ''}
|
||||
onChange={(e) => onDraftChange(r.field, e.target.value)}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
size='small'
|
||||
placeholder={PLACEHOLDER[r.field]}
|
||||
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>
|
||||
)}
|
||||
|
||||
@ -99,7 +99,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
body.values = draftToPatch(values, grid?.rows ?? []);
|
||||
}
|
||||
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();
|
||||
if (text) {
|
||||
headerPatch[field] = text;
|
||||
@ -215,6 +215,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
documentType={statement.document.document_type}
|
||||
editing={editing}
|
||||
draft={header}
|
||||
byField={places.byField}
|
||||
onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))}
|
||||
/>
|
||||
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
const f = (value: string | null, extra: Partial<HeaderFieldOut> = {}): HeaderFieldOut => ({
|
||||
@ -15,7 +16,11 @@ const header = (over: Partial<StatementHeaderOut> = {}) =>
|
||||
inn: f('3528104476'),
|
||||
report_date: f('2024-12-31'),
|
||||
units: f('тыс. руб.'),
|
||||
organization_name: 'мусор',
|
||||
organization_name: 'ООО Ромашка',
|
||||
organization_name_modified: false,
|
||||
okei: null,
|
||||
needs_review_fields: [],
|
||||
source: {},
|
||||
okud: null,
|
||||
okpo: null,
|
||||
okopf: null,
|
||||
@ -24,24 +29,53 @@ const header = (over: Partial<StatementHeaderOut> = {}) =>
|
||||
...over,
|
||||
}) as StatementHeaderOut;
|
||||
|
||||
describe('headerFields — шапка как в старом АРМ: только надёжно извлекаемые поля', () => {
|
||||
it('три поля по порядку, дата — dd.mm.yyyy', () => {
|
||||
expect(headerFields(header()).map((r) => [r.field, r.label, r.text])).toEqual([
|
||||
['inn', 'ИНН', '3528104476'],
|
||||
['report_date', 'Дата отчёта', '31.12.2024'],
|
||||
['units', 'Единица измерения', 'тыс. руб.'],
|
||||
const fixtureHeader = (statementV2Json as unknown as StatementOut).header;
|
||||
const fixtureIssue = (statementV2Json as unknown as StatementOut).issues.find((i) => i.id === 'i3') as IssueOut;
|
||||
|
||||
describe('headerFields — шапка: организация, поля на проверку, подсказки источника', () => {
|
||||
it('четыре поля по порядку', () => {
|
||||
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('низкая уверенность — внимание; правка — исходное значение в подсказке; пусто — прочерк', () => {
|
||||
const rows = headerFields(
|
||||
header({
|
||||
inn: f('312120035', { confidence: '61.54' }),
|
||||
units: f('руб.', { modified: true, original_value: 'тыс. руб.' }),
|
||||
report_date: f(null),
|
||||
}),
|
||||
);
|
||||
expect(rows[0]).toMatchObject({ attention: true, modified: false, tip: 'Уверенность: 61.54%' });
|
||||
expect(rows[1]).toMatchObject({ text: '—', attention: true });
|
||||
expect(rows[2]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' });
|
||||
it('внимание только по needs_review_fields', () => {
|
||||
const rows = headerFields(fixtureHeader);
|
||||
expect(rows[2]).toMatchObject({ attention: true, canConfirm: true });
|
||||
expect(rows[0]).toMatchObject({ attention: false, canConfirm: false });
|
||||
const low = headerFields(header({ inn: f('312120035', { confidence: '50' }) }));
|
||||
expect(low[1].attention).toBe(false);
|
||||
const empty = headerFields(header({ inn: f(null) }));
|
||||
expect(empty[1]).toMatchObject({ attention: false, text: '—' });
|
||||
});
|
||||
it('подтвердить можно только дату с известным значением', () => {
|
||||
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';
|
||||
|
||||
export type HeaderFieldKey = 'inn' | 'report_date' | 'units';
|
||||
export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units';
|
||||
|
||||
export type HeaderFieldRow = {
|
||||
field: HeaderFieldKey;
|
||||
@ -11,27 +11,46 @@ export type HeaderFieldRow = {
|
||||
attention: boolean;
|
||||
modified: boolean;
|
||||
tip: string | undefined;
|
||||
issues: IssueOut[];
|
||||
canConfirm: boolean;
|
||||
};
|
||||
|
||||
const FIELDS: [HeaderFieldKey, string][] = [
|
||||
['organization_name', 'Организация'],
|
||||
['inn', 'ИНН'],
|
||||
['report_date', 'Дата отчёта'],
|
||||
['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]) => {
|
||||
const h: HeaderFieldOut = header[field];
|
||||
const text = (field === 'report_date' ? formatReportDate(h.value) : h.value) ?? '—';
|
||||
const lowConfidence = h.confidence !== null && h.confidence !== undefined && Number(h.confidence) < 100;
|
||||
const modified = h.modified === true;
|
||||
const isOrg = field === 'organization_name';
|
||||
const h: HeaderFieldOut | null = isOrg ? null : header[field];
|
||||
const raw = isOrg ? header.organization_name : (h?.value ?? null);
|
||||
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;
|
||||
if (modified) tip = `Исходное: ${h.original_value ?? '—'}`;
|
||||
else if (lowConfidence) tip = `Уверенность: ${h.confidence}%`;
|
||||
return { field, label, text, raw: h.value, attention: !modified && (h.value === null || lowConfidence), modified, tip };
|
||||
if (modified) tip = isOrg ? recognized : `Исходное: ${h?.original_value ?? '—'}`;
|
||||
else if (recognized) {
|
||||
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