fix(web): синхронизация встроенного фронта с arm-web@test (783c3f0) (#123)

Перенесён диапазон arm-web 1237105..783c3f0 (3 коммитов, 5 файлов набора
синхронизации) скриптом docs/scripts/sync-web.sh. После переноса web/ и
arm-web@test идентичны по набору. Коммиты arm-web:
- a3e53c8 fix(DocumentReview): виртуализация таблицы ОСВ — большая ОСВ больше не роняет вкладку (#38, AURORA-1665)
- bbb1b59 docs(spec): просмотр ОСВ — плоская таблица, колонки по факту (#39, AURORA-1666)
- 94cb0a4 fix(DocumentReview): заголовок колонки аналитики по виду, ИНН только при контрагентах (#39, AURORA-1666)

Co-Authored-By: sync-web.sh <noreply@aepif.ru>
This commit is contained in:
Andrey Epifancev 2026-10-17 14:46:42 +04:00
parent 19b44c4058
commit 3d3d935ff4
5 changed files with 171 additions and 12 deletions

View File

@ -1,5 +1,5 @@
import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
import { Alert, Button, Input, Select, Table, message } from 'antd';
import { Alert, Button, Input, Select, Table, Tooltip, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
@ -9,31 +9,53 @@ import { formatAmount } from '../../utils/money';
import { confirmButtonStyle } from './ReviewCard';
import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
import { osvBodyHeight } from './osvTableSize';
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
const MONEY_WIDTH = 160;
const money = (key: AmountKey, title: string) => ({
key,
title,
dataIndex: key,
align: 'right' as const,
width: 140,
width: MONEY_WIDTH,
render: (v: string | null) => formatAmount(v, { zeroDash: true }),
});
const group = (title: string, children: ColumnsType<OsvRow>): ColumnsType<OsvRow>[number] => ({ title, align: 'center', children });
function buildColumns(hasDepartments: boolean): ColumnsType<OsvRow> {
interface ColumnsOptions {
hasDepartments: boolean;
hasCounterparties: boolean;
analyticsTitle: string;
analyticsHint: string | null;
}
/** Ширина колонок до сумм (Субсчёт, [Подразделение], аналитика, [ИНН]) и всей таблицы с шестью суммами по MONEY_WIDTH. */
const widthBeforeAmounts = ({ hasDepartments, hasCounterparties }: ColumnsOptions) =>
100 + (hasDepartments ? 160 : 0) + 320 + (hasCounterparties ? 120 : 0);
function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
const { hasDepartments, hasCounterparties, analyticsTitle, analyticsHint } = options;
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 },
{
key: 'analytics',
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
dataIndex: 'analytics',
width: 320,
render: (v: string | null) => v ?? '—',
},
...(hasCounterparties ? [{ 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(
@ -48,6 +70,17 @@ interface ConfirmProblem {
failures: FailedCheckOut[];
}
/** Высота области прокрутки числом, пересчитывается при изменении размера окна. */
function useBodyHeight(): number {
const [height, setHeight] = useState(() => osvBodyHeight(window.innerHeight));
useEffect(() => {
const onResize = () => setHeight(osvBodyHeight(window.innerHeight));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return height;
}
export function OsvDocument({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
const [entries, setEntries] = useState<EntryOut[]>([]);
const [loading, setLoading] = useState(true);
@ -66,13 +99,23 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
};
}, [document.id]);
const bodyHeight = useBodyHeight();
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 columnsOptions = useMemo(
() => ({
hasDepartments: table.hasDepartments,
hasCounterparties: table.hasCounterparties,
analyticsTitle: table.analyticsTitle,
analyticsHint: table.analyticsHint,
}),
[table.hasDepartments, table.hasCounterparties, table.analyticsTitle, table.analyticsHint],
);
const columns = useMemo(() => buildColumns(columnsOptions), [columnsOptions]);
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
// колонки до сумм: Субсчёт, [Подразделение], Контрагент, ИНН — под ними подпись «Итого по документу»
const labelSpan = table.hasDepartments ? 4 : 3;
// колонки до сумм: Субсчёт, [Подразделение], аналитика, [ИНН] — под ними подпись «Итого по документу»
const labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0);
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
const confirm = async () => {
@ -107,7 +150,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
<Input
allowClear
prefix={<SearchOutlined />}
placeholder='Контрагент или ИНН'
placeholder={table.hasCounterparties ? 'Контрагент или ИНН' : 'Поиск по аналитике'}
style={{ width: 320 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
@ -143,6 +186,8 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
/>
)}
<Table<OsvRow>
// virtual: в DOM только видимые строки (ОСВ на десятки тысяч строк иначе роняет вкладку); нужны числовые scroll.x и scroll.y
virtual
bordered
size='small'
rowKey='key'
@ -151,7 +196,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
dataSource={rows}
pagination={false}
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
scroll={{ y: 'calc(100vh - 340px)', x: table.hasDepartments ? 1540 : 1380 }}
scroll={{ y: bodyHeight, x: widthBeforeAmounts(columnsOptions) + AMOUNT_KEYS.length * MONEY_WIDTH }}
summary={() =>
entries.length > 0 && (
<Table.Summary fixed>

View File

@ -113,6 +113,55 @@ describe('flattenEntries', () => {
total: expect.objectContaining({ opening_debit: null }),
accounts: [],
hasDepartments: false,
analyticsTitle: 'Аналитика',
analyticsHint: null,
hasCounterparties: 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: 'Контрагенты' });
const os = (id: string, name: string | null = 'Основные средства') =>
e(id, '60', 'OTHER', `Объект ${id}`, { opening_debit: '1' }, { group_name: name });
it('только контрагенты — «Контрагент», ИНН и поиск по ИНН нужны', () => {
const t = flattenEntries([acc, cp('a'), cp('b')]);
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Контрагент', null, true]);
});
it('договор под контрагентом вид не меняет', () => {
const t = flattenEntries([acc, cp('a'), e('d', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '1' }, { group_name: 'Договоры' })]);
expect([t.analyticsTitle, t.hasCounterparties]).toEqual(['Контрагент', true]);
});
it('только основные средства — название вида из файла, без ИНН', () => {
const t = flattenEntries([acc, os('a'), os('b')]);
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Основные средства', null, false]);
});
it('смесь видов — «Аналитика» и перечень видов в подсказке', () => {
const t = flattenEntries([acc, cp('a'), os('b')]);
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual([
'Аналитика',
'Виды аналитики: Контрагенты, Основные средства',
true,
]);
});
it('два разных вида не-контрагентов — тоже «Аналитика»', () => {
const t = flattenEntries([acc, os('a'), os('b', 'Номенклатура')]);
expect([t.analyticsTitle, t.analyticsHint]).toEqual(['Аналитика', 'Виды аналитики: Основные средства, Номенклатура']);
});
it('вид без названия — «Аналитика»', () => {
const t = flattenEntries([acc, os('a', null)]);
expect([t.analyticsTitle, t.hasCounterparties]).toEqual(['Аналитика', false]);
});
it('структурные строки вид не определяют; без листьев аналитики — «Аналитика» без ИНН', () => {
const t = flattenEntries([e('60', null, 'ACCOUNT', '60', { opening_debit: '5' }, { account_code: '60' })]);
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Аналитика', null, false]);
});
});

View File

@ -29,8 +29,41 @@ export type OsvTable = {
accounts: string[];
/** В файле есть уровень «Подразделение» — показать колонку. */
hasDepartments: boolean;
/** Заголовок колонки аналитики по виду строк: «Контрагент», название вида из файла («Основные средства») или «Аналитика». */
analyticsTitle: string;
/** Перечень видов аналитики для подсказки у заголовка — только когда заголовок «Аналитика» из-за смеси видов. */
analyticsHint: string | null;
/** Среди строк есть контрагенты (COUNTERPARTY): только тогда нужны колонка ИНН и поиск по ИНН. */
hasCounterparties: boolean;
};
const MIXED_TITLE = 'Аналитика';
/** Заголовок колонки аналитики по видам строк-листьев. Вид — COUNTERPARTY или название группировки из шапки файла
* (`group_name`, у остальных типов — OTHER). Один вид: контрагенты → «Контрагент», иначе его название; смесь или
* вид без названия → «Аналитика», а при смеси перечень видов уходит в подсказку. */
export function analyticsHeading(leaves: EntryOut[]): Pick<OsvTable, 'analyticsTitle' | 'analyticsHint' | 'hasCounterparties'> {
const kinds = new Map<string, string | null>();
for (const leaf of leaves) {
const name = leaf.group_name?.trim() || null;
const key = leaf.group_type === 'COUNTERPARTY' ? 'COUNTERPARTY' : `name:${name ?? ''}`;
const label = leaf.group_type === 'COUNTERPARTY' ? (name ?? 'Контрагенты') : name;
if (!kinds.has(key) || kinds.get(key) === null) kinds.set(key, label);
}
const hasCounterparties = kinds.has('COUNTERPARTY');
if (kinds.size === 1) {
const [[key, label]] = [...kinds];
if (key === 'COUNTERPARTY') return { analyticsTitle: 'Контрагент', analyticsHint: null, hasCounterparties };
return { analyticsTitle: label ?? MIXED_TITLE, analyticsHint: null, hasCounterparties };
}
const labels = [...kinds.values()].map((label) => label ?? 'без названия');
return {
analyticsTitle: MIXED_TITLE,
analyticsHint: labels.length > 0 ? `Виды аналитики: ${labels.join(', ')}` : null,
hasCounterparties,
};
}
const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']);
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
@ -54,10 +87,12 @@ export function flattenEntries(entries: EntryOut[]): OsvTable {
};
const rows: OsvRow[] = [];
const leaves: EntryOut[] = [];
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;
if (!structural) leaves.push(entry);
const chain = [entry, ...ancestors(entry)];
const department = chain.find((item) => item.group_type === 'DEPARTMENT');
rows.push({
@ -73,5 +108,5 @@ export function flattenEntries(entries: EntryOut[]): OsvTable {
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') };
return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT'), ...analyticsHeading(leaves) };
}

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { OSV_MIN_BODY_HEIGHT, osvBodyHeight } from './osvTableSize';
describe('osvBodyHeight', () => {
it('вычитает высоту обвязки из высоты окна', () => {
expect(osvBodyHeight(1000)).toBe(660);
});
it('округляет вниз до целого', () => {
expect(osvBodyHeight(1000.7)).toBe(660);
});
it('в низком окне не опускается ниже минимума', () => {
expect(osvBodyHeight(400)).toBe(OSV_MIN_BODY_HEIGHT);
});
it('на нечисловом значении возвращает минимум', () => {
expect(osvBodyHeight(Number.NaN)).toBe(OSV_MIN_BODY_HEIGHT);
});
});

View File

@ -0,0 +1,13 @@
/** Высота тела таблицы не меньше этой, даже в низком окне. */
export const OSV_MIN_BODY_HEIGHT = 240;
/** Сколько высоты окна занимает всё над и под телом таблицы: шапка страницы, кнопки, поиск, шапка и «Итого» таблицы. */
export const OSV_CHROME_HEIGHT = 340;
/**
* Высота области прокрутки таблицы ОСВ числом (виртуальной таблице antd нужен числовой scroll.y, calc() не подходит).
* Повторяет прежнее `calc(100vh - 340px)`, но не ниже минимума; мусорное значение окна даёт минимум.
*/
export function osvBodyHeight(viewportHeight: number): number {
if (!Number.isFinite(viewportHeight)) return OSV_MIN_BODY_HEIGHT;
return Math.max(OSV_MIN_BODY_HEIGHT, Math.floor(viewportHeight - OSV_CHROME_HEIGHT));
}