Merge pull request 'Просмотр ОСВ как в 1С: строки счетов и субсчетов, без строк-показателей (#51, AURORA-1702, AURORA-1703)' (#52) from feat/51-osv-view-rows into test
This commit is contained in:
commit
de4e477f2d
@ -1,7 +1,7 @@
|
|||||||
# document-review-ui Specification
|
# document-review-ui Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — плоской таблицей строк только для чтения.
|
Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — таблицей строк в порядке файла (как в 1С) только для чтения.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -81,12 +81,19 @@ column_id, amount}`; ответ заменяет состояние экрана
|
|||||||
- **THEN** экран показывает ошибку со ссылкой на уже подтверждённую форму
|
- **THEN** экран показывает ошибку со ссылкой на уже подтверждённую форму
|
||||||
|
|
||||||
### Requirement: ОСВ-документ
|
### Requirement: ОСВ-документ
|
||||||
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) плоской
|
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) таблицей в порядке
|
||||||
таблицей строк нижнего уровня (договоры отдельными строками не идут — их сумма в строке контрагента) с колонками:
|
файла, как в 1С (AURORA-1702): строки счёта, субсчетов и подразделений, у которых есть вложенные строки, — строки-группы
|
||||||
субсчёт, подразделение (если в файле есть этот уровень), аналитика, ИНН (если есть контрагенты), сальдо на начало Дт/Кт,
|
(жирным, со своими сальдо и оборотами), вложенные строки — с отступом по уровню в колонке аналитики. Договоры отдельными
|
||||||
обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм.
|
строками не идут — их сумма в строке контрагента; субсчёт или подразделение без вложенных строк и без сумм не выводится.
|
||||||
Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения не
|
Строки-показатели колонки «Показатели» и строки «Кол.», «Вал.» (§1.3 правил предметной области: `group_type = OTHER`,
|
||||||
учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name`
|
`data_type` не BU/SUM и не равен виду родителя — NU, QUANTITY, CURRENCY или пусто у «ПР»/«ВР») в рублёвую таблицу
|
||||||
|
SHALL NOT попадать и родителя группой не делают: строка БУ остаётся строкой с наименованием и суммами в рублях
|
||||||
|
(AURORA-1703). Колонки: субсчёт, подразделение (если в файле есть этот уровень), аналитика (у строки счёта — её текст),
|
||||||
|
ИНН (если есть контрагенты), сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с
|
||||||
|
разделителями разрядов, рубли), только чтение. Фильтр по счёту оставляет счёт и все его субсчета («62» — 62, 62.01,
|
||||||
|
62.02), при поиске по аналитике или ИНН строки-группы скрываются. Кнопка «Подтвердить» — как для форм.
|
||||||
|
Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения,
|
||||||
|
строки-группы и строки-показатели не учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name`
|
||||||
(«Основные средства»); смесь видов или вид без `group_name` — «Аналитика», а перечень видов — во всплывающей подсказке
|
(«Основные средства»); смесь видов или вид без `group_name` — «Аналитика», а перечень видов — во всплывающей подсказке
|
||||||
заголовка. Колонка «ИНН» и подсказка поиска «Контрагент или ИНН» SHALL показываться только при наличии контрагентов
|
заголовка. Колонка «ИНН» и подсказка поиска «Контрагент или ИНН» SHALL показываться только при наличии контрагентов
|
||||||
(`COUNTERPARTY`); иначе колонки ИНН нет, а поиск подписан «Поиск по аналитике».
|
(`COUNTERPARTY`); иначе колонки ИНН нет, а поиск подписан «Поиск по аналитике».
|
||||||
@ -95,13 +102,21 @@ column_id, amount}`; ответ заменяет состояние экрана
|
|||||||
- **WHEN** листья документа — аналитика с `group_name` «Основные средства», контрагентов нет
|
- **WHEN** листья документа — аналитика с `group_name` «Основные средства», контрагентов нет
|
||||||
- **THEN** колонка называется «Основные средства», колонки ИНН нет, поиск подписан «Поиск по аналитике»
|
- **THEN** колонка называется «Основные средства», колонки ИНН нет, поиск подписан «Поиск по аналитике»
|
||||||
|
|
||||||
|
#### Scenario: Строки счёта и субсчетов
|
||||||
|
- **WHEN** аналитик открывает ОСВ по счёту 62 с субсчетами 62.01 и 62.02 и контрагентами под ними
|
||||||
|
- **THEN** в таблице жирным видны строки 62, 62.01, 62.02 с их сальдо и оборотами, контрагенты — под своим субсчётом с отступом
|
||||||
|
|
||||||
|
#### Scenario: ОСВ с колонкой «Показатели»
|
||||||
|
- **WHEN** у позиции номенклатуры счёта 41 в файле строки «БУ» и «Кол.»
|
||||||
|
- **THEN** в таблице строка позиции с наименованием и суммами в рублях, строки «Кол.» нет
|
||||||
|
|
||||||
#### Scenario: Подтверждение ОСВ
|
#### Scenario: Подтверждение ОСВ
|
||||||
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
|
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
|
||||||
- **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION`
|
- **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION`
|
||||||
|
|
||||||
### Requirement: Виртуализация таблицы ОСВ-документа
|
### Requirement: Виртуализация таблицы ОСВ-документа
|
||||||
Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не
|
Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не
|
||||||
отрисовываются. Строки по-прежнему загружаются целиком, поиск по аналитике (и ИНН) и фильтр по субсчёту — клиентские;
|
отрисовываются. Строки по-прежнему загружаются целиком, поиск по аналитике (и ИНН) и фильтр по счёту — клиентские;
|
||||||
серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении.
|
серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении.
|
||||||
Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт»
|
Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт»
|
||||||
закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без
|
закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без
|
||||||
|
|||||||
@ -127,6 +127,8 @@ export interface IssueCountsOut {
|
|||||||
}
|
}
|
||||||
/** Уровень строки ОСВ (DBML `osv_group_type`). */
|
/** Уровень строки ОСВ (DBML `osv_group_type`). */
|
||||||
export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER';
|
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 {
|
export interface EntryOut {
|
||||||
id: string;
|
id: string;
|
||||||
@ -136,6 +138,7 @@ export interface EntryOut {
|
|||||||
row_value: string | null;
|
row_value: string | null;
|
||||||
group_type: OsvGroupType | null;
|
group_type: OsvGroupType | null;
|
||||||
group_name: string | null;
|
group_name: string | null;
|
||||||
|
data_type: OsvDataType | null;
|
||||||
account_code: string | null;
|
account_code: string | null;
|
||||||
counterparty_name: string | null;
|
counterparty_name: string | null;
|
||||||
counterparty_inn: string | null;
|
counterparty_inn: string | null;
|
||||||
|
|||||||
@ -10,11 +10,15 @@ import { IssuesPanel } from './IssuesPanel';
|
|||||||
import { confirmButtonStyle } from './ReviewCard';
|
import { confirmButtonStyle } from './ReviewCard';
|
||||||
import { expectedActualText, failureKey } from './checkText';
|
import { expectedActualText, failureKey } from './checkText';
|
||||||
import { confirmErrorInfo } from './confirmError';
|
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';
|
import { osvBodyHeight } from './osvTableSize';
|
||||||
|
|
||||||
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
|
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
|
||||||
const MONEY_WIDTH = 160;
|
const MONEY_WIDTH = 160;
|
||||||
|
/** Отступ одного уровня вложенности в колонке аналитики, px. */
|
||||||
|
const INDENT = 16;
|
||||||
|
/** Строки-группы (счёт, субсчёт, подразделение с вложенными строками) — жирным, как промежуточные итоги в 1С. */
|
||||||
|
const groupRowStyle = { fontWeight: 600 };
|
||||||
|
|
||||||
const money = (key: AmountKey, title: string) => ({
|
const money = (key: AmountKey, title: string) => ({
|
||||||
key,
|
key,
|
||||||
@ -48,7 +52,8 @@ function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
|
|||||||
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
|
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
|
||||||
dataIndex: 'analytics',
|
dataIndex: 'analytics',
|
||||||
width: 320,
|
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 }] : []),
|
...(hasCounterparties ? [{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 }] : []),
|
||||||
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
|
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 {
|
interface ConfirmProblem {
|
||||||
code: string | undefined;
|
code: string | undefined;
|
||||||
message: string;
|
message: string;
|
||||||
@ -164,7 +160,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
placeholder='Субсчёт'
|
placeholder='Счёт / субсчёт'
|
||||||
style={{ width: 160 }}
|
style={{ width: 160 }}
|
||||||
value={account}
|
value={account}
|
||||||
onChange={setAccount}
|
onChange={setAccount}
|
||||||
@ -199,6 +195,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
|||||||
bordered
|
bordered
|
||||||
size='small'
|
size='small'
|
||||||
rowKey='key'
|
rowKey='key'
|
||||||
|
onRow={(row) => ({ style: row.isGroup ? groupRowStyle : undefined })}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={rows}
|
dataSource={rows}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { EntryOut, OsvGroupType } from '../../types/api';
|
import type { EntryOut, OsvGroupType } from '../../types/api';
|
||||||
import { flattenEntries } from './osvRows';
|
import { filterRows, flattenEntries } from './osvRows';
|
||||||
|
|
||||||
let row = 0;
|
let row = 0;
|
||||||
const e = (
|
const e = (
|
||||||
@ -18,6 +18,7 @@ const e = (
|
|||||||
row_value: value,
|
row_value: value,
|
||||||
group_type: groupType,
|
group_type: groupType,
|
||||||
group_name: null,
|
group_name: null,
|
||||||
|
data_type: 'BU',
|
||||||
account_code: null,
|
account_code: null,
|
||||||
counterparty_name: null,
|
counterparty_name: null,
|
||||||
counterparty_inn: null,
|
counterparty_inn: null,
|
||||||
@ -32,16 +33,29 @@ const e = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('flattenEntries', () => {
|
describe('flattenEntries', () => {
|
||||||
it('строки — только аналитика нижнего уровня с субсчётом', () => {
|
it('все строки в порядке файла: счёт и субсчёт — группы, вложенные — с отступом по уровню (AURORA-1702)', () => {
|
||||||
const { rows } = flattenEntries([
|
const { rows } = flattenEntries([
|
||||||
e('62', null, 'ACCOUNT', '62', { opening_debit: '10' }, { account_code: '62' }),
|
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('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' }),
|
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([
|
expect(rows.map((r) => [r.account, r.analytics, r.inn, r.opening_debit, r.depth, r.isGroup])).toEqual([
|
||||||
['62.01', 'ООО А', '123', '7'],
|
['62', '62', null, '10', 0, true],
|
||||||
['62.01', 'ООО Б', null, '3'],
|
['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' }),
|
e('a', 'd', 'COUNTERPARTY', 'РБ', {}, { account_code: '62.01' }),
|
||||||
]);
|
]);
|
||||||
expect(hasDepartments).toBe(true);
|
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('договоры не отдельными строками: остаётся строка контрагента', () => {
|
it('договоры не отдельными строками: остаётся строка контрагента', () => {
|
||||||
@ -63,7 +82,10 @@ describe('flattenEntries', () => {
|
|||||||
e('c1', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '60' }, { account_code: '58.03' }),
|
e('c1', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '60' }, { account_code: '58.03' }),
|
||||||
e('c2', 'a', 'CONTRACT', 'Договор 2', { opening_debit: '40' }, { 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) — строки аналитики', () => {
|
it('объекты ОС (OTHER) — строки аналитики', () => {
|
||||||
@ -71,19 +93,20 @@ describe('flattenEntries', () => {
|
|||||||
e('01', null, 'ACCOUNT', '01', {}, { account_code: '01' }),
|
e('01', null, 'ACCOUNT', '01', {}, { account_code: '01' }),
|
||||||
e('o', '01', 'OTHER', 'Автопогрузчик', { opening_debit: '5' }, { account_code: '01.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([
|
const { rows } = flattenEntries([
|
||||||
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
|
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.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('62.22', '62', 'ACCOUNT', '62.22', {}, { account_code: '62.22' }),
|
||||||
e('d', '62', 'DEPARTMENT', 'Опт', { turnover_debit: '1' }, { account_code: '62' }),
|
e('d', '62', 'DEPARTMENT', 'Опт', { turnover_debit: '1' }, { account_code: '62' }),
|
||||||
]);
|
]);
|
||||||
expect(rows.map((r) => [r.account, r.department, r.analytics])).toEqual([
|
expect(rows.map((r) => [r.account, r.department, r.analytics, r.isGroup])).toEqual([
|
||||||
['62.21', null, null],
|
['62', null, '62', true],
|
||||||
['62', 'Опт', null],
|
['62.21', null, '62.21', false],
|
||||||
|
['62', 'Опт', null, false],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -97,14 +120,14 @@ describe('flattenEntries', () => {
|
|||||||
expect(total.opening_debit).toBeNull();
|
expect(total.opening_debit).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('субсчета для фильтра — уникальные, в натуральном порядке', () => {
|
it('счета и субсчета для фильтра — уникальные, в натуральном порядке', () => {
|
||||||
const { accounts } = flattenEntries([
|
const { accounts } = flattenEntries([
|
||||||
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
|
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
|
||||||
e('b', '62', 'COUNTERPARTY', 'Б', {}, { account_code: '62.10' }),
|
e('b', '62', 'COUNTERPARTY', 'Б', {}, { account_code: '62.10' }),
|
||||||
e('a', '62', 'COUNTERPARTY', 'А', {}, { account_code: '62.02' }),
|
e('a', '62', 'COUNTERPARTY', 'А', {}, { account_code: '62.02' }),
|
||||||
e('c', '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('пустой документ', () => {
|
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: вид аналитики', () => {
|
describe('flattenEntries: вид аналитики', () => {
|
||||||
const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' });
|
const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' });
|
||||||
const cp = (id: string) => e(id, '60', 'COUNTERPARTY', `ООО ${id}`, { opening_debit: '1' }, { group_name: 'Контрагенты' });
|
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]);
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -12,20 +12,24 @@ export const AMOUNT_KEYS = [
|
|||||||
export type AmountKey = (typeof AMOUNT_KEYS)[number];
|
export type AmountKey = (typeof AMOUNT_KEYS)[number];
|
||||||
export type Amounts = Record<AmountKey, string | null>;
|
export type Amounts = Record<AmountKey, string | null>;
|
||||||
|
|
||||||
/** Строка плоской таблицы «Просмотр ОСВ»: аналитика нижнего уровня (контрагент, объект ОС). */
|
/** Строка таблицы «Просмотр ОСВ» в порядке файла: счёт, субсчёт, подразделение или аналитика (контрагент, объект ОС). */
|
||||||
export type OsvRow = Amounts & {
|
export type OsvRow = Amounts & {
|
||||||
key: string;
|
key: string;
|
||||||
account: string;
|
account: string;
|
||||||
department: string | null;
|
department: string | null;
|
||||||
analytics: string | null;
|
analytics: string | null;
|
||||||
inn: string | null;
|
inn: string | null;
|
||||||
|
/** Глубина вложенности (0 — корень) — отступ в колонке аналитики, как в 1С. */
|
||||||
|
depth: number;
|
||||||
|
/** У строки есть вложенные строки (счёт, субсчёт, подразделение с аналитикой) — выводится жирным. */
|
||||||
|
isGroup: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OsvTable = {
|
export type OsvTable = {
|
||||||
rows: OsvRow[];
|
rows: OsvRow[];
|
||||||
/** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */
|
/** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */
|
||||||
total: Amounts;
|
total: Amounts;
|
||||||
/** Субсчета строк для фильтра, в натуральном порядке. */
|
/** Счета и субсчета строк для фильтра, в натуральном порядке. */
|
||||||
accounts: string[];
|
accounts: string[];
|
||||||
/** В файле есть уровень «Подразделение» — показать колонку. */
|
/** В файле есть уровень «Подразделение» — показать колонку. */
|
||||||
hasDepartments: boolean;
|
hasDepartments: boolean;
|
||||||
@ -65,42 +69,55 @@ export function analyticsHeading(leaves: EntryOut[]): Pick<OsvTable, 'analyticsT
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']);
|
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);
|
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
|
||||||
|
|
||||||
/** Строки ОСВ (порядок API — по row_number) → плоская таблица. Договоры (CONTRACT) отдельными строками не идут: их сумма
|
/** Строка-показатель (§1.3 правил предметной области: «Кол.», «НУ», «Вал.», «ПР»/«ВР» колонки «Показатели»): не уровень
|
||||||
* уже в строке контрагента, как в сводной ОСВ (§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 {
|
export function flattenEntries(entries: EntryOut[]): OsvTable {
|
||||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||||
const withChildren = new Set(
|
const parentOf = (entry: EntryOut) => (entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined);
|
||||||
entries.filter((entry) => entry.group_type !== 'CONTRACT' && entry.parent_entry_id).map((entry) => entry.parent_entry_id as string),
|
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 ancestors = (entry: EntryOut): EntryOut[] => {
|
||||||
const chain: 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++) {
|
for (let steps = 0; current && current.id !== entry.id && steps < entries.length; steps++) {
|
||||||
chain.push(current);
|
chain.push(current);
|
||||||
current = current.parent_entry_id ? byId.get(current.parent_entry_id) : undefined;
|
current = parentOf(current);
|
||||||
}
|
}
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows: OsvRow[] = [];
|
const rows: OsvRow[] = [];
|
||||||
const leaves: EntryOut[] = [];
|
const leaves: EntryOut[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of shown) {
|
||||||
if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue;
|
const isGroup = withChildren.has(entry.id);
|
||||||
const structural = STRUCTURE.has(entry.group_type ?? '');
|
const structural = STRUCTURE.has(entry.group_type ?? '');
|
||||||
if (structural && !hasAmount(entry)) continue;
|
if (structural && !isGroup && !hasAmount(entry)) continue;
|
||||||
if (!structural) leaves.push(entry);
|
if (!structural && !isGroup) leaves.push(entry);
|
||||||
const chain = [entry, ...ancestors(entry)];
|
const parents = ancestors(entry);
|
||||||
|
const chain = [entry, ...parents];
|
||||||
const department = chain.find((item) => item.group_type === 'DEPARTMENT');
|
const department = chain.find((item) => item.group_type === 'DEPARTMENT');
|
||||||
rows.push({
|
rows.push({
|
||||||
key: entry.id,
|
key: entry.id,
|
||||||
account: chain.find((item) => item.account_code)?.account_code ?? '',
|
account: chain.find((item) => item.account_code)?.account_code ?? '',
|
||||||
department: department?.row_value ?? null,
|
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,
|
inn: entry.counterparty_inn,
|
||||||
|
depth: parents.length,
|
||||||
|
isGroup,
|
||||||
...(Object.fromEntries(AMOUNT_KEYS.map((key) => [key, entry[key]])) as Amounts),
|
...(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 }));
|
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) };
|
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)))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user