Единая нормализация ББ/ОФР: экран формы и карточка файла по 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
4 changed files with 123 additions and 44 deletions
Showing only changes of commit 8ab116fdba - Show all commits

View File

@ -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>
)}

View File

@ -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} />

View File

@ -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([]);
});
});

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';
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,
};
});
}