From c2f33e7af2267d80283a607f0dcafc2434625c48 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:19:37 +0400 Subject: [PATCH] =?UTF-8?q?feat(statement):=20=D1=80=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=B0=20=D1=81=D0=B2=D0=BE=D0=B8=D1=85=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=D1=85=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/formLayout.test.ts | 131 +++++++++++++++++- src/widgets/DocumentReview/formLayout.ts | 107 +++++++++++--- 2 files changed, 220 insertions(+), 18 deletions(-) diff --git a/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts index a73a408..f031438 100644 --- a/src/widgets/DocumentReview/formLayout.test.ts +++ b/src/widgets/DocumentReview/formLayout.test.ts @@ -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[number]): r is Extract[number], { kind: 'line' }> => + r.kind === 'line'; const shape = (rows: ReturnType) => 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 => ({ + ...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) => + 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(); + 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]); + }); +}); diff --git a/src/widgets/DocumentReview/formLayout.ts b/src/widgets/DocumentReview/formLayout.ts index 5a2d39f..f949df1 100644 --- a/src/widgets/DocumentReview/formLayout.ts +++ b/src/widgets/DocumentReview/formLayout.ts @@ -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 codeNum(r) === null)].map((r) => lineRow(r, [], false)); } +const isLine = (item: LayoutRow): item is Extract => 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(); + 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(); 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 }): 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;