feat(osv): «Просмотр ОСВ» — плоская таблица вместо дерева (#12)
- Строка = аналитика нижнего уровня (контрагент, объект ОС); строки счетов и субсчетов не показываются, договоры (CONTRACT) — в строке контрагента, как в сводной ОСВ (§3); субсчёт/подразделение без нижних строк с суммами — строкой с пустой аналитикой. - Колонки: Субсчёт · Подразделение (если уровень есть в файле) · Контрагент / аналитика · ИНН · 6 сумм; закреплённая строка «Итого по документу» = сумма корневых строк счёта (= «Итого» файла 1С). - Поиск по контрагенту/ИНН, фильтр по субсчёту, «Строк: N» — на клиенте. - flattenEntries (osvRows.ts) и точное sumAmounts (копейками, BigInt) с тестами; EntryOut.group_type — как в схеме arm-core; entryTree удалён. - Пустой документ: «В документе нет строк» вместо «No data». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
08971736b1
commit
c3fcac2efd
@ -73,12 +73,16 @@ export interface DocumentOut extends DocumentBrief {
|
||||
confirmed_at: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
/** Уровень строки ОСВ (DBML `osv_group_type`). */
|
||||
export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER';
|
||||
|
||||
export interface EntryOut {
|
||||
id: string;
|
||||
row_number: number | null;
|
||||
level: number | null;
|
||||
parent_entry_id: string | null;
|
||||
row_value: string | null;
|
||||
group_type: OsvGroupType | null;
|
||||
group_name: string | null;
|
||||
account_code: string | null;
|
||||
counterparty_name: string | null;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatAmount, parseAmountInput } from './money';
|
||||
import { formatAmount, parseAmountInput, sumAmounts } from './money';
|
||||
|
||||
const NBSP = ' ';
|
||||
|
||||
@ -55,3 +55,23 @@ describe('parseAmountInput', () => {
|
||||
expect(parseAmountInput('abc')).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumAmounts', () => {
|
||||
it('складывает точно, без float', () => {
|
||||
expect(sumAmounts(['0.1', '0.2'])).toBe('0.30');
|
||||
expect(sumAmounts(['613926948.12', '2555438965.10'])).toBe('3169365913.22');
|
||||
});
|
||||
it('отрицательные и разное число знаков', () => {
|
||||
expect(sumAmounts(['-8213.34', '100', '0.5'])).toBe('-8112.84');
|
||||
expect(sumAmounts(['-0.5', '0.2'])).toBe('-0.30');
|
||||
});
|
||||
it('пустые значения пропускает; все пустые — null', () => {
|
||||
expect(sumAmounts([null, '5', undefined, ''])).toBe('5.00');
|
||||
expect(sumAmounts([null, null])).toBeNull();
|
||||
expect(sumAmounts([])).toBeNull();
|
||||
});
|
||||
it('нераспознанная сумма — null (итог не выдумываем)', () => {
|
||||
expect(sumAmounts(['1', 'abc'])).toBeNull();
|
||||
expect(sumAmounts(['1.234'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const NBSP = ' ';
|
||||
const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/;
|
||||
const INPUT = /^-?\d+(\.\d{1,2})?$/;
|
||||
const MONEY = /^(-?)(\d+)(?:\.(\d{1,2}))?$/;
|
||||
|
||||
export type AmountFormat = {
|
||||
/** Ноль показывать прочерком (свод, ОСВ — как в макете [30]). */
|
||||
@ -45,3 +46,23 @@ export function parseAmountInput(input: string): { ok: true; value: string | nul
|
||||
}
|
||||
return INPUT.test(normalized) ? { ok: true, value: normalized } : { ok: false };
|
||||
}
|
||||
|
||||
/** Точная сумма строк Decimal из API (копейками, без float) → «123.45»; пустые пропускаются, все пустые — null.
|
||||
* Сумма, которую не удалось разобрать (больше двух знаков, текст), делает итог null — итог не выдумываем. */
|
||||
export function sumAmounts(values: ReadonlyArray<string | null | undefined>): string | null {
|
||||
let cents = 0n;
|
||||
let any = false;
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined || value === '') continue;
|
||||
const match = MONEY.exec(value);
|
||||
if (!match) return null;
|
||||
const [, sign, intPart, frac = ''] = match;
|
||||
const amount = BigInt(intPart) * 100n + BigInt(frac.padEnd(2, '0'));
|
||||
cents += sign ? -amount : amount;
|
||||
any = true;
|
||||
}
|
||||
if (!any) return null;
|
||||
const negative = cents < 0n;
|
||||
const abs = negative ? -cents : cents;
|
||||
return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Table, message } from 'antd';
|
||||
import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Input, Select, Table, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
@ -8,9 +8,9 @@ import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
import { confirmButtonStyle } from './ReviewCard';
|
||||
import { confirmErrorInfo } from './confirmError';
|
||||
import { type EntryNode, buildEntryTree } from './entryTree';
|
||||
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
|
||||
|
||||
const money = (key: keyof EntryOut, title: string) => ({
|
||||
const money = (key: AmountKey, title: string) => ({
|
||||
key,
|
||||
title,
|
||||
dataIndex: key,
|
||||
@ -19,21 +19,28 @@ const money = (key: keyof EntryOut, title: string) => ({
|
||||
render: (v: string | null) => formatAmount(v, { zeroDash: true }),
|
||||
});
|
||||
|
||||
const columns: ColumnsType<EntryNode> = [
|
||||
{
|
||||
key: 'row_value',
|
||||
title: 'Счёт / контрагент',
|
||||
dataIndex: 'row_value',
|
||||
render: (v: string | null, r) => v ?? r.counterparty_name ?? r.account_code ?? '',
|
||||
},
|
||||
{ key: 'counterparty_inn', title: 'ИНН', dataIndex: 'counterparty_inn', width: 120 },
|
||||
money('opening_debit', 'Сальдо нач. Дт'),
|
||||
money('opening_credit', 'Сальдо нач. Кт'),
|
||||
money('turnover_debit', 'Обороты Дт'),
|
||||
money('turnover_credit', 'Обороты Кт'),
|
||||
money('closing_debit', 'Сальдо кон. Дт'),
|
||||
money('closing_credit', 'Сальдо кон. Кт'),
|
||||
];
|
||||
const group = (title: string, children: ColumnsType<OsvRow>): ColumnsType<OsvRow>[number] => ({ title, align: 'center', children });
|
||||
|
||||
function buildColumns(hasDepartments: boolean): ColumnsType<OsvRow> {
|
||||
return [
|
||||
{ key: 'account', title: 'Субсчёт', dataIndex: 'account', width: 100, fixed: 'left' },
|
||||
...(hasDepartments ? [{ key: 'department', title: 'Подразделение', dataIndex: 'department', width: 160 }] : []),
|
||||
{ key: 'analytics', title: 'Контрагент / аналитика', dataIndex: 'analytics', width: 320, render: (v: string | null) => v ?? '—' },
|
||||
{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 },
|
||||
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
|
||||
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
|
||||
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
|
||||
];
|
||||
}
|
||||
|
||||
/** Поиск по контрагенту/ИНН без учёта регистра и фильтр по субсчёту — на клиенте: строки документа уже загружены целиком. */
|
||||
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;
|
||||
@ -59,7 +66,13 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
||||
};
|
||||
}, [document.id]);
|
||||
|
||||
const tree = useMemo(() => buildEntryTree(entries), [entries]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [account, setAccount] = useState<string | undefined>();
|
||||
const table = useMemo(() => flattenEntries(entries), [entries]);
|
||||
const columns = useMemo(() => buildColumns(table.hasDepartments), [table.hasDepartments]);
|
||||
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
|
||||
// колонки до сумм: Субсчёт, [Подразделение], Контрагент, ИНН — под ними подпись «Итого по документу»
|
||||
const labelSpan = table.hasDepartments ? 4 : 3;
|
||||
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
|
||||
|
||||
const confirm = async () => {
|
||||
@ -90,6 +103,25 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
||||
</Button>
|
||||
<span style={{ marginLeft: 12, color: '#667085' }}>Суммы в рублях; строки ОСВ только для чтения</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder='Контрагент или ИНН'
|
||||
style={{ width: 320 }}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder='Субсчёт'
|
||||
style={{ width: 160 }}
|
||||
value={account}
|
||||
onChange={setAccount}
|
||||
options={table.accounts.map((code) => ({ value: code, label: code }))}
|
||||
/>
|
||||
<span style={{ marginLeft: 'auto', color: '#667085' }}>Строк: {rows.length}</span>
|
||||
</div>
|
||||
{problem && (
|
||||
<Alert
|
||||
type='error'
|
||||
@ -110,14 +142,32 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Table<EntryNode>
|
||||
<Table<OsvRow>
|
||||
bordered
|
||||
size='small'
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={tree}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 260px)', x: 1300 }}
|
||||
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
|
||||
scroll={{ y: 'calc(100vh - 340px)', x: table.hasDepartments ? 1540 : 1380 }}
|
||||
summary={() =>
|
||||
entries.length > 0 && (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row style={{ fontWeight: 600, background: '#fafafa' }}>
|
||||
<Table.Summary.Cell index={0} colSpan={labelSpan}>
|
||||
Итого по документу
|
||||
</Table.Summary.Cell>
|
||||
{AMOUNT_KEYS.map((key, i) => (
|
||||
<Table.Summary.Cell key={key} index={labelSpan + i} align='right'>
|
||||
{formatAmount(table.total[key], { zeroDash: true })}
|
||||
</Table.Summary.Cell>
|
||||
))}
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EntryOut } from '../../types/api';
|
||||
import { buildEntryTree } from './entryTree';
|
||||
|
||||
const e = (id: string, parent: string | null, row: number): EntryOut => ({
|
||||
id,
|
||||
row_number: row,
|
||||
level: null,
|
||||
parent_entry_id: parent,
|
||||
row_value: id,
|
||||
group_name: null,
|
||||
account_code: null,
|
||||
counterparty_name: null,
|
||||
counterparty_inn: null,
|
||||
opening_debit: null,
|
||||
opening_credit: null,
|
||||
turnover_debit: null,
|
||||
turnover_credit: null,
|
||||
closing_debit: null,
|
||||
closing_credit: null,
|
||||
});
|
||||
|
||||
describe('buildEntryTree', () => {
|
||||
it('вкладывает детей и сохраняет порядок', () => {
|
||||
const tree = buildEntryTree([e('60', null, 1), e('60.01', '60', 2), e('ООО А', '60.01', 3), e('62', null, 4)]);
|
||||
expect(tree.map((n) => n.id)).toEqual(['60', '62']);
|
||||
expect(tree[0].children?.[0].id).toBe('60.01');
|
||||
expect(tree[0].children?.[0].children?.[0].id).toBe('ООО А');
|
||||
expect(tree[1].children).toBeUndefined();
|
||||
});
|
||||
it('ребёнок с неизвестным родителем — в корень', () => {
|
||||
expect(buildEntryTree([e('x', 'missing', 1)]).map((n) => n.id)).toEqual(['x']);
|
||||
});
|
||||
it('самоссылка — в корень, без рекурсии', () => {
|
||||
const tree = buildEntryTree([e('a', 'a', 1), e('b', null, 2)]);
|
||||
expect(tree.map((n) => n.id)).toEqual(['a', 'b']);
|
||||
expect(tree[0].children).toBeUndefined();
|
||||
});
|
||||
it('цикл из двух — оба в корень, ничего не теряется', () => {
|
||||
const tree = buildEntryTree([e('a', 'b', 1), e('b', 'a', 2)]);
|
||||
expect(tree.map((n) => n.id)).toEqual(['a', 'b']);
|
||||
expect(tree.every((n) => n.children === undefined)).toBe(true);
|
||||
});
|
||||
it('дубль id — второй экземпляр пропускается', () => {
|
||||
const tree = buildEntryTree([e('a', null, 1), e('a', null, 2), e('c', 'a', 3)]);
|
||||
expect(tree.map((n) => n.id)).toEqual(['a']);
|
||||
expect(tree[0].row_number).toBe(1);
|
||||
expect(tree[0].children?.map((n) => n.id)).toEqual(['c']);
|
||||
});
|
||||
});
|
||||
@ -1,35 +0,0 @@
|
||||
import type { EntryOut } from '../../types/api';
|
||||
|
||||
export type EntryNode = EntryOut & { key: string; children?: EntryNode[] };
|
||||
|
||||
/** Дерево строк ОСВ по parent_entry_id; порядок — как пришёл (по row_number).
|
||||
* Защита от «грязных» данных: дубль id — второй экземпляр пропускается; самоссылка и цикл — такие строки идут в корень. */
|
||||
export function buildEntryTree(input: EntryOut[]): EntryNode[] {
|
||||
const seen = new Set<string>();
|
||||
const entries = input.filter((entry) => !seen.has(entry.id) && seen.add(entry.id));
|
||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
|
||||
// Строка в цикле, если, идя вверх по родителям, мы вернёмся к ней самой.
|
||||
const inCycle = (start: EntryOut): boolean => {
|
||||
let current = start.parent_entry_id ? byId.get(start.parent_entry_id) : undefined;
|
||||
for (let steps = 0; current && steps <= entries.length; steps++) {
|
||||
if (current.id === start.id) return true;
|
||||
current = current.parent_entry_id ? byId.get(current.parent_entry_id) : undefined;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const nodes = new Map<string, EntryNode>();
|
||||
for (const entry of entries) nodes.set(entry.id, { ...entry, key: entry.id });
|
||||
const roots: EntryNode[] = [];
|
||||
for (const entry of entries) {
|
||||
const node = nodes.get(entry.id) as EntryNode;
|
||||
const parent = entry.parent_entry_id && !inCycle(entry) ? nodes.get(entry.parent_entry_id) : undefined;
|
||||
if (parent) {
|
||||
parent.children = [...(parent.children ?? []), node];
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
118
src/widgets/DocumentReview/osvRows.test.ts
Normal file
118
src/widgets/DocumentReview/osvRows.test.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EntryOut, OsvGroupType } from '../../types/api';
|
||||
import { flattenEntries } from './osvRows';
|
||||
|
||||
let row = 0;
|
||||
const e = (
|
||||
id: string,
|
||||
parent: string | null,
|
||||
groupType: OsvGroupType,
|
||||
value: string,
|
||||
amounts: Partial<Pick<EntryOut, 'opening_debit' | 'turnover_debit' | 'closing_credit'>> = {},
|
||||
extra: Partial<EntryOut> = {},
|
||||
): EntryOut => ({
|
||||
id,
|
||||
row_number: ++row,
|
||||
level: null,
|
||||
parent_entry_id: parent,
|
||||
row_value: value,
|
||||
group_type: groupType,
|
||||
group_name: null,
|
||||
account_code: null,
|
||||
counterparty_name: null,
|
||||
counterparty_inn: null,
|
||||
opening_debit: null,
|
||||
opening_credit: null,
|
||||
turnover_debit: null,
|
||||
turnover_credit: null,
|
||||
closing_debit: null,
|
||||
closing_credit: null,
|
||||
...amounts,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('flattenEntries', () => {
|
||||
it('строки — только аналитика нижнего уровня с субсчётом', () => {
|
||||
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('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'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('подразделение — из родительской строки DEPARTMENT', () => {
|
||||
const { rows, hasDepartments } = flattenEntries([
|
||||
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
|
||||
e('62.01', '62', 'ACCOUNT', '62.01', {}, { account_code: '62.01' }),
|
||||
e('d', '62.01', 'DEPARTMENT', 'БП Аренда', {}, { account_code: '62.01' }),
|
||||
e('a', 'd', 'COUNTERPARTY', 'РБ', {}, { account_code: '62.01' }),
|
||||
]);
|
||||
expect(hasDepartments).toBe(true);
|
||||
expect(rows.map((r) => [r.department, r.analytics])).toEqual([['БП Аренда', 'РБ']]);
|
||||
});
|
||||
|
||||
it('договоры не отдельными строками: остаётся строка контрагента', () => {
|
||||
const { rows } = flattenEntries([
|
||||
e('58', null, 'ACCOUNT', '58.03', {}, { account_code: '58.03' }),
|
||||
e('a', '58', 'COUNTERPARTY', 'РБ ТК', { opening_debit: '100' }, { 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' }),
|
||||
]);
|
||||
expect(rows.map((r) => [r.analytics, r.opening_debit])).toEqual([['РБ ТК', '100']]);
|
||||
});
|
||||
|
||||
it('объекты ОС (OTHER) — строки аналитики', () => {
|
||||
const { rows } = 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', 'Автопогрузчик']]);
|
||||
});
|
||||
|
||||
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],
|
||||
]);
|
||||
});
|
||||
|
||||
it('итог — сумма корневых строк, а не листьев (развёрнутое сальдо 1С)', () => {
|
||||
const { total } = flattenEntries([
|
||||
e('60', null, 'ACCOUNT', '60', { closing_credit: '100.10' }, { account_code: '60' }),
|
||||
e('a', '60', 'COUNTERPARTY', 'А', { closing_credit: '150.10' }, { account_code: '60' }),
|
||||
e('76', null, 'ACCOUNT', '76', { closing_credit: '0.20' }, { account_code: '76' }),
|
||||
]);
|
||||
expect(total.closing_credit).toBe('100.30');
|
||||
expect(total.opening_debit).toBeNull();
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
it('пустой документ', () => {
|
||||
expect(flattenEntries([])).toEqual({
|
||||
rows: [],
|
||||
total: expect.objectContaining({ opening_debit: null }),
|
||||
accounts: [],
|
||||
hasDepartments: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
77
src/widgets/DocumentReview/osvRows.ts
Normal file
77
src/widgets/DocumentReview/osvRows.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import type { EntryOut } from '../../types/api';
|
||||
import { sumAmounts } from '../../utils/money';
|
||||
|
||||
export const AMOUNT_KEYS = [
|
||||
'opening_debit',
|
||||
'opening_credit',
|
||||
'turnover_debit',
|
||||
'turnover_credit',
|
||||
'closing_debit',
|
||||
'closing_credit',
|
||||
] as const;
|
||||
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;
|
||||
};
|
||||
|
||||
export type OsvTable = {
|
||||
rows: OsvRow[];
|
||||
/** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */
|
||||
total: Amounts;
|
||||
/** Субсчета строк для фильтра, в натуральном порядке. */
|
||||
accounts: string[];
|
||||
/** В файле есть уровень «Подразделение» — показать колонку. */
|
||||
hasDepartments: boolean;
|
||||
};
|
||||
|
||||
const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']);
|
||||
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
|
||||
|
||||
/** Строки ОСВ (порядок API — по row_number) → плоская таблица. Договоры (CONTRACT) отдельными строками не идут: их сумма
|
||||
* уже в строке контрагента, как в сводной ОСВ (§3). Субсчёт или подразделение без нижних строк, но с суммами — строка с
|
||||
* пустой аналитикой. Битые ссылки на родителя и циклы не роняют разбор: предки ищутся с ограничением шагов. */
|
||||
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 ancestors = (entry: EntryOut): EntryOut[] => {
|
||||
const chain: EntryOut[] = [];
|
||||
let current = entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined;
|
||||
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;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
const rows: OsvRow[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue;
|
||||
const structural = STRUCTURE.has(entry.group_type ?? '');
|
||||
if (structural && !hasAmount(entry)) continue;
|
||||
const chain = [entry, ...ancestors(entry)];
|
||||
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),
|
||||
inn: entry.counterparty_inn,
|
||||
...(Object.fromEntries(AMOUNT_KEYS.map((key) => [key, entry[key]])) as Amounts),
|
||||
});
|
||||
}
|
||||
|
||||
const roots = entries.filter((entry) => !entry.parent_entry_id || !byId.has(entry.parent_entry_id));
|
||||
const total = Object.fromEntries(AMOUNT_KEYS.map((key) => [key, sumAmounts(roots.map((root) => root[key]))])) as Amounts;
|
||||
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') };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user