Просмотр ОСВ как в 1С: строки счетов и субсчетов, без строк-показателей (#51, AURORA-1702, AURORA-1703) #52

Merged
aep merged 1 commits from feat/51-osv-view-rows into test 2026-10-20 23:07:37 +03:00
5 changed files with 213 additions and 51 deletions
Showing only changes of commit e202711584 - Show all commits

View File

@ -1,7 +1,7 @@
# document-review-ui Specification
## Purpose
Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — плоской таблицей строк только для чтения.
Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — таблицей строк в порядке файла (как в 1С) только для чтения.
## Requirements
@ -81,12 +81,19 @@ column_id, amount}`; ответ заменяет состояние экрана
- **THEN** экран показывает ошибку со ссылкой на уже подтверждённую форму
### Requirement: ОСВ-документ
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) плоской
таблицей строк нижнего уровня (договоры отдельными строками не идут — их сумма в строке контрагента) с колонками:
субсчёт, подразделение (если в файле есть этот уровень), аналитика, ИНН (если есть контрагенты), сальдо на начало Дт/Кт,
обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм.
Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения не
учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name`
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) таблицей в порядке
файла, как в 1С (AURORA-1702): строки счёта, субсчетов и подразделений, у которых есть вложенные строки, — строки-группы
(жирным, со своими сальдо и оборотами), вложенные строки — с отступом по уровню в колонке аналитики. Договоры отдельными
строками не идут — их сумма в строке контрагента; субсчёт или подразделение без вложенных строк и без сумм не выводится.
Строки-показатели колонки «Показатели» и строки «Кол.», «Вал.» (§1.3 правил предметной области: `group_type = OTHER`,
`data_type` не BU/SUM и не равен виду родителя — NU, QUANTITY, CURRENCY или пусто у «ПР»/«ВР») в рублёвую таблицу
SHALL NOT попадать и родителя группой не делают: строка БУ остаётся строкой с наименованием и суммами в рублях
(AURORA-1703). Колонки: субсчёт, подразделение (если в файле есть этот уровень), аналитика (у строки счёта — её текст),
ИНН (если есть контрагенты), сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с
разделителями разрядов, рубли), только чтение. Фильтр по счёту оставляет счёт и все его субсчета («62» — 62, 62.01,
62.02), при поиске по аналитике или ИНН строки-группы скрываются. Кнопка «Подтвердить» — как для форм.
Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения,
строки-группы и строки-показатели не учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name`
(«Основные средства»); смесь видов или вид без `group_name` — «Аналитика», а перечень видов — во всплывающей подсказке
заголовка. Колонка «ИНН» и подсказка поиска «Контрагент или ИНН» SHALL показываться только при наличии контрагентов
(`COUNTERPARTY`); иначе колонки ИНН нет, а поиск подписан «Поиск по аналитике».
@ -95,13 +102,21 @@ column_id, amount}`; ответ заменяет состояние экрана
- **WHEN** листья документа — аналитика с `group_name` «Основные средства», контрагентов нет
- **THEN** колонка называется «Основные средства», колонки ИНН нет, поиск подписан «Поиск по аналитике»
#### Scenario: Строки счёта и субсчетов
- **WHEN** аналитик открывает ОСВ по счёту 62 с субсчетами 62.01 и 62.02 и контрагентами под ними
- **THEN** в таблице жирным видны строки 62, 62.01, 62.02 с их сальдо и оборотами, контрагенты — под своим субсчётом с отступом
#### Scenario: ОСВ с колонкой «Показатели»
- **WHEN** у позиции номенклатуры счёта 41 в файле строки «БУ» и «Кол.»
- **THEN** в таблице строка позиции с наименованием и суммами в рублях, строки «Кол.» нет
#### Scenario: Подтверждение ОСВ
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
- **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION`
### Requirement: Виртуализация таблицы ОСВ-документа
Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не
отрисовываются. Строки по-прежнему загружаются целиком, поиск по аналитике (и ИНН) и фильтр по субсчёту — клиентские;
отрисовываются. Строки по-прежнему загружаются целиком, поиск по аналитике (и ИНН) и фильтр по счёту — клиентские;
серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении.
Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт»
закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без

View File

@ -127,6 +127,8 @@ export interface IssueCountsOut {
}
/** Уровень строки ОСВ (DBML `osv_group_type`). */
export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER';
/** Вид сумм строки ОСВ (`osv_data_type`): BU, SUM — рубли; NU, CURRENCY, QUANTITY — строки-показатели под строкой БУ. */
export type OsvDataType = 'BU' | 'NU' | 'CURRENCY' | 'QUANTITY' | 'SUM';
export interface EntryOut {
id: string;
@ -136,6 +138,7 @@ export interface EntryOut {
row_value: string | null;
group_type: OsvGroupType | null;
group_name: string | null;
data_type: OsvDataType | null;
account_code: string | null;
counterparty_name: string | null;
counterparty_inn: string | null;

View File

@ -10,11 +10,15 @@ import { IssuesPanel } from './IssuesPanel';
import { confirmButtonStyle } from './ReviewCard';
import { expectedActualText, failureKey } from './checkText';
import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, filterRows, flattenEntries } from './osvRows';
import { osvBodyHeight } from './osvTableSize';
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
const MONEY_WIDTH = 160;
/** Отступ одного уровня вложенности в колонке аналитики, px. */
const INDENT = 16;
/** Строки-группы (счёт, субсчёт, подразделение с вложенными строками) — жирным, как промежуточные итоги в 1С. */
const groupRowStyle = { fontWeight: 600 };
const money = (key: AmountKey, title: string) => ({
key,
@ -48,7 +52,8 @@ function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
dataIndex: 'analytics',
width: 320,
render: (v: string | null) => v ?? '—',
// отступ по уровню вложенности — дерево строк как в 1С
render: (v: string | null, row: OsvRow) => <span style={{ paddingLeft: row.depth * INDENT }}>{v ?? '—'}</span>,
},
...(hasCounterparties ? [{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 }] : []),
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
@ -57,15 +62,6 @@ function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
];
}
/** Поиск по аналитике (и по ИНН, если он есть) без учёта регистра и фильтр по субсчёту — на клиенте: строки документа уже загружены целиком. */
function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
const q = query.trim().toLowerCase();
return rows.filter(
(row) =>
(!account || row.account === account) && (!q || (row.analytics ?? '').toLowerCase().includes(q) || (row.inn ?? '').includes(q)),
);
}
interface ConfirmProblem {
code: string | undefined;
message: string;
@ -164,7 +160,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
/>
<Select
allowClear
placeholder='Субсчёт'
placeholder='Счёт / субсчёт'
style={{ width: 160 }}
value={account}
onChange={setAccount}
@ -199,6 +195,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
bordered
size='small'
rowKey='key'
onRow={(row) => ({ style: row.isGroup ? groupRowStyle : undefined })}
loading={loading}
columns={columns}
dataSource={rows}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { EntryOut, OsvGroupType } from '../../types/api';
import { flattenEntries } from './osvRows';
import { filterRows, flattenEntries } from './osvRows';
let row = 0;
const e = (
@ -18,6 +18,7 @@ const e = (
row_value: value,
group_type: groupType,
group_name: null,
data_type: 'BU',
account_code: null,
counterparty_name: null,
counterparty_inn: null,
@ -32,16 +33,29 @@ const e = (
});
describe('flattenEntries', () => {
it('строки — только аналитика нижнего уровня с субсчётом', () => {
it('все строки в порядке файла: счёт и субсчёт — группы, вложенные — с отступом по уровню (AURORA-1702)', () => {
const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', { opening_debit: '10' }, { account_code: '62' }),
e('62.01', '62', 'ACCOUNT', '62.01', {}, { account_code: '62.01' }),
e('62.01', '62', 'ACCOUNT', '62.01', { opening_debit: '10' }, { account_code: '62.01' }),
e('a', '62.01', 'COUNTERPARTY', 'ООО А', { opening_debit: '7' }, { account_code: '62.01', counterparty_inn: '123' }),
e('b', '62.01', 'COUNTERPARTY', 'ООО Б', { opening_debit: '3' }, { account_code: '62.01' }),
]);
expect(rows.map((r) => [r.account, r.analytics, r.inn, r.opening_debit])).toEqual([
['62.01', 'ООО А', '123', '7'],
['62.01', 'ООО Б', null, '3'],
expect(rows.map((r) => [r.account, r.analytics, r.inn, r.opening_debit, r.depth, r.isGroup])).toEqual([
['62', '62', null, '10', 0, true],
['62.01', '62.01', null, '10', 1, true],
['62.01', 'ООО А', '123', '7', 2, false],
['62.01', 'ООО Б', null, '3', 2, false],
]);
});
it('группа без сумм всё равно выводится — это заголовок вложенных строк', () => {
const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
e('a', '62', 'COUNTERPARTY', 'ООО А', { opening_debit: '7' }, { account_code: '62' }),
]);
expect(rows.map((r) => [r.analytics, r.isGroup])).toEqual([
['62', true],
['ООО А', false],
]);
});
@ -53,7 +67,12 @@ describe('flattenEntries', () => {
e('a', 'd', 'COUNTERPARTY', 'РБ', {}, { account_code: '62.01' }),
]);
expect(hasDepartments).toBe(true);
expect(rows.map((r) => [r.department, r.analytics])).toEqual([['БП Аренда', 'РБ']]);
expect(rows.map((r) => [r.department, r.analytics, r.isGroup])).toEqual([
[null, '62', true],
[null, '62.01', true],
['БП Аренда', null, true],
['БП Аренда', 'РБ', false],
]);
});
it('договоры не отдельными строками: остаётся строка контрагента', () => {
@ -63,7 +82,10 @@ describe('flattenEntries', () => {
e('c1', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '60' }, { account_code: '58.03' }),
e('c2', 'a', 'CONTRACT', 'Договор 2', { opening_debit: '40' }, { account_code: '58.03' }),
]);
expect(rows.map((r) => [r.analytics, r.opening_debit])).toEqual([['РБ ТК', '100']]);
expect(rows.map((r) => [r.analytics, r.opening_debit, r.isGroup])).toEqual([
['58.03', null, true],
['РБ ТК', '100', false],
]);
});
it('объекты ОС (OTHER) — строки аналитики', () => {
@ -71,19 +93,20 @@ describe('flattenEntries', () => {
e('01', null, 'ACCOUNT', '01', {}, { account_code: '01' }),
e('o', '01', 'OTHER', 'Автопогрузчик', { opening_debit: '5' }, { account_code: '01.01' }),
]);
expect(rows.map((r) => [r.account, r.analytics])).toEqual([['01.01', 'Автопогрузчик']]);
expect(rows.filter((r) => !r.isGroup).map((r) => [r.account, r.analytics])).toEqual([['01.01', 'Автопогрузчик']]);
});
it('субсчёт и подразделение без нижних строк — строка с пустой аналитикой; пустые без сумм — нет', () => {
it('субсчёт и подразделение без нижних строк — строка без отдельной аналитики; пустые без сумм — нет', () => {
const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
e('62.21', '62', 'ACCOUNT', '62.21', { closing_credit: '128041.39' }, { account_code: '62.21' }),
e('62.22', '62', 'ACCOUNT', '62.22', {}, { account_code: '62.22' }),
e('d', '62', 'DEPARTMENT', 'Опт', { turnover_debit: '1' }, { account_code: '62' }),
]);
expect(rows.map((r) => [r.account, r.department, r.analytics])).toEqual([
['62.21', null, null],
['62', 'Опт', null],
expect(rows.map((r) => [r.account, r.department, r.analytics, r.isGroup])).toEqual([
['62', null, '62', true],
['62.21', null, '62.21', false],
['62', 'Опт', null, false],
]);
});
@ -97,14 +120,14 @@ describe('flattenEntries', () => {
expect(total.opening_debit).toBeNull();
});
it('субсчета для фильтра — уникальные, в натуральном порядке', () => {
it('счета и субсчета для фильтра — уникальные, в натуральном порядке', () => {
const { accounts } = flattenEntries([
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
e('b', '62', 'COUNTERPARTY', 'Б', {}, { account_code: '62.10' }),
e('a', '62', 'COUNTERPARTY', 'А', {}, { account_code: '62.02' }),
e('c', '62', 'COUNTERPARTY', 'В', {}, { account_code: '62.02' }),
]);
expect(accounts).toEqual(['62.02', '62.10']);
expect(accounts).toEqual(['62', '62.02', '62.10']);
});
it('пустой документ', () => {
@ -120,6 +143,72 @@ describe('flattenEntries', () => {
});
});
describe('flattenEntries: строки-показатели «Кол.», «НУ», «Вал.» (AURORA-1703)', () => {
const ind = (id: string, parent: string, label: string, dataType: EntryOut['data_type'], amount: string) =>
e(id, parent, 'OTHER', label, { opening_debit: amount }, { account_code: '41.01', data_type: dataType });
it('строка БУ остаётся строкой с рублями, показатели под ней не выводятся', () => {
const { rows } = flattenEntries([
e('41', null, 'ACCOUNT', '41.01', { opening_debit: '1000.00' }, { account_code: '41.01' }),
e('n', '41', 'OTHER', 'Мука', { opening_debit: '1000.00' }, { account_code: '41.01', group_name: 'Номенклатура' }),
ind('q', 'n', 'Кол.', 'QUANTITY', '25.000'),
ind('t', 'n', 'НУ', 'NU', '990.00'),
ind('c', 'n', 'Вал.', 'CURRENCY', '11.00'),
ind('p', 'n', 'ПР', null, '10.00'),
]);
expect(rows.map((r) => [r.analytics, r.opening_debit, r.isGroup])).toEqual([
['41.01', '1000.00', true],
['Мука', '1000.00', false],
]);
});
it('показатель под строкой счёта не делает счёт группой без вложенных строк', () => {
const { rows } = flattenEntries([
e('10', null, 'ACCOUNT', '10.01', { opening_debit: '5.00' }, { account_code: '10.01' }),
ind('q', '10', 'Кол.', 'QUANTITY', '3.000'),
]);
expect(rows.map((r) => [r.analytics, r.opening_debit, r.isGroup])).toEqual([['10.01', '5.00', false]]);
});
it('показатели не определяют вид аналитики', () => {
const t = flattenEntries([
e('41', null, 'ACCOUNT', '41.01', {}, { account_code: '41.01' }),
e('n', '41', 'OTHER', 'Мука', { opening_debit: '1' }, { group_name: 'Номенклатура' }),
ind('q', 'n', 'Кол.', 'QUANTITY', '1'),
]);
expect([t.analyticsTitle, t.analyticsHint]).toEqual(['Номенклатура', null]);
});
it('документ целиком в НУ (вид из шапки) — строки обычные, не показатели', () => {
const nu = { data_type: 'NU' as const };
const { rows } = flattenEntries([
e('68', null, 'ACCOUNT', '68', { opening_debit: '5' }, { account_code: '68', ...nu }),
e('a', '68', 'COUNTERPARTY', 'ИФНС', { opening_debit: '5' }, { account_code: '68', ...nu }),
]);
expect(rows.map((r) => r.analytics)).toEqual(['68', 'ИФНС']);
});
it('строка «БУ» под счётом с видом из шапки SUM — обычная строка, не показатель', () => {
const { rows } = flattenEntries([
e('41', null, 'ACCOUNT', '41.01', { opening_debit: '5' }, { account_code: '41.01', data_type: 'SUM' }),
e('n', '41', 'OTHER', 'Мука', { opening_debit: '5' }, { account_code: '41.01', data_type: 'BU' }),
]);
expect(rows.map((r) => r.analytics)).toEqual(['41.01', 'Мука']);
});
it('старые документы без вида строк (data_type null) — дерево как раньше', () => {
const none = { data_type: null };
const { rows } = flattenEntries([
e('60', null, 'ACCOUNT', '60', { opening_debit: '5' }, { account_code: '60', ...none }),
e('o', '60', 'OTHER', 'Прочее', { opening_debit: '5' }, { account_code: '60', ...none }),
]);
expect(rows.map((r) => [r.analytics, r.isGroup])).toEqual([
['60', true],
['Прочее', false],
]);
});
});
describe('flattenEntries: вид аналитики', () => {
const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' });
const cp = (id: string) => e(id, '60', 'COUNTERPARTY', `ООО ${id}`, { opening_debit: '1' }, { group_name: 'Контрагенты' });
@ -165,3 +254,33 @@ describe('flattenEntries: вид аналитики', () => {
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Аналитика', null, false]);
});
});
describe('filterRows', () => {
const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', { opening_debit: '10' }, { account_code: '62' }),
e('62.01', '62', 'ACCOUNT', '62.01', { opening_debit: '7' }, { account_code: '62.01' }),
e('a', '62.01', 'COUNTERPARTY', 'ООО Ромашка', { opening_debit: '7' }, { account_code: '62.01', counterparty_inn: '7701' }),
e('62.02', '62', 'ACCOUNT', '62.02', { opening_debit: '3' }, { account_code: '62.02' }),
e('b', '62.02', 'COUNTERPARTY', 'ООО Лютик', { opening_debit: '3' }, { account_code: '62.02' }),
e('620', null, 'ACCOUNT', '620', { opening_debit: '1' }, { account_code: '620' }),
]);
const keys = (list: typeof rows) => list.map((r) => r.key);
it('без фильтров — все строки', () => {
expect(keys(filterRows(rows, '', undefined))).toEqual(['62', '62.01', 'a', '62.02', 'b', '620']);
});
it('субсчёт — его строка и вложенные', () => {
expect(keys(filterRows(rows, '', '62.01'))).toEqual(['62.01', 'a']);
});
it('счёт — он сам и все субсчета, но не счёт с тем же началом кода', () => {
expect(keys(filterRows(rows, '', '62'))).toEqual(['62', '62.01', 'a', '62.02', 'b']);
});
it('поиск скрывает строки-группы и ищет по аналитике без учёта регистра и по ИНН', () => {
expect(keys(filterRows(rows, 'ромаш', undefined))).toEqual(['a']);
expect(keys(filterRows(rows, ' 7701 ', undefined))).toEqual(['a']);
expect(keys(filterRows(rows, '62', undefined))).toEqual(['620']);
});
});

View File

@ -12,20 +12,24 @@ export const AMOUNT_KEYS = [
export type AmountKey = (typeof AMOUNT_KEYS)[number];
export type Amounts = Record<AmountKey, string | null>;
/** Строка плоской таблицы «Просмотр ОСВ»: аналитика нижнего уровня (контрагент, объект ОС). */
/** Строка таблицы «Просмотр ОСВ» в порядке файла: счёт, субсчёт, подразделение или аналитика (контрагент, объект ОС). */
export type OsvRow = Amounts & {
key: string;
account: string;
department: string | null;
analytics: string | null;
inn: string | null;
/** Глубина вложенности (0 — корень) — отступ в колонке аналитики, как в 1С. */
depth: number;
/** У строки есть вложенные строки (счёт, субсчёт, подразделение с аналитикой) — выводится жирным. */
isGroup: boolean;
};
export type OsvTable = {
rows: OsvRow[];
/** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */
total: Amounts;
/** Субсчета строк для фильтра, в натуральном порядке. */
/** Счета и субсчета строк для фильтра, в натуральном порядке. */
accounts: string[];
/** В файле есть уровень «Подразделение» — показать колонку. */
hasDepartments: boolean;
@ -65,42 +69,55 @@ export function analyticsHeading(leaves: EntryOut[]): Pick<OsvTable, 'analyticsT
}
const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']);
/** Виды сумм в рублях: такие строки — обычные строки таблицы, не показатели. */
const RUBLE_TYPES = new Set(['BU', 'SUM']);
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
/** Строки ОСВ (порядок API — по row_number) → плоская таблица. Договоры (CONTRACT) отдельными строками не идут: их сумма
* уже в строке контрагента, как в сводной ОСВ (§3). Субсчёт или подразделение без нижних строк, но с суммами — строка с
* пустой аналитикой. Битые ссылки на родителя и циклы не роняют разбор: предки ищутся с ограничением шагов. */
/** Строка-показатель (§1.3 правил предметной области: «Кол.», «НУ», «Вал.», «ПР»/«ВР» колонки «Показатели»): не уровень
* группировки, а вид сумм родителя — OTHER, вид не рублёвый и не совпадает с видом родителя. Совпадение с родителем —
* документ целиком в одном виде (НУ из шапки) или старый документ без вида строк. */
const isIndicator = (entry: EntryOut, parent: EntryOut | undefined) =>
parent !== undefined && entry.group_type === 'OTHER' && !RUBLE_TYPES.has(entry.data_type ?? '') && entry.data_type !== parent.data_type;
/** Строки ОСВ (порядок API — по row_number) → таблица в порядке файла, как в 1С: счёт и субсчета — строки-группы над
* вложенными строками. Договоры (CONTRACT) отдельными строками не идут: их сумма уже в строке контрагента, как в сводной
* ОСВ (§3). Строки-показатели (`isIndicator`) в рублёвую таблицу не попадают и родителя группой не делают. Субсчёт или
* подразделение без вложенных строк выводится, только если у него есть суммы. Битые ссылки на родителя и циклы не
* роняют разбор: предки ищутся с ограничением шагов. */
export function flattenEntries(entries: EntryOut[]): OsvTable {
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const withChildren = new Set(
entries.filter((entry) => entry.group_type !== 'CONTRACT' && entry.parent_entry_id).map((entry) => entry.parent_entry_id as string),
);
const parentOf = (entry: EntryOut) => (entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined);
const shown = entries.filter((entry) => entry.group_type !== 'CONTRACT' && !isIndicator(entry, parentOf(entry)));
const withChildren = new Set(shown.map((entry) => entry.parent_entry_id).filter((id): id is string => id !== null));
const ancestors = (entry: EntryOut): EntryOut[] => {
const chain: EntryOut[] = [];
let current = entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined;
let current = parentOf(entry);
for (let steps = 0; current && current.id !== entry.id && steps < entries.length; steps++) {
chain.push(current);
current = current.parent_entry_id ? byId.get(current.parent_entry_id) : undefined;
current = parentOf(current);
}
return chain;
};
const rows: OsvRow[] = [];
const leaves: EntryOut[] = [];
for (const entry of entries) {
if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue;
for (const entry of shown) {
const isGroup = withChildren.has(entry.id);
const structural = STRUCTURE.has(entry.group_type ?? '');
if (structural && !hasAmount(entry)) continue;
if (!structural) leaves.push(entry);
const chain = [entry, ...ancestors(entry)];
if (structural && !isGroup && !hasAmount(entry)) continue;
if (!structural && !isGroup) leaves.push(entry);
const parents = ancestors(entry);
const chain = [entry, ...parents];
const department = chain.find((item) => item.group_type === 'DEPARTMENT');
rows.push({
key: entry.id,
account: chain.find((item) => item.account_code)?.account_code ?? '',
department: department?.row_value ?? null,
analytics: structural ? null : (entry.row_value ?? entry.counterparty_name),
analytics: entry.group_type === 'DEPARTMENT' ? null : (entry.row_value ?? entry.counterparty_name),
inn: entry.counterparty_inn,
depth: parents.length,
isGroup,
...(Object.fromEntries(AMOUNT_KEYS.map((key) => [key, entry[key]])) as Amounts),
});
}
@ -110,3 +127,14 @@ export function flattenEntries(entries: EntryOut[]): OsvTable {
const accounts = [...new Set(rows.map((row) => row.account).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true }));
return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT'), ...analyticsHeading(leaves) };
}
/** Фильтр по счёту — счёт и все его субсчета («62» → 62, 62.01, 62.02); поиск по аналитике (и ИНН) без учёта регистра.
* При поиске строки-группы скрываются: остаются только найденные строки. На клиенте: строки документа загружены целиком. */
export function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
const q = query.trim().toLowerCase();
return rows.filter(
(row) =>
(!account || row.account === account || row.account.startsWith(`${account}.`)) &&
(!q || (!row.isGroup && ((row.analytics ?? '').toLowerCase().includes(q) || (row.inn ?? '').includes(q)))),
);
}