diff --git a/openspec/changes/demo-prototype/design.md b/openspec/changes/demo-prototype/design.md
index 23e1fac..fbf99ab 100644
--- a/openspec/changes/demo-prototype/design.md
+++ b/openspec/changes/demo-prototype/design.md
@@ -145,3 +145,9 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд
признак контрагента — в фильтре «Контрагенты». Цена: низкая.
- 08.10.2026 — R-12: [41] заголовок колонки формы без распознанной подписи — «На <дата периода>» или «Колонка N»; длинный
список нарушений — с прокруткой (120 px). Цена: низкая.
+- 08.10.2026 — R-13 (согласовано с владельцем в чате): форма ББ/ОФР [41] раскладывается «как на бумаге» на фронте —
+ порядок строк и заголовки разделов восстанавливаются по соотношениям §7 (`formLayout.ts`), итоги XX00 жирные (кроме 2900),
+ строки вне формы — «Прочие строки»; приклеенные OCR заголовки разделов срезаются только при показе (полное имя — в
+ подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина:
+ сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются;
+ при появлении порядка строк в API (issue arm-core) раскладку можно упростить.
diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx
index d4dee03..95dc1d2 100644
--- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx
+++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx
@@ -1,6 +1,7 @@
-import { Input } from 'antd';
+import { Input, Tooltip, Typography } from 'antd';
import type { ReactNode } from 'react';
import type { DocumentType, StatementHeaderOut } from '../../types/api';
+import { okudCode, reportingPeriodTitle } from './formLayout';
import { formTitle, formatReportDate } from './reviewView';
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) {
- const input = (field: HeaderField, placeholder: string, current: string | null) => (
+ const input = (field: HeaderField, placeholder: string, current: string | null, width = 200) => (
onDraftChange(field, e.target.value)}
- style={{ width: 200, textAlign: 'right' }}
+ style={{ width, textAlign: 'right' }}
/>
);
const reportDate = formatReportDate(h.report_date.value);
- const codes: [string, string | null][] = [
- ['по ОКПО', h.okpo],
- ['ИНН', h.inn.value],
- ['по ОКОПФ', h.okopf],
- ['по ОКФС', h.okfs],
+ 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 shownCodes = codes.filter(([, v]) => v);
+ const organization = h.organization_name ? (
+ // мусорный OCR-текст не должен съедать экран: две строки, полный текст — в подсказке
+
+
+ {h.organization_name}
+
+
+ ) : (
+ '—'
+ );
return (
-
{formTitle(documentType)}
- {reportDate &&
на {reportDate}
}
+
Бухгалтерская отчётность
+ {period &&
{period}
}
- {h.organization_name ?? '-'}
- {editing ? input('inn', 'ИНН', h.inn.value) : (h.inn.value ?? '-')}
- {h.okud ?? '-'}
- {editing ? input('report_date', 'ГГГГ-ММ-ДД', h.report_date.value) : (reportDate ?? '-')}
- {editing ? input('units', 'тыс. руб.', h.units.value) : (h.units.value ?? '-')}
- {h.address && {h.address}
}
+ {organization}
+ {h.address && {h.address}
}
- {shownCodes.length > 0 && (
-
- {shownCodes.map(([label, value], i) => (
-
0 ? '1px solid #667085' : undefined,
- }}>
- {label}
- {value}
-
- ))}
-
- )}
+
+ {codes.map(([label, value], i) => (
+
0 ? '1px solid #667085' : undefined }}>
+ {label}
+ {value}
+
+ ))}
+
+
+
+
{formTitle(documentType)}
+ {reportDate &&
на {reportDate}
}
+
+
+ {okudCode(documentType, h.okud) ?? '—'}
+ {editing ? input('report_date', 'ГГГГ-ММ-ДД', h.report_date.value) : (reportDate ?? '—')}
+ {editing ? input('units', 'тыс. руб.', h.units.value) : (h.units.value ?? '—')}
);
diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx
index e237f67..bbdafec 100644
--- a/src/widgets/DocumentReview/StatementReview.tsx
+++ b/src/widgets/DocumentReview/StatementReview.tsx
@@ -192,6 +192,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
/>
)}
= [
- { key: 'name', title: 'Наименование показателя', dataIndex: 'name' },
- { key: 'code', title: 'Код', dataIndex: 'code', width: 70, align: 'center' as const },
+const SECTION_BG = '#f2f4f7';
+
+export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) {
+ 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 = [
+ {
+ 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 = {r.displayName || '—'};
+ return r.fullName ? {text} : text;
+ },
+ },
+ {
+ key: 'code',
+ title: 'Код',
+ width: 60,
+ align: 'center' as const,
+ onCell: (r) => sectionSpan(r, false),
+ render: (_: unknown, r: LayoutRow) =>
+ r.kind === 'line' ? {r.row.code} : null,
+ },
...columns.map((col) => ({
key: col.id,
title: columnTitle(col),
- width: 140,
+ width: 112,
align: 'right' as const,
- onCell: (row: GridRow) => {
- return { style: { background: cellBackground(row.cells[col.id]) } };
- },
- render: (_: unknown, row: GridRow) => {
+ onCell: (r: LayoutRow) =>
+ r.kind === 'section'
+ ? sectionSpan(r, false)
+ : { 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];
if (!value) {
return null;
@@ -69,5 +100,5 @@ export function StatementTable({ columns, rows, editable, draft, onDraftChange }
},
})),
];
- return size='small' bordered rowKey='key' columns={tableColumns} dataSource={rows} pagination={false} />;
+ return size='small' bordered rowKey='key' columns={tableColumns} dataSource={layout} pagination={false} />;
}
diff --git a/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts
new file mode 100644
index 0000000..5cce65f
--- /dev/null
+++ b/src/widgets/DocumentReview/formLayout.test.ts
@@ -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) =>
+ 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();
+ });
+});
diff --git a/src/widgets/DocumentReview/formLayout.ts b/src/widgets/DocumentReview/formLayout.ts
new file mode 100644
index 0000000..ef511cd
--- /dev/null
+++ b/src/widgets/DocumentReview/formLayout.ts
@@ -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();
+ 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> = { 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;
+}