feat: шапка формы ББ/ОФР как в старом АРМ — только ИНН / Дата отчёта / Единица измерения с подсветкой уверенности (H-5, #3)

Макет [41] рисует бумажную шапку с ОКПО/ОКОПФ/ОКФС/адресом, которых распознавание не отдаёт
(arm-scan-gateway#15) — вместо пустых рамок показываем то, что извлекается надёжно.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 10:49:17 +04:00
parent 9d4fc20c7f
commit 34ad6ef081
6 changed files with 136 additions and 107 deletions

View File

@ -151,3 +151,8 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд
подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина: подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина:
сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются; сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются;
при появлении порядка строк в API (issue arm-core) раскладку можно упростить. при появлении порядка строк в API (issue arm-core) раскладку можно упростить.
- 08.10.2026 — R-14 (по замечанию владельца H-5): шапка формы [41] — как в старом АРМ (`reference-old-arm`
`PDFResultViewer`): компактная таблица ИНН / Дата отчёта / Единица измерения (подсветка уверенности и правки, правка в
режиме «Редактировать»), затем «Бухгалтерский баланс на …». Реквизиты бумажной шапки макета (ОКПО, ОКОПФ, ОКФС, адрес,
название организации) не показываются: распознавание их не отдаёт или отдаёт мусор (arm-scan-gateway#15). Заменяет
шапку из R-13. Цена ошибки: низкая — один компонент `StatementHeaderBlock`.

View File

@ -1,100 +1,68 @@
import { Input, Tooltip, Typography } from 'antd'; import { Input, Tooltip } from 'antd';
import type { ReactNode } from 'react';
import type { DocumentType, StatementHeaderOut } from '../../types/api'; import type { DocumentType, StatementHeaderOut } from '../../types/api';
import { okudCode, reportingPeriodTitle } from './formLayout'; import { type HeaderFieldKey, headerFields } from './headerFields';
import { formTitle, formatReportDate } from './reviewView'; import { formTitle, formatReportDate } from './reviewView';
export type HeaderDraft = { inn?: string; report_date?: string; units?: string }; export type HeaderDraft = Partial<Record<HeaderFieldKey, string>>;
type HeaderField = keyof HeaderDraft;
type Props = { type Props = {
header: StatementHeaderOut; header: StatementHeaderOut;
documentType: DocumentType; documentType: DocumentType;
editing: boolean; editing: boolean;
draft: HeaderDraft; draft: HeaderDraft;
onDraftChange: (field: HeaderField, text: string) => void; onDraftChange: (field: HeaderFieldKey, text: string) => void;
}; };
const rowStyle = { const ATTENTION_BG = '#fff7e6';
display: 'flex', const MODIFIED_BG = '#e6f4ff';
justifyContent: 'space-between', const PLACEHOLDER: Record<HeaderFieldKey, string> = { inn: 'ИНН', report_date: 'ГГГГ-ММ-ДД', units: 'тыс. руб.' };
alignItems: 'center', const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' };
gap: 16,
padding: '6px 0',
borderBottom: '1px solid #eaecf0',
};
const valueStyle = { fontWeight: 600, textAlign: 'right' as const };
function Row({ label, children }: { label: string; children: ReactNode }) { function fieldBackground(modified: boolean, attention: boolean): string | undefined {
return ( if (modified) return MODIFIED_BG;
<div style={rowStyle}> return attention ? ATTENTION_BG : undefined;
<span>{label}</span>
<span style={valueStyle}>{children}</span>
</div>
);
} }
const boxCell = { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '4px 8px' }; /**
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица ИНН / Дата отчёта / Единица измерения —
/** Шапка формы «как бумажная» (макет [41]): реквизиты слева, коды в рамке справа, затем заголовок формы и её реквизиты. */ * ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
export function StatementHeaderBlock({ header: h, documentType, editing, draft, onDraftChange }: Props) { * (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
const input = (field: HeaderField, placeholder: string, current: string | null, width = 200) => ( */
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange }: 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) => (
<tr key={r.field}>
<td style={{ ...cell, color: '#475467' }}>{r.label}</td>
<td
style={{
...cell,
fontWeight: 600,
minWidth: 160,
background: fieldBackground(r.modified, r.attention),
}}>
{editing ? (
<Input <Input
size='small' size='small'
placeholder={placeholder} placeholder={PLACEHOLDER[r.field]}
value={draft[field] ?? current ?? ''} value={draft[r.field] ?? r.raw ?? ''}
onChange={(e) => onDraftChange(field, e.target.value)} onChange={(e) => onDraftChange(r.field, e.target.value)}
style={{ width, textAlign: 'right' }}
/> />
);
const reportDate = formatReportDate(h.report_date.value);
const period = reportingPeriodTitle(h.report_date.value);
// ИНН — только в рамке кодов, как в макете; поле правки — там же
const codes: [string, ReactNode][] = [
['по ОКПО', h.okpo ?? '—'],
['ИНН', editing ? input('inn', 'ИНН', h.inn.value, 130) : (h.inn.value ?? '—')],
['по ОКОПФ', h.okopf ?? '—'],
['по ОКФС', h.okfs ?? '—'],
];
const organization = h.organization_name ? (
// мусорный OCR-текст не должен съедать экран: две строки, полный текст — в подсказке
<Tooltip title={h.organization_name}>
<Typography.Paragraph ellipsis={{ rows: 2 }} style={{ margin: 0, fontWeight: 600, textAlign: 'right' }}>
{h.organization_name}
</Typography.Paragraph>
</Tooltip>
) : ( ) : (
'—' <Tooltip title={r.tip}>{r.text}</Tooltip>
); )}
return ( </td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> </tr>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 18, fontWeight: 600 }}>Бухгалтерская отчётность</div>
{period && <div style={{ fontWeight: 500 }}>{period}</div>}
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<Row label='Организация'>{organization}</Row>
{h.address && <Row label='Адрес в пределах места нахождения'>{h.address}</Row>}
</div>
<div style={{ flex: '0 0 auto', border: '1px solid #667085', minWidth: 190 }}>
{codes.map(([label, value], i) => (
<div key={label} style={{ ...boxCell, borderTop: i > 0 ? '1px solid #667085' : undefined }}>
<span>{label}</span>
<span style={{ fontWeight: 600 }}>{value}</span>
</div>
))} ))}
</div> </tbody>
</div> </table>
<div style={{ textAlign: 'center', marginTop: 8 }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 18, fontWeight: 600 }}>{formTitle(documentType)}</div> <div style={{ fontSize: 18, fontWeight: 600 }}>{formTitle(documentType)}</div>
{reportDate && <div style={{ fontWeight: 500 }}>на {reportDate}</div>} {reportDate && <div style={{ fontWeight: 500 }}>на {reportDate}</div>}
</div> </div>
<div>
<Row label='Форма по ОКУД'>{okudCode(documentType, h.okud) ?? '—'}</Row>
<Row label='Отчётная дата'>{editing ? input('report_date', 'ГГГГ-ММ-ДД', h.report_date.value) : (reportDate ?? '—')}</Row>
<Row label='Единица измерения'>{editing ? input('units', 'тыс. руб.', h.units.value) : (h.units.value ?? '—')}</Row>
</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formLayout, okudCode, reportingPeriodTitle } from './formLayout'; import { formLayout } from './formLayout';
import type { GridRow } from './statementGrid'; import type { GridRow } from './statementGrid';
const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({ const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({
@ -85,16 +85,3 @@ describe('formLayout — ОФР, справочные строки', () => {
).toEqual(['2410', '2400 (итог)', '2900', '2910']); ).toEqual(['2410', '2400 (итог)', '2900', '2910']);
}); });
}); });
describe('реквизиты формы', () => {
it('ОКУД по типу формы, если не распознан', () => {
expect(okudCode('BALANCE', null)).toBe('0710001');
expect(okudCode('INCOME', null)).toBe('0710002');
expect(okudCode('BALANCE', '0710099')).toBe('0710099');
});
it('заголовок отчётности за период по дате отчёта', () => {
expect(reportingPeriodTitle('2025-09-30')).toBe('за январь – сентябрь 2025 г.');
expect(reportingPeriodTitle('2024-12-31')).toBe('за январь – декабрь 2024 г.');
expect(reportingPeriodTitle(null)).toBeNull();
});
});

View File

@ -104,18 +104,3 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] {
export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] { export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] {
return type === 'BALANCE' ? balanceLayout(rows) : incomeLayout(rows); return type === 'BALANCE' ? balanceLayout(rows) : incomeLayout(rows);
} }
/** ОКУД формы: распознанный, иначе код формы по её типу (0710001 — ББ, 0710002 — ОФР; §12 п. 1). */
export function okudCode(type: DocumentType, recognized: string | null): string | null {
if (recognized) return recognized;
const byType: Partial<Record<DocumentType, string>> = { BALANCE: '0710001', INCOME: '0710002' };
return byType[type] ?? null;
}
const MONTHS = ['январь', 'февраль', 'март', 'апрель', 'май', 'июнь', 'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь'];
/** «за январь – сентябрь 2025 г.» по дате отчёта (шапка «Бухгалтерская отчётность» макета). */
export function reportingPeriodTitle(reportDate: string | null): string | null {
const m = reportDate ? /^(\d{4})-(\d{2})/.exec(reportDate) : null;
return m ? `за январь – ${MONTHS[Number(m[2]) - 1]} ${m[1]} г.` : null;
}

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api';
import { headerFields } from './headerFields';
const f = (value: string | null, extra: Partial<HeaderFieldOut> = {}): HeaderFieldOut => ({
value,
original_value: value,
confidence: null,
verified: null,
modified: false,
...extra,
});
const header = (over: Partial<StatementHeaderOut> = {}) =>
({
inn: f('3528104476'),
report_date: f('2024-12-31'),
units: f('тыс. руб.'),
organization_name: 'мусор',
okud: null,
okpo: null,
okopf: null,
okfs: null,
address: null,
...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', 'Единица измерения', 'тыс. руб.'],
]);
});
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: 'Исходное: тыс. руб.' });
});
});

View File

@ -0,0 +1,37 @@
import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api';
import { formatReportDate } from './reviewView';
export type HeaderFieldKey = 'inn' | 'report_date' | 'units';
export type HeaderFieldRow = {
field: HeaderFieldKey;
label: string;
text: string;
raw: string | null;
attention: boolean;
modified: boolean;
tip: string | undefined;
};
const FIELDS: [HeaderFieldKey, string][] = [
['inn', 'ИНН'],
['report_date', 'Дата отчёта'],
['units', 'Единица измерения'],
];
/**
* Шапка формы — только поля, которые распознавание извлекает надёжно (как в старом АРМ, PDFResultViewer):
* ИНН, дата отчёта, единица измерения. Пустое или с уверенностью < 100 — «требует внимания», правка — исходное в подсказке.
*/
export function headerFields(header: StatementHeaderOut): 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;
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 };
});
}