Просмотр ОСВ: плоская таблица вместо дерева (#12) #13

Merged
aep merged 1 commits from feat/12-osv-flat-table into test 2026-10-08 15:23:29 +03:00
8 changed files with 314 additions and 109 deletions

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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')}`;
}

View File

@ -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>
);

View File

@ -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']);
});
});

View File

@ -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;
}

View 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,
});
});
});

View 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') };
}