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:
parent
9d4fc20c7f
commit
34ad6ef081
@ -151,3 +151,8 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд
|
||||
подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина:
|
||||
сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются;
|
||||
при появлении порядка строк в API (issue arm-core) раскладку можно упростить.
|
||||
- 08.10.2026 — R-14 (по замечанию владельца H-5): шапка формы [41] — как в старом АРМ (`reference-old-arm`
|
||||
`PDFResultViewer`): компактная таблица ИНН / Дата отчёта / Единица измерения (подсветка уверенности и правки, правка в
|
||||
режиме «Редактировать»), затем «Бухгалтерский баланс на …». Реквизиты бумажной шапки макета (ОКПО, ОКОПФ, ОКФС, адрес,
|
||||
название организации) не показываются: распознавание их не отдаёт или отдаёт мусор (arm-scan-gateway#15). Заменяет
|
||||
шапку из R-13. Цена ошибки: низкая — один компонент `StatementHeaderBlock`.
|
||||
|
||||
@ -1,100 +1,68 @@
|
||||
import { Input, Tooltip, Typography } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Input, Tooltip } from 'antd';
|
||||
import type { DocumentType, StatementHeaderOut } from '../../types/api';
|
||||
import { okudCode, reportingPeriodTitle } from './formLayout';
|
||||
import { type HeaderFieldKey, headerFields } from './headerFields';
|
||||
import { formTitle, formatReportDate } from './reviewView';
|
||||
|
||||
export type HeaderDraft = { inn?: string; report_date?: string; units?: string };
|
||||
type HeaderField = keyof HeaderDraft;
|
||||
export type HeaderDraft = Partial<Record<HeaderFieldKey, string>>;
|
||||
|
||||
type Props = {
|
||||
header: StatementHeaderOut;
|
||||
documentType: DocumentType;
|
||||
editing: boolean;
|
||||
draft: HeaderDraft;
|
||||
onDraftChange: (field: HeaderField, text: string) => void;
|
||||
onDraftChange: (field: HeaderFieldKey, text: string) => void;
|
||||
};
|
||||
|
||||
const rowStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid #eaecf0',
|
||||
};
|
||||
const valueStyle = { fontWeight: 600, textAlign: 'right' as const };
|
||||
const ATTENTION_BG = '#fff7e6';
|
||||
const MODIFIED_BG = '#e6f4ff';
|
||||
const PLACEHOLDER: Record<HeaderFieldKey, string> = { inn: 'ИНН', report_date: 'ГГГГ-ММ-ДД', units: 'тыс. руб.' };
|
||||
const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' };
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<span>{label}</span>
|
||||
<span style={valueStyle}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
function fieldBackground(modified: boolean, attention: boolean): string | undefined {
|
||||
if (modified) return MODIFIED_BG;
|
||||
return attention ? ATTENTION_BG : undefined;
|
||||
}
|
||||
|
||||
const boxCell = { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '4px 8px' };
|
||||
|
||||
/** Шапка формы «как бумажная» (макет [41]): реквизиты слева, коды в рамке справа, затем заголовок формы и её реквизиты. */
|
||||
export function StatementHeaderBlock({ header: h, documentType, editing, draft, onDraftChange }: Props) {
|
||||
const input = (field: HeaderField, placeholder: string, current: string | null, width = 200) => (
|
||||
<Input
|
||||
size='small'
|
||||
placeholder={placeholder}
|
||||
value={draft[field] ?? current ?? ''}
|
||||
onChange={(e) => onDraftChange(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>
|
||||
) : (
|
||||
'—'
|
||||
);
|
||||
/**
|
||||
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица ИНН / Дата отчёта / Единица измерения —
|
||||
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
||||
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
|
||||
*/
|
||||
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange }: Props) {
|
||||
const reportDate = formatReportDate(header.report_date.value);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<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 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
|
||||
size='small'
|
||||
placeholder={PLACEHOLDER[r.field]}
|
||||
value={draft[r.field] ?? r.raw ?? ''}
|
||||
onChange={(e) => onDraftChange(r.field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title={r.tip}>{r.text}</Tooltip>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{formTitle(documentType)}</div>
|
||||
{reportDate && <div style={{ fontWeight: 500 }}>на {reportDate}</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formLayout, okudCode, reportingPeriodTitle } from './formLayout';
|
||||
import { formLayout } from './formLayout';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({
|
||||
@ -85,16 +85,3 @@ describe('formLayout — ОФР, справочные строки', () => {
|
||||
).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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -104,18 +104,3 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] {
|
||||
export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] {
|
||||
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;
|
||||
}
|
||||
|
||||
47
src/widgets/DocumentReview/headerFields.test.ts
Normal file
47
src/widgets/DocumentReview/headerFields.test.ts
Normal 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: 'Исходное: тыс. руб.' });
|
||||
});
|
||||
});
|
||||
37
src/widgets/DocumentReview/headerFields.ts
Normal file
37
src/widgets/DocumentReview/headerFields.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user