feat: форма ББ/ОФР как в макете [41] — разделы, порядок формы, жирные итоги, ИНН в рамке кодов, ОКУД, отчётность за период (#3)

Сервер отдаёт строки по коду и без заголовков разделов (§12, OQ-36): порядок и разделы восстанавливаются
по соотношениям §7 только при показе; приклеенные OCR заголовки срезаются, полное имя — в подсказке.

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:45:52 +04:00
parent f756aff6ee
commit 9d4fc20c7f
6 changed files with 315 additions and 47 deletions

View File

@ -145,3 +145,9 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд
признак контрагента — в фильтре «Контрагенты». Цена: низкая. признак контрагента — в фильтре «Контрагенты». Цена: низкая.
- 08.10.2026 — R-12: [41] заголовок колонки формы без распознанной подписи — «На <дата периода>» или «Колонка N»; длинный - 08.10.2026 — R-12: [41] заголовок колонки формы без распознанной подписи — «На <дата периода>» или «Колонка N»; длинный
список нарушений — с прокруткой (120 px). Цена: низкая. список нарушений — с прокруткой (120 px). Цена: низкая.
- 08.10.2026 — R-13 (согласовано с владельцем в чате): форма ББ/ОФР [41] раскладывается «как на бумаге» на фронте —
порядок строк и заголовки разделов восстанавливаются по соотношениям §7 (`formLayout.ts`), итоги XX00 жирные (кроме 2900),
строки вне формы — «Прочие строки»; приклеенные OCR заголовки разделов срезаются только при показе (полное имя — в
подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина:
сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются;
при появлении порядка строк в API (issue arm-core) раскладку можно упростить.

View File

@ -1,6 +1,7 @@
import { Input } from 'antd'; import { Input, Tooltip, Typography } from 'antd';
import type { ReactNode } from 'react'; 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 { formTitle, formatReportDate } from './reviewView'; import { formTitle, formatReportDate } from './reviewView';
export type HeaderDraft = { inn?: string; report_date?: string; units?: string }; export type HeaderDraft = { inn?: string; report_date?: string; units?: string };
@ -33,58 +34,66 @@ function Row({ label, children }: { label: string; children: ReactNode }) {
); );
} }
/** Шапка формы «как бумажная»: заголовок, реквизиты организации, коды (макет [41]). */ const boxCell = { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '4px 8px' };
/** Шапка формы «как бумажная» (макет [41]): реквизиты слева, коды в рамке справа, затем заголовок формы и её реквизиты. */
export function StatementHeaderBlock({ header: h, documentType, editing, draft, onDraftChange }: Props) { export function StatementHeaderBlock({ header: h, documentType, editing, draft, onDraftChange }: Props) {
const input = (field: HeaderField, placeholder: string, current: string | null) => ( const input = (field: HeaderField, placeholder: string, current: string | null, width = 200) => (
<Input <Input
size='small' size='small'
placeholder={placeholder} placeholder={placeholder}
value={draft[field] ?? current ?? ''} value={draft[field] ?? current ?? ''}
onChange={(e) => onDraftChange(field, e.target.value)} onChange={(e) => onDraftChange(field, e.target.value)}
style={{ width: 200, textAlign: 'right' }} style={{ width, textAlign: 'right' }}
/> />
); );
const reportDate = formatReportDate(h.report_date.value); const reportDate = formatReportDate(h.report_date.value);
const codes: [string, string | null][] = [ const period = reportingPeriodTitle(h.report_date.value);
['по ОКПО', h.okpo], // ИНН — только в рамке кодов, как в макете; поле правки — там же
['ИНН', h.inn.value], const codes: [string, ReactNode][] = [
['по ОКОПФ', h.okopf], ['по ОКПО', h.okpo ?? '—'],
['по ОКФС', h.okfs], ['ИНН', editing ? input('inn', 'ИНН', h.inn.value, 130) : (h.inn.value ?? '—')],
['по ОКОПФ', h.okopf ?? '—'],
['по ОКФС', h.okfs ?? '—'],
]; ];
const shownCodes = codes.filter(([, v]) => v); 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>
) : (
'—'
);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 18, fontWeight: 600 }}>{formTitle(documentType)}</div> <div style={{ fontSize: 18, fontWeight: 600 }}>Бухгалтерская отчётность</div>
{reportDate && <div style={{ fontWeight: 500 }}>на {reportDate}</div>} {period && <div style={{ fontWeight: 500 }}>{period}</div>}
</div> </div>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}> <div style={{ flex: '1 1 auto', minWidth: 0 }}>
<Row label='Организация'>{h.organization_name ?? '-'}</Row> <Row label='Организация'>{organization}</Row>
<Row label='ИНН'>{editing ? input('inn', 'ИНН', h.inn.value) : (h.inn.value ?? '-')}</Row> {h.address && <Row label='Адрес в пределах места нахождения'>{h.address}</Row>}
<Row label='Форма по ОКУД'>{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>
{h.address && <Row label='Адрес'>{h.address}</Row>}
</div> </div>
{shownCodes.length > 0 && ( <div style={{ flex: '0 0 auto', border: '1px solid #667085', minWidth: 190 }}>
<div style={{ flex: '0 0 auto', border: '1px solid #667085', minWidth: 170 }}> {codes.map(([label, value], i) => (
{shownCodes.map(([label, value], i) => ( <div key={label} style={{ ...boxCell, borderTop: i > 0 ? '1px solid #667085' : undefined }}>
<div <span>{label}</span>
key={label} <span style={{ fontWeight: 600 }}>{value}</span>
style={{ </div>
display: 'flex', ))}
justifyContent: 'space-between', </div>
gap: 12, </div>
padding: '4px 8px', <div style={{ textAlign: 'center', marginTop: 8 }}>
borderTop: i > 0 ? '1px solid #667085' : undefined, <div style={{ fontSize: 18, fontWeight: 600 }}>{formTitle(documentType)}</div>
}}> {reportDate && <div style={{ fontWeight: 500 }}>на {reportDate}</div>}
<span>{label}</span> </div>
<span style={{ fontWeight: 600 }}>{value}</span> <div>
</div> <Row label='Форма по ОКУД'>{okudCode(documentType, h.okud) ?? '—'}</Row>
))} <Row label='Отчётная дата'>{editing ? input('report_date', 'ГГГГ-ММ-ДД', h.report_date.value) : (reportDate ?? '—')}</Row>
</div> <Row label='Единица измерения'>{editing ? input('units', 'тыс. руб.', h.units.value) : (h.units.value ?? '—')}</Row>
)}
</div> </div>
</div> </div>
); );

View File

@ -192,6 +192,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
/> />
)} )}
<StatementTable <StatementTable
documentType={statement.document.document_type}
columns={grid.columns} columns={grid.columns}
rows={grid.rows} rows={grid.rows}
editable={editing && actions.canSave} editable={editing && actions.canSave}

View File

@ -1,11 +1,13 @@
import { Input, Table, Tooltip } from 'antd'; import { Input, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut, ValueOut } from '../../types/api'; import type { ColumnOut, DocumentType, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money'; import { formatAmount, parseAmountInput } from '../../utils/money';
import { type LayoutRow, formLayout } from './formLayout';
import { columnTitle } from './reviewView'; import { columnTitle } from './reviewView';
import { type GridRow, cellFlags, cellKey } from './statementGrid'; import { type GridRow, cellFlags, cellKey } from './statementGrid';
type Props = { type Props = {
documentType: DocumentType;
columns: ColumnOut[]; columns: ColumnOut[];
rows: GridRow[]; rows: GridRow[];
editable: boolean; editable: boolean;
@ -31,19 +33,48 @@ function cellTip(value: ValueOut): string | undefined {
return value.confidence ? `Уверенность: ${value.confidence}%` : undefined; return value.confidence ? `Уверенность: ${value.confidence}%` : undefined;
} }
export function StatementTable({ columns, rows, editable, draft, onDraftChange }: Props) { const SECTION_BG = '#f2f4f7';
const tableColumns: ColumnsType<GridRow> = [
{ key: 'name', title: 'Наименование показателя', dataIndex: 'name' }, export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) {
{ key: 'code', title: 'Код', dataIndex: 'code', width: 70, align: 'center' as const }, const layout = formLayout(documentType, rows);
const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
const tableColumns: ColumnsType<LayoutRow> = [
{
key: 'name',
title: 'Наименование показателя',
onCell: (r) => ({
...sectionSpan(r, true),
style: r.kind === 'section' ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 } : undefined,
}),
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return r.title;
const text = <span style={{ fontWeight: r.total ? 600 : undefined }}>{r.displayName || '—'}</span>;
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
},
},
{
key: 'code',
title: 'Код',
width: 60,
align: 'center' as const,
onCell: (r) => sectionSpan(r, false),
render: (_: unknown, r: LayoutRow) =>
r.kind === 'line' ? <span style={{ fontWeight: r.total ? 600 : undefined }}>{r.row.code}</span> : null,
},
...columns.map((col) => ({ ...columns.map((col) => ({
key: col.id, key: col.id,
title: columnTitle(col), title: columnTitle(col),
width: 140, width: 112,
align: 'right' as const, align: 'right' as const,
onCell: (row: GridRow) => { onCell: (r: LayoutRow) =>
return { style: { background: cellBackground(row.cells[col.id]) } }; r.kind === 'section'
}, ? sectionSpan(r, false)
render: (_: unknown, row: GridRow) => { : { style: { background: cellBackground(r.row.cells[col.id]), fontWeight: r.total ? 600 : undefined } },
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return null;
const row = r.row;
const value = row.cells[col.id]; const value = row.cells[col.id];
if (!value) { if (!value) {
return null; return null;
@ -69,5 +100,5 @@ export function StatementTable({ columns, rows, editable, draft, onDraftChange }
}, },
})), })),
]; ];
return <Table<GridRow> size='small' bordered rowKey='key' columns={tableColumns} dataSource={rows} pagination={false} />; return <Table<LayoutRow> size='small' bordered rowKey='key' columns={tableColumns} dataSource={layout} pagination={false} />;
} }

View File

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { formLayout, okudCode, reportingPeriodTitle } from './formLayout';
import type { GridRow } from './statementGrid';
const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({
key: code ?? 'x',
lineId: code ?? 'x',
code,
name,
cells: {},
});
const shape = (rows: ReturnType<typeof formLayout>) =>
rows.map((r) => (r.kind === 'section' ? `# ${r.title}` : `${r.row.code}${r.total ? ' (итог)' : ''}`));
describe('formLayout — бухгалтерский баланс (0710001)', () => {
it('порядок формы: статьи раздела, затем итог; 1600 после раздела II; 1700 в конце', () => {
const rows = ['1100', '1105', '1150', '1200', '1210', '1300', '1310', '1400', '1410', '1500', '1510', '1600', '1700'].map((c) =>
row(c),
);
expect(shape(formLayout('BALANCE', rows))).toEqual([
'# АКТИВ',
'# I. Внеоборотные активы',
'1105',
'1150',
'1100 (итог)',
'# II. Оборотные активы',
'1210',
'1200 (итог)',
'1600 (итог)',
'# ПАССИВ',
'# III. Капитал и резервы',
'1310',
'1300 (итог)',
'# IV. Долгосрочные обязательства',
'1410',
'1400 (итог)',
'# V. Краткосрочные обязательства',
'1510',
'1500 (итог)',
'1700 (итог)',
]);
});
it('пустой раздел не выводится; строки вне формы — в конце под «Прочие строки»', () => {
const rows = [row('1150'), row('9999'), row(null, 'без кода')];
expect(shape(formLayout('BALANCE', rows))).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '# Прочие строки', '9999', 'null']);
});
it('срезает приклеенные OCR заголовки разделов из названия, полное — в fullName', () => {
const [, , line] = formLayout('BALANCE', [row('1105', 'АКТИВ I. Внеоборотные активы Гудвил')]);
expect(line.kind === 'line' && line.displayName).toBe('Гудвил');
expect(line.kind === 'line' && line.fullName).toBe('АКТИВ I. Внеоборотные активы Гудвил');
});
it('если после среза ничего не осталось — название не трогаем', () => {
const [, , line] = formLayout('BALANCE', [row('1110', 'I. Внеоборотные активы')]);
expect(line.kind === 'line' && line.displayName).toBe('I. Внеоборотные активы');
expect(line.kind === 'line' && line.fullName).toBeNull();
});
});
describe('formLayout — ОФР (0710002)', () => {
it('итог XX00 — после статей своей сотни, без заголовков разделов', () => {
const rows = ['2100', '2110', '2120', '2200', '2210', '2300', '2310', '2400', '2410'].map((c) => row(c));
expect(shape(formLayout('INCOME', rows))).toEqual([
'2110',
'2120',
'2100 (итог)',
'2210',
'2200 (итог)',
'2310',
'2300 (итог)',
'2410',
'2400 (итог)',
]);
});
});
describe('formLayout — ОФР, справочные строки', () => {
it('2900 «Базовая прибыль на акцию» — не итог и стоит перед 2910', () => {
expect(
shape(
formLayout(
'INCOME',
['2910', '2900', '2400', '2410'].map((c) => row(c)),
),
),
).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

@ -0,0 +1,121 @@
// Раскладка формы ББ/ОФР «как на бумаге» (макет [41]). Сервер отдаёт только строки с кодом, отсортированные по коду,
// без заголовков разделов (05-domain-rules §12, OQ-36). Порядок и разделы восстанавливаем из структуры формы, которую
// задают соотношения §7: 1100 = Σ 11xx, 1200 = Σ 12xx, 1600 = 1100 + 1200, 1300…1500 = Σ своих строк, 1700 = 1300 + 1400 + 1500;
// у ОФР итог XX00 считается из статей своей сотни. Только показ: данные не меняются.
import type { DocumentType } from '../../types/api';
import type { GridRow } from './statementGrid';
export type LayoutRow =
| { kind: 'section'; key: string; title: string; level: 1 | 2 }
| { kind: 'line'; key: string; row: GridRow; total: boolean; displayName: string; fullName: string | null };
type Section = { title: string; hundred: number };
const BALANCE_ASSETS: Section[] = [
{ title: 'I. Внеоборотные активы', hundred: 11 },
{ title: 'II. Оборотные активы', hundred: 12 },
];
const BALANCE_LIABILITIES: Section[] = [
{ title: 'III. Капитал и резервы', hundred: 13 },
{ title: 'IV. Долгосрочные обязательства', hundred: 14 },
{ title: 'V. Краткосрочные обязательства', hundred: 15 },
];
const BALANCE_PREFIXES = ['актив', 'пассив', ...[...BALANCE_ASSETS, ...BALANCE_LIABILITIES].map((s) => s.title.toLowerCase())];
const codeNum = (row: GridRow): number | null => (row.code && /^\d{4}$/.test(row.code) ? Number(row.code) : null);
// 2900 «Базовая прибыль (убыток) на акцию» — справочная строка ОФР, а не итог своей сотни
const isTotal = (code: number) => code % 100 === 0 && code !== 2900;
/** Статьи сотни по коду, итог XX00 — последним. */
const byFormOrder = (a: GridRow, b: GridRow) => {
const ca = codeNum(a) as number;
const cb = codeNum(b) as number;
return Number(isTotal(ca)) - Number(isTotal(cb)) || ca - cb;
};
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
/** Срезает приклеенные OCR заголовки разделов («АКТИВ I. Внеоборотные активы Гудвил» → «Гудвил»). */
function stripSectionPrefixes(name: string | null, prefixes: string[]): { displayName: string; fullName: string | null } {
if (!name) return { displayName: '', fullName: null };
let rest = name.replace(/\s+/g, ' ').trim();
for (let changed = true; changed; ) {
changed = false;
for (const prefix of prefixes) {
if (normalize(rest).startsWith(prefix) && normalize(rest).length > prefix.length) {
rest = rest.slice(prefix.length).trim();
changed = true;
}
}
}
return rest === name.trim() || rest === '' ? { displayName: name, fullName: null } : { displayName: rest, fullName: name };
}
function lineRow(row: GridRow, prefixes: string[]): LayoutRow {
const code = codeNum(row);
return { kind: 'line', key: row.key, row, total: code !== null && isTotal(code), ...stripSectionPrefixes(row.name, prefixes) };
}
function balanceLayout(rows: GridRow[]): LayoutRow[] {
const out: LayoutRow[] = [];
const used = new Set<string>();
const take = (pred: (code: number) => boolean) =>
rows.filter((r) => {
const c = codeNum(r);
return c !== null && pred(c);
});
const block = (header: string, sections: Section[], grandTotal: number) => {
const parts = sections.map((s) => ({ s, lines: take((c) => Math.floor(c / 100) === s.hundred).sort(byFormOrder) }));
const total = take((c) => c === grandTotal);
if (parts.every((p) => p.lines.length === 0) && total.length === 0) return;
out.push({ kind: 'section', key: `h-${header}`, title: header, level: 1 });
for (const { s, lines } of parts) {
if (lines.length === 0) continue;
out.push({ kind: 'section', key: `s-${s.hundred}`, title: s.title, level: 2 });
for (const r of lines) {
out.push(lineRow(r, BALANCE_PREFIXES));
used.add(r.key);
}
}
for (const r of total) {
out.push(lineRow(r, BALANCE_PREFIXES));
used.add(r.key);
}
};
block('АКТИВ', BALANCE_ASSETS, 1600);
block('ПАССИВ', BALANCE_LIABILITIES, 1700);
const rest = rows.filter((r) => !used.has(r.key));
if (rest.length > 0) {
out.push({ kind: 'section', key: 's-rest', title: 'Прочие строки', level: 2 });
for (const r of rest) out.push(lineRow(r, BALANCE_PREFIXES));
}
return out;
}
function incomeLayout(rows: GridRow[]): LayoutRow[] {
const coded = rows.filter((r) => codeNum(r) !== null);
const sorted = [...coded].sort((a, b) => {
const ha = Math.floor((codeNum(a) as number) / 100);
const hb = Math.floor((codeNum(b) as number) / 100);
return ha - hb || byFormOrder(a, b);
});
return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, []));
}
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;
}