fix(DocumentReview): просмотр ОСВ как в 1С — строки счетов и субсчетов, без строк-показателей (#51, AURORA-1702, AURORA-1703)
- AURORA-1702: таблица в порядке файла — счёт, субсчёт и подразделение с вложенными строками выводятся строками-группами (жирным, со своими сальдо и оборотами), вложенные — с отступом по уровню; фильтр по счёту оставляет счёт и все его субсчета, при поиске строки-группы скрываются. - AURORA-1703: строки-показатели («Кол.», «НУ», «Вал.», «ПР»/«ВР»: OTHER, вид не BU/SUM и не равен виду родителя) в рублёвую таблицу не попадают и родителя группой не делают — строка БУ снова видна с наименованием и рублями. В EntryOut добавлен data_type (ядро его уже отдаёт). - filterRows перенесён в osvRows.ts (чистая функция с тестами); спека document-review-ui обновлена. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a3c0010501
commit
e202711584
@ -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 открываться без
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)))),
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user