feat(statement): раскладка строк без кода и расшифровок на своих местах (#43)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:19:37 +04:00
parent e4ee7f09b2
commit c2f33e7af2
2 changed files with 220 additions and 18 deletions

View File

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { formLayout } from './formLayout';
import type { StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { detailCount, filterLayout, formLayout } from './formLayout';
import type { GridRow } from './statementGrid';
import { buildStatementGrid } from './statementGrid';
const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({
key: code ?? 'x',
@ -21,6 +24,8 @@ const row = (code: string | null, name: string | null = `строка ${code}`):
codeModified: false,
});
const sub = (code: string, parentCode: string | null): GridRow => ({ ...row(code), parentCode });
const isLineRow = (r: ReturnType<typeof formLayout>[number]): r is Extract<ReturnType<typeof formLayout>[number], { kind: 'line' }> =>
r.kind === 'line';
const shape = (rows: ReturnType<typeof formLayout>) =>
rows.map((r) => (r.kind === 'section' ? `# ${r.title}` : `${r.row.code}${r.total ? ' (итог)' : ''}`));
@ -146,3 +151,127 @@ describe('formLayout — строки детализации (parent_code)', ()
expect(codes.filter((c) => c === '11101')).toHaveLength(1);
});
});
// --- Вложения: строки без кода и расшифровки (план 6, P6-2/P6-3/P6-4) ---
const at = (key: string, code: string | null, rowIndex: number | null, extra: Partial<GridRow> = {}): GridRow => ({
...row(code),
key,
lineId: key,
rowIndex,
...extra,
});
const bad = (key: string, rowIndex: number, status: 'unreadable' | 'missing' = 'unreadable', codeText: string | null = 'ИЧ,250'): GridRow =>
at(key, null, rowIndex, { lineStatus: status, codeText, name: `имя ${key}` });
const det = (key: string, rowIndex: number, parentRowIndex: number | null): GridRow =>
at(key, null, rowIndex, { lineStatus: 'detail', parentRowIndex, codeText: null, name: key });
const shapeV2 = (rows: ReturnType<typeof formLayout>) =>
rows.map((r) => {
if (r.kind === 'section') return `# ${r.title}`;
if (r.row.lineStatus === 'detail') return `> ${r.row.name}`;
if (r.row.lineStatus !== 'ok') return `?${r.row.codeText ?? '-'}`;
return r.row.code as string;
});
describe('formLayout — вложения', () => {
it('строка без кода под ближайшей предыдущей по row_index', () => {
const rows = [at('a', '1230', 0), at('b', '1240', 1), bad('u', 2), at('c', '1250', 3)];
expect(shapeV2(formLayout('BALANCE', rows)).slice(-4)).toEqual(['1230', '1240', '?ИЧ,250', '1250']);
});
it('якорь по файлу, а не по коду', () => {
const rows = [at('a', '1250', 0), bad('u', 1), at('c', '1210', 2)];
expect(shapeV2(formLayout('BALANCE', rows)).slice(-3)).toEqual(['1210', '1250', '?ИЧ,250']);
});
it('нет якоря — в начало первого раздела', () => {
const bal = shapeV2(formLayout('BALANCE', [bad('m', 0, 'missing', null), at('a', '1110', 1)]));
expect(bal.slice(0, 4)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '?-', '1110']);
expect(shapeV2(formLayout('INCOME', [bad('m', 0, 'missing', null), at('a', '2110', 1)]))[0]).toBe('?-');
});
it('расшифровки под родителем по row_index с отступом', () => {
const layout = formLayout('BALANCE', [at('p', '1150', 4), det('d6', 6, 4), det('d5', 5, 4)]);
expect(shapeV2(layout)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '> d5', '> d6']);
expect(layout.filter(isLineRow).map((r) => r.child)).toEqual([false, true, true]);
});
it('расшифровка без родителя — в начало первого раздела', () => {
const rows = [at('p', '1150', 4), det('x', 5, null), det('y', 6, 99)];
expect(shapeV2(formLayout('BALANCE', rows))).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '> x', '> y', '1150']);
});
it('повтор кода: по row_index, вложение у своего экземпляра', () => {
const rows = [at('b', '2410', 9), at('a', '2410', 6), bad('u', 7)];
expect(formLayout('INCOME', rows).map((r) => (r.kind === 'line' ? r.key : ''))).toEqual(['a', 'u', 'b']);
});
it('шаблонная строка не якорь', () => {
const rows = [at('a', '1210', 0), at('t', '1250', null, { origin: 'template', lineId: null }), bad('u', 1)];
expect(shapeV2(formLayout('BALANCE', rows)).slice(-3)).toEqual(['1210', '?ИЧ,250', '1250']);
});
it('якорь — пятизначная подстрока', () => {
const rows = [at('a', '1150', 0), at('s', '11501', 1, { parentCode: '1150' }), bad('u', 2)];
expect(shapeV2(formLayout('BALANCE', rows)).slice(-3)).toEqual(['1150', '11501', '?ИЧ,250']);
});
it('фикстура целиком', () => {
const { rows } = buildStatementGrid(statementV2Json as StatementOut);
const layout = formLayout('BALANCE', rows);
expect(shapeV2(layout).map((s, i) => (s === '1370' ? `1370 ${(layout[i] as { key: string }).key}` : s))).toEqual([
'# АКТИВ',
'# I. Внеоборотные активы',
'> справочно',
'1110',
'1150',
'> в том числе: пригодные к использованию',
'1170',
'1100',
'# II. Оборотные активы',
'1210',
'1230',
'1240',
'?ИЧ,250',
'?-',
'1250',
'1200',
'1600',
'# ПАССИВ',
'# III. Капитал и резервы',
'1370 l-1370a',
'1370 l-1370b',
]);
});
});
describe('filterLayout и detailCount', () => {
const rows = [at('p', '1150', 0), at('s', '11501', 1, { parentCode: '1150' }), bad('u', 2), det('d', 3, 0)];
const none = new Set<string>();
it('showDetails false убирает расшифровки', () => {
const out = filterLayout(formLayout('BALANCE', rows), { showDetails: false, hiddenKeys: none });
expect(shapeV2(out)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '11501', '?ИЧ,250']);
});
it('скрытая подстрока уходит, вложение скрытого якоря остаётся', () => {
const out = filterLayout(formLayout('BALANCE', rows), { showDetails: true, hiddenKeys: new Set(['s']) });
expect(shapeV2(out)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '> d', '?ИЧ,250']);
});
it('detailCount: фикстура — 2, форма без расшифровок — 0', () => {
expect(detailCount(buildStatementGrid(statementV2Json as StatementOut).rows)).toBe(2);
expect(detailCount([row('1150')])).toBe(0);
});
});
describe('formLayout — имена (P6-4)', () => {
const named = (referenceName: string | null, name: string | null) => {
const [, , line] = formLayout('BALANCE', [{ ...row('1105'), referenceName, name }]);
return line.kind === 'line' ? line : (null as never);
};
it('перенос слова в файловом имени — это то же имя', () => {
const l = named('Дебиторская задолженность', 'Дебиторская задолжен-ность');
expect([l.displayName, l.secondaryName]).toEqual(['Дебиторская задолженность', null]);
});
it('другое файловое имя — второй строкой', () => {
const l = named('Дебиторская задолженность', 'Прочие оборотные');
expect([l.displayName, l.secondaryName]).toEqual(['Дебиторская задолженность', 'Прочие оборотные']);
});
it('приклеенные заголовки не дают второго имени, сырое — в fullName', () => {
const l = named('Гудвил', 'АКТИВ I. Внеоборотные активы Гудвил');
expect([l.displayName, l.secondaryName, l.fullName]).toEqual(['Гудвил', null, 'АКТИВ I. Внеоборотные активы Гудвил']);
});
it('нет справочного имени — имя из файла, второго нет', () => {
const l = named(null, 'Прочее');
expect([l.displayName, l.secondaryName]).toEqual(['Прочее', null]);
});
});

View File

@ -13,9 +13,13 @@ export type LayoutRow =
row: GridRow;
total: boolean;
grand: boolean;
/** строка детализации, выведенная под своим родителем (отступ в названии) */
/** подстрока под родителем или расшифровка — отступ в названии */
child: boolean;
/** справочное имя, иначе файловое без приклеенных заголовков */
displayName: string;
/** файловое имя, если отличается от справочного (P6-4) */
secondaryName: string | null;
/** сырое line_name для подсказки, если показано не оно */
fullName: string | null;
};
@ -32,6 +36,9 @@ const BALANCE_LIABILITIES: Section[] = [
];
const BALANCE_PREFIXES = ['актив', 'пассив', ...[...BALANCE_ASSETS, ...BALANCE_LIABILITIES].map((s) => s.title.toLowerCase())];
/** По номеру строки в файле, null (достроенная, созданная правкой) — последней. */
const byRowIndex = (a: GridRow, b: GridRow) => (a.rowIndex ?? Number.POSITIVE_INFINITY) - (b.rowIndex ?? Number.POSITIVE_INFINITY) || 0;
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;
@ -39,14 +46,20 @@ const isTotal = (code: number) => code % 100 === 0 && code !== 2900;
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;
return Number(isTotal(ca)) - Number(isTotal(cb)) || ca - cb || byRowIndex(a, b);
};
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
/** Для сравнения имён: без регистра, ё = е, только буквы и цифры (переносы и пробелы OCR не мешают). */
const comparable = (s: string) =>
s
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[^\p{L}\p{N}]/gu, '');
/** Срезает приклеенные OCR заголовки разделов («АКТИВ I. Внеоборотные активы Гудвил» → «Гудвил»). */
function stripSectionPrefixes(name: string | null, prefixes: string[]): { displayName: string; fullName: string | null } {
if (!name) return { displayName: '', fullName: null };
function stripSectionPrefixes(name: string | null, prefixes: string[]): string {
if (!name) return '';
let rest = name.replace(/\s+/g, ' ').trim();
for (let changed = true; changed; ) {
changed = false;
@ -57,7 +70,17 @@ function stripSectionPrefixes(name: string | null, prefixes: string[]): { displa
}
}
}
return rest === name.trim() || rest === '' ? { displayName: name, fullName: null } : { displayName: rest, fullName: name };
if (rest === '' || rest === name.trim()) return name;
return rest;
}
/** Имена строки (P6-4): справочное — основное; файловое — вторым, если отличается; сырое — в подсказку. */
function lineNames(row: GridRow, prefixes: string[]): { displayName: string; secondaryName: string | null; fullName: string | null } {
const stripped = stripSectionPrefixes(row.name, prefixes);
const displayName = row.referenceName ?? stripped;
const secondaryName = row.referenceName && stripped && comparable(stripped) !== comparable(row.referenceName) ? stripped : null;
const shown = secondaryName ?? displayName;
return { displayName, secondaryName, fullName: row.name && row.name.trim() !== shown ? row.name : null };
}
// строки «БАЛАНС» по активу (1600) и пассиву (1700) выделяются цветом (просьба владельца 09.10.2026)
@ -72,7 +95,7 @@ function lineRow(row: GridRow, prefixes: string[], balance: boolean): Extract<La
total: code !== null && isTotal(code),
grand: balance && GRAND_TOTALS.has(row.code ?? ''),
child: false,
...stripSectionPrefixes(row.name, prefixes),
...lineNames(row, prefixes),
};
}
@ -122,15 +145,57 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] {
return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [], false));
}
const isLine = (item: LayoutRow): item is Extract<LayoutRow, { kind: 'line' }> => item.kind === 'line';
/**
* Строка детализации (parent_code от ядра) выводится сразу под родителем, по коду; родитель — только обычная строка формы
* (parent_code = null) из этого же набора. Без родителя в форме строка остаётся там, где была бы без parent_code.
* Вложения: строки без кода (unreadable/missing) и расшифровки (detail) стоят на своих местах. Якорь — ключ строки `ok`
* из файла (не шаблонной): у расшифровки — строка с `rowIndex === parentRowIndex`, у остальных — ближайшая предыдущая
* по `rowIndex`. Якорь ищем по ключу строки, не по коду: код может повторяться, а строку скрыть.
*/
function insertAttachments(base: LayoutRow[], attachments: GridRow[], okRows: GridRow[], prefixes: string[]): LayoutRow[] {
if (attachments.length === 0) return base;
const anchors = okRows.filter((r) => r.rowIndex !== null).sort(byRowIndex);
const anchorKey = (a: GridRow): string | null => {
if (a.lineStatus === 'detail') return anchors.find((r) => r.rowIndex === a.parentRowIndex)?.key ?? null;
if (a.rowIndex === null) return null;
let found: string | null = null;
for (const r of anchors) {
if ((r.rowIndex as number) < a.rowIndex) found = r.key;
}
return found;
};
const groups = new Map<string | null, GridRow[]>();
for (const a of attachments) {
const k = anchorKey(a);
groups.set(k, [...(groups.get(k) ?? []), a]);
}
const make = (a: GridRow): LayoutRow => ({ ...lineRow(a, prefixes, false), child: a.lineStatus === 'detail' });
const emit = (k: string | null): LayoutRow[] =>
(groups.get(k) ?? []).sort((a, b) => byRowIndex(a, b) || a.key.localeCompare(b.key)).map(make);
const out: LayoutRow[] = [];
let head = 0;
while (head < base.length && base[head].kind === 'section') out.push(base[head++]);
out.push(...emit(null));
for (const item of base.slice(head)) {
out.push(item);
if (isLine(item) && groups.has(item.key)) out.push(...emit(item.key));
}
return out;
}
/**
* Раскладывает по разделам только строки `ok`. Подстрока (parent_code от ядра) идёт сразу под родителем — обычной строкой
* формы (parent_code = null) из этого же набора; без родителя в форме остаётся там, где была бы без parent_code.
* Затем вставляются вложения без кода и расшифровки (insertAttachments). Расшифровки возвращаются всегда, скрывает filterLayout.
*/
export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] {
const parents = new Set(rows.filter((r) => r.parentCode === null && r.code !== null).map((r) => r.code as string));
const okRows = rows.filter((r) => r.lineStatus === 'ok');
const attachments = rows.filter((r) => r.lineStatus !== 'ok');
const prefixes = type === 'BALANCE' ? BALANCE_PREFIXES : [];
const parents = new Set(okRows.filter((r) => r.parentCode === null && r.code !== null).map((r) => r.code as string));
const children = new Map<string, GridRow[]>();
const top: GridRow[] = [];
for (const r of rows) {
for (const r of okRows) {
if (r.parentCode !== null && parents.has(r.parentCode)) {
children.set(r.parentCode, [...(children.get(r.parentCode) ?? []), r]);
} else {
@ -138,17 +203,25 @@ export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] {
}
}
const base = type === 'BALANCE' ? balanceLayout(top) : incomeLayout(top);
if (children.size === 0) return base;
const prefixes = type === 'BALANCE' ? BALANCE_PREFIXES : [];
const out: LayoutRow[] = [];
const withKids: LayoutRow[] = [];
for (const item of base) {
out.push(item);
withKids.push(item);
if (item.kind !== 'line' || !item.row.code) continue;
const kids = children.get(item.row.code);
if (!kids) continue;
children.delete(item.row.code); // два ряда с одним кодом родителя: подстроки выводим один раз
const sorted = [...kids].sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '') || a.key.localeCompare(b.key));
for (const kid of sorted) out.push({ ...lineRow(kid, prefixes, type === 'BALANCE'), child: true });
const sorted = [...kids].sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '') || byRowIndex(a, b) || a.key.localeCompare(b.key));
for (const kid of sorted) withKids.push({ ...lineRow(kid, prefixes, type === 'BALANCE'), child: true });
}
return out;
return insertAttachments(withKids, attachments, okRows, prefixes);
}
/** Скрытие — после раскладки: вложение скрытого якоря остаётся, потому что якорь выбран по ключу строки. */
export function filterLayout(layout: LayoutRow[], opts: { showDetails: boolean; hiddenKeys: ReadonlySet<string> }): LayoutRow[] {
return layout.filter(
(item) => item.kind === 'section' || ((opts.showDetails || item.row.lineStatus !== 'detail') && !opts.hiddenKeys.has(item.key)),
);
}
/** Сколько в форме расшифровок (для «Показать расшифровки (N)»). */
export const detailCount = (rows: GridRow[]): number => rows.filter((r) => r.lineStatus === 'detail').length;