Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user