Andrey Epifancev 52b813617a fix(web): синхронизация встроенного фронта с arm-web@test (de4e477) (#146)
Перенесён диапазон arm-web a3c0010..de4e477 (1 коммитов, 4 файлов набора
синхронизации) скриптом docs/scripts/sync-web.sh. После переноса web/ и
arm-web@test идентичны по набору. Коммиты arm-web:
- e202711 fix(DocumentReview): просмотр ОСВ как в 1С — строки счетов и субсчетов, без строк-показателей (#51, AURORA-1702, AURORA-1703)

Co-Authored-By: sync-web.sh <noreply@aepif.ru>
2026-10-21 00:08:27 +04:00

141 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** Глубина вложенности (0 — корень) — отступ в колонке аналитики, как в 1С. */
depth: number;
/** У строки есть вложенные строки (счёт, субсчёт, подразделение с аналитикой) — выводится жирным. */
isGroup: boolean;
};
export type OsvTable = {
rows: OsvRow[];
/** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */
total: Amounts;
/** Счета и субсчета строк для фильтра, в натуральном порядке. */
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 RUBLE_TYPES = new Set(['BU', 'SUM']);
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
/** Строка-показатель (§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 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 = parentOf(entry);
for (let steps = 0; current && current.id !== entry.id && steps < entries.length; steps++) {
chain.push(current);
current = parentOf(current);
}
return chain;
};
const rows: OsvRow[] = [];
const leaves: EntryOut[] = [];
for (const entry of shown) {
const isGroup = withChildren.has(entry.id);
const structural = STRUCTURE.has(entry.group_type ?? '');
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: 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),
});
}
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'), ...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)))),
);
}