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:
parent
f756aff6ee
commit
9d4fc20c7f
@ -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) раскладку можно упростить.
|
||||||
|
|||||||
@ -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
|
|
||||||
key={label}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 12,
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderTop: i > 0 ? '1px solid #667085' : undefined,
|
|
||||||
}}>
|
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span style={{ fontWeight: 600 }}>{value}</span>
|
<span style={{ fontWeight: 600 }}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/widgets/DocumentReview/formLayout.test.ts
Normal file
100
src/widgets/DocumentReview/formLayout.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
121
src/widgets/DocumentReview/formLayout.ts
Normal file
121
src/widgets/DocumentReview/formLayout.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user