Перенесён диапазон 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>
141 lines
8.7 KiB
TypeScript
141 lines
8.7 KiB
TypeScript
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)))),
|
||
);
|
||
}
|