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 строки. Причина:
|
подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 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`.
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
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