// =============================================
// Recycle Object — Internal Finance Workspace
// =============================================
const Finance = {
WORKSPACE_VERSION: 7,
currentTab: 'operations',
workspace: null,
summary: null,
data: {
orders: [],
imports: [],
employees: [],
timeEntries: [],
indirectMonths: {},
financeAccounts: [],
financeCategories: [],
financeDirections: [],
financeCounterparties: [],
financeTransactions: [],
bankAccounts: [],
bankTransactions: [],
tochkaSnapshot: null,
fintabloSnapshot: null,
},
ui: {
operations: {
search: '',
account: '',
category: '',
direction: '',
queue: 'review',
period: 'all',
start_date: '',
end_date: '',
limit: 200,
selected_keys: [],
focus_tx_key: '',
show_hidden_accounts: false,
batch_category_id: '__keep__',
batch_project_id: '__keep__',
batch_project_label: '',
batch_note: '',
batch_template_name: '',
template_name: '',
},
report: {
month: '',
},
composeKind: '',
},
_loadingPromise: null,
GROUP_LABELS: {
income: 'Доход',
direct: 'Прямые расходы',
payroll: 'Зарплата',
taxes: 'Налоги',
commercial: 'Коммерция',
overhead: 'Косвенные',
investment: 'Инвестиции',
finance: 'Финансовый контур',
other: 'Прочее',
},
ACCOUNT_TYPE_LABELS: {
bank: 'Банк',
cash: 'Наличные',
settlement: 'Расчеты',
reserve: 'Резерв',
},
ACCOUNT_STATUS_LABELS: {
active: 'Активен',
planned: 'План',
archived: 'Архив',
},
SOURCE_KIND_LABELS: {
legacy_import: 'Legacy import',
bank_api: 'Bank API',
manual: 'Ручной ввод',
internal: 'Внутренний модуль',
monthly_snapshot: 'Помесячный срез',
research: 'Исследование',
},
SOURCE_STATUS_LABELS: {
active: 'Работает',
planned: 'План',
archived: 'Архив',
},
PROJECT_TYPE_LABELS: {
core: 'Основное направление',
channel: 'Канал продаж',
site: 'Площадка',
support: 'Внутренний контур',
archived: 'Архив',
},
COUNTERPARTY_ROLE_LABELS: {
vendor: 'Поставщик',
client: 'Клиент',
employee: 'Сотрудник',
tax: 'Налоги / фонды',
bank: 'Банк',
channel: 'Канал продаж',
logistics: 'Логистика',
other: 'Другое',
},
RESEARCH_MODE_LABELS: {
system: 'Системный шаблон',
history: 'История операций',
hybrid: 'История + web',
manual: 'Только вручную',
},
RULE_TRIGGER_LABELS: {
description: 'По описанию',
counterparty: 'По контрагенту',
counterparty_account: 'Контрагент + счет',
keyword_bundle: 'Набор ключевых слов',
inn: 'По ИНН',
},
TRANSACTION_KIND_LABELS: {
expense: 'Расход',
income: 'Доход',
transfer: 'Перевод',
payroll: 'ЗП',
tax: 'Налог',
owner_money: 'Собственник',
ignore: 'Не учитывать',
},
TRANSACTION_ROUTE_LABELS: {
manual: 'Подтверждено',
draft: 'Черновик',
auto: 'Авторазнос',
review: 'На проверку',
unmatched: 'Нужен разбор',
ignored: 'Скрыто',
},
FIXED_ASSET_TYPE_LABELS: {
equipment: 'Оборудование',
tool: 'Инструмент',
furniture: 'Мебель',
vehicle: 'Транспорт',
software: 'НМА / софт',
other: 'Другое',
},
FACT_FIELD_LABELS: {
fact_revenue: 'Выручка',
fact_total: 'Расходы',
fact_salary: 'Зарплата',
fact_materials: 'Материалы',
fact_hardware: 'Фурнитура',
fact_packaging: 'Упаковка',
fact_delivery: 'Доставка',
fact_printing: 'Нанесение',
fact_molds: 'Молды',
fact_taxes: 'Налоги',
fact_commercial: 'Коммерческий',
fact_charity: 'Благотворительность',
fact_other: 'Прочее',
},
IMPORTANT_IMPORT_FIELDS: [
'fact_materials', 'fact_hardware', 'fact_packaging',
'fact_printing', 'fact_molds', 'fact_commercial', 'fact_charity',
],
async load() {
return this.reload();
},
async reload() {
if (this._loadingPromise) return this._loadingPromise;
this._showLoading('Собираю текущий финансовый контур...');
this._loadingPromise = (async () => {
try {
const [
workspaceRaw,
orders,
imports,
employees,
timeEntries,
financeAccounts,
financeCategories,
financeDirections,
financeCounterparties,
financeTransactions,
bankAccounts,
bankTransactions,
tochkaSnapshot,
fintabloSnapshot,
] = await Promise.all([
(typeof loadFinanceWorkspace === 'function') ? loadFinanceWorkspace() : null,
(typeof loadOrders === 'function') ? loadOrders({}) : [],
(typeof loadAllFintabloImports === 'function') ? loadAllFintabloImports() : [],
(typeof loadEmployees === 'function') ? loadEmployees() : [],
(typeof loadTimeEntries === 'function') ? loadTimeEntries() : [],
(typeof loadFinanceAccounts === 'function') ? loadFinanceAccounts() : [],
(typeof loadFinanceCategories === 'function') ? loadFinanceCategories() : [],
(typeof loadFinanceDirections === 'function') ? loadFinanceDirections() : [],
(typeof loadFinanceCounterparties === 'function') ? loadFinanceCounterparties() : [],
(typeof loadFinanceTransactions === 'function') ? loadFinanceTransactions() : [],
(typeof loadBankAccounts === 'function') ? loadBankAccounts() : [],
(typeof loadBankTransactions === 'function') ? loadBankTransactions() : [],
(typeof loadTochkaSnapshot === 'function') ? loadTochkaSnapshot() : null,
(typeof loadFintabloSnapshot === 'function') ? loadFintabloSnapshot() : null,
]);
const normalizedSnapshot = (tochkaSnapshot && typeof tochkaSnapshot === 'object') ? tochkaSnapshot : null;
const normalizedFintabloSnapshot = (fintabloSnapshot && typeof fintabloSnapshot === 'object') ? fintabloSnapshot : null;
const normalizedWorkspace = this._normalizeWorkspace(workspaceRaw, App.settings || {});
this.data = {
orders: Array.isArray(orders) ? orders : [],
imports: Array.isArray(imports) ? imports : [],
employees: Array.isArray(employees) ? employees : [],
timeEntries: Array.isArray(timeEntries) ? timeEntries : [],
indirectMonths: (typeof loadIndirectCostsData === 'function') ? (loadIndirectCostsData() || {}) : {},
financeAccounts: Array.isArray(financeAccounts) ? financeAccounts : [],
financeCategories: Array.isArray(financeCategories) ? financeCategories : [],
financeDirections: Array.isArray(financeDirections) ? financeDirections : [],
financeCounterparties: Array.isArray(financeCounterparties) ? financeCounterparties : [],
financeTransactions: Array.isArray(financeTransactions) ? financeTransactions : [],
bankAccounts: Array.isArray(bankAccounts) ? bankAccounts : [],
bankTransactions: Array.isArray(bankTransactions) ? bankTransactions : [],
tochkaSnapshot: normalizedSnapshot,
fintabloSnapshot: normalizedFintabloSnapshot,
};
this.workspace = this._hydrateWorkspaceFromFintablo(
this._hydrateWorkspaceFromTochka(
this._hydrateWorkspaceFromRelationalFinance(
normalizedWorkspace,
this.data,
),
normalizedSnapshot,
),
normalizedFintabloSnapshot,
);
if (!this.ui?.report?.month) {
this.ui.report = {
...(this.ui.report || {}),
month: this._businessMonthFromDate(this._todayDateLocal()),
};
}
this.summary = this._buildSummary({
workspace: this.workspace,
orders: this.data.orders,
imports: this.data.imports,
employees: this.data.employees,
timeEntries: this.data.timeEntries,
indirectMonths: this.data.indirectMonths,
financeAccounts: this.data.financeAccounts,
financeCategories: this.data.financeCategories,
financeDirections: this.data.financeDirections,
financeCounterparties: this.data.financeCounterparties,
financeTransactions: this.data.financeTransactions,
bankAccounts: this.data.bankAccounts,
bankTransactions: this.data.bankTransactions,
tochkaSnapshot: this.data.tochkaSnapshot,
fintabloSnapshot: this.data.fintabloSnapshot,
});
this.render();
if (this.currentTab === 'legacy' && typeof FinTablo !== 'undefined' && typeof FinTablo.load === 'function') {
await FinTablo.load();
}
} catch (err) {
console.error('Finance.load error:', err);
this._showError(err?.message || 'Не удалось загрузить финансы');
} finally {
this._loadingPromise = null;
}
})();
return this._loadingPromise;
},
render() {
const loading = document.getElementById('finance-loading');
const error = document.getElementById('finance-error');
const content = document.getElementById('finance-content');
if (loading) loading.style.display = 'none';
if (error) error.style.display = 'none';
if (content) content.style.display = '';
this._renderStats();
this._renderTabs();
this._renderCurrentTab();
},
_renderStats() {
const root = document.getElementById('finance-stats');
if (!root || !this.summary) return;
const stats = [
{
label: 'Источники',
value: `${this.summary.sources.active}/${this.summary.sources.total}`,
note: this.summary.sources.planned > 0 ? `${this.summary.sources.planned} в плане` : 'без висящих подключений',
},
{
label: 'Счета',
value: String(this.summary.accounts.total),
note: `${this.summary.accounts.bank} банк · ${this.summary.accounts.cash} наличные`,
},
{
label: 'Проекты',
value: String(this.summary.projects.active),
note: `${this.summary.projects.total} всего направлений`,
},
{
label: 'Статьи',
value: String(this.summary.categories.active),
note: `${this.summary.categories.total} всего`,
},
{
label: 'Авторазнос',
value: `${this.summary.automation.autoCount}/${this.summary.rules.enabled}`,
note: `${this.summary.transactions.reviewCount} на проверке`,
},
{
label: 'Правила',
value: `${this.summary.rules.enabled}/${this.summary.rules.total}`,
note: `${this.summary.rules.autoApply} авто-применяются`,
},
{
label: 'Точка',
value: this.summary.tochka.accounts > 0 ? String(this.summary.tochka.accounts) : '—',
note: this.summary.tochka.syncedAt
? `${this.summary.tochka.transactions} движений · sync ${this.summary.tochka.syncedAt.slice(0, 10)}`
: 'снапшот банка еще не загружен',
},
{
label: 'Разнесено',
value: `${this.summary.transactions.confirmedCount}/${this.summary.transactions.total}`,
note: `${this.summary.transactions.transferCount} переводов · ${this.summary.transactions.payrollCount} выплат ЗП`,
},
];
root.innerHTML = stats.map(stat => `
${this._esc(stat.label)}
${this._esc(stat.value)}
${this._esc(stat.note)}
`).join('');
},
_renderTabs() {
document.querySelectorAll('#page-import .finance-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === this.currentTab);
});
},
_renderCurrentTab() {
const tabs = ['overview', 'operations', 'payroll', 'tools', 'legacy', 'accounts', 'categories', 'projects', 'automation'];
tabs.forEach(tab => {
const pane = document.getElementById(`finance-tab-${tab}`);
if (!pane) return;
pane.style.display = (tab === this.currentTab) ? '' : 'none';
});
const statsRoot = document.getElementById('finance-stats');
if (statsRoot) {
statsRoot.style.display = (this.currentTab === 'overview') ? '' : 'none';
}
if (this.currentTab === 'overview') this._renderOverviewTab();
if (this.currentTab === 'operations') this._renderOperationsTab();
if (this.currentTab === 'payroll') this._renderPayrollTab();
if (this.currentTab === 'tools') this._renderToolsTab();
if (this.currentTab === 'legacy' && typeof FinTablo !== 'undefined' && typeof FinTablo.load === 'function') {
FinTablo.load();
}
},
_renderOverviewTab() {
const root = document.getElementById('finance-tab-overview');
if (!root || !this.summary || !this.workspace) return;
const accounts = this.summary.accounts.rows || [];
const usedAccounts = accounts.filter(item => item.transactionCount > 0);
const unusedAccounts = accounts.filter(item => item.transactionCount === 0);
const queuePreview = this.summary.automation.queuePreview || [];
const fixedAssets = this.summary.fixedAssets || { rows: [] };
const reports = this.summary.reports || {};
const reportMonth = reports.month || this._businessMonthFromDate(this._todayDateLocal());
const reportMonthLabel = reports.monthLabel || this._formatBusinessMonth(reportMonth);
const opiu = reports.opiu || {};
const profitability = reports.profitability || { rows: [] };
const obligations = reports.obligations || {};
const balance = reports.balance || {};
const reportPayroll = reports.payroll || { rows: [] };
root.innerHTML = `
${this._coverageItem('Банк Точка', this._formatRange(this.summary.tochka.range), `${this.summary.tochka.transactions} движений · ${this.summary.tochka.accounts} счетов`)}
${this._coverageItem('FinTablo', this._formatRange(this.summary.coverage.importRange), `${this.summary.imports.total} импортов · ${this.summary.imports.distinctDeals} сделок`)}
${this._coverageItem('FinTablo деньги', this._formatRange(this.summary.fintablo.range), `${this.summary.fintablo.transactions} операций · ${this.summary.fintablo.accounts} счетов`)}
${this._coverageItem('Часы', this._formatRange(this.summary.coverage.timeRange), `${this.summary.timeEntries.total} записей`)}
${this._coverageItem('Косвенные', this._formatMonthRange(this.summary.indirect.range), `${this.summary.indirect.months} мес.`)}
${this._coverageItem('ОПиУ: выручка', this.fmtRub(opiu.revenue || 0), `EBITDA ${this.fmtRub(opiu.ebitda || 0)}`)}
${this._coverageItem('ФОТ начислено', this.fmtRub(reportPayroll.totalAccrued || 0), `выплачено ${this.fmtRub(reportPayroll.paidConfirmed || 0)}`)}
${this._coverageItem('Обязательства', this.fmtRub((obligations.payrollPayable || 0) + (obligations.fixedAssetPayable || 0)), `${obligations.unassignedPayrollCount || 0} неразмеченных выплат`)}
${this._coverageItem('Баланс активов', this.fmtRub(balance.assetsTotal || 0), balance.syncLabel || 'операционный срез')}
${usedAccounts.length > 0
? usedAccounts.slice(0, 8).map(item => `
${this._esc(item.name)}
${this._esc(`${item.transactionCount} движений · последнее ${item.lastTransactionDate || '—'}`)}
`).join('')
: '
В текущем срезе нет использованных счетов.
'}
${unusedAccounts.length > 0
? unusedAccounts.map(item => `
${this._esc(item.name)}
${this._esc(item.note || 'Без движений в текущем окне истории')}
`).join('')
: '
Все видимые счета уже попали в историю.
'}
${(this.workspace.projects || [])
.filter(item => item.active !== false)
.map(item => this._pill(item.name))
.join('') || 'Направления ещё не настроены '}
На новой странице это будет главным бизнес-измерением операций вместо перегруженного набора технических полей.
${queuePreview.length > 0
? queuePreview.slice(0, 5).map(item => `
${this._esc(item.counterpartyName || 'Без контрагента')} · ${this._esc(item.amountLabel)}
${this._esc(item.categoryName || 'Без статьи')} · ${this._esc(item.directionName || item.projectName || 'Без направления')}
${this._esc(item.reasonSummary)}
`).join('')
: '
После обновления снапшота здесь будут свежие предложения по разнесению.
'}
Денежные операции, начисленный payroll и амортизация уже собраны в управленческую структуру месяца.
${this._reportMetricRow('Выручка', opiu.revenue || 0)}
${this._reportMetricRow('Прямые расходы', -(opiu.direct || 0))}
${this._reportMetricRow('Валовая прибыль', opiu.grossProfit || 0, true)}
${this._reportMetricRow('ФОТ производство', -(opiu.payrollProduction || 0))}
${this._reportMetricRow('ФОТ фикс', -(opiu.payrollFixed || 0))}
${this._reportMetricRow('Коммерческие', -(opiu.commercial || 0))}
${this._reportMetricRow('Косвенные', -(opiu.overhead || 0))}
${this._reportMetricRow('EBITDA', opiu.ebitda || 0, true)}
${this._reportMetricRow('Амортизация', -(opiu.amortization || 0))}
${this._reportMetricRow('Налоги', -(opiu.taxes || 0))}
${this._reportMetricRow('Операционная прибыль', opiu.operatingProfit || 0, true)}
Здесь уже видно, кому бизнес должен, где есть переплата и какие payroll-операции пока не подвязаны к человеку.
Контур
Кому / что
Сумма
Комментарий
${obligations.payrollRows.length > 0
? obligations.payrollRows.map(item => `
Зарплата
${this._esc(item.employeeName || 'Сотрудник')}
${this._esc(item.payable > 0 ? this.fmtRub(item.payable) : `переплата ${this.fmtRub(item.overpaid || 0)}`)}
${this._esc(item.payable > 0 ? 'Начислено больше, чем подтверждено выплатами' : 'Подтверждено выплат больше, чем начислено')}
`).join('')
: ''}
${obligations.assetRows.length > 0
? obligations.assetRows.map(item => `
Имущество
${this._esc(item.name || 'Объект')}
${this._esc(item.payable_amount > 0 ? this.fmtRub(item.payable_amount) : `переплата ${this.fmtRub(item.overpaid_amount || 0)}`)}
${this._esc(item.vendor_name || item.project_name || 'Поставщик не указан')}
`).join('')
: ''}
${obligations.unassignedPayrollCount > 0 ? `
Payroll
Без сотрудника
${this._esc(this.fmtRub(obligations.unassignedPayrollAmount || 0))}
${this._esc(`${obligations.unassignedPayrollCount} операций ждут ручной привязки`)}
` : ''}
${(obligations.payrollRows.length || obligations.assetRows.length || obligations.unassignedPayrollCount)
? ''
: 'За выбранный месяц нет открытых обязательств и переплат. '}
${this._coverageItem('Объекты', String(fixedAssets.active || 0), `${fixedAssets.total || 0} карточек в реестре`)}
${this._coverageItem('Стоимость покупки', this.fmtRub(fixedAssets.historicalCost || 0), 'что было поставлено на баланс имущества')}
${this._coverageItem('Остаточная стоимость', this.fmtRub(fixedAssets.residualValue || 0), 'сколько еще живет в активе')}
${this._coverageItem(`Амортизация за ${fixedAssets.reportMonthLabel || 'текущий месяц'}`, this.fmtRub(fixedAssets.currentMonthAmortization || 0), 'что должно лечь в ОПиУ')}
${this._coverageItem('Долг поставщикам', this.fmtRub(fixedAssets.payableAmount || 0), fixedAssets.overpaidAmount > 0 ? `переплата ${this.fmtRub(fixedAssets.overpaidAmount || 0)}` : 'если объект оплачен не полностью')}
Здесь логика ровно финтабловская: покупка оборудования не падает целиком в расход, а живет как актив; в расход идет только ежемесячная амортизация.
Покупка ОС это инвестиционное движение денег, а не расход месяца.
Амортизация это ежемесячный расход, который ровно размазывает стоимость объекта по сроку службы.
Остаточная стоимость показывает, сколько оборудования еще сидит в балансе бизнеса.
Долг поставщику нужен, если объект уже принят к учету, но оплачен не полностью.
Направление / сделка
Выручка
Расходы
Маржа
${profitability.displayRows?.length > 0
? profitability.displayRows.map(item => `
${this._esc(item.label || 'Без направления')}${item.isSystem ? 'общий контур бизнеса ' : ''}
${this._esc(this.fmtRub(item.revenue || 0))}
${this._esc(this.fmtRub((item.direct || 0) + (item.commercial || 0) + (item.payroll || 0) + (item.taxes || 0) + (item.other || 0)))}
${this._esc(this.fmtRub(item.margin || 0))}
`).join('')
: 'Пока не хватает размеченных проектных операций, чтобы честно собрать рентабельность. '}
${this._coverageItem('Payroll в заказах', this.fmtRub(profitability.allocatedPayroll || 0), `${profitability.businessRowsCount || 0} строк рентабельности`)}
${this._coverageItem('Общий фикс ФОТ', this.fmtRub(profitability.sharedPayroll || 0), 'пока без аллокации по сделкам')}
${this._coverageItem('Production без заказа', this.fmtRub(profitability.unassignedPayroll || 0), `${profitability.unassignedCount || 0} операций без проектной привязки`)}
Здесь уже учитывается начисленный production payroll, разложенный по заказам и часам. Fixed payroll пока показывается отдельным shared-контуром, чтобы рентабельность проектов не выглядела лучше реальности.
Строка
Сумма
Комментарий
Деньги в банке
${this._esc(this.fmtRub(balance.bankMoney || 0))}
${this._esc(balance.syncLabel || 'живой срез')}
Деньги вне банка
${this._esc(this.fmtRub(balance.nonBankMoney || 0))}
Наличные, карты, moneybags и внешние карманы
Остаток оборудования
${this._esc(this.fmtRub(balance.fixedAssetsResidual || 0))}
То, что еще живет в активе и не доамортизировано
Переплаты / дебиторка
${this._esc(this.fmtRub(balance.receivables || 0))}
Сейчас в основном это переплата сотрудникам
Активы всего
${this._esc(this.fmtRub(balance.assetsTotal || 0))}
Операционный контур на текущую дату
Обязательства
${this._esc(this.fmtRub(balance.liabilities || 0))}
Зарплаты к выплате + долг по имуществу
Собственный капитал контура
${this._esc(this.fmtRub(balance.equity || 0))}
Активы минус обязательства
Это пока операционный баланс на базе живых банковых и moneybag-снапшотов. Исторический баланс по любой дате доберем следующим отдельным слоем.
Сотрудник
Начислено
Выплачено
Долг / переплата
Что ушло в работу
${reportPayroll.rows.length > 0
? reportPayroll.rows.slice(0, 10).map(item => `
${this._esc(item.employeeName)}
${this._esc(item.isProduction ? 'Производство' : 'Фикс')}
${this._esc(this.fmtRub(item.accrued || 0))}
${this._esc(this.fmtRub(item.paid || 0))}
${this._esc(item.payable > 0 ? this.fmtRub(item.payable) : `переплата ${this.fmtRub(item.overpaid || 0)}`)}
${this._esc(item.focusLabel || 'Без привязки к заказам')}
`).join('')
: 'Пока нет payroll-данных за выбранный месяц. '}
Объект
Направление
Стоимость
В месяц
Накоплено
Остаток
К оплате
Статус
${fixedAssets.rows.length > 0
? fixedAssets.rows.map(item => this._renderFixedAssetReportRow(item)).join('')
: 'Пока нет карточек имущества. Их можно завести в Инструментах учета и сразу получить баланс оборудования и месячную амортизацию. '}
`;
},
_renderOperationsTab() {
if (typeof document === 'undefined') return;
const root = document.getElementById('finance-tab-operations');
if (!root || !this.summary || !this.workspace) return;
const filters = this.ui.operations || {};
const composeKind = this.ui.composeKind || '';
const dateWindow = this._resolveOperationsDateWindow(filters);
const filteredRows = this._filterOperationRows(this.summary.transactions.rows || []);
const visibleRows = filteredRows.slice(0, filters.limit || 200);
const selectedSet = this._selectedOperationKeySet();
const focusedKey = this._resolveFocusedOperationKey(visibleRows);
const focusedItem = visibleRows.find(item => String(item.txKey || '') === focusedKey) || null;
const selectedVisibleCount = visibleRows.filter(item => selectedSet.has(String(item.txKey || ''))).length;
const allVisibleSelected = visibleRows.length > 0 && selectedVisibleCount === visibleRows.length;
const selectedCount = selectedSet.size;
const selectedKeys = Array.from(selectedSet);
const selectedSuggestedCount = this._countSuggestedOperationKeys(selectedKeys);
root.innerHTML = `
${composeKind ? this._renderManualComposer(composeKind) : ''}
${this._renderOperationQueueTab('all', 'Все', this.summary.transactions.rows || [], filters.queue)}
${this._renderOperationQueueTab('review', 'На проверке', this.summary.transactions.rows || [], filters.queue)}
${this._renderOperationQueueTab('manual', 'Ручные', this.summary.transactions.rows || [], filters.queue)}
${this._renderOperationQueueTab('auto', 'Авто', this.summary.transactions.rows || [], filters.queue)}
${this._renderOperationQueueTab('transfers', 'Переводы', this.summary.transactions.rows || [], filters.queue)}
${this._renderOperationQueueTab('payroll', 'ЗП', this.summary.transactions.rows || [], filters.queue)}
${this._accountFilterOptions(filters.account)}
${this._categoryFilterOptions(filters.category)}
${this._directionFilterOptions(filters.direction)}
Скрытые счета
Сбросить фильтры
Период
${this._renderOperationsPeriodPresets(filters.period)}
${filters.period === 'custom' ? `
` : ''}
${this._renderOperationsContextBar({
filters,
visibleRows,
filteredRows,
dateWindow,
})}
${selectedCount > 0 ? `
Пакетный разнос
Выбрано ${this._esc(String(selectedCount))} операций · подсказка есть для ${this._esc(String(selectedSuggestedCount))}
Быстрые действия
Подсказки
Подсказки + подтвердить
Применить поля
Поля + подтвердить
Подтвердить
В перевод
В ЗП
Скрыть
Сбросить
Очистить выбор
Быстрые очистки
Без статьи
Без направления
Без сделки
Без заметки
Очистить поля
${this._renderOperationQuickPresets('batch')}
` : ''}
Лента операций
Сначала день и итог дня, внутри компактный реестр операций без лишней бухгалтерской мишуры.
${this._esc(filters.queue === 'review' ? 'Фокус: на проверке · по дням' : `${dateWindow.label} · по дням`)}
${visibleRows.length > 0
? this._groupOperationsByDay(visibleRows).map(group => this._renderOperationDayGroup(group, selectedSet, focusedKey)).join('')
: '
Нет операций под текущий фильтр.
'}
${filteredRows.length > visibleRows.length
? ``
: ''}
${focusedItem
? this._renderOperationInspector(focusedItem, {
isSelected: selectedSet.has(String(focusedItem.txKey || '')),
index: visibleRows.findIndex(row => String(row.txKey || '') === String(focusedItem.txKey || '')),
total: visibleRows.length,
})
: '
Выбери операцию слева, и здесь откроется карточка разнесения.
'}
${this._orderDatalistOptions()}
`;
},
_renderPayrollTab() {
const root = document.getElementById('finance-tab-payroll');
if (!root || !this.summary || !this.workspace) return;
const reportPayroll = this.summary.reports?.payroll || { rows: [] };
const rows = reportPayroll.rows || [];
root.innerHTML = `
${this._coverageItem('Начислено всего', this.fmtRub(reportPayroll.totalAccrued || 0), `${reportPayroll.employeeCount || 0} сотрудников в срезе`)}
${this._coverageItem('Производство / фикс', `${this.fmtRub(reportPayroll.productionAccrued || 0)} / ${this.fmtRub(reportPayroll.nonProductionAccrued || 0)}`, 'начисления за месяц')}
${this._coverageItem('Подтверждено выплат', this.fmtRub(reportPayroll.paidConfirmed || 0), `всего помечено ${this.fmtRub(reportPayroll.paidMarked || 0)}`)}
${this._coverageItem('К выплате / переплата', `${this.fmtRub(reportPayroll.payableAmount || 0)} / ${this.fmtRub(reportPayroll.overpaidAmount || 0)}`, 'обязательство по сотрудникам')}
Здесь видно не только списания, а связка начислено → выплачено → долг / переплата. Это уже ближе к настоящей зарплатной ведомости, а не к простому списку банковских операций.
К выплате: ${this._esc(this.fmtRub(reportPayroll.payableAmount || 0))}
Переплата: ${this._esc(this.fmtRub(reportPayroll.overpaidAmount || 0))}
Без сотрудника: ${this._esc(`${reportPayroll.unassignedPaymentsCount || 0} шт. · ${this.fmtRub(reportPayroll.unassignedPaymentsAmount || 0)}`)}
Если выплата на самом деле была переводом или налогом, это можно прямо здесь перевести в другой тип операции.
Сотрудник
Часы
Начислено
Выплачено
Долг / переплата
Что ушло в работу
${rows.length > 0
? rows.map(item => `
${this._esc(item.employeeName)}
${this._esc(item.isProduction ? 'Производство' : 'Фикс')} · ${this._esc(item.payrollProfile || '—')}
${this._esc(String(this._roundMoney(item.totalHours || 0)))}
${this._esc(this.fmtRub(item.accrued || 0))}
${this._esc(this.fmtRub(item.paid || 0))}
${this._esc(item.payable > 0 ? this.fmtRub(item.payable) : `переплата ${this.fmtRub(item.overpaid || 0)}`)}
${this._esc(item.focusLabel || 'Без привязки к заказам')}
`).join('')
: 'Пока нет payroll-данных за выбранный месяц. '}
Дата
Контрагент
Счет
Сумма
Статус
${(reportPayroll.unassignedPayments || []).length > 0
? (reportPayroll.unassignedPayments || []).map(item => `
${this._esc(item.date || '—')}
${this._esc(item.counterpartyName || 'Без контрагента')}
${this._esc(item.accountLabel || '—')}
${this._esc(item.amountLabel || this.fmtRub(item.amount || 0))}
${this._esc(item.routeLabel || '—')}
`).join('')
: 'За этот месяц нет payroll-операций без сотрудника. '}
`;
},
_renderToolsTab() {
const root = document.getElementById('finance-tab-tools');
if (!root || !this.summary || !this.workspace) return;
const used = (this.summary.accounts.rows || []).filter(item => item.transactionCount > 0);
const fixedAssetRows = this.summary.fixedAssets?.rows || [];
const fixedAssetMap = new Map(fixedAssetRows.map(item => [String(item.id || ''), item]));
root.innerHTML = `
Здесь осталась служебная настройка: счета, статьи, направления и автоправила. Ежедневная работа по операциям теперь вынесена отдельно.
Здесь теперь можно не только архивировать счета, но и решать, показывать ли их в ежедневном экране «Деньги». Это особенно важно для налоговых, благотворительных и скрытых карманов из FinTablo.
Счет
Тип
Ответственный
В деньгах
Скрыт в total
Статус
Движения
${(this.workspace.accounts || []).map(account => this._renderAccountToolRow(account, used)).join('')}
Активно
Название
Тип
Комментарий
${(this.workspace.projects || []).map(project => this._renderProjectToolRow(project)).join('')}
Здесь живут станки, техника, мебель, инструменты и софт, которые не надо списывать одним махом. Для каждого объекта считаем стоимость, ежемесячную амортизацию, остаток и долг поставщику, если оплата была частичной.
Актив
Объект
Тип
Стоимость
Оплачено
Старт ОПиУ
Срок, мес
Направление
Куплено ранее
Остаток / долг
${(this.workspace.fixedAssets || []).length > 0
? (this.workspace.fixedAssets || []).map(item => this._renderFixedAssetToolRow(item, fixedAssetMap.get(String(item.id || '')))).join('')
: 'Пока нет объектов имущества. Добавь оборудование, и в Отчетах сразу появятся остаточная стоимость, амортизация и долг поставщику. '}
Активна
Название
Группа
Связь
${(this.workspace.categories || []).map(category => this._renderCategoryToolRow(category)).join('')}
Вкл
Название
Триггер
Статья
Направление
Авто
${(this.workspace.rules || []).map(rule => this._renderRuleToolRow(rule)).join('')}
Здесь живут ежемесячные траты вне Точки: зарубежные подписки, внешние карты и любые регулярные движения, которые должны автоматически появляться в «Деньги».
Вкл
Название
Счет
Тип
Сумма
Старт
День
Статья
Направление
Комментарий
${(this.workspace.recurringTransactions || []).length > 0
? (this.workspace.recurringTransactions || []).map(item => this._renderRecurringRow(item)).join('')
: 'Пока нет автосписаний. Сюда удобно вынести Полину карту, зарубежные сервисы, нал и любые регулярные внебанковские платежи. '}
`;
},
_renderAccountsTab() {
const root = document.getElementById('finance-tab-accounts');
if (!root || !this.workspace) return;
root.innerHTML = `
Источник
Тип
Статус
Что закрывает
${(this.workspace.sources || []).map(source => `
${this._esc(source.name)}
${this._esc(this.SOURCE_KIND_LABELS[source.kind] || source.kind || '—')}
${this._statusChip(source.status, 'source')}
${this._esc(source.note || '—')}
`).join('')}
Здесь лучше хранить не только реальные банковские счета, но и наличные у людей, Китай, депозиты, налоговые резервы и любые отдельные карманы движения денег.
Название
Тип
Ответственный
Источник
В деньгах
Скрыт в total
Статус
Заметка
${(this.workspace.accounts || []).map(account => this._renderAccountRow(account)).join('')}
`;
},
_renderCategoriesTab() {
const root = document.getElementById('finance-tab-categories');
if (!root || !this.workspace || !this.summary) return;
const groupPills = Object.entries(this.summary.categories.byGroup || {})
.sort((a, b) => b[1] - a[1])
.map(([group, count]) => this._pill(`${this.GROUP_LABELS[group] || group}: ${count}`))
.join('');
const optimizationNotes = [
'Доходы разделены по каналам: корп, интернет-магазин, маркетплейсы, музеи, воркшопы.',
'Налоги разведены отдельно: по заказам, по сотрудникам, УСН / ЕНП, НДС.',
'Прямые расходы выделены в производственный контур: материалы, упаковка, нанесение, молды, подрядчики, логистика.',
'Финансовые движения вроде депозитов, вкладов и переводов не смешиваются с операционными затратами.',
];
root.innerHTML = `
${groupPills}
${optimizationNotes.map(line => `
${this._esc(line)}
`).join('')}
Логика статей здесь уже ближе к тому, как вы реально пользуетесь системой: сначала проект и тип расхода, потом уже детализация внутри направления.
Активна
Название
Группа
Контур
Источник
Связь с калькулятором
${(this.workspace.categories || []).map(category => this._renderCategoryRow(category)).join('')}
`;
},
_renderProjectsTab() {
const root = document.getElementById('finance-tab-projects');
if (!root || !this.workspace) return;
root.innerHTML = `
Проект здесь - это не только “заказ”, а любое устойчивое направление: основное производство, воркшопы, интернет-магазин, маркетплейсы, музейный контур, отдельная площадка вроде Дмитрова.
Активен
Название
Тип
Доходная статья по умолчанию
Комментарий
${(this.workspace.projects || []).map(project => this._renderProjectRow(project)).join('')}
`;
},
_renderAutomationTab() {
const root = document.getElementById('finance-tab-automation');
if (!root || !this.workspace || !this.summary) return;
const queue = this.workspace.queueConfig || this._defaultQueueConfig();
const queuePreview = this.summary.automation.queuePreview || [];
root.innerHTML = `
Прямой browser-fetch в Точку может упираться в CORS, поэтому рабочий путь здесь такой: локальный sync-скрипт или автоматизация обновляют снапшот, а страница уже строит по нему review queue.
${this._coverageItem('Sync', this.summary.tochka.syncedAt ? this.summary.tochka.syncedAt.slice(0, 10) : 'Нет', this.summary.tochka.syncedAt ? 'данные банка доступны' : 'нужно обновить снапшот')}
${this._coverageItem('Счета', String(this.summary.tochka.accounts || 0), this.summary.tochka.accounts > 0 ? 'получены из Точки' : 'нет данных')}
${this._coverageItem('Движения', String(this.summary.tochka.transactions || 0), this.summary.tochka.latestTransactionDate ? `последнее ${this.summary.tochka.latestTransactionDate}` : 'нет транзакций')}
${this._coverageItem('Авто / review', `${this.summary.automation.autoCount || 0} / ${this.summary.automation.reviewCount || 0}`, `${this.summary.automation.unmatchedCount || 0} без матча`)}
Дата
Счет
Контрагент / описание
Сумма
Предложение
Уверенность
Почему
${queuePreview.length > 0
? queuePreview.map(item => `
${this._esc(item.date || '—')}
${this._esc(item.accountLabel || '—')}
${this._esc(item.counterpartyName || 'Без контрагента')}
${this._esc(item.descriptionShort || 'Без описания')}
${this._esc(item.amountLabel)}
${this._esc(item.categoryName || 'Без статьи')} → ${this._esc(item.projectName || 'Без проекта')}${this._esc(item.routeLabel)}
${this._esc(this._formatPercent(item.confidence))}
${this._esc(item.reasonSummary)}
`).join('')
: 'Снапшот Точки еще не загружен или в нем пока нет движений за выбранный период. '}
Здесь живет не просто список названий, а знание о контрагенте: кто это, что он обычно продает, к какому проекту тяготеет и какой статьей закрывается по умолчанию.
Активен
Профиль / контрагент
Роль
ИНН
Что может продавать / делать
Проект по умолчанию
Статья по умолчанию
Research
Ключи матчинга
${(this.workspace.counterparties || []).map(item => this._renderCounterpartyRow(item)).join('')}
Приоритет правил простой: сначала точные системные совпадения (налоги / банки / каналы), потом контрагент + счет, потом описание и ключевые слова, и только потом web-угадывание для новых случаев.
Вкл
Название
Триггер
Что ищем
Счет
Профиль
Проект
Статья
Уверенность
Авто
Комментарий
${(this.workspace.rules || []).map(rule => this._renderRuleRow(rule)).join('')}
`;
},
setTab(tab) {
this._syncWorkspaceFromDom();
this.summary = this._buildSummary({
workspace: this.workspace,
orders: this.data.orders,
imports: this.data.imports,
employees: this.data.employees,
timeEntries: this.data.timeEntries,
indirectMonths: this.data.indirectMonths,
tochkaSnapshot: this.data.tochkaSnapshot,
fintabloSnapshot: this.data.fintabloSnapshot,
});
this.currentTab = tab || 'overview';
this._renderTabs();
this._renderCurrentTab();
},
setOperationsFilter(field, value) {
this._syncWorkspaceFromDom();
const nextValue = String(value || '').trim();
this.ui.operations = {
...this.ui.operations,
[field]: nextValue,
limit: field === 'limit' ? Number(value) || 200 : 200,
};
if (field === 'period' && nextValue !== 'custom') {
this.ui.operations.start_date = '';
this.ui.operations.end_date = '';
}
this._renderOperationsTab();
},
setReportMonth(value) {
this.ui.report = {
...(this.ui.report || {}),
month: this._parseBusinessMonth(value) || this._businessMonthFromDate(this._todayDateLocal()),
};
this.summary = this._buildSummary({
workspace: this.workspace,
orders: this.data.orders,
imports: this.data.imports,
employees: this.data.employees,
timeEntries: this.data.timeEntries,
indirectMonths: this.data.indirectMonths,
tochkaSnapshot: this.data.tochkaSnapshot,
fintabloSnapshot: this.data.fintabloSnapshot,
});
if (this.currentTab === 'payroll') this._renderPayrollTab();
else this._renderOverviewTab();
},
toggleHiddenAccountsFilter() {
this._syncWorkspaceFromDom();
this.ui.operations = {
...this.ui.operations,
show_hidden_accounts: !this.ui?.operations?.show_hidden_accounts,
limit: 200,
};
this._renderOperationsTab();
},
resetOperationsFilters() {
this._syncWorkspaceFromDom();
this.ui.operations = {
search: '',
account: '',
category: '',
direction: '',
queue: 'review',
period: 'all',
start_date: '',
end_date: '',
limit: 200,
show_hidden_accounts: false,
selected_keys: this.ui?.operations?.selected_keys || [],
focus_tx_key: this.ui?.operations?.focus_tx_key || '',
batch_category_id: this.ui?.operations?.batch_category_id || '__keep__',
batch_project_id: this.ui?.operations?.batch_project_id || '__keep__',
batch_project_label: this.ui?.operations?.batch_project_label || '',
batch_note: this.ui?.operations?.batch_note || '',
batch_template_name: this.ui?.operations?.batch_template_name || '',
template_name: this.ui?.operations?.template_name || '',
};
this._renderOperationsTab();
},
clearOperationsFilter(field) {
this._syncWorkspaceFromDom();
const current = { ...(this.ui.operations || {}) };
if (field === 'period') {
current.period = 'all';
current.start_date = '';
current.end_date = '';
} else if (field === 'show_hidden_accounts') {
current.show_hidden_accounts = false;
} else if (Object.prototype.hasOwnProperty.call(current, field)) {
current[field] = '';
}
this.ui.operations = {
...current,
limit: 200,
};
this._renderOperationsTab();
},
showMoreOperations() {
this._syncWorkspaceFromDom();
this.ui.operations.limit = (this.ui.operations.limit || 200) + 200;
this._renderOperationsTab();
},
setBatchField(field, value) {
this.ui.operations = {
...this.ui.operations,
[field]: String(value ?? ''),
};
},
setOperationUiField(field, value) {
this.ui.operations = {
...this.ui.operations,
[field]: String(value ?? ''),
};
},
toggleOperationSelection(txKey) {
const key = String(txKey || '');
if (!key) return;
const next = new Set(this._selectedOperationKeySet());
if (next.has(key)) next.delete(key);
else next.add(key);
this.ui.operations.selected_keys = Array.from(next);
this._renderOperationsTab();
},
toggleAllVisibleOperations() {
const visibleKeys = this._visibleOperationKeys();
if (visibleKeys.length === 0) return;
const next = new Set(this._selectedOperationKeySet());
const allSelected = visibleKeys.every(key => next.has(key));
visibleKeys.forEach(key => {
if (allSelected) next.delete(key);
else next.add(key);
});
this.ui.operations.selected_keys = Array.from(next);
this._renderOperationsTab();
},
toggleDayOperationSelection(dateKey) {
const keys = this._visibleDayOperationKeys(dateKey);
if (keys.length === 0) return;
const next = new Set(this._selectedOperationKeySet());
const allSelected = keys.every(key => next.has(key));
keys.forEach(key => {
if (allSelected) next.delete(key);
else next.add(key);
});
this.ui.operations.selected_keys = Array.from(next);
this._renderOperationsTab();
},
clearOperationSelection() {
this.ui.operations.selected_keys = [];
this._renderOperationsTab();
},
focusOperation(txKey) {
this._syncWorkspaceFromDom();
this.ui.operations = {
...this.ui.operations,
focus_tx_key: String(txKey || '').trim(),
};
this._renderOperationsTab();
},
focusRelativeOperation(offset = 1) {
this._syncWorkspaceFromDom();
const keys = this._visibleOperationKeys();
const currentKey = String(this.ui?.operations?.focus_tx_key || '').trim();
const nextKey = this._pickNeighborOperationKey(keys, currentKey, Number(offset) || 1);
if (!nextKey || nextKey === currentKey) return;
this.ui.operations = {
...this.ui.operations,
focus_tx_key: nextKey,
};
this._renderOperationsTab();
},
async applyBatchAction(action) {
this._syncWorkspaceFromDom();
const keys = Array.from(this._selectedOperationKeySet());
if (keys.length === 0) {
App.toast('Сначала выберите операции');
return;
}
keys.forEach(txKey => {
if (action === 'confirm') {
this._confirmTransactionDecisionLocally(txKey);
return;
}
if (action === 'reset') {
this.workspace.transactionDecisions = (this.workspace.transactionDecisions || []).filter(item => String(item?.tx_key || '') !== String(txKey || ''));
return;
}
this._markTransactionKindLocally(txKey, action);
});
this.ui.operations.selected_keys = [];
const toastMap = {
confirm: 'Пачка операций подтверждена',
transfer: 'Пачка операций переведена в переводы',
payroll: 'Пачка операций переведена в зарплаты',
ignore: 'Пачка операций скрыта',
reset: 'Ручная разметка по выбранным операциям сброшена',
};
await this._persistWorkspace(toastMap[action] || 'Пакетная операция выполнена');
},
async applyBatchFields(confirmAfter = false) {
this._syncWorkspaceFromDom();
const keys = Array.from(this._selectedOperationKeySet());
if (keys.length === 0) {
App.toast('Сначала выберите операции');
return;
}
const batch = this._currentBatchDraft();
const hasFields = batch.categoryId !== '__keep__'
|| batch.projectId !== '__keep__'
|| !!batch.projectLabel
|| !!batch.note;
if (!hasFields && !confirmAfter) {
App.toast('Выберите хотя бы одно поле для пакетного применения');
return;
}
keys.forEach(txKey => {
const tx = this._findTransactionByKey(txKey);
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
this._applyBatchFieldsToDecision(decision, tx, batch);
if (confirmAfter) decision.confirmed = true;
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
});
this.ui.operations.selected_keys = [];
const toastMessage = confirmAfter
? (hasFields ? 'Поля применены и операции подтверждены' : 'Выбранные операции подтверждены')
: 'Поля применены к выбранным операциям';
await this._persistWorkspace(toastMessage);
},
resetBatchDraft() {
this.ui.operations = {
...this.ui.operations,
batch_category_id: '__keep__',
batch_project_id: '__keep__',
batch_project_label: '',
batch_note: '',
batch_template_name: '',
};
this._renderOperationsTab();
},
async applyBatchClear(mode = 'all') {
this._syncWorkspaceFromDom();
const keys = Array.from(this._selectedOperationKeySet());
if (keys.length === 0) {
App.toast('Сначала выберите операции');
return;
}
const clearMode = String(mode || 'all').trim() || 'all';
keys.forEach(txKey => {
const tx = this._findTransactionByKey(txKey);
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
this._applyBatchClearToDecision(decision, tx, clearMode);
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
});
this.ui.operations.selected_keys = [];
const toastMap = {
category: 'Статья снята у выбранных операций',
project: 'Направление снято у выбранных операций',
project_label: 'Сделка снята у выбранных операций',
note: 'Заметка стерта у выбранных операций',
all: 'Поля очищены у выбранных операций',
};
await this._persistWorkspace(toastMap[clearMode] || 'Поля очищены у выбранных операций');
},
async applyBatchSuggestions(confirmAfter = false) {
this._syncWorkspaceFromDom();
const keys = Array.from(this._selectedOperationKeySet());
if (keys.length === 0) {
App.toast('Сначала выберите операции');
return;
}
const result = this._applySuggestionsToKeys(keys, confirmAfter);
if (result.appliedKeys.length === 0) {
App.toast('Для выбранных операций пока нет полезных подсказок');
return;
}
this.ui.operations.selected_keys = result.skippedKeys;
const toastMessage = confirmAfter
? `Подсказки применены и подтверждены: ${result.appliedKeys.length}`
: `Подсказки применены: ${result.appliedKeys.length}`;
await this._persistWorkspace(toastMessage);
},
async applyBatchPreset(presetId, confirmAfter = true) {
this._syncWorkspaceFromDom();
const keys = Array.from(this._selectedOperationKeySet());
if (keys.length === 0) {
App.toast('Сначала выберите операции');
return;
}
const preset = this._findOperationPreset(presetId);
if (!preset) {
App.toast('Не нашли готовый набор');
return;
}
const result = this._applyPresetToKeys(keys, preset, confirmAfter);
if (result.appliedKeys.length === 0) {
App.toast('Не удалось применить готовый набор к выбранным операциям');
return;
}
this.ui.operations.selected_keys = result.skippedKeys;
const toastMessage = confirmAfter
? `Набор «${preset.label}» применен и подтвержден: ${result.appliedKeys.length}`
: `Набор «${preset.label}» применен: ${result.appliedKeys.length}`;
await this._persistWorkspace(toastMessage);
},
async saveBatchTemplate() {
this._syncWorkspaceFromDom();
if (!this.workspace) return;
const selectedCount = this._selectedOperationKeySet().size;
if (selectedCount === 0) {
App.toast('Сначала выберите хотя бы одну операцию');
return;
}
const batch = this._currentBatchDraft();
const template = this._prepareOperationTemplate({
label: this._currentOperationTemplateDraftName('batch'),
kind: batch.categoryId && batch.categoryId !== '__keep__'
? this._kindFromCategoryId(batch.categoryId, this.workspace, null)
: '',
category_id: batch.categoryId === '__keep__' ? '' : batch.categoryId,
project_id: batch.projectId === '__keep__' ? '' : batch.projectId,
project_label: batch.projectLabel,
note: batch.note,
}, 'Шаблон пачки');
if (!template) {
App.toast('Сначала задай статью, направление или заметку для шаблона');
return;
}
const saved = this._upsertOperationTemplate(template);
if (!saved) {
App.toast('Не удалось сохранить шаблон');
return;
}
this.ui.operations = {
...this.ui.operations,
batch_template_name: '',
};
await this._persistWorkspace(`Шаблон «${saved.label}» сохранен для пачки`);
},
markAllVisibleAs(kind) {
this.ui.composeKind = String(kind || '').trim();
this._renderOperationsTab();
},
closeOperationComposer() {
this.ui.composeKind = '';
this._renderOperationsTab();
},
async createManualOperation() {
this._syncWorkspaceFromDom();
const root = document.querySelector('[data-finance-compose]');
if (!root || !this.workspace) return;
const mode = String(root.dataset.mode || this.ui.composeKind || '').trim();
const date = this._rowValue(root, 'date') || new Date().toISOString().slice(0, 10);
const accountId = this._rowValue(root, 'account_id');
const amount = Math.abs(this._num(this._rowValue(root, 'amount')));
const counterpartyName = this._rowValue(root, 'counterparty_name');
const description = this._rowValue(root, 'description');
const categoryId = this._rowValue(root, 'category_id');
const projectId = this._rowValue(root, 'project_id');
const projectLabel = this._rowValue(root, 'project_label');
const note = this._rowValue(root, 'note');
const transferAccountId = this._rowValue(root, 'transfer_account_id');
if (!mode) {
App.toast('Не выбрали тип операции');
return;
}
if (!accountId) {
App.toast('Нужно выбрать счет');
return;
}
if (amount <= 0) {
App.toast('Укажите сумму операции');
return;
}
if (mode === 'transfer' && !transferAccountId) {
App.toast('Для перевода нужен второй счет');
return;
}
const manualId = this._uid('manual_tx');
const direction = mode === 'income' ? 'in' : 'out';
const rawTx = {
manualId,
transactionId: `manual:${manualId}`,
accountId,
accountLabel: this._findById(this.workspace.accounts, accountId)?.name || accountId,
date,
direction,
amount,
counterpartyName: counterpartyName || (mode === 'transfer' ? 'Внутренний перевод' : 'Ручная операция'),
description,
sourceKind: 'manual',
};
this.workspace.manualTransactions = this._normalizeManualTransactions([
...(this.workspace.manualTransactions || []),
{
id: manualId,
date,
account_id: accountId,
direction,
amount,
counterparty_name: rawTx.counterpartyName,
description,
created_at: new Date().toISOString(),
},
]);
const txKey = this._transactionKey(rawTx);
const decision = this._normalizeTransactionDecisions([{
tx_key: txKey,
kind: mode === 'transfer' ? 'transfer' : (mode === 'income' ? 'income' : 'expense'),
project_id: mode === 'transfer' ? '' : projectId,
project_label: mode === 'transfer' ? '' : projectLabel,
category_id: mode === 'transfer' ? 'finance_transfers' : categoryId,
counterparty_id: '',
payroll_employee_id: '',
transfer_account_id: mode === 'transfer' ? transferAccountId : '',
note,
confirmed: true,
counterparty_name: rawTx.counterpartyName,
counterparty_inn: '',
updated_at: new Date().toISOString(),
}])[0];
this._upsertTransactionDecision(decision);
this.ui.composeKind = '';
await this._persistWorkspace('Ручная операция добавлена');
},
async updateManualTransaction(txKey) {
this._syncWorkspaceFromDom();
const root = this._findTransactionRowRoot(txKey);
if (!root || !this.workspace) return;
const date = this._rowValue(root, 'manual_date') || new Date().toISOString().slice(0, 10);
const accountId = this._rowValue(root, 'manual_account_id');
const amount = Math.abs(this._num(this._rowValue(root, 'manual_amount')));
const direction = this._rowValue(root, 'manual_direction') === 'in' ? 'in' : 'out';
const counterpartyName = this._rowValue(root, 'manual_counterparty_name');
const description = this._rowValue(root, 'manual_description');
if (!accountId) {
App.toast('Нужно выбрать счет');
return;
}
if (amount <= 0) {
App.toast('Укажите сумму операции');
return;
}
this._updateManualTransactionRecord(txKey, {
date,
account_id: accountId,
direction,
amount,
counterparty_name: counterpartyName,
description,
});
await this._persistWorkspace('Ручная операция обновлена');
},
async removeManualTransaction(txKey) {
if (!this.workspace) return;
this._focusNeighborBeforeMutation(txKey);
const manualRows = this._manualTransactionsToBankRows(this.workspace);
const matched = manualRows.find(item => this._transactionKey(item) === String(txKey || ''));
if (!matched?.manualId) return;
this.workspace.manualTransactions = (this.workspace.manualTransactions || []).filter(item => String(item?.id || '') !== String(matched.manualId));
this.workspace.transactionDecisions = (this.workspace.transactionDecisions || []).filter(item => String(item?.tx_key || '') !== String(txKey || ''));
this.ui.operations.selected_keys = (this.ui.operations.selected_keys || []).filter(item => String(item) !== String(txKey || ''));
await this._persistWorkspace('Ручная операция удалена');
},
async confirmTransactionDecision(txKey) {
this._syncWorkspaceFromDom();
this._focusNeighborBeforeMutation(txKey);
this._confirmTransactionDecisionLocally(txKey);
this.ui.operations.selected_keys = (this.ui.operations.selected_keys || []).filter(item => String(item) !== String(txKey || ''));
await this._persistWorkspace('Разноска по операции сохранена');
},
async applySuggestedDecision(txKey, confirmAfter = false) {
this._syncWorkspaceFromDom();
const row = this._findTransactionRowByKey(txKey);
const tx = row || this._findTransactionByKey(txKey);
if (!tx || !this._hasMeaningfulSuggestion(row?.suggestion)) {
App.toast('По этой операции пока нет полезной подсказки');
return;
}
if (confirmAfter) this._focusNeighborBeforeMutation(txKey);
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
this._applySuggestionToDecision(decision, row, tx);
if (confirmAfter) decision.confirmed = true;
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
if (confirmAfter) {
this.ui.operations.selected_keys = (this.ui.operations.selected_keys || []).filter(item => String(item) !== String(txKey || ''));
}
await this._persistWorkspace(confirmAfter ? 'Подсказка применена и операция подтверждена' : 'Подсказка применена к операции');
},
async applyOperationPreset(txKey, presetId, confirmAfter = true) {
this._syncWorkspaceFromDom();
const tx = this._findTransactionByKey(txKey);
const preset = this._findOperationPreset(presetId);
if (!tx) {
App.toast('Не нашли операцию для готового набора');
return;
}
if (!preset) {
App.toast('Не нашли готовый набор');
return;
}
if (confirmAfter) this._focusNeighborBeforeMutation(txKey);
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
this._applyPresetToDecision(decision, tx, preset);
if (confirmAfter) decision.confirmed = true;
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
if (confirmAfter) {
this.ui.operations.selected_keys = (this.ui.operations.selected_keys || []).filter(item => String(item) !== String(txKey || ''));
}
const toastMessage = confirmAfter
? `Набор «${preset.label}» применен и операция подтверждена`
: `Набор «${preset.label}» применен к операции`;
await this._persistWorkspace(toastMessage);
},
async saveOperationTemplate(txKey) {
this._syncWorkspaceFromDom();
if (!this.workspace) return;
const decision = this._findTransactionDecision(txKey);
if (!decision) {
App.toast('Сначала разметьте операцию: статья, направление или тип');
return;
}
const template = this._prepareOperationTemplate({
label: this._currentOperationTemplateDraftName('single'),
kind: decision.kind,
category_id: decision.category_id,
project_id: decision.project_id,
project_label: decision.project_label,
counterparty_id: decision.counterparty_id,
payroll_employee_id: decision.payroll_employee_id,
transfer_account_id: decision.transfer_account_id,
note: decision.note,
}, 'Мой шаблон');
if (!template) {
App.toast('В этой операции пока нечего сохранять в шаблон');
return;
}
const saved = this._upsertOperationTemplate(template);
if (!saved) {
App.toast('Не удалось сохранить шаблон');
return;
}
this.ui.operations = {
...this.ui.operations,
template_name: '',
};
await this._persistWorkspace(`Шаблон «${saved.label}» сохранен`);
},
async removeOperationTemplate(templateId) {
if (!this.workspace) return;
const current = this._normalizeOperationTemplates(this.workspace.operationTemplates || []);
const matched = current.find(item => String(item.id || '') === String(templateId || ''));
if (!matched) {
App.toast('Не нашли шаблон для удаления');
return;
}
this.workspace.operationTemplates = current.filter(item => String(item.id || '') !== String(templateId || ''));
await this._persistWorkspace(`Шаблон «${matched.label}» удален`);
},
async markTransactionKind(txKey, kind) {
this._syncWorkspaceFromDom();
this._focusNeighborBeforeMutation(txKey);
this._markTransactionKindLocally(txKey, kind);
this.ui.operations.selected_keys = (this.ui.operations.selected_keys || []).filter(item => String(item) !== String(txKey || ''));
await this._persistWorkspace('Тип операции обновлен');
},
async resetTransactionDecision(txKey) {
this._syncWorkspaceFromDom();
this._focusNeighborBeforeMutation(txKey);
this.workspace.transactionDecisions = (this.workspace.transactionDecisions || []).filter(item => String(item?.tx_key || '') !== String(txKey || ''));
this.ui.operations.selected_keys = (this.ui.operations.selected_keys || []).filter(item => String(item) !== String(txKey || ''));
await this._persistWorkspace('Ручная разметка по операции сброшена');
},
addAccount() {
this._syncWorkspaceFromDom();
this.workspace.accounts.push({
id: this._uid('account'),
name: 'Новый счёт',
type: 'cash',
owner: '',
source_id: 'cash_manual',
status: 'planned',
note: '',
show_in_money: true,
legacy_hide_in_total: false,
external_ref: '',
});
this.currentTab = 'tools';
this._renderTabs();
this._renderCurrentTab();
},
addCategory() {
this._syncWorkspaceFromDom();
this.workspace.categories.push({
id: this._uid('category'),
name: 'Новая статья',
group: 'other',
bucket: 'manual',
source_id: 'cash_manual',
mapping: '',
active: true,
});
this.currentTab = 'tools';
this._renderTabs();
this._renderCurrentTab();
},
addProject() {
this._syncWorkspaceFromDom();
this.workspace.projects.push({
id: this._uid('project'),
name: 'Новый проект',
type: 'core',
default_income_category_id: '',
note: '',
active: true,
});
this.currentTab = 'tools';
this._renderTabs();
this._renderCurrentTab();
},
addFixedAsset() {
this._syncWorkspaceFromDom();
this.workspace.fixedAssets = this._normalizeFixedAssets([
...(this.workspace.fixedAssets || []),
{
id: this._uid('asset'),
active: true,
name: 'Новый объект',
asset_type: 'equipment',
purchase_cost: 100000,
paid_amount: 100000,
accepted_date: '',
opiu_start_month: this._businessMonthFromDate(this._todayDateLocal()),
useful_life_months: 36,
project_id: '',
purchased_earlier: false,
vendor_name: '',
note: '',
},
]);
this.currentTab = 'tools';
this._renderTabs();
this._renderCurrentTab();
},
addCounterparty() {
this._syncWorkspaceFromDom();
this.workspace.counterparties.push({
id: this._uid('counterparty'),
name: 'Новый профиль',
role: 'vendor',
inn: '',
what_they_sell: '',
default_project_id: '',
default_category_id: '',
research_mode: 'hybrid',
match_hint: '',
note: '',
active: true,
});
this.currentTab = 'automation';
this._renderTabs();
this._renderAutomationTab();
},
addRule() {
this._syncWorkspaceFromDom();
this.workspace.rules.push({
id: this._uid('rule'),
name: 'Новое правило',
trigger_type: 'description',
trigger: '',
account_scope: 'any',
counterparty_id: '',
project_id: '',
category_id: '',
confidence: 0.7,
auto_apply: false,
note: '',
active: true,
});
this.currentTab = 'tools';
this._renderTabs();
this._renderCurrentTab();
},
addRecurringTransaction() {
this._syncWorkspaceFromDom();
this.workspace.recurringTransactions = this._normalizeRecurringTransactions([
...(this.workspace.recurringTransactions || []),
{
id: this._uid('recurring'),
active: true,
name: 'Новое автосписание',
account_id: (this.workspace.accounts || []).find(item => item.show_in_money !== false)?.id || '',
kind: 'expense',
amount: 0,
cadence: 'monthly',
start_date: this._businessDateFromDate(this._todayDateLocal()),
day_of_month: Number(this._businessDateFromDate(this._todayDateLocal()).slice(8, 10)) || 1,
category_id: 'commercial_site',
project_id: '',
counterparty_name: '',
description: '',
note: '',
},
]);
this.currentTab = 'tools';
this._renderTabs();
this._renderCurrentTab();
},
removeAccount(id) {
this._syncWorkspaceFromDom();
this.workspace.accounts = (this.workspace.accounts || []).filter(item => item.id !== id);
this._renderToolsTab();
},
removeCategory(id) {
this._syncWorkspaceFromDom();
this.workspace.categories = (this.workspace.categories || []).filter(item => item.id !== id);
this._renderToolsTab();
},
removeProject(id) {
this._syncWorkspaceFromDom();
this.workspace.projects = (this.workspace.projects || []).filter(item => item.id !== id);
this._renderToolsTab();
},
removeFixedAsset(id) {
this._syncWorkspaceFromDom();
this.workspace.fixedAssets = (this.workspace.fixedAssets || []).filter(item => item.id !== id);
this._renderToolsTab();
},
removeCounterparty(id) {
this._syncWorkspaceFromDom();
this.workspace.counterparties = (this.workspace.counterparties || []).filter(item => item.id !== id);
this._renderAutomationTab();
},
removeRule(id) {
this._syncWorkspaceFromDom();
this.workspace.rules = (this.workspace.rules || []).filter(item => item.id !== id);
this._renderToolsTab();
},
removeRecurringTransaction(id) {
this._syncWorkspaceFromDom();
this.workspace.recurringTransactions = (this.workspace.recurringTransactions || []).filter(item => item.id !== id);
this._renderToolsTab();
},
async saveWorkspace() {
this._syncWorkspaceFromDom();
await this._persistWorkspace('Финансовая структура и ручная разноска сохранены');
},
resetWorkspace() {
if (typeof confirm === 'function' && !confirm('Сбросить счета, статьи, проекты и авторазнос к рекомендованной схеме?')) return;
this.workspace = this._defaultWorkspace(App.settings || {});
this.summary = this._buildSummary({
workspace: this.workspace,
orders: this.data.orders,
imports: this.data.imports,
employees: this.data.employees,
timeEntries: this.data.timeEntries,
indirectMonths: this.data.indirectMonths,
tochkaSnapshot: this.data.tochkaSnapshot,
fintabloSnapshot: this.data.fintabloSnapshot,
});
this.render();
App.toast('Подставил рекомендованную финансовую схему');
},
async _persistWorkspace(toastMessage) {
if (!this.workspace) return;
this.workspace.updated_at = new Date().toISOString();
if (typeof saveFinanceWorkspace === 'function') {
await saveFinanceWorkspace(this.workspace);
}
this.summary = this._buildSummary({
workspace: this.workspace,
orders: this.data.orders,
imports: this.data.imports,
employees: this.data.employees,
timeEntries: this.data.timeEntries,
indirectMonths: this.data.indirectMonths,
tochkaSnapshot: this.data.tochkaSnapshot,
fintabloSnapshot: this.data.fintabloSnapshot,
});
this.render();
if (toastMessage) App.toast(toastMessage);
},
_syncWorkspaceFromDom() {
if (!this.workspace) return;
const accountRows = Array.from(document.querySelectorAll('[data-finance-account-row]'));
if (accountRows.length > 0) {
this.workspace.accounts = this._normalizeAccounts(accountRows.map(row => ({
id: row.dataset.financeAccountRow,
name: this._rowValue(row, 'name'),
type: this._rowValue(row, 'type'),
owner: this._rowValue(row, 'owner'),
source_id: this._rowValue(row, 'source_id'),
status: this._rowValue(row, 'status'),
note: this._rowValue(row, 'note'),
show_in_money: this._rowChecked(row, 'show_in_money'),
legacy_hide_in_total: this._rowChecked(row, 'legacy_hide_in_total'),
external_ref: this._rowValue(row, 'external_ref'),
})));
}
const categoryRows = Array.from(document.querySelectorAll('[data-finance-category-row]'));
if (categoryRows.length > 0) {
this.workspace.categories = categoryRows.map(row => ({
id: row.dataset.financeCategoryRow,
name: this._rowValue(row, 'name'),
group: this._rowValue(row, 'group'),
bucket: this._rowValue(row, 'bucket'),
source_id: this._rowValue(row, 'source_id'),
mapping: this._rowValue(row, 'mapping'),
active: this._rowChecked(row, 'active'),
}));
}
const projectRows = Array.from(document.querySelectorAll('[data-finance-project-row]'));
if (projectRows.length > 0) {
this.workspace.projects = projectRows.map(row => ({
id: row.dataset.financeProjectRow,
name: this._rowValue(row, 'name'),
type: this._rowValue(row, 'type'),
default_income_category_id: this._rowValue(row, 'default_income_category_id'),
note: this._rowValue(row, 'note'),
active: this._rowChecked(row, 'active'),
}));
}
const fixedAssetRows = Array.from(document.querySelectorAll('[data-finance-fixed-asset-row]'));
if (fixedAssetRows.length > 0) {
this.workspace.fixedAssets = this._normalizeFixedAssets(fixedAssetRows.map(row => ({
id: row.dataset.financeFixedAssetRow,
active: this._rowChecked(row, 'active'),
name: this._rowValue(row, 'name'),
asset_type: this._rowValue(row, 'asset_type'),
purchase_cost: this._rowValue(row, 'purchase_cost'),
paid_amount: this._rowValue(row, 'paid_amount'),
accepted_date: this._rowValue(row, 'accepted_date'),
opiu_start_month: this._rowValue(row, 'opiu_start_month'),
useful_life_months: this._rowValue(row, 'useful_life_months'),
project_id: this._rowValue(row, 'project_id'),
purchased_earlier: this._rowChecked(row, 'purchased_earlier'),
vendor_name: this._rowValue(row, 'vendor_name'),
note: this._rowValue(row, 'note'),
})));
}
const counterpartyRows = Array.from(document.querySelectorAll('[data-finance-counterparty-row]'));
if (counterpartyRows.length > 0) {
this.workspace.counterparties = counterpartyRows.map(row => ({
id: row.dataset.financeCounterpartyRow,
name: this._rowValue(row, 'name'),
role: this._rowValue(row, 'role'),
inn: this._rowValue(row, 'inn'),
what_they_sell: this._rowValue(row, 'what_they_sell'),
default_project_id: this._rowValue(row, 'default_project_id'),
default_category_id: this._rowValue(row, 'default_category_id'),
research_mode: this._rowValue(row, 'research_mode'),
match_hint: this._rowValue(row, 'match_hint'),
note: this._rowValue(row, 'note'),
active: this._rowChecked(row, 'active'),
}));
}
const ruleRows = Array.from(document.querySelectorAll('[data-finance-rule-row]'));
if (ruleRows.length > 0) {
this.workspace.rules = ruleRows.map(row => ({
id: row.dataset.financeRuleRow,
name: this._rowValue(row, 'name'),
trigger_type: this._rowValue(row, 'trigger_type'),
trigger: this._rowValue(row, 'trigger'),
account_scope: this._rowValue(row, 'account_scope'),
counterparty_id: this._rowValue(row, 'counterparty_id'),
project_id: this._rowValue(row, 'project_id'),
category_id: this._rowValue(row, 'category_id'),
confidence: this._clamp01(this._rowValue(row, 'confidence'), 0.7),
auto_apply: this._rowChecked(row, 'auto_apply'),
note: this._rowValue(row, 'note'),
active: this._rowChecked(row, 'active'),
}));
}
const recurringRows = Array.from(document.querySelectorAll('[data-finance-recurring-row]'));
if (recurringRows.length > 0) {
this.workspace.recurringTransactions = this._normalizeRecurringTransactions(recurringRows.map(row => ({
id: row.dataset.financeRecurringRow,
active: this._rowChecked(row, 'active'),
name: this._rowValue(row, 'name'),
account_id: this._rowValue(row, 'account_id'),
kind: this._rowValue(row, 'kind'),
amount: this._rowValue(row, 'amount'),
cadence: this._rowValue(row, 'cadence'),
start_date: this._rowValue(row, 'start_date'),
day_of_month: this._rowValue(row, 'day_of_month'),
category_id: this._rowValue(row, 'category_id'),
project_id: this._rowValue(row, 'project_id'),
counterparty_name: this._rowValue(row, 'counterparty_name'),
description: this._rowValue(row, 'description'),
note: this._rowValue(row, 'note'),
})));
}
const queueRoot = document.querySelector('[data-finance-queue-config]');
if (queueRoot) {
this.workspace.queueConfig = {
dailySyncEnabled: this._rowChecked(queueRoot, 'dailySyncEnabled'),
autoApplyThreshold: this._clamp01(this._rowValue(queueRoot, 'autoApplyThreshold'), 0.85),
reviewThreshold: this._clamp01(this._rowValue(queueRoot, 'reviewThreshold'), 0.55),
researchEnabled: this._rowChecked(queueRoot, 'researchEnabled'),
cadenceLabel: this._rowValue(queueRoot, 'cadenceLabel') || 'раз в день',
researchSources: this._splitList(this._rowValue(queueRoot, 'researchSources')),
note: this._rowValue(queueRoot, 'note'),
};
}
const txSelector = this.currentTab === 'payroll'
? '#finance-tab-payroll [data-finance-payroll-row]'
: '#finance-tab-operations [data-finance-tx-row]';
const txRows = Array.from(document.querySelectorAll(txSelector));
if (txRows.length > 0) {
const nextMap = new Map((this.workspace.transactionDecisions || []).map(item => [String(item.tx_key || ''), item]));
txRows.forEach(row => {
const txKey = row.dataset.financeTxRow || row.dataset.financePayrollRow || '';
if (!txKey) return;
const decision = this._decisionFromRow(row, txKey);
if (decision) nextMap.set(String(txKey), decision);
else nextMap.delete(String(txKey));
});
this.workspace.transactionDecisions = this._normalizeTransactionDecisions(Array.from(nextMap.values()));
}
},
_rowValue(root, field) {
const input = root.querySelector(`[data-field="${field}"]`);
return String(input?.value || '').trim();
},
_rowChecked(root, field) {
return !!root.querySelector(`[data-field="${field}"]`)?.checked;
},
_normalizeWorkspace(raw, settings = {}) {
const defaults = this._defaultWorkspace(settings);
const sourceRaw = (raw && typeof raw === 'object') ? raw : {};
return {
version: this.WORKSPACE_VERSION,
updated_at: sourceRaw.updated_at || null,
sources: this._mergeById(defaults.sources, sourceRaw.sources),
accounts: this._normalizeAccounts(this._mergeById(defaults.accounts, sourceRaw.accounts)),
categories: this._mergeById(defaults.categories, sourceRaw.categories),
projects: this._mergeById(defaults.projects, sourceRaw.projects),
fixedAssets: this._normalizeFixedAssets(this._mergeById(defaults.fixedAssets, sourceRaw.fixedAssets)),
counterparties: this._mergeById(defaults.counterparties, sourceRaw.counterparties),
rules: this._mergeById(defaults.rules, sourceRaw.rules),
manualTransactions: this._normalizeManualTransactions(sourceRaw.manualTransactions),
recurringTransactions: this._normalizeRecurringTransactions(sourceRaw.recurringTransactions),
operationTemplates: this._normalizeOperationTemplates(sourceRaw.operationTemplates),
transactionDecisions: this._normalizeTransactionDecisions(sourceRaw.transactionDecisions),
queueConfig: {
...defaults.queueConfig,
...(sourceRaw.queueConfig && typeof sourceRaw.queueConfig === 'object' ? sourceRaw.queueConfig : {}),
},
};
},
_mergeById(defaultRows = [], incomingRows = []) {
const incoming = Array.isArray(incomingRows) ? incomingRows : [];
const map = new Map();
incoming.forEach(row => {
if (!row || typeof row !== 'object') return;
if (!row.id) row.id = this._uid('row');
map.set(String(row.id), row);
});
const merged = (defaultRows || []).map(def => {
const incomingRow = map.get(String(def.id));
if (incomingRow) {
map.delete(String(def.id));
return { ...def, ...incomingRow };
}
return { ...def };
});
map.forEach(row => merged.push({ ...row }));
return merged;
},
_defaultWorkspace(settings = {}) {
return {
version: this.WORKSPACE_VERSION,
updated_at: null,
sources: this._defaultSources(),
accounts: this._normalizeAccounts(this._defaultAccounts(settings)),
categories: this._defaultCategories(),
projects: this._defaultProjects(),
fixedAssets: this._defaultFixedAssets(),
counterparties: this._defaultCounterparties(),
rules: this._defaultRules(),
manualTransactions: [],
recurringTransactions: [],
operationTemplates: [],
transactionDecisions: [],
queueConfig: this._defaultQueueConfig(),
};
},
_defaultFixedAssets() {
return [];
},
_normalizeManualTransactions(list) {
return (Array.isArray(list) ? list : [])
.filter(item => item && typeof item === 'object')
.map(item => ({
id: String(item.id || this._uid('manual_tx')).trim(),
date: this._parseBusinessDate(item.date) || new Date().toISOString().slice(0, 10),
account_id: String(item.account_id || '').trim(),
direction: String(item.direction || 'out') === 'in' ? 'in' : 'out',
amount: Math.abs(this._num(item.amount)),
counterparty_name: String(item.counterparty_name || '').trim(),
description: String(item.description || '').trim(),
created_at: String(item.created_at || '').trim() || null,
}))
.filter(item => item.account_id && item.amount > 0);
},
_normalizeAccounts(list) {
return (Array.isArray(list) ? list : [])
.filter(item => item && typeof item === 'object')
.map(item => ({
id: String(item.id || this._uid('account')).trim(),
name: String(item.name || 'Счёт').trim(),
type: String(item.type || 'cash').trim() || 'cash',
owner: String(item.owner || '').trim(),
source_id: String(item.source_id || 'cash_manual').trim() || 'cash_manual',
status: String(item.status || 'active').trim() || 'active',
note: String(item.note || '').trim(),
show_in_money: item.show_in_money !== false,
legacy_hide_in_total: !!item.legacy_hide_in_total,
external_ref: String(item.external_ref || '').trim(),
}));
},
_normalizeRecurringTransactions(list) {
return (Array.isArray(list) ? list : [])
.filter(item => item && typeof item === 'object')
.map(item => {
const startDate = this._parseBusinessDate(item.start_date) || this._businessDateFromDate(this._todayDateLocal());
const defaultDay = Number(startDate.slice(8, 10)) || 1;
const cadence = String(item.cadence || 'monthly').trim() || 'monthly';
return {
id: String(item.id || this._uid('recurring')).trim(),
active: item.active !== false,
name: String(item.name || 'Автосписание').trim() || 'Автосписание',
account_id: String(item.account_id || '').trim(),
kind: String(item.kind || 'expense') === 'income' ? 'income' : 'expense',
amount: Math.abs(this._num(item.amount)),
cadence,
start_date: startDate,
day_of_month: Math.min(31, Math.max(1, Number(item.day_of_month) || defaultDay)),
category_id: String(item.category_id || '').trim(),
project_id: String(item.project_id || '').trim(),
counterparty_name: String(item.counterparty_name || '').trim(),
description: String(item.description || '').trim(),
note: String(item.note || '').trim(),
};
})
.filter(item => item.account_id && item.amount > 0);
},
_normalizeOperationTemplates(list) {
const allowedKinds = new Set(Object.keys(this.TRANSACTION_KIND_LABELS));
return (Array.isArray(list) ? list : [])
.filter(item => item && typeof item === 'object')
.map(item => ({
id: String(item.id || this._uid('op_template')).trim(),
label: String(item.label || item.name || 'Мой шаблон').trim() || 'Мой шаблон',
kind: allowedKinds.has(String(item.kind || '').trim()) ? String(item.kind || '').trim() : '',
category_id: String(item.category_id || item.categoryId || '').trim(),
project_id: String(item.project_id || item.projectId || '').trim(),
project_label: String(item.project_label || item.projectLabel || '').trim(),
counterparty_id: String(item.counterparty_id || item.counterpartyId || '').trim(),
payroll_employee_id: item.payroll_employee_id == null ? String(item.payrollEmployeeId || '').trim() : String(item.payroll_employee_id).trim(),
transfer_account_id: String(item.transfer_account_id || item.transferAccountId || '').trim(),
note: String(item.note || '').trim(),
updated_at: String(item.updated_at || '').trim() || null,
}))
.filter(item => item.label && this._hasMeaningfulOperationTemplate(item));
},
_normalizeFixedAssets(list) {
const fallbackMonth = this._businessMonthFromDate(this._todayDateLocal());
return (Array.isArray(list) ? list : [])
.filter(item => item && typeof item === 'object')
.map(item => {
const acceptedDate = this._parseBusinessDate(item.accepted_date) || '';
const startMonth = this._parseBusinessMonth(item.opiu_start_month)
|| this._parseBusinessMonth(acceptedDate)
|| fallbackMonth;
return {
id: String(item.id || this._uid('asset')).trim(),
active: item.active !== false,
name: String(item.name || 'Новое имущество').trim() || 'Новое имущество',
asset_type: String(item.asset_type || 'equipment').trim() || 'equipment',
purchase_cost: Math.abs(this._num(item.purchase_cost)),
paid_amount: Math.abs(this._num(item.paid_amount)),
accepted_date: acceptedDate,
opiu_start_month: startMonth,
useful_life_months: Math.min(240, Math.max(1, Number(item.useful_life_months) || 36)),
project_id: String(item.project_id || '').trim(),
purchased_earlier: !!item.purchased_earlier,
vendor_name: String(item.vendor_name || '').trim(),
note: String(item.note || '').trim(),
};
})
.filter(item => item.purchase_cost > 0);
},
_financeAccountWorkspaceId(row) {
if (!row || typeof row !== 'object') return '';
return String(row.legacy_id || row.id || '').trim();
},
_financeCategoryWorkspaceId(row) {
if (!row || typeof row !== 'object') return '';
return String(row.legacy_id || row.id || '').trim();
},
_financeDirectionWorkspaceId(row) {
if (!row || typeof row !== 'object') return '';
return String(row.legacy_id || row.id || '').trim();
},
_financeCounterpartyWorkspaceId(row) {
if (!row || typeof row !== 'object') return '';
return String(row.legacy_id || row.id || '').trim();
},
_hydrateWorkspaceFromRelationalFinance(workspace, data = {}) {
const next = this._normalizeWorkspace(workspace, App.settings || {});
(Array.isArray(data?.financeAccounts) ? data.financeAccounts : []).forEach(row => {
const accountId = this._financeAccountWorkspaceId(row);
if (!accountId) return;
const existing = next.accounts.find(item => String(item?.id || '') === accountId);
const metadata = row?.metadata_json && typeof row.metadata_json === 'object' ? row.metadata_json : {};
const original = metadata.original && typeof metadata.original === 'object' ? metadata.original : {};
const patch = {
id: accountId,
name: String(row.name || original.name || 'Счет').trim() || 'Счет',
type: String(row.account_kind || original.type || 'settlement').trim() || 'settlement',
owner: String(row.owner_name || original.owner || '').trim(),
source_id: String(metadata.source_slug || original.source_id || '').trim(),
status: row.is_active === false ? 'archived' : (String(original.status || 'active').trim() || 'active'),
note: String(original.note || '').trim(),
show_in_money: row.is_hidden ? false : (original.show_in_money !== false),
legacy_hide_in_total: !!row.is_hidden,
external_ref: String(row.external_account_id || row.account_number || original.external_ref || '').trim(),
bank_name: String(row.bank_name || original.bank_name || '').trim(),
bank_bic: String(row.bank_bic || original.bank_bic || '').trim(),
sort_order: Number(row.sort_order || original.sort_order || 0) || 0,
};
if (existing) Object.assign(existing, patch);
else next.accounts.push(patch);
});
(Array.isArray(data?.financeCategories) ? data.financeCategories : []).forEach(row => {
const categoryId = this._financeCategoryWorkspaceId(row);
if (!categoryId) return;
const existing = next.categories.find(item => String(item?.id || '') === categoryId);
const metadata = row?.metadata_json && typeof row.metadata_json === 'object' ? row.metadata_json : {};
const original = metadata.original && typeof metadata.original === 'object' ? metadata.original : {};
const patch = {
id: categoryId,
name: String(row.name || original.name || 'Статья').trim() || 'Статья',
group: String(original.group || row.category_group || 'other').trim() || 'other',
bucket: String(row.bucket || original.bucket || 'general').trim() || 'general',
source_id: String(metadata.source_id || original.source_id || '').trim(),
mapping: String(row.code || original.mapping || '').trim(),
active: row.is_active !== false && original.active !== false,
color: String(row.color || original.color || '').trim(),
sort_order: Number(row.sort_order || original.sort_order || 0) || 0,
};
if (existing) Object.assign(existing, patch);
else next.categories.push(patch);
});
(Array.isArray(data?.financeDirections) ? data.financeDirections : []).forEach(row => {
const projectId = this._financeDirectionWorkspaceId(row);
if (!projectId) return;
const existing = next.projects.find(item => String(item?.id || '') === projectId);
const metadata = row?.metadata_json && typeof row.metadata_json === 'object' ? row.metadata_json : {};
const original = metadata.original && typeof metadata.original === 'object' ? metadata.original : {};
const patch = {
id: projectId,
name: String(row.name || original.name || 'Направление').trim() || 'Направление',
type: String(metadata.project_type || original.type || 'core').trim() || 'core',
note: String(original.note || '').trim(),
active: row.is_active !== false && original.active !== false,
sort_order: Number(row.sort_order || original.sort_order || 0) || 0,
};
if (existing) Object.assign(existing, patch);
else next.projects.push(patch);
});
(Array.isArray(data?.financeCounterparties) ? data.financeCounterparties : []).forEach(row => {
const counterpartyId = this._financeCounterpartyWorkspaceId(row);
if (!counterpartyId) return;
const existing = next.counterparties.find(item => String(item?.id || '') === counterpartyId);
const metadata = row?.metadata_json && typeof row.metadata_json === 'object' ? row.metadata_json : {};
const original = metadata.original && typeof metadata.original === 'object' ? metadata.original : {};
const patch = {
id: counterpartyId,
name: String(row.name || original.name || 'Контрагент').trim() || 'Контрагент',
role: String(metadata.role || original.role || 'other').trim() || 'other',
inn: String(row.inn || original.inn || '').trim(),
note: String(row.notes || original.note || '').trim(),
what_they_sell: String(original.what_they_sell || '').trim(),
default_project_id: String(metadata.default_project_id || original.default_project_id || '').trim(),
default_category_id: String(metadata.default_category_id || original.default_category_id || '').trim(),
research_mode: String(original.research_mode || 'manual').trim() || 'manual',
match_hint: String(metadata.match_hint || original.match_hint || '').trim(),
active: original.active !== false,
};
if (existing) Object.assign(existing, patch);
else next.counterparties.push(patch);
});
next.accounts = this._normalizeAccounts(next.accounts);
next.categories = this._normalizeCategories(next.categories);
next.projects = this._normalizeProjects(next.projects);
next.counterparties = this._normalizeCounterparties(next.counterparties);
return next;
},
_hydrateWorkspaceFromTochka(workspace, tochkaSnapshot) {
const next = {
...(workspace || {}),
accounts: this._normalizeAccounts(Array.isArray(workspace?.accounts) ? workspace.accounts.map(item => ({ ...item })) : []),
};
const bankAccounts = Array.isArray(tochkaSnapshot?.accounts) ? tochkaSnapshot.accounts : [];
bankAccounts.forEach(account => {
const accountId = String(account?.accountId || '').trim();
if (!accountId) return;
const bankNumber = this._extractBankAccountNumber(accountId);
const tail = bankNumber.slice(-4);
const displayName = String(account?.displayName || '').trim() || `Точка ••••${tail || accountId.slice(-4)}`;
const existing = next.accounts.find(item => this._workspaceAccountMatchesBank(item, account));
if (existing) {
existing.type = existing.type || 'bank';
existing.source_id = 'tochka_api';
if (!existing.status || existing.status === 'planned') existing.status = 'active';
const digits = this._digitsOnly(`${existing.name || ''} ${existing.note || ''}`);
if (tail && !digits.includes(tail)) {
existing.note = [existing.note, `Точка ••••${tail}`].filter(Boolean).join(' · ');
}
return;
}
next.accounts.push({
id: `tochka_${(bankNumber || accountId).slice(-8)}`,
name: displayName,
type: 'bank',
owner: 'Компания',
source_id: 'tochka_api',
status: 'active',
note: `Счет Точка ••••${tail || accountId.slice(-4)}`,
show_in_money: true,
legacy_hide_in_total: false,
external_ref: bankNumber || accountId,
});
});
return next;
},
_hydrateWorkspaceFromFintablo(workspace, fintabloSnapshot) {
const next = {
...(workspace || {}),
accounts: this._normalizeAccounts(Array.isArray(workspace?.accounts) ? workspace.accounts.map(item => ({ ...item })) : []),
};
const canonicalMoneybags = this._canonicalFintabloMoneybags(fintabloSnapshot);
canonicalMoneybags.forEach(moneybag => {
const existing = next.accounts.find(item => this._workspaceAccountMatchesFintablo(item, moneybag));
const defaultVisible = this._defaultMoneyVisibilityForFintablo(moneybag);
if (existing) {
existing.source_id = existing.source_id || 'orders_fintablo';
if (!existing.external_ref) existing.external_ref = moneybag.number || String(moneybag.moneybagId || '');
if (moneybag.hideInTotal) existing.legacy_hide_in_total = true;
if (existing.show_in_money == null) existing.show_in_money = defaultVisible;
if (!existing.note && moneybag.hideInTotal) existing.note = 'Скрыт в totals в FinTablo';
return;
}
next.accounts.push({
id: String(moneybag.id || this._uid('account')),
name: moneybag.displayName || 'FinTablo счёт',
type: moneybag.sourceKind === 'bank' ? 'bank' : (moneybag.sourceKind === 'cash' ? 'cash' : 'settlement'),
owner: '',
source_id: 'orders_fintablo',
status: moneybag.archived ? 'archived' : 'active',
note: moneybag.hideInTotal ? 'Скрыт в totals в FinTablo' : '',
show_in_money: defaultVisible,
legacy_hide_in_total: !!moneybag.hideInTotal,
external_ref: moneybag.number || String(moneybag.moneybagId || ''),
});
});
next.accounts = this._normalizeAccounts(next.accounts);
return next;
},
_workspaceAccountMatchesBank(account, bankAccount) {
const accountId = String(bankAccount?.accountId || '').trim();
const bankNumber = this._extractBankAccountNumber(accountId);
const last4 = bankNumber.slice(-4);
const accountText = `${account?.id || ''} ${account?.name || ''} ${account?.note || ''}`;
const accountDigits = this._digitsOnly(accountText);
if (bankNumber && accountDigits.includes(bankNumber)) return true;
if (last4 && accountDigits.includes(last4)) return true;
return this._normalizeText(account?.name || '') === this._normalizeText(bankAccount?.displayName || '');
},
_workspaceAccountMatchesFintablo(account, moneybag) {
const legacyId = String(moneybag?.id || '').trim();
if (legacyId && String(account?.id || '') === legacyId) return true;
const externalRef = String(account?.external_ref || '').trim();
const moneybagNumber = this._digitsOnly(moneybag?.number || '');
const accountDigits = this._digitsOnly(`${account?.id || ''} ${account?.name || ''} ${account?.note || ''} ${externalRef}`);
if (moneybagNumber && accountDigits.includes(moneybagNumber)) return true;
if (moneybagNumber && moneybagNumber.slice(-4) && accountDigits.includes(moneybagNumber.slice(-4))) return true;
return this._normalizeText(account?.name || '') === this._normalizeText(moneybag?.displayName || '');
},
_canonicalFintabloMoneybags(snapshot) {
const rows = Array.isArray(snapshot?.accounts) ? snapshot.accounts : [];
const grouped = new Map();
rows.forEach(item => {
const rawType = String(item?.type || '').trim().toLowerCase();
const sourceKind = String(item?.sourceKind || (rawType === 'bank' ? 'bank' : (['nal', 'card'].includes(rawType) ? 'cash' : 'settlement'))).trim();
const numberKey = this._digitsOnly(item?.number || '');
const key = numberKey || `bag:${String(item?.moneybagId || item?.id || '')}`;
const candidate = {
...item,
moneybagId: String(item?.moneybagId || '').trim(),
id: String(item?.id || '').trim(),
displayName: String(item?.displayName || item?.name || '').trim(),
sourceKind: sourceKind || (String(item?.type || '') === 'bank' ? 'bank' : 'cash'),
number: String(item?.number || '').trim(),
archived: !!item?.archived,
hideInTotal: !!item?.hideInTotal,
};
const current = grouped.get(key);
if (!current || this._fintabloMoneybagRank(candidate) > this._fintabloMoneybagRank(current)) {
grouped.set(key, candidate);
}
});
return Array.from(grouped.values());
},
_fintabloMoneybagRank(item) {
const visibleScore = item?.hideInTotal ? 0 : 10;
const activeScore = item?.archived ? 0 : 5;
const namedScore = String(item?.displayName || '').length / 100;
return visibleScore + activeScore + namedScore;
},
_defaultMoneyVisibilityForFintablo(moneybag) {
if (!moneybag) return true;
if (moneybag.archived || moneybag.hideInTotal) return false;
const text = this._normalizeText([moneybag.displayName, moneybag.number].filter(Boolean).join(' '));
if (['благотвор', 'налог', 'копил', 'амортиз', 'депозит', 'резерв'].some(word => text.includes(word))) return false;
return true;
},
_extractBankAccountNumber(value) {
const raw = String(value || '').trim();
if (!raw) return '';
const head = raw.split('/')[0] || raw;
return this._digitsOnly(head);
},
_manualTransactionsToBankRows(workspace) {
return this._normalizeManualTransactions(workspace?.manualTransactions).map(item => {
const account = this._findById(workspace?.accounts, item.account_id);
return {
manualId: item.id,
transactionId: `manual:${item.id}`,
accountId: item.account_id,
accountLabel: account?.name || item.account_id,
date: item.date,
direction: item.direction,
amount: item.amount,
counterpartyName: item.counterparty_name,
description: item.description,
sourceKind: 'manual',
};
});
},
_recurringTransactionsToBankRows(workspace) {
const today = this._businessDateFromDate(this._todayDateLocal());
return this._normalizeRecurringTransactions(workspace?.recurringTransactions).flatMap(item => {
if (item.active === false) return [];
if (String(item.cadence || 'monthly') !== 'monthly') return [];
const account = this._findById(workspace?.accounts, item.account_id);
const occurrences = [];
let cursor = this._dateFromBusinessDate(item.start_date);
const hardStop = this._dateFromBusinessDate(today);
let guard = 0;
while (cursor && hardStop && cursor <= hardStop && guard < 72) {
const year = cursor.getFullYear();
const month = cursor.getMonth();
const monthLastDay = new Date(year, month + 1, 0).getDate();
const day = Math.min(item.day_of_month || Number(item.start_date.slice(8, 10)) || 1, monthLastDay);
const occurrenceDate = this._businessDateFromDate(new Date(year, month, day, 12, 0, 0, 0));
if (occurrenceDate >= item.start_date && occurrenceDate <= today) {
occurrences.push({
recurringTemplateId: item.id,
recurringTemplateName: item.name,
transactionId: `recurring:${item.id}:${occurrenceDate}`,
accountId: item.account_id,
accountLabel: account?.name || item.account_id,
date: occurrenceDate,
direction: item.kind === 'income' ? 'in' : 'out',
amount: item.amount,
counterpartyName: item.counterparty_name || item.name,
description: item.description || item.name,
sourceKind: 'manual',
categoryHint: item.category_id,
projectHint: item.project_id,
noteHint: item.note,
});
}
cursor = new Date(year, month + 1, 1, 12, 0, 0, 0);
guard += 1;
}
return occurrences;
});
},
_fintabloTransactionsToRows(snapshot, tochkaAccounts = []) {
const txRows = Array.isArray(snapshot?.transactions) ? snapshot.transactions : [];
const moneybagMap = new Map(this._canonicalFintabloMoneybags(snapshot).map(item => [String(item.id || ''), item]));
return txRows
.filter(item => item && typeof item === 'object')
.map(item => {
const accountId = String(item.accountId || item.moneybagId || '').trim();
const moneybag = moneybagMap.get(accountId) || null;
if (!this._shouldIncludeFintabloMoneybagInOperations(moneybag, tochkaAccounts)) return null;
return {
source: 'fintablo',
sourceKind: String(item.sourceKind || moneybag?.sourceKind || 'cash'),
transactionId: String(item.transactionId || item.legacyTransactionId || '').trim(),
legacyTransactionId: String(item.legacyTransactionId || '').trim(),
accountId,
accountLabel: String(item.accountLabel || moneybag?.displayName || accountId).trim(),
bankNumber: String(item.bankNumber || moneybag?.number || '').trim(),
date: this._parseBusinessDate(item.date),
direction: String(item.direction || '') === 'in' ? 'in' : 'out',
amount: Math.abs(this._num(item.amount)),
currency: String(item.currency || moneybag?.currency || 'RUB').trim() || 'RUB',
counterpartyName: String(item.counterpartyName || '').trim(),
counterpartyInn: String(item.counterpartyInn || '').trim(),
description: String(item.description || '').trim(),
paymentId: '',
documentNumber: '',
group: String(item.group || '').trim(),
legacyMoneybagId: String(item.legacyMoneybagId || moneybag?.moneybagId || '').trim(),
partnerId: String(item.partnerId || '').trim(),
dealId: String(item.dealId || '').trim(),
categoryId: String(item.categoryId || '').trim(),
directionId: String(item.directionId || '').trim(),
};
})
.filter(item => item && item.date && item.amount > 0);
},
_canonicalTochkaAccounts() {
const relational = Array.isArray(this.data?.bankAccounts) ? this.data.bankAccounts : [];
if (relational.length > 0) {
return relational
.filter(item => item && typeof item === 'object')
.map(item => {
const raw = item.raw_json && typeof item.raw_json === 'object' ? item.raw_json : {};
const accountId = String(item.external_id || item.external_account_id || raw.accountId || raw.account_number || '').trim();
const accountNumber = String(item.account_number || raw.accountNumber || raw.account_number || accountId).trim();
return {
accountId,
accountNumber,
displayName: String(item.display_name || raw.displayName || raw.accountLabel || raw.name || accountId).trim(),
ownerName: String(item.owner_name || raw.ownerName || raw.owner || '').trim(),
currency: String(item.currency_code || raw.currency || 'RUB').trim() || 'RUB',
currentBalance: this._num(item.last_balance || raw.currentBalance || raw.balance || 0),
bank_name: String(item.bank_name || raw.bank_name || raw.bankName || '').trim(),
bank_bic: String(item.bank_bic || raw.bank_bic || raw.bic || '').trim(),
status: String(item.status || raw.status || '').trim(),
};
})
.filter(item => item.accountId);
}
return Array.isArray(this.data?.tochkaSnapshot?.accounts) ? this.data.tochkaSnapshot.accounts : [];
},
_canonicalExternalTransactions(workspace = this.workspace) {
const relational = this._financeTransactionsToRows({
financeTransactions: this.data?.financeTransactions,
financeAccounts: this.data?.financeAccounts,
financeCategories: this.data?.financeCategories,
financeDirections: this.data?.financeDirections,
financeCounterparties: this.data?.financeCounterparties,
workspace,
});
if (relational.length > 0) return relational;
const tochkaAccounts = this._canonicalTochkaAccounts();
const fintabloRows = this._fintabloTransactionsToRows(this.data?.fintabloSnapshot, tochkaAccounts);
return [
...((this.data?.tochkaSnapshot?.transactions || []).filter(item => item && typeof item === 'object')),
...fintabloRows,
];
},
_financeTransactionsToRows({ financeTransactions, financeAccounts, financeCategories, financeDirections, financeCounterparties, workspace }) {
const rows = Array.isArray(financeTransactions) ? financeTransactions : [];
if (rows.length === 0) return [];
const accountRows = Array.isArray(financeAccounts) ? financeAccounts : [];
const categoryRows = Array.isArray(financeCategories) ? financeCategories : [];
const directionRows = Array.isArray(financeDirections) ? financeDirections : [];
const counterpartyRows = Array.isArray(financeCounterparties) ? financeCounterparties : [];
const accountsByDbId = new Map(accountRows.map(item => [String(item?.id || ''), item]));
const categoriesByDbId = new Map(categoryRows.map(item => [String(item?.id || ''), item]));
const directionsByDbId = new Map(directionRows.map(item => [String(item?.id || ''), item]));
const counterpartiesByDbId = new Map(counterpartyRows.map(item => [String(item?.id || ''), item]));
const tochkaAccounts = this._canonicalTochkaAccounts();
return rows
.filter(item => item && typeof item === 'object')
.map(item => {
const metadata = item?.metadata_json && typeof item.metadata_json === 'object' ? item.metadata_json : {};
const raw = item?.raw_json && typeof item.raw_json === 'object'
? item.raw_json
: (metadata.original && typeof metadata.original === 'object' ? metadata.original : {});
const accountRow = accountsByDbId.get(String(item.account_id || '')) || null;
const categoryRow = categoriesByDbId.get(String(item.category_id || '')) || null;
const directionRow = directionsByDbId.get(String(item.direction_id || '')) || null;
const counterpartyRow = counterpartiesByDbId.get(String(item.counterparty_id || '')) || null;
const accountId = this._financeAccountWorkspaceId(accountRow) || String(metadata.legacy_account_id || raw.accountId || raw.account_id || '').trim();
const categoryId = this._financeCategoryWorkspaceId(categoryRow) || String(metadata.legacy_category_id || raw.categoryId || raw.category_id || '').trim();
const projectId = this._financeDirectionWorkspaceId(directionRow) || String(metadata.legacy_direction_id || raw.directionId || raw.direction_id || raw.project_id || '').trim();
const counterpartyId = this._financeCounterpartyWorkspaceId(counterpartyRow) || String(metadata.legacy_counterparty_id || raw.counterpartyId || raw.counterparty_id || '').trim();
const importedFrom = String(item.imported_from || '').trim();
const sourceKind = String(accountRow?.account_kind || raw.sourceKind || '').trim().toLowerCase();
if (importedFrom === 'fintablo_snapshot' && sourceKind === 'bank' && this._relationalFinanceAccountMatchesBank(accountRow, tochkaAccounts, raw)) {
return null;
}
const type = String(item.transaction_type || '').trim().toLowerCase();
const rawDirection = String(raw.direction || '').trim().toLowerCase();
const direction = rawDirection === 'in' || rawDirection === 'out'
? rawDirection
: (type === 'income' ? 'in' : 'out');
return {
source: importedFrom === 'fintablo_snapshot' ? 'fintablo' : 'finance_phase1',
sourceKind: sourceKind || (direction === 'in' ? 'bank' : 'settlement'),
transactionId: String(item.external_transaction_id || item.legacy_tx_key || item.id || '').trim(),
legacyTransactionId: String(item.legacy_tx_key || item.id || '').trim(),
accountId,
accountLabel: String(accountRow?.name || raw.accountLabel || raw.displayName || accountId).trim(),
bankNumber: String(accountRow?.account_number || accountRow?.external_account_id || raw.bankNumber || '').trim(),
date: this._parseBusinessDate(item.occurred_on || raw.date),
direction,
amount: Math.abs(this._num(item.amount_rub || item.amount)),
currency: String(item.currency_code || raw.currency || 'RUB').trim() || 'RUB',
counterpartyName: String(counterpartyRow?.name || raw.counterpartyName || raw.counterparty_name || '').trim(),
counterpartyInn: String(counterpartyRow?.inn || raw.counterpartyInn || raw.counterparty_inn || '').trim(),
description: String(item.description || raw.description || '').trim(),
paymentId: String(item.external_reference || raw.paymentId || '').trim(),
documentNumber: '',
group: type === 'transfer' ? 'transfer' : String(raw.group || '').trim(),
legacyMoneybagId: String(raw.legacyMoneybagId || '').trim(),
partnerId: String(raw.partnerId || '').trim(),
dealId: String(raw.dealId || '').trim(),
categoryId,
directionId: projectId,
categoryHint: categoryId,
projectHint: projectId,
projectLabel: String(item.linked_order_label || raw.project_label || raw.linked_order_label || '').trim(),
kindHint: ['transfer', 'payroll', 'tax', 'charity', 'asset', 'adjustment'].includes(type) ? type : '',
reviewStatus: String(item.review_status || '').trim(),
counterpartyHint: counterpartyId,
employeeHint: item.employee_id != null ? String(item.employee_id) : '',
noteHint: String(item.note || raw.note || '').trim(),
};
})
.filter(item => item && item.date && item.amount > 0);
},
_relationalFinanceAccountMatchesBank(accountRow, tochkaAccounts = [], raw = {}) {
const accountNumber = this._digitsOnly(accountRow?.account_number || accountRow?.external_account_id || raw.bankNumber || raw.accountId || '');
const accountName = this._normalizeText(accountRow?.name || raw.accountLabel || '');
return (Array.isArray(tochkaAccounts) ? tochkaAccounts : []).some(item => {
const bankNumber = this._extractBankAccountNumber(item?.accountId || item?.accountNumber || '');
if (accountNumber && bankNumber && accountNumber === bankNumber) return true;
if (accountNumber && bankNumber && accountNumber.slice(-4) && bankNumber.endsWith(accountNumber.slice(-4))) return true;
return accountName && accountName === this._normalizeText(item?.displayName || '');
});
},
_shouldIncludeFintabloMoneybagInOperations(moneybag, tochkaAccounts = []) {
if (!moneybag) return false;
const sourceKind = String(moneybag.sourceKind || '').trim();
if (sourceKind === 'bank') return !this._moneybagMatchesTochkaAccount(moneybag, tochkaAccounts);
return true;
},
_moneybagMatchesTochkaAccount(moneybag, tochkaAccounts = []) {
const bagNumber = this._digitsOnly(moneybag?.number || '');
const bagText = this._normalizeText(moneybag?.displayName || '');
return (tochkaAccounts || []).some(account => {
const accountNumber = this._extractBankAccountNumber(account?.accountId);
if (bagNumber && accountNumber && bagNumber === accountNumber) return true;
if (bagNumber && accountNumber && bagNumber.slice(-4) && accountNumber.endsWith(bagNumber.slice(-4))) return true;
return bagText && bagText === this._normalizeText(account?.displayName || '');
});
},
_defaultSources() {
return [
{
id: 'orders_fintablo',
name: 'FinTablo import',
kind: 'legacy_import',
status: 'active',
note: 'Исторический импорт денег по сделкам и заказам.',
},
{
id: 'tochka_api',
name: 'Точка API',
kind: 'bank_api',
status: 'planned',
note: 'Расчётные счета, балансы и банковские выписки.',
},
{
id: 'cash_manual',
name: 'Ручные счета и наличные',
kind: 'manual',
status: 'active',
note: 'Наличные у людей, ручные переводы и операционные карманы.',
},
{
id: 'payroll_internal',
name: 'Сотрудники и табель',
kind: 'internal',
status: 'active',
note: 'Часы, зарплата, распределение по производству и управлению.',
},
{
id: 'indirect_monthly',
name: 'Косвенные расходы',
kind: 'monthly_snapshot',
status: 'active',
note: 'Помесячные косвенные расходы и ставка на час.',
},
{
id: 'counterparty_research',
name: 'Research по контрагентам',
kind: 'research',
status: 'planned',
note: 'История оплат + внешний поиск по названию / ИНН для новых ООО и ИП.',
},
];
},
_defaultAccounts(settings = {}) {
const bankName = String(settings.company_bank_name || 'Точка — основной р/с').trim();
const bankAccount = String(settings.company_bank_account || '').trim();
const last4 = bankAccount ? bankAccount.slice(-4) : '';
const bankLabel = last4 ? `${bankName} ••••${last4}` : bankName;
return [
{
id: 'bank_tochka_main',
name: bankLabel,
type: 'bank',
owner: 'Компания',
source_id: 'tochka_api',
status: 'active',
note: bankAccount ? `Основной расчётный счёт (${last4})` : 'Основной расчётный счёт компании',
show_in_money: true,
legacy_hide_in_total: false,
external_ref: bankAccount,
},
{
id: 'bank_tax_reserve',
name: 'Налоговый резерв / отдельный р/с',
type: 'reserve',
owner: 'Компания',
source_id: 'tochka_api',
status: 'planned',
note: 'Для налогов, фондов и переводов в отдельный карман.',
show_in_money: false,
legacy_hide_in_total: true,
external_ref: '',
},
{
id: 'bank_secondary_ops',
name: 'Второй операционный р/с',
type: 'bank',
owner: 'Компания',
source_id: 'tochka_api',
status: 'planned',
note: 'Под отдельные юридические лица или спец-контур.',
show_in_money: true,
legacy_hide_in_total: false,
external_ref: '',
},
{
id: 'cash_katya_china',
name: 'Наличные / Китай — Катя',
type: 'cash',
owner: 'Катя',
source_id: 'cash_manual',
status: 'active',
note: 'Китайские оплаты, быстрые закупки и ручные расчеты.',
show_in_money: true,
legacy_hide_in_total: false,
external_ref: '',
},
{
id: 'cash_lesha',
name: 'Наличные — Леша',
type: 'cash',
owner: 'Леша',
source_id: 'cash_manual',
status: 'active',
note: 'Хоз. расходы, ЗП наличными и оперативные траты.',
show_in_money: true,
legacy_hide_in_total: false,
external_ref: '',
},
{
id: 'cash_dmitrov',
name: 'Наличные — Дмитров',
type: 'cash',
owner: 'Площадка Дмитров',
source_id: 'cash_manual',
status: 'active',
note: 'Отдельный наличный контур площадки / производства.',
show_in_money: true,
legacy_hide_in_total: false,
external_ref: '',
},
{
id: 'settlement_china_bybit',
name: 'Китай / расчеты / bybit',
type: 'settlement',
owner: 'Закупки',
source_id: 'cash_manual',
status: 'planned',
note: 'Отдельный карман под Китай, трансграничные оплаты и курсовые риски.',
show_in_money: true,
legacy_hide_in_total: false,
external_ref: '',
},
{
id: 'reserve_deposit',
name: 'Депозит / резерв',
type: 'reserve',
owner: 'Компания',
source_id: 'cash_manual',
status: 'active',
note: 'Деньги, которые не должны смешиваться с операционным cashflow.',
show_in_money: false,
legacy_hide_in_total: true,
external_ref: '',
},
];
},
_defaultCategories() {
return [
{ id: 'income_corporate_orders', name: 'Корпоративные заказы', group: 'income', bucket: 'project', source_id: 'orders_fintablo', mapping: 'корп / кастом', active: true },
{ id: 'income_workshops', name: 'Воркшопы', group: 'income', bucket: 'channel', source_id: 'orders_fintablo', mapping: 'воркшопы', active: true },
{ id: 'income_online_store', name: 'Интернет-магазин', group: 'income', bucket: 'channel', source_id: 'orders_fintablo', mapping: 'эквайринг / D2C', active: true },
{ id: 'income_marketplaces', name: 'Маркетплейсы', group: 'income', bucket: 'channel', source_id: 'orders_fintablo', mapping: 'Ozon / WB / платформы', active: true },
{ id: 'income_museum_sales', name: 'Музейные продажи', group: 'income', bucket: 'channel', source_id: 'orders_fintablo', mapping: 'музеи и госучреждения', active: true },
{ id: 'income_buyout', name: 'Выкуп / реализация', group: 'income', bucket: 'channel', source_id: 'orders_fintablo', mapping: 'выкуп и реализация', active: true },
{ id: 'direct_materials', name: 'Пластик и материалы', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'fact_materials', active: true },
{ id: 'direct_hardware', name: 'Фурнитура', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'fact_hardware', active: true },
{ id: 'direct_packaging', name: 'Упаковка', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'fact_packaging', active: true },
{ id: 'direct_printing', name: 'Нанесение / типография', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'fact_printing', active: true },
{ id: 'direct_molds', name: 'Молды и оснастка', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'fact_molds', active: true },
{ id: 'direct_delivery', name: 'Доставка и логистика', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'fact_delivery', active: true },
{ id: 'direct_subcontractors', name: 'Подрядчики производства', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'типография / уф / шитье / монтаж', active: true },
{ id: 'direct_prototyping', name: 'Прототипы и сэмплы', group: 'direct', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'прототипы / образцы', active: true },
{ id: 'payroll_production', name: 'ЗП производство', group: 'payroll', bucket: 'production', source_id: 'payroll_internal', mapping: 'табель / production', active: true },
{ id: 'payroll_office', name: 'ЗП офис и управление', group: 'payroll', bucket: 'overhead', source_id: 'payroll_internal', mapping: 'табель / office', active: true },
{ id: 'payroll_marketplaces', name: 'ЗП по каналу продаж', group: 'payroll', bucket: 'channel', source_id: 'payroll_internal', mapping: 'маркетплейсы / ecom', active: true },
{ id: 'taxes_orders', name: 'Налоги по заказам', group: 'taxes', bucket: 'orders', source_id: 'orders_fintablo', mapping: 'fact_taxes', active: true },
{ id: 'taxes_payroll', name: 'Налоги на сотрудников', group: 'taxes', bucket: 'payroll', source_id: 'payroll_internal', mapping: 'НДФЛ / взносы', active: true },
{ id: 'taxes_usn', name: 'УСН / ЕНП', group: 'taxes', bucket: 'finance', source_id: 'cash_manual', mapping: 'налоги компании', active: true },
{ id: 'taxes_vat', name: 'НДС / спецналоги', group: 'taxes', bucket: 'finance', source_id: 'cash_manual', mapping: 'НДС и спецслучаи', active: true },
{ id: 'commercial_marketing', name: 'Маркетинг и реклама', group: 'commercial', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'marketing', active: true },
{ id: 'commercial_marketplace_fees', name: 'Комиссии каналов продаж', group: 'commercial', bucket: 'channel', source_id: 'orders_fintablo', mapping: 'эквайринг / комиссии', active: true },
{ id: 'commercial_site', name: 'Сайт и digital', group: 'commercial', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'site / services', active: true },
{ id: 'commercial_photo', name: 'Фото / контент / съемки', group: 'commercial', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'photo / content', active: true },
{ id: 'overhead_rent', name: 'Аренда и обслуживание пространства', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'rent / office', active: true },
{ id: 'overhead_workshop', name: 'Обслуживание цеха', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'workshop', active: true },
{ id: 'overhead_software', name: 'Программы и сервисы', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'subscriptions', active: true },
{ id: 'overhead_bank', name: 'Банковское обслуживание', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'bank', active: true },
{ id: 'overhead_internet', name: 'Интернет и связь', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'internet', active: true },
{ id: 'overhead_household', name: 'Хоз. товары и расходники', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'household', active: true },
{ id: 'overhead_fuel', name: 'Бензин и локальная логистика', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'fuel', active: true },
{ id: 'overhead_certification', name: 'Сертификация и испытания', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'certification', active: true },
{ id: 'overhead_tools', name: 'Инструменты и ремонт', group: 'overhead', bucket: 'monthly', source_id: 'indirect_monthly', mapping: 'tools / repair', active: true },
{ id: 'overhead_amortization', name: 'Амортизация', group: 'overhead', bucket: 'monthly', source_id: 'cash_manual', mapping: 'manual amortization', active: true },
{ id: 'investment_fixed_assets', name: 'Покупка ОС / оборудования', group: 'investment', bucket: 'balance', source_id: 'cash_manual', mapping: 'fixed assets purchase', active: true },
{ id: 'finance_transfers', name: 'Переводы между счетами', group: 'finance', bucket: 'finance', source_id: 'cash_manual', mapping: 'internal transfer', active: true },
{ id: 'finance_owner_money', name: 'Вклады / вывод собственника', group: 'finance', bucket: 'finance', source_id: 'cash_manual', mapping: 'owner money', active: true },
{ id: 'other_charity', name: 'Благотворительность', group: 'other', bucket: 'other', source_id: 'orders_fintablo', mapping: 'fact_charity', active: true },
{ id: 'other_misc', name: 'Прочее', group: 'other', bucket: 'other', source_id: 'orders_fintablo', mapping: 'fact_other', active: true },
];
},
_defaultProjects() {
return [
{
id: 'project_recycle_object',
name: 'Recycle Object',
type: 'core',
default_income_category_id: 'income_corporate_orders',
note: 'Основное производство и кастомные / корп заказы.',
active: true,
},
{
id: 'project_workshops',
name: 'Воркшопы',
type: 'channel',
default_income_category_id: 'income_workshops',
note: 'Отдельный контур выездных и внутренних воркшопов.',
active: true,
},
{
id: 'project_online_store',
name: 'Интернет-магазин',
type: 'channel',
default_income_category_id: 'income_online_store',
note: 'D2C, эквайринг и онлайн-продажи.',
active: true,
},
{
id: 'project_marketplaces',
name: 'Маркетплейсы',
type: 'channel',
default_income_category_id: 'income_marketplaces',
note: 'Поступления и расходы по платформам вроде Ozon / WB.',
active: true,
},
{
id: 'project_museum_sales',
name: 'Музейные магазины',
type: 'channel',
default_income_category_id: 'income_museum_sales',
note: 'Продажи и выкуп через музеи, фонды и госучреждения.',
active: true,
},
{
id: 'project_dmitrov',
name: 'Дмитров',
type: 'site',
default_income_category_id: 'income_corporate_orders',
note: 'Отдельная площадка / производственный контур.',
active: true,
},
];
},
_defaultCounterparties() {
return [
{
id: 'cp_tax_treasury',
name: 'Налоговая / казначейство',
role: 'tax',
inn: '',
what_they_sell: 'ЕНП, налоги, госплатежи',
default_project_id: '',
default_category_id: 'taxes_usn',
research_mode: 'system',
match_hint: 'ифнс; уфк; казначейство; енп; налог',
note: 'Почти всегда это не проектная закупка, а налоговый контур.',
active: true,
},
{
id: 'cp_social_funds',
name: 'Соцфонды / ОСФР',
role: 'tax',
inn: '',
what_they_sell: 'Страховые взносы и обязательные выплаты за сотрудников',
default_project_id: '',
default_category_id: 'taxes_payroll',
research_mode: 'system',
match_hint: 'осфр; сфр; страховые взносы; несчастных случаев',
note: 'Налоговый слой по сотрудникам.',
active: true,
},
{
id: 'cp_online_acquiring',
name: 'Интернет-эквайринг / ЮMoney',
role: 'channel',
inn: '',
what_they_sell: 'Эквайринг, онлайн-оплаты, выплаты интернет-магазина',
default_project_id: 'project_online_store',
default_category_id: 'income_online_store',
research_mode: 'hybrid',
match_hint: 'юмани; эквайринг; yookassa; online payment',
note: 'Поступления чаще всего относятся к интернет-магазину; списания - к комиссиям канала.',
active: true,
},
{
id: 'cp_marketplaces',
name: 'Маркетплейсы / платформы',
role: 'channel',
inn: '',
what_they_sell: 'Выплаты и комиссии маркетплейсов',
default_project_id: 'project_marketplaces',
default_category_id: 'income_marketplaces',
research_mode: 'hybrid',
match_hint: 'ozon; вайлдберриз; интернет решения; marketplace',
note: 'Использовать для Ozon, WB и похожих платформ.',
active: true,
},
{
id: 'cp_museums',
name: 'Музеи / фонды / госучреждения',
role: 'client',
inn: '',
what_they_sell: 'Выкуп или реализация музейной продукции',
default_project_id: 'project_museum_sales',
default_category_id: 'income_museum_sales',
research_mode: 'hybrid',
match_hint: 'музей; уфк; фонд; галерея; эрмитаж; пушкин',
note: 'Часто определяется по названию учреждения и типу договора.',
active: true,
},
{
id: 'cp_production_contractors',
name: 'Производственные подрядчики',
role: 'vendor',
inn: '',
what_they_sell: 'Печать, типография, шитье, фрезеровка, монтаж, производственные услуги',
default_project_id: 'project_recycle_object',
default_category_id: 'direct_subcontractors',
research_mode: 'hybrid',
match_hint: 'типография; уф печать; шитье; фрезеровка; монтаж',
note: 'Если новый контрагент выглядит как производственный подрядчик, сначала смотреть сюда.',
active: true,
},
{
id: 'cp_china_logistics',
name: 'Китай / логистика',
role: 'logistics',
inn: '',
what_they_sell: 'Закупка из Китая, логистика, трансграничные расчеты',
default_project_id: 'project_recycle_object',
default_category_id: 'direct_delivery',
research_mode: 'hybrid',
match_hint: 'china; taobao; bybit; cargo; логистика',
note: 'Полезно для оплат в Китай и связанных с ними логистических цепочек.',
active: true,
},
];
},
_defaultRules() {
return [
{
id: 'rule_salary_cash',
name: 'Наличные + "зп" -> зарплата',
trigger_type: 'keyword_bundle',
trigger: 'зп; зарплата',
account_scope: 'cash_any',
counterparty_id: '',
project_id: 'project_recycle_object',
category_id: 'payroll_production',
confidence: 0.86,
auto_apply: true,
note: 'Основано на реальном legacy-паттерне с выплатами из наличных.',
active: true,
},
{
id: 'rule_tax_keywords',
name: 'Казначейство / налог -> УСН / ЕНП',
trigger_type: 'keyword_bundle',
trigger: 'ифнс; казначейство; енп; налог',
account_scope: 'any',
counterparty_id: 'cp_tax_treasury',
project_id: '',
category_id: 'taxes_usn',
confidence: 0.95,
auto_apply: true,
note: 'Системное правило для налогового контура.',
active: true,
},
{
id: 'rule_social_fund',
name: 'ОСФР / страховые взносы',
trigger_type: 'keyword_bundle',
trigger: 'осфр; страховые взносы; сфр',
account_scope: 'any',
counterparty_id: 'cp_social_funds',
project_id: '',
category_id: 'taxes_payroll',
confidence: 0.96,
auto_apply: true,
note: 'Налоги на сотрудников и фонды.',
active: true,
},
{
id: 'rule_online_store',
name: 'ЮMoney / эквайринг -> интернет-магазин',
trigger_type: 'counterparty',
trigger: 'ЮМани; YooMoney; эквайринг',
account_scope: 'bank_any',
counterparty_id: 'cp_online_acquiring',
project_id: 'project_online_store',
category_id: 'income_online_store',
confidence: 0.88,
auto_apply: true,
note: 'Поступления канала D2C и сопутствующие движения.',
active: true,
},
{
id: 'rule_marketplaces',
name: 'Маркетплейсы -> канал продаж',
trigger_type: 'keyword_bundle',
trigger: 'ozon; wildberries; marketplace; интернет решения',
account_scope: 'bank_any',
counterparty_id: 'cp_marketplaces',
project_id: 'project_marketplaces',
category_id: 'income_marketplaces',
confidence: 0.9,
auto_apply: true,
note: 'Срабатывает на поступления и помогает не путать с корп-заказами.',
active: true,
},
{
id: 'rule_production_vendor',
name: 'Подрядчик + основной счет -> прямые расходы',
trigger_type: 'counterparty_account',
trigger: 'типография; печать; шитье; монтаж',
account_scope: 'bank_tochka_main',
counterparty_id: 'cp_production_contractors',
project_id: 'project_recycle_object',
category_id: 'direct_subcontractors',
confidence: 0.74,
auto_apply: false,
note: 'Лучше сначала показать на проверку, если поставщик новый.',
active: true,
},
];
},
_defaultQueueConfig() {
return {
dailySyncEnabled: true,
autoApplyThreshold: 0.85,
reviewThreshold: 0.55,
researchEnabled: true,
cadenceLabel: 'раз в день',
researchSources: ['history', 'keywords', 'web', 'order_match'],
note: 'Показывать, почему выбран проект, статья и профиль контрагента.',
};
},
_selectedOperationKeySet() {
return new Set((this.ui?.operations?.selected_keys || []).map(item => String(item || '')).filter(Boolean));
},
_findTransactionRowRoot(txKey) {
if (typeof document === 'undefined') return null;
const key = String(txKey || '');
return Array.from(document.querySelectorAll('[data-finance-tx-row], [data-finance-payroll-row]'))
.find(node => String(node.dataset.financeTxRow || node.dataset.financePayrollRow || '') === key) || null;
},
_replaceOperationKeyInUi(oldTxKey, nextTxKey) {
const prevKey = String(oldTxKey || '');
const nextKey = String(nextTxKey || '');
const selected = new Set((this.ui?.operations?.selected_keys || []).map(item => String(item || '')).filter(Boolean));
const hadSelected = selected.delete(prevKey);
if (hadSelected && nextKey) selected.add(nextKey);
this.ui.operations = {
...this.ui.operations,
selected_keys: Array.from(selected),
focus_tx_key: String(this.ui?.operations?.focus_tx_key || '') === prevKey ? nextKey : (this.ui?.operations?.focus_tx_key || ''),
};
},
_visibleOperationKeys() {
return this._visibleOperationRows().map(item => String(item.txKey || '')).filter(Boolean);
},
_visibleOperationRows() {
const filteredRows = this._filterOperationRows(this.summary?.transactions?.rows || []);
return filteredRows.slice(0, this.ui?.operations?.limit || 200);
},
_visibleDayOperationKeys(dateKey) {
const day = this._parseBusinessDate(dateKey) || '__no_date__';
return this._visibleOperationRows()
.filter(item => (this._parseBusinessDate(item?.date) || '__no_date__') === day)
.map(item => String(item.txKey || ''))
.filter(Boolean);
},
_currentBatchDraft() {
return {
categoryId: String(this.ui?.operations?.batch_category_id || '__keep__'),
projectId: String(this.ui?.operations?.batch_project_id || '__keep__'),
projectLabel: String(this.ui?.operations?.batch_project_label || '').trim(),
note: String(this.ui?.operations?.batch_note || '').trim(),
};
},
_currentOperationTemplateDraftName(scope = 'single') {
if (scope === 'batch') return String(this.ui?.operations?.batch_template_name || '').trim();
return String(this.ui?.operations?.template_name || '').trim();
},
_countSuggestedOperationKeys(keys = []) {
return (Array.isArray(keys) ? keys : [])
.map(item => String(item || '').trim())
.filter(Boolean)
.filter(txKey => this._hasMeaningfulSuggestion(this._findTransactionRowByKey(txKey)?.suggestion))
.length;
},
_builtinOperationPresets() {
return [
{
id: 'materials',
label: 'Материалы',
categoryId: 'direct_materials',
projectId: 'project_recycle_object',
},
{
id: 'site_digital',
label: 'Сайт / digital',
categoryId: 'commercial_site',
projectId: '__clear__',
projectLabel: '__clear__',
},
{
id: 'tax_orders',
label: 'Налоги заказа',
kind: 'tax',
categoryId: 'taxes_orders',
projectId: '__clear__',
projectLabel: '__clear__',
},
{
id: 'charity',
label: 'Благотворительность',
categoryId: 'other_charity',
projectId: '__clear__',
projectLabel: '__clear__',
},
{
id: 'internal_transfer',
label: 'Перевод',
kind: 'transfer',
categoryId: 'finance_transfers',
projectId: '__clear__',
projectLabel: '__clear__',
},
{
id: 'production_payroll',
label: 'ЗП производство',
kind: 'payroll',
categoryId: 'payroll_production',
projectId: 'project_recycle_object',
},
];
},
_customOperationPresets() {
return this._normalizeOperationTemplates(this.workspace?.operationTemplates || []).map(item => ({
id: item.id,
label: item.label,
kind: item.kind,
categoryId: item.category_id,
projectId: item.project_id,
projectLabel: item.project_label,
counterpartyId: item.counterparty_id,
payrollEmployeeId: item.payroll_employee_id,
transferAccountId: item.transfer_account_id,
note: item.note,
custom: true,
}));
},
_operationQuickPresets() {
return [
...this._builtinOperationPresets(),
...this._customOperationPresets(),
];
},
_findOperationPreset(presetId) {
return this._operationQuickPresets().find(item => String(item?.id || '') === String(presetId || '')) || null;
},
_renderOperationPresetButtons(presets = [], scope = 'single', txKey = '') {
const mode = scope === 'batch' ? 'batch' : 'single';
return (Array.isArray(presets) ? presets : [])
.map(preset => {
const applyClick = mode === 'batch'
? `Finance.applyBatchPreset('${this._escJs(preset.id)}', true)`
: `Finance.applyOperationPreset('${this._escJs(txKey)}', '${this._escJs(preset.id)}', true)`;
const removeButton = preset.custom
? `× `
: '';
return `
${this._esc(preset.label)}
${removeButton}
`;
})
.join('');
},
_renderOperationQuickPresets(scope = 'single', txKey = '') {
const mode = scope === 'batch' ? 'batch' : 'single';
const builtinPresets = this._builtinOperationPresets();
const customPresets = this._customOperationPresets();
const copyTitle = mode === 'batch' ? 'Готовые наборы' : 'Готовые сценарии';
const copyText = mode === 'batch'
? 'Один клик: применить сценарий и подтвердить выбранную пачку.'
: 'Один клик: применить сценарий, подтвердить и пойти дальше.';
const customCopyText = mode === 'batch'
? 'Твои собственные пачки для повторяемых сценариев.'
: 'Твои собственные сценарии рядом со встроенными пресетами.';
const builderField = mode === 'batch' ? 'batch_template_name' : 'template_name';
const builderValue = this._currentOperationTemplateDraftName(mode);
const builderPlaceholder = mode === 'batch'
? 'Название шаблона для пачки'
: 'Название шаблона для операции';
const builderAction = mode === 'batch'
? 'Finance.saveBatchTemplate()'
: `Finance.saveOperationTemplate('${this._escJs(txKey)}')`;
return `
${this._esc(copyTitle)}
${this._esc(copyText)}
${this._renderOperationPresetButtons(builtinPresets, mode, txKey)}
${customPresets.length > 0 ? `
Мои шаблоны
${this._esc(customCopyText)}
${this._renderOperationPresetButtons(customPresets, mode, txKey)}
` : ''}
Сохранить как шаблон
`;
},
_hasMeaningfulOperationTemplate(template) {
if (!template || typeof template !== 'object') return false;
return !!(
template.kind
|| template.category_id
|| template.project_id
|| template.project_label
|| template.counterparty_id
|| template.payroll_employee_id
|| template.transfer_account_id
|| template.note
);
},
_suggestOperationTemplateLabel(template) {
if (!template || typeof template !== 'object') return 'Мой шаблон';
const categoryName = template.category_id ? this._findById(this.workspace?.categories, template.category_id)?.name || '' : '';
const projectName = template.project_id ? this._findById(this.workspace?.projects, template.project_id)?.name || '' : '';
const explicitProject = String(template.project_label || '').trim();
const kindLabel = this.TRANSACTION_KIND_LABELS[String(template.kind || '').trim()] || '';
return [
categoryName,
projectName || explicitProject,
!categoryName && !projectName && !explicitProject ? kindLabel : '',
!categoryName && !projectName && !explicitProject && !kindLabel ? String(template.note || '').trim().slice(0, 28) : '',
].filter(Boolean).join(' · ') || 'Мой шаблон';
},
_prepareOperationTemplate(raw = {}, fallbackLabel = '') {
const template = this._normalizeOperationTemplates([{
...raw,
label: String(raw.label || fallbackLabel || '').trim() || this._suggestOperationTemplateLabel(raw),
updated_at: new Date().toISOString(),
}])[0];
return template && this._hasMeaningfulOperationTemplate(template) ? template : null;
},
_upsertOperationTemplate(template) {
if (!this.workspace) return null;
const normalized = this._prepareOperationTemplate(template);
if (!normalized) return null;
const current = this._normalizeOperationTemplates(this.workspace.operationTemplates || []);
const byId = current.find(item => String(item.id || '') === String(normalized.id || ''));
const byLabel = !byId
? current.find(item => this._normalizeText(item.label || '') === this._normalizeText(normalized.label || ''))
: null;
const targetId = String(byId?.id || byLabel?.id || normalized.id || this._uid('op_template'));
const nextTemplate = {
...(byId || byLabel || {}),
...normalized,
id: targetId,
};
const nextMap = new Map(current.map(item => [String(item.id || ''), item]));
nextMap.set(targetId, nextTemplate);
this.workspace.operationTemplates = this._normalizeOperationTemplates(Array.from(nextMap.values()));
return nextTemplate;
},
_pickNeighborOperationKey(keys = [], currentKey = '', offset = 1) {
const normalizedKeys = (Array.isArray(keys) ? keys : []).map(item => String(item || '')).filter(Boolean);
if (normalizedKeys.length === 0) return '';
const key = String(currentKey || '').trim();
const step = Number(offset) || 1;
const index = normalizedKeys.indexOf(key);
if (index === -1) return normalizedKeys[0] || '';
return normalizedKeys[index + step]
|| normalizedKeys[index - 1]
|| normalizedKeys[index]
|| '';
},
_focusNeighborBeforeMutation(txKey, offset = 1) {
const keys = this._visibleOperationKeys();
const nextKey = this._pickNeighborOperationKey(keys, txKey, offset);
this.ui.operations = {
...this.ui.operations,
focus_tx_key: nextKey,
};
},
_resolveFocusedOperationKey(visibleRows = []) {
const rows = Array.isArray(visibleRows) ? visibleRows : [];
const current = String(this.ui?.operations?.focus_tx_key || '').trim();
if (current && rows.some(item => String(item?.txKey || '') === current)) return current;
const fallback = String(rows[0]?.txKey || '').trim();
if (fallback !== current) {
this.ui.operations = {
...this.ui.operations,
focus_tx_key: fallback,
};
}
return fallback;
},
_normalizeTransactionDecisions(list) {
const allowedKinds = new Set(Object.keys(this.TRANSACTION_KIND_LABELS));
const map = new Map();
(Array.isArray(list) ? list : []).forEach(item => {
if (!item || typeof item !== 'object') return;
const txKey = String(item.tx_key || item.id || '').trim();
if (!txKey) return;
const kind = String(item.kind || '').trim();
map.set(txKey, {
id: txKey,
tx_key: txKey,
kind: allowedKinds.has(kind) ? kind : '',
project_id: String(item.project_id || '').trim(),
project_label: String(item.project_label || '').trim(),
category_id: String(item.category_id || '').trim(),
counterparty_id: String(item.counterparty_id || '').trim(),
payroll_employee_id: item.payroll_employee_id == null ? '' : String(item.payroll_employee_id).trim(),
transfer_account_id: String(item.transfer_account_id || '').trim(),
note: String(item.note || '').trim(),
confirmed: !!item.confirmed,
tx_date: this._parseBusinessDate(item.tx_date) || '',
tx_amount: this._num(item.tx_amount),
tx_direction: String(item.tx_direction || '').trim(),
account_id: String(item.account_id || '').trim(),
account_label: String(item.account_label || '').trim(),
counterparty_name: String(item.counterparty_name || '').trim(),
counterparty_inn: String(item.counterparty_inn || '').trim(),
description: String(item.description || '').trim(),
updated_at: item.updated_at || null,
});
});
return Array.from(map.values());
},
_findTransactionDecision(txKey) {
return this._findById(this.workspace?.transactionDecisions, txKey) || null;
},
_findTransactionRowByKey(txKey) {
return (this.summary?.transactions?.rows || []).find(item => String(item?.txKey || '') === String(txKey || '')) || null;
},
_hasMeaningfulSuggestion(suggestion) {
if (!suggestion || typeof suggestion !== 'object') return false;
return !!(
suggestion.categoryId
|| suggestion.projectId
|| suggestion.counterpartyProfileId
|| ['transfer', 'owner_money', 'payroll', 'tax', 'ignore'].includes(String(suggestion.kind || ''))
);
},
_describeSuggestion(suggestion) {
if (!this._hasMeaningfulSuggestion(suggestion)) return 'Нового контрагента нужно дообучить';
const parts = [
suggestion?.categoryName || '',
suggestion?.projectName || '',
suggestion?.counterpartyProfileName || '',
].filter(Boolean);
return parts.join(' → ') || 'Подсказка системы готова';
},
_confirmTransactionDecisionLocally(txKey) {
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
decision.confirmed = true;
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
},
_applySuggestionToDecision(decision, row, tx) {
if (!decision) return decision;
const suggestion = row?.suggestion || null;
if (!this._hasMeaningfulSuggestion(suggestion)) return decision;
const inferredKind = suggestion.kind
|| this._inferTransactionKind(tx, suggestion, this.workspace, this._guessEmployeeForTransaction(tx, this.data.employees));
this._applyKindToDecision(decision, tx, inferredKind);
decision.category_id = String(suggestion.categoryId || '').trim();
decision.project_id = String(suggestion.projectId || '').trim();
decision.counterparty_id = String(suggestion.counterpartyProfileId || '').trim();
if (decision.category_id) {
const derivedKind = this._kindFromCategoryId(decision.category_id, this.workspace, tx);
if (!suggestion.kind && ['transfer', 'owner_money', 'payroll', 'tax'].includes(derivedKind)) {
this._applyKindToDecision(decision, tx, derivedKind);
} else if (!suggestion.kind) {
decision.kind = derivedKind;
}
}
if (decision.kind === 'transfer') {
decision.project_id = '';
decision.counterparty_id = decision.counterparty_id || '';
} else if (decision.kind === 'owner_money' || decision.kind === 'ignore') {
decision.project_id = '';
decision.counterparty_id = '';
}
return decision;
},
_applyPresetToDecision(decision, tx, preset) {
if (!decision || !preset) return decision;
if (preset.kind) {
this._applyKindToDecision(decision, tx, preset.kind);
}
if (Object.prototype.hasOwnProperty.call(preset, 'categoryId')) {
decision.category_id = preset.categoryId === '__clear__'
? ''
: String(preset.categoryId || '').trim();
if (decision.category_id) {
const derivedKind = this._kindFromCategoryId(decision.category_id, this.workspace, tx);
if (!preset.kind && ['transfer', 'owner_money', 'payroll', 'tax'].includes(derivedKind)) {
this._applyKindToDecision(decision, tx, derivedKind);
} else if (!preset.kind) {
decision.kind = derivedKind;
if (derivedKind !== 'payroll') decision.payroll_employee_id = '';
if (derivedKind !== 'transfer') decision.transfer_account_id = '';
}
}
}
if (Object.prototype.hasOwnProperty.call(preset, 'projectId')) {
if (preset.projectId === '__clear__') {
decision.project_id = '';
decision.project_label = '';
} else {
decision.project_id = String(preset.projectId || '').trim();
}
}
if (Object.prototype.hasOwnProperty.call(preset, 'projectLabel')) {
decision.project_label = preset.projectLabel === '__clear__'
? ''
: String(preset.projectLabel || '').trim();
}
if (Object.prototype.hasOwnProperty.call(preset, 'counterpartyId')) {
decision.counterparty_id = preset.counterpartyId === '__clear__'
? ''
: String(preset.counterpartyId || '').trim();
}
if (Object.prototype.hasOwnProperty.call(preset, 'payrollEmployeeId')) {
decision.payroll_employee_id = preset.payrollEmployeeId === '__clear__'
? ''
: String(preset.payrollEmployeeId || '').trim();
}
if (Object.prototype.hasOwnProperty.call(preset, 'transferAccountId')) {
decision.transfer_account_id = preset.transferAccountId === '__clear__'
? ''
: String(preset.transferAccountId || '').trim();
}
if (Object.prototype.hasOwnProperty.call(preset, 'note')) {
decision.note = preset.note === '__clear__'
? ''
: String(preset.note || '').trim();
}
if (decision.kind === 'transfer') {
decision.project_id = '';
decision.project_label = '';
} else if (decision.kind === 'owner_money' || decision.kind === 'ignore') {
decision.project_id = '';
decision.project_label = '';
decision.counterparty_id = '';
}
return decision;
},
_applyPresetToKeys(keys = [], preset, confirmAfter = false) {
const normalizedKeys = (Array.isArray(keys) ? keys : []).map(item => String(item || '').trim()).filter(Boolean);
const appliedKeys = [];
normalizedKeys.forEach(txKey => {
const tx = this._findTransactionByKey(txKey);
if (!tx) return;
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
this._applyPresetToDecision(decision, tx, preset);
if (confirmAfter) decision.confirmed = true;
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
appliedKeys.push(txKey);
});
return {
appliedKeys,
skippedKeys: normalizedKeys.filter(txKey => !appliedKeys.includes(txKey)),
};
},
_applySuggestionsToKeys(keys = [], confirmAfter = false) {
const normalizedKeys = (Array.isArray(keys) ? keys : []).map(item => String(item || '').trim()).filter(Boolean);
const appliedKeys = [];
normalizedKeys.forEach(txKey => {
const row = this._findTransactionRowByKey(txKey);
const tx = row || this._findTransactionByKey(txKey);
if (!tx || !this._hasMeaningfulSuggestion(row?.suggestion)) return;
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
this._applySuggestionToDecision(decision, row, tx);
if (confirmAfter) decision.confirmed = true;
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
appliedKeys.push(txKey);
});
return {
appliedKeys,
skippedKeys: normalizedKeys.filter(txKey => !appliedKeys.includes(txKey)),
};
},
_applyBatchFieldsToDecision(decision, tx, batch) {
if (!decision || !batch) return decision;
if (batch.categoryId !== '__keep__') {
decision.category_id = String(batch.categoryId || '').trim();
if (decision.category_id) {
const derivedKind = this._kindFromCategoryId(decision.category_id, this.workspace, tx);
if (['transfer', 'owner_money', 'payroll', 'tax'].includes(derivedKind)) {
this._applyKindToDecision(decision, tx, derivedKind);
} else {
decision.kind = derivedKind;
}
}
}
if (batch.projectId !== '__keep__') {
decision.project_id = String(batch.projectId || '').trim();
}
if (batch.projectLabel) {
decision.project_label = batch.projectLabel;
}
if (batch.note) {
decision.note = batch.note;
}
return decision;
},
_applyBatchClearToDecision(decision, tx, clearMode = 'all') {
if (!decision) return decision;
const mode = String(clearMode || 'all').trim() || 'all';
if (mode === 'category' || mode === 'all') {
decision.category_id = '';
if (['tax', 'payroll'].includes(String(decision.kind || ''))) {
decision.kind = tx?.direction === 'in' ? 'income' : 'expense';
if (decision.kind !== 'payroll') decision.payroll_employee_id = '';
}
}
if (mode === 'project' || mode === 'all') {
decision.project_id = '';
}
if (mode === 'project_label' || mode === 'all') {
decision.project_label = '';
}
if (mode === 'note' || mode === 'all') {
decision.note = '';
}
return decision;
},
_applyKindToDecision(decision, tx, kind) {
decision.kind = String(kind || '').trim();
if (decision.kind === 'transfer') {
decision.category_id = decision.category_id || 'finance_transfers';
decision.project_id = '';
decision.project_label = '';
decision.payroll_employee_id = '';
} else if (decision.kind === 'owner_money') {
decision.category_id = decision.category_id || 'finance_owner_money';
decision.project_id = '';
decision.project_label = '';
decision.payroll_employee_id = '';
decision.transfer_account_id = '';
} else if (decision.kind === 'tax') {
decision.category_id = decision.category_id || 'taxes_usn';
decision.payroll_employee_id = '';
} else if (decision.kind === 'payroll') {
const guessedEmployee = decision.payroll_employee_id
? this._findById(this.data.employees, decision.payroll_employee_id)
: this._guessEmployeeForTransaction(tx, this.data.employees);
if (guessedEmployee && !decision.payroll_employee_id) decision.payroll_employee_id = String(guessedEmployee.id || '');
decision.category_id = decision.category_id || this._employeePayrollCategoryId(guessedEmployee) || 'payroll_production';
decision.project_id = decision.project_id || 'project_recycle_object';
decision.transfer_account_id = '';
} else if (decision.kind === 'ignore') {
decision.project_id = '';
decision.project_label = '';
decision.category_id = '';
decision.counterparty_id = '';
decision.payroll_employee_id = '';
decision.transfer_account_id = '';
}
return decision;
},
_markTransactionKindLocally(txKey, kind) {
const tx = this._findTransactionByKey(txKey);
const decision = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
this._applyKindToDecision(decision, tx, kind);
decision.updated_at = new Date().toISOString();
this._upsertTransactionDecision(decision);
},
_seedTransactionDecision(txKey) {
const tx = this._findTransactionByKey(txKey);
const seeded = this._normalizeTransactionDecisions([{
tx_key: txKey,
tx_date: tx?.date || '',
tx_amount: this._num(tx?.amount),
tx_direction: tx?.direction || '',
account_id: tx?.accountId || '',
account_label: tx?.accountLabel || '',
counterparty_name: tx?.counterpartyName || '',
counterparty_inn: tx?.counterpartyInn || '',
description: tx?.description || '',
}])[0];
return seeded || {
id: txKey,
tx_key: txKey,
kind: '',
project_id: '',
project_label: '',
category_id: '',
counterparty_id: '',
payroll_employee_id: '',
transfer_account_id: '',
note: '',
confirmed: false,
tx_date: '',
tx_amount: 0,
tx_direction: '',
account_id: '',
account_label: '',
counterparty_name: '',
counterparty_inn: '',
description: '',
updated_at: null,
};
},
_upsertTransactionDecision(decision) {
const normalized = this._normalizeTransactionDecisions([decision])[0];
if (!normalized) return;
const map = new Map((this.workspace?.transactionDecisions || []).map(item => [String(item.tx_key || ''), item]));
map.set(String(normalized.tx_key), normalized);
this.workspace.transactionDecisions = this._normalizeTransactionDecisions(Array.from(map.values()));
},
_hasMeaningfulDecision(decision) {
if (!decision) return false;
return !!(
decision.confirmed
|| decision.kind
|| decision.project_id
|| decision.project_label
|| decision.category_id
|| decision.counterparty_id
|| decision.payroll_employee_id
|| decision.transfer_account_id
|| decision.note
);
},
_decisionFromRow(row, txKey) {
const tx = this._findTransactionByKey(txKey);
const existing = this._findTransactionDecision(txKey) || this._seedTransactionDecision(txKey);
const normalized = this._normalizeTransactionDecisions([{
...existing,
tx_key: txKey,
kind: this._rowValue(row, 'kind'),
project_id: this._rowValue(row, 'project_id'),
project_label: this._rowValue(row, 'project_label'),
category_id: this._rowValue(row, 'category_id'),
counterparty_id: this._rowValue(row, 'counterparty_id'),
payroll_employee_id: this._rowValue(row, 'payroll_employee_id'),
transfer_account_id: this._rowValue(row, 'transfer_account_id'),
note: this._rowValue(row, 'note'),
confirmed: this._rowChecked(row, 'confirmed'),
tx_date: tx?.date || existing.tx_date || '',
tx_amount: this._num(tx?.amount ?? existing.tx_amount),
tx_direction: tx?.direction || existing.tx_direction || '',
account_id: tx?.accountId || existing.account_id || '',
account_label: tx?.accountLabel || existing.account_label || '',
counterparty_name: tx?.counterpartyName || existing.counterparty_name || '',
counterparty_inn: tx?.counterpartyInn || existing.counterparty_inn || '',
description: tx?.description || existing.description || '',
updated_at: new Date().toISOString(),
}])[0];
return this._hasMeaningfulDecision(normalized) ? normalized : null;
},
_findTransactionByKey(txKey) {
const normalizedKey = String(txKey || '');
const manualRows = this._manualTransactionsToBankRows(this.workspace);
const recurringRows = this._recurringTransactionsToBankRows(this.workspace);
return [
...((this.summary?.transactions?.rows || []).filter(item => item && typeof item === 'object')),
...manualRows,
...recurringRows,
...this._canonicalExternalTransactions(this.workspace),
].find(item => String(item?.txKey || this._transactionKey(item)) === normalizedKey) || null;
},
_findManualTransactionByTxKey(txKey) {
const matched = this._manualTransactionsToBankRows(this.workspace)
.find(item => this._transactionKey(item) === String(txKey || ''));
if (!matched?.manualId) return null;
return this._findById(this.workspace?.manualTransactions, matched.manualId) || null;
},
_manualTransactionToRawRow(item, workspace = this.workspace) {
if (!item) return null;
const account = this._findById(workspace?.accounts, item.account_id);
return {
manualId: item.id,
transactionId: `manual:${item.id}`,
accountId: item.account_id,
accountLabel: account?.name || item.account_id,
date: item.date,
direction: item.direction,
amount: item.amount,
counterpartyName: item.counterparty_name,
description: item.description,
sourceKind: 'manual',
};
},
_updateManualTransactionRecord(txKey, patch = {}) {
if (!this.workspace) return { oldTxKey: String(txKey || ''), newTxKey: '' };
const current = this._findManualTransactionByTxKey(txKey);
if (!current) return { oldTxKey: String(txKey || ''), newTxKey: '' };
const nextManual = this._normalizeManualTransactions([{
...current,
...patch,
id: current.id,
}])[0];
if (!nextManual) return { oldTxKey: String(txKey || ''), newTxKey: '' };
this.workspace.manualTransactions = this._normalizeManualTransactions(
(this.workspace.manualTransactions || []).map(item => String(item?.id || '') === String(current.id || '') ? nextManual : item)
);
const oldKey = String(txKey || '');
const nextRawTx = this._manualTransactionToRawRow(nextManual, this.workspace);
const newTxKey = String(this._transactionKey(nextRawTx) || '');
const existingDecision = this._findTransactionDecision(oldKey);
if (existingDecision) {
const nextDecision = this._normalizeTransactionDecisions([{
...existingDecision,
id: newTxKey,
tx_key: newTxKey,
tx_date: nextManual.date,
tx_amount: nextManual.amount,
tx_direction: nextManual.direction,
account_id: nextManual.account_id,
account_label: nextRawTx?.accountLabel || nextManual.account_id,
counterparty_name: nextManual.counterparty_name,
description: nextManual.description,
updated_at: new Date().toISOString(),
}])[0];
const map = new Map((this.workspace.transactionDecisions || []).map(item => [String(item?.tx_key || ''), item]));
map.delete(oldKey);
if (nextDecision) map.set(newTxKey, nextDecision);
this.workspace.transactionDecisions = this._normalizeTransactionDecisions(Array.from(map.values()));
}
this._replaceOperationKeyInUi(oldKey, newTxKey);
return {
manualId: String(current.id || ''),
oldTxKey: oldKey,
newTxKey,
};
},
_transactionKey(tx) {
if (!tx || typeof tx !== 'object') return '';
const accountId = String(tx.accountId || '');
const explicitId = String(tx.transactionId || tx.paymentId || tx.documentNumber || '').trim();
if (explicitId) return `${accountId}::${explicitId}`;
const amountKey = Math.round(this._num(tx.amount) * 100);
return [
accountId,
String(tx.date || ''),
String(tx.direction || ''),
String(amountKey),
this._normalizeText(tx.counterpartyName || '').slice(0, 48),
this._normalizeText(tx.description || '').slice(0, 72),
].join('::');
},
_buildDecisionMap(workspace) {
return new Map(this._normalizeTransactionDecisions(workspace?.transactionDecisions || []).map(item => [String(item.tx_key || ''), item]));
},
_employeePayrollCategoryId(employee) {
const role = String(employee?.role || '').toLowerCase();
if (['management', 'office', 'sales', 'admin'].includes(role)) return 'payroll_office';
if (['marketplaces', 'ecom', 'shop'].includes(role)) return 'payroll_marketplaces';
return employee ? 'payroll_production' : '';
},
_guessEmployeeForTransaction(tx, employees = []) {
const txText = this._transactionText(tx);
let best = null;
(employees || []).filter(item => item && item.is_active !== false).forEach(employee => {
const fullName = this._normalizeText(employee.name || '');
if (!fullName) return;
let score = 0;
let reason = '';
if (txText.includes(fullName)) {
score = 0.92;
reason = `имя "${employee.name}"`;
} else {
const shortName = fullName.split(' ')[0] || '';
if (shortName.length >= 3 && txText.includes(shortName)) {
score = 0.76;
reason = `короткое имя "${shortName}"`;
}
}
if (!score) return;
const candidate = { ...employee, matchScore: score, matchReason: reason };
if (!best || candidate.matchScore > best.matchScore) best = candidate;
});
return best;
},
_kindFromCategoryId(categoryId, workspace, tx) {
const category = this._findById(workspace?.categories, categoryId);
if (String(categoryId || '') === 'finance_transfers') return 'transfer';
if (String(categoryId || '') === 'finance_owner_money') return 'owner_money';
if (category?.group === 'payroll') return 'payroll';
if (category?.group === 'taxes') return 'tax';
return String(tx?.direction || '') === 'in' ? 'income' : 'expense';
},
_looksLikeTransferTransaction(tx, suggestion) {
const text = this._transactionText(tx);
return String(suggestion?.categoryId || '') === 'finance_transfers'
|| text.includes('перевод')
|| text.includes('между счетами')
|| text.includes('собственных средств');
},
_looksLikePayrollTransaction(tx, suggestion, employeeGuess = null) {
const text = this._transactionText(tx);
const categoryId = String(suggestion?.categoryId || '');
return categoryId.startsWith('payroll_')
|| text.includes('зарплат')
|| text.includes('зп')
|| text.includes('аванс')
|| !!employeeGuess;
},
_inferTransactionKind(tx, suggestion, workspace, employeeGuess = null) {
if (suggestion?.kind) return String(suggestion.kind);
const categoryKind = this._kindFromCategoryId(suggestion?.categoryId, workspace, tx);
if (suggestion?.categoryId) return categoryKind;
if (this._looksLikeTransferTransaction(tx, suggestion)) return 'transfer';
if (this._looksLikePayrollTransaction(tx, suggestion, employeeGuess)) return 'payroll';
return String(tx?.direction || '') === 'in' ? 'income' : 'expense';
},
_buildFixedAssetRows(workspace, asOfMonth = this._businessMonthFromDate(this._todayDateLocal())) {
return this._normalizeFixedAssets(workspace?.fixedAssets).map(item => {
const plan = this._buildAmortizationPlan(item.purchase_cost, item.useful_life_months);
const monthOffset = this._businessMonthDistance(item.opiu_start_month, asOfMonth);
const scheduleIndex = monthOffset == null ? -1 : monthOffset + 1;
const monthsElapsed = Math.max(0, Math.min(item.useful_life_months, scheduleIndex));
const accumulated = this._roundMoney(plan.slice(0, monthsElapsed).reduce((sum, value) => sum + this._num(value), 0));
const residual = this._roundMoney(Math.max(0, this._num(item.purchase_cost) - accumulated));
const currentMonthAmortization = (scheduleIndex >= 1 && scheduleIndex <= item.useful_life_months)
? this._roundMoney(plan[scheduleIndex - 1] || 0)
: 0;
const payableAmount = item.purchased_earlier
? 0
: this._roundMoney(Math.max(0, this._num(item.purchase_cost) - this._num(item.paid_amount)));
const overpaidAmount = item.purchased_earlier
? 0
: this._roundMoney(Math.max(0, this._num(item.paid_amount) - this._num(item.purchase_cost)));
const status = residual <= 0
? 'amortized'
: scheduleIndex < 1
? 'planned'
: 'active';
return {
...item,
monthly_amortization: this._roundMoney(plan[0] || 0),
current_month_amortization: currentMonthAmortization,
accumulated_amortization: accumulated,
residual_value: residual,
payable_amount: payableAmount,
overpaid_amount: overpaidAmount,
months_elapsed: monthsElapsed,
months_remaining: Math.max(0, item.useful_life_months - monthsElapsed),
status,
project_name: this._resolveProjectName(item.project_id, workspace),
type_label: this.FIXED_ASSET_TYPE_LABELS[item.asset_type] || item.asset_type || 'Другое',
};
});
},
_buildAmortizationPlan(cost, months) {
const safeCost = this._roundMoney(cost);
const safeMonths = Math.min(240, Math.max(1, Number(months) || 1));
const base = this._roundMoney(safeCost / safeMonths);
const plan = [];
let remaining = safeCost;
for (let index = 0; index < safeMonths; index += 1) {
const slice = index === safeMonths - 1 ? remaining : Math.min(remaining, base);
const normalized = this._roundMoney(slice);
plan.push(normalized);
remaining = this._roundMoney(remaining - normalized);
}
return plan;
},
_buildManagementReports({ workspace, employees, timeEntries, transactionsRows, orders, bankAccounts, tochkaSnapshot, fintabloSnapshot }) {
const availableMonths = this._collectAvailableReportMonths({
transactionsRows,
timeEntries,
fixedAssets: workspace?.fixedAssets || [],
});
const selectedMonth = this._parseBusinessMonth(this.ui?.report?.month)
|| availableMonths[0]?.value
|| this._businessMonthFromDate(this._todayDateLocal());
const monthRows = (transactionsRows || []).filter(item => String(item?.date || '').startsWith(`${selectedMonth}-`));
const fixedAssetRows = this._buildFixedAssetRows(workspace, selectedMonth);
const payroll = this._buildPayrollManagementReport({
employees,
timeEntries,
orders,
monthRows,
selectedMonth,
});
const opiu = this._buildOpiuReport({
workspace,
monthRows,
fixedAssetRows,
payroll,
});
const profitability = this._buildProfitabilityReport({
monthRows,
payroll,
});
const obligations = this._buildObligationsReport({
payroll,
fixedAssetRows,
});
const balance = this._buildBalanceReport({
workspace,
payroll,
fixedAssetRows,
bankAccounts,
tochkaSnapshot,
fintabloSnapshot,
});
return {
month: selectedMonth,
monthLabel: this._formatBusinessMonth(selectedMonth),
availableMonths,
payroll,
opiu,
profitability,
obligations,
balance,
};
},
_collectAvailableReportMonths({ transactionsRows, timeEntries, fixedAssets }) {
const monthSet = new Set();
(transactionsRows || []).forEach(item => {
const month = this._parseBusinessMonth(item?.date);
if (month) monthSet.add(month);
});
(timeEntries || []).forEach(item => {
const month = this._parseBusinessMonth(item?.date);
if (month) monthSet.add(month);
});
this._normalizeFixedAssets(fixedAssets).forEach(item => {
const month = this._parseBusinessMonth(item?.opiu_start_month);
if (month) monthSet.add(month);
});
const fallback = this._businessMonthFromDate(this._todayDateLocal());
if (fallback) monthSet.add(fallback);
return Array.from(monthSet)
.sort((a, b) => String(b).localeCompare(String(a)))
.slice(0, 24)
.map(value => ({ value, label: this._formatBusinessMonth(value) }));
},
_employeeActiveInMonth(employee, month) {
if (!employee) return false;
const firedMonth = this._parseBusinessMonth(employee.fired_date);
if (firedMonth && String(firedMonth) < String(month)) return false;
return employee.is_active !== false || firedMonth === String(month);
},
_allocateAmountByWeights(entries = [], amount = 0, weightField = 'weight') {
const normalizedEntries = (Array.isArray(entries) ? entries : [])
.map(item => ({
...item,
[weightField]: this._num(item?.[weightField]),
}))
.filter(item => this._num(item?.[weightField]) > 0);
const totalAmount = this._roundMoney(amount);
if (normalizedEntries.length === 0 || totalAmount <= 0) return [];
const totalWeight = normalizedEntries.reduce((sum, item) => sum + this._num(item?.[weightField]), 0);
if (totalWeight <= 0) return [];
let remaining = totalAmount;
return normalizedEntries.map((item, index) => {
const isLast = index === normalizedEntries.length - 1;
const slice = isLast
? remaining
: this._roundMoney(totalAmount * (this._num(item?.[weightField]) / totalWeight));
const amountSlice = this._roundMoney(Math.min(remaining, Math.max(0, slice)));
remaining = this._roundMoney(remaining - amountSlice);
return {
...item,
amount: amountSlice,
};
});
},
_findEmployeeForTimeEntry(entry, employees = []) {
const employeeId = entry?.employee_id != null ? String(entry.employee_id) : '';
if (employeeId) {
const direct = (employees || []).find(item => String(item?.id || '') === employeeId);
if (direct) return direct;
}
const worker = this._normalizeText(entry?.employee_name || entry?.worker_name || '');
if (!worker) return null;
return (employees || []).find(item => {
const candidate = this._normalizeText(item?.name || '');
return candidate && (candidate === worker || candidate.startsWith(worker) || worker.startsWith(candidate));
}) || null;
},
_isWeekendDate(value) {
const date = this._dateFromBusinessDate(value);
if (!date) return false;
const day = date.getDay();
return day === 0 || day === 6;
},
_normalizePayrollEmployeeConfig(employee) {
const role = String(employee?.role || '').trim().toLowerCase();
const payrollProfile = String(employee?.payroll_profile || '').trim()
|| ((this._num(employee?.pay_base_salary_month) > 0 && role !== 'production') ? 'salary_monthly' : 'hourly');
const baseSalary = this._roundMoney(employee?.pay_base_salary_month || (this._num(employee?.pay_white_salary) + this._num(employee?.pay_black_salary)));
const baseHours = Math.max(1, this._num(employee?.pay_base_hours_month) || 176);
const baseRate = baseSalary > 0 ? this._roundMoney(baseSalary / baseHours) : 0;
const hourlyRate = this._roundMoney(
this._num(employee?.hourly_rate)
|| this._num(employee?.hourly_cost)
|| this._num(employee?.cost_per_hour)
|| this._num(employee?.fot_per_hour)
|| this._num(employee?.pay_overtime_hour_rate)
|| baseRate
);
const weekendRate = this._roundMoney(this._num(employee?.pay_weekend_hour_rate) || hourlyRate || baseRate);
const holidayRate = this._roundMoney(this._num(employee?.pay_holiday_hour_rate) || weekendRate || hourlyRate || baseRate);
return {
role,
payrollProfile,
baseSalary,
baseHours,
baseRate: this._roundMoney(baseRate || hourlyRate),
hourlyRate: this._roundMoney(hourlyRate || baseRate),
weekendRate,
holidayRate,
isProduction: role === 'production',
};
},
_buildPayrollManagementReport({ employees, timeEntries, orders, monthRows, selectedMonth }) {
const ordersById = new Map((orders || []).map(item => [String(item?.id || ''), item]));
const productionStats = new Map();
(timeEntries || []).forEach(entry => {
const date = this._parseBusinessDate(entry?.date);
if (!date || !String(date).startsWith(`${selectedMonth}-`)) return;
const employee = this._findEmployeeForTimeEntry(entry, employees);
if (!employee || String(employee?.role || '').toLowerCase() !== 'production') return;
const key = String(employee.id || '');
if (!productionStats.has(key)) {
productionStats.set(key, {
regularHours: 0,
weekendHours: 0,
holidayHours: 0,
topOrders: {},
});
}
const stats = productionStats.get(key);
const hours = this._num(entry?.hours);
if (this._isWeekendDate(date)) stats.weekendHours += hours;
else stats.regularHours += hours;
const explicitName = String(entry?.order_name || '').trim();
const orderId = entry?.order_id != null ? String(entry.order_id) : '';
const orderName = explicitName || ordersById.get(orderId)?.order_name || ordersById.get(orderId)?.name || '';
if (orderName) stats.topOrders[orderName] = (stats.topOrders[orderName] || 0) + hours;
});
const payments = new Map();
const marked = new Map();
const unassignedPayments = [];
(monthRows || []).forEach(row => {
if (String(row?.kind || '') !== 'payroll') return;
const employeeId = String(row?.payrollEmployeeId || '');
const amount = this._roundMoney(Math.abs(this._num(row?.amount)));
if (!employeeId) {
unassignedPayments.push({
date: row?.date || '',
amount,
amountLabel: row?.amountLabel || this.fmtRub(amount),
counterpartyName: row?.counterpartyName || '',
accountLabel: row?.accountLabel || '',
routeLabel: row?.routeLabel || '',
});
return;
}
marked.set(employeeId, this._roundMoney((marked.get(employeeId) || 0) + amount));
if (row?.confirmed) payments.set(employeeId, this._roundMoney((payments.get(employeeId) || 0) + amount));
});
const rows = (employees || [])
.filter(employee => this._employeeActiveInMonth(employee, selectedMonth))
.map(employee => {
const config = this._normalizePayrollEmployeeConfig(employee);
const stats = productionStats.get(String(employee.id || '')) || {
regularHours: 0,
weekendHours: 0,
holidayHours: 0,
topOrders: {},
};
let accrued = 0;
if (config.isProduction) {
if (config.payrollProfile === 'hourly') {
accrued = this._roundMoney(
stats.regularHours * config.hourlyRate
+ stats.weekendHours * config.weekendRate
+ stats.holidayHours * config.holidayRate
);
} else {
const overtimeHours = Math.max(0, stats.regularHours - config.baseHours);
accrued = this._roundMoney(
config.baseSalary
+ overtimeHours * config.hourlyRate
+ stats.weekendHours * config.weekendRate
+ stats.holidayHours * config.holidayRate
);
}
} else {
accrued = this._roundMoney(config.baseSalary);
}
const paid = this._roundMoney(payments.get(String(employee.id || '')) || 0);
const markedPaid = this._roundMoney(marked.get(String(employee.id || '')) || 0);
const payable = this._roundMoney(Math.max(0, accrued - paid));
const overpaid = this._roundMoney(Math.max(0, paid - accrued));
const topTargets = this._topEntries(stats.topOrders, 3).map(([name, hours]) => `${name} · ${this._roundMoney(hours)}ч`);
const payrollAllocations = config.isProduction
? this._allocateAmountByWeights(
Object.entries(stats.topOrders || {}).map(([label, hours]) => ({ label, hours })),
accrued,
'hours'
)
: [];
const allocatedAccrued = this._roundMoney(payrollAllocations.reduce((sum, item) => sum + this._num(item.amount), 0));
const unassignedAccrued = config.isProduction
? this._roundMoney(Math.max(0, accrued - allocatedAccrued))
: 0;
return {
employeeId: String(employee.id || ''),
employeeName: employee.name || `Сотрудник ${employee.id}`,
role: config.role || 'other',
payrollProfile: config.payrollProfile,
isProduction: config.isProduction,
regularHours: this._roundMoney(stats.regularHours),
weekendHours: this._roundMoney(stats.weekendHours),
holidayHours: this._roundMoney(stats.holidayHours),
totalHours: this._roundMoney(stats.regularHours + stats.weekendHours + stats.holidayHours),
accrued,
paid,
markedPaid,
payable,
overpaid,
payrollAllocations,
allocatedAccrued,
unassignedAccrued,
focusLabel: topTargets.join(' · '),
};
})
.filter(item => item.accrued > 0 || item.paid > 0 || item.markedPaid > 0 || item.totalHours > 0)
.sort((a, b) => {
const debtDiff = this._num(b.payable) - this._num(a.payable);
if (Math.abs(debtDiff) > 0.009) return debtDiff;
return String(a.employeeName || '').localeCompare(String(b.employeeName || ''), 'ru');
});
return {
month: selectedMonth,
monthLabel: this._formatBusinessMonth(selectedMonth),
rows,
employeeCount: rows.length,
productionAccrued: this._roundMoney(rows.filter(item => item.isProduction).reduce((sum, item) => sum + this._num(item.accrued), 0)),
nonProductionAccrued: this._roundMoney(rows.filter(item => !item.isProduction).reduce((sum, item) => sum + this._num(item.accrued), 0)),
totalAccrued: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.accrued), 0)),
paidConfirmed: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.paid), 0)),
paidMarked: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.markedPaid), 0)),
payableAmount: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.payable), 0)),
overpaidAmount: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.overpaid), 0)),
allocatedProductionAccrued: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.allocatedAccrued), 0)),
unassignedProductionAccrued: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.unassignedAccrued), 0)),
unassignedPayments,
unassignedPaymentsAmount: this._roundMoney(unassignedPayments.reduce((sum, item) => sum + this._num(item.amount), 0)),
unassignedPaymentsCount: unassignedPayments.length,
};
},
_buildOpiuReport({ workspace, monthRows, fixedAssetRows, payroll }) {
const totals = {
revenue: 0,
direct: 0,
commercial: 0,
overhead: 0,
taxes: 0,
other: 0,
investment: 0,
};
(monthRows || []).forEach(row => {
if (['transfer', 'owner_money', 'ignore'].includes(String(row?.kind || ''))) return;
const group = String(row?.categoryGroup || '').trim();
const amount = this._roundMoney(Math.abs(this._num(row?.amount)));
if (!group || amount <= 0) return;
if (group === 'income' && String(row?.direction || '') === 'in') totals.revenue += amount;
else if (String(row?.direction || '') === 'out') {
if (group === 'direct') totals.direct += amount;
else if (group === 'commercial') totals.commercial += amount;
else if (group === 'overhead' && String(row?.categoryId || '') !== 'overhead_amortization') totals.overhead += amount;
else if (group === 'taxes') totals.taxes += amount;
else if (group === 'investment') totals.investment += amount;
else if (!['finance', 'payroll', 'income'].includes(group)) totals.other += amount;
}
});
Object.keys(totals).forEach(key => { totals[key] = this._roundMoney(totals[key]); });
const amortization = this._roundMoney((fixedAssetRows || []).reduce((sum, item) => sum + this._num(item.current_month_amortization), 0));
const payrollProduction = this._roundMoney(payroll?.productionAccrued || 0);
const payrollFixed = this._roundMoney(payroll?.nonProductionAccrued || 0);
const grossProfit = this._roundMoney(totals.revenue - totals.direct);
const ebitda = this._roundMoney(grossProfit - payrollProduction - payrollFixed - totals.commercial - totals.overhead - totals.other);
const operatingProfit = this._roundMoney(ebitda - amortization - totals.taxes);
return {
...totals,
payrollProduction,
payrollFixed,
amortization,
grossProfit,
ebitda,
operatingProfit,
};
},
_buildProfitabilityReport({ monthRows, payroll }) {
const map = new Map();
const ensureEntry = (key, label = '') => {
const safeKey = String(key || '').trim();
if (!safeKey) return null;
if (!map.has(safeKey)) {
map.set(safeKey, {
label: label || safeKey,
revenue: 0,
direct: 0,
commercial: 0,
payroll: 0,
taxes: 0,
other: 0,
});
}
return map.get(safeKey);
};
(monthRows || []).forEach(row => {
if (['transfer', 'owner_money', 'ignore', 'payroll'].includes(String(row?.kind || ''))) return;
const directionName = String(row?.projectName || '').trim();
const projectLabel = String(row?.projectLabel || '').trim();
const key = projectLabel || directionName;
if (!key) return;
const item = ensureEntry(key, projectLabel ? `${directionName || 'Без направления'} · ${projectLabel}` : directionName);
if (!item) return;
const amount = this._roundMoney(Math.abs(this._num(row?.amount)));
if (String(row?.direction || '') === 'in' && row?.categoryGroup === 'income') item.revenue += amount;
if (String(row?.direction || '') === 'out') {
if (row?.categoryGroup === 'direct') item.direct += amount;
else if (row?.categoryGroup === 'commercial') item.commercial += amount;
else if (row?.categoryGroup === 'taxes') item.taxes += amount;
else if (!['finance', 'investment', 'overhead'].includes(String(row?.categoryGroup || ''))) item.other += amount;
}
});
(payroll?.rows || []).forEach(row => {
(row?.payrollAllocations || []).forEach(allocation => {
const key = String(allocation?.label || '').trim();
const item = ensureEntry(key, key);
if (!item) return;
item.payroll = this._roundMoney(this._num(item.payroll) + this._num(allocation?.amount));
});
});
const businessRows = Array.from(map.values())
.map(item => ({
...item,
margin: this._roundMoney(item.revenue - item.direct - item.commercial - item.payroll - item.taxes - item.other),
}))
.sort((a, b) => this._num(b.margin) - this._num(a.margin));
const systemRows = [];
const sharedPayroll = this._roundMoney(payroll?.nonProductionAccrued || 0);
const unassignedPayroll = this._roundMoney(payroll?.unassignedProductionAccrued || 0);
if (sharedPayroll > 0) {
systemRows.push({
label: 'Общий фикс ФОТ',
revenue: 0,
direct: 0,
commercial: 0,
payroll: sharedPayroll,
taxes: 0,
other: 0,
margin: this._roundMoney(-sharedPayroll),
isSystem: true,
});
}
if (unassignedPayroll > 0) {
systemRows.push({
label: 'Неразнесенный production ФОТ',
revenue: 0,
direct: 0,
commercial: 0,
payroll: unassignedPayroll,
taxes: 0,
other: 0,
margin: this._roundMoney(-unassignedPayroll),
isSystem: true,
});
}
const rows = businessRows.concat(systemRows);
const displayRows = businessRows
.slice(0, Math.max(0, 6 - systemRows.length))
.concat(systemRows);
return {
rows,
displayRows,
totalRevenue: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.revenue), 0)),
totalMargin: this._roundMoney(rows.reduce((sum, item) => sum + this._num(item.margin), 0)),
allocatedPayroll: this._roundMoney(businessRows.reduce((sum, item) => sum + this._num(item.payroll), 0)),
businessRowsCount: businessRows.length,
sharedPayroll,
unassignedPayroll,
unassignedCount: (monthRows || []).filter(item => !item?.projectId && !item?.projectLabel && !['transfer', 'owner_money', 'ignore', 'payroll'].includes(String(item?.kind || ''))).length,
};
},
_buildObligationsReport({ payroll, fixedAssetRows }) {
const payrollRows = (payroll?.rows || []).filter(item => item.payable > 0 || item.overpaid > 0 || item.markedPaid > 0);
const assetRows = (fixedAssetRows || []).filter(item => item.payable_amount > 0 || item.overpaid_amount > 0);
return {
payrollRows,
assetRows,
payrollPayable: this._roundMoney(payroll?.payableAmount || 0),
payrollOverpaid: this._roundMoney(payroll?.overpaidAmount || 0),
fixedAssetPayable: this._roundMoney(assetRows.reduce((sum, item) => sum + this._num(item.payable_amount), 0)),
fixedAssetOverpaid: this._roundMoney(assetRows.reduce((sum, item) => sum + this._num(item.overpaid_amount), 0)),
unassignedPayrollAmount: this._roundMoney(payroll?.unassignedPaymentsAmount || 0),
unassignedPayrollCount: payroll?.unassignedPaymentsCount || 0,
};
},
_buildBalanceReport({ workspace, payroll, fixedAssetRows, bankAccounts, tochkaSnapshot, fintabloSnapshot }) {
const canonicalTochkaAccounts = this._canonicalTochkaAccounts();
const tochkaAccounts = Array.isArray(bankAccounts) && bankAccounts.length > 0
? bankAccounts
: (canonicalTochkaAccounts.length > 0
? canonicalTochkaAccounts
: (Array.isArray(tochkaSnapshot?.accounts) ? tochkaSnapshot.accounts : []));
const fintabloAccounts = this._canonicalFintabloMoneybags(fintabloSnapshot);
const bankMoney = this._roundMoney(tochkaAccounts.reduce((sum, item) => sum + this._num(item?.currentBalance ?? item?.last_balance), 0));
const nonBankMoney = this._roundMoney(fintabloAccounts
.filter(item => !this._moneybagMatchesTochkaAccount(item, tochkaAccounts))
.filter(item => {
const account = (workspace?.accounts || []).find(candidate => this._workspaceAccountMatchesFintablo(candidate, item));
if (!account) return !item.hideInTotal && !item.archived;
return account.status !== 'archived' && account.show_in_money !== false;
})
.reduce((sum, item) => sum + this._num(item?.balance), 0));
const fixedAssetsResidual = this._roundMoney((fixedAssetRows || []).reduce((sum, item) => sum + this._num(item.residual_value), 0));
const receivables = this._roundMoney(this._num(payroll?.overpaidAmount));
const liabilities = this._roundMoney(
this._num(payroll?.payableAmount)
+ (fixedAssetRows || []).reduce((sum, item) => sum + this._num(item.payable_amount), 0)
);
const assetsTotal = this._roundMoney(bankMoney + nonBankMoney + fixedAssetsResidual + receivables);
return {
bankMoney,
nonBankMoney,
fixedAssetsResidual,
receivables,
liabilities,
equity: this._roundMoney(assetsTotal - liabilities),
assetsTotal,
syncLabel: this.data?.bankTransactions?.[0]?.occurred_on
? `банк на ${String(this.data.bankTransactions[0].occurred_on).slice(0, 10)}`
: (tochkaSnapshot?.synced_at
? `банк на ${String(tochkaSnapshot.synced_at).slice(0, 10)}`
: (fintabloSnapshot?.synced_at
? `moneybags на ${String(fintabloSnapshot.synced_at).slice(0, 10)}`
: 'без свежего снапшота')),
};
},
_buildSummary({
workspace,
orders,
imports,
employees,
timeEntries,
indirectMonths,
financeAccounts,
financeCategories,
financeDirections,
financeCounterparties,
financeTransactions,
bankAccounts,
bankTransactions,
tochkaSnapshot,
fintabloSnapshot,
}) {
const validOrderDates = [];
const invalidOrderDates = [];
const statusCounts = {};
(orders || []).forEach(order => {
const status = String(order?.status || 'unknown');
statusCounts[status] = (statusCounts[status] || 0) + 1;
const candidate = this._parseBusinessDate(order?.deadline_end)
|| this._parseBusinessDate(order?.deadline_start)
|| this._parseBusinessDate(order?.created_at);
if (candidate) {
validOrderDates.push(candidate);
} else {
const raw = String(order?.deadline_end || order?.deadline_start || order?.created_at || '').slice(0, 10);
if (raw) {
invalidOrderDates.push({
orderId: order.id,
orderName: order.order_name || `Заказ ${order.id}`,
value: raw,
});
}
}
});
const importFieldCounts = {};
const importFieldSums = {};
const categoryCounts = {};
const importDates = [];
const distinctDeals = new Set();
(imports || []).forEach(row => {
const data = row?.import_data || row || {};
const raw = data.raw_data || {};
const importDate = this._parseBusinessDate(row?.import_date) || this._parseBusinessDate(row?.updated_at);
if (importDate) importDates.push(importDate);
if (raw.dealId != null) distinctDeals.add(String(raw.dealId));
Object.entries(data).forEach(([key, value]) => {
if (!/^fact_/.test(key)) return;
const amount = this._num(value);
if (amount === 0) return;
importFieldCounts[key] = (importFieldCounts[key] || 0) + 1;
importFieldSums[key] = (importFieldSums[key] || 0) + amount;
});
Object.entries(raw.field_breakdown || {}).forEach(([, items]) => {
(items || []).forEach(item => {
const name = String(item?.category || 'Без категории').trim() || 'Без категории';
categoryCounts[name] = (categoryCounts[name] || 0) + 1;
});
});
});
const roles = {};
const payrollProfiles = {};
let monthlyWhite = 0;
let monthlyBlack = 0;
let payrollEmployees = 0;
(employees || []).forEach(employee => {
const role = String(employee?.role || 'unknown');
roles[role] = (roles[role] || 0) + 1;
const profile = String(employee?.payroll_profile || 'unspecified');
payrollProfiles[profile] = (payrollProfiles[profile] || 0) + 1;
const white = this._num(employee?.pay_white_salary);
const black = this._num(employee?.pay_black_salary);
const base = this._num(employee?.pay_base_salary_month);
if (white > 0 || black > 0 || base > 0 || !['hourly', 'unspecified'].includes(profile)) payrollEmployees += 1;
monthlyWhite += white;
monthlyBlack += black || Math.max(0, base - white);
});
const timeDates = [];
const topWorkers = {};
const topStages = {};
let withOrderHours = 0;
let withoutOrderHours = 0;
(timeEntries || []).forEach(entry => {
const date = this._parseBusinessDate(entry?.date);
if (date) timeDates.push(date);
const hours = this._num(entry?.hours);
const worker = String(entry?.employee_name || entry?.worker_name || entry?.employee_id || 'unknown');
topWorkers[worker] = (topWorkers[worker] || 0) + hours;
const stage = this._extractStageLabel(entry?.task_description);
topStages[stage] = (topStages[stage] || 0) + hours;
if (entry?.order_id != null || String(entry?.order_name || '').trim()) withOrderHours += hours;
else withoutOrderHours += hours;
});
const categoryByGroup = {};
(workspace?.categories || []).forEach(category => {
const key = String(category?.group || 'other');
categoryByGroup[key] = (categoryByGroup[key] || 0) + 1;
});
const projectByType = {};
let activeProjects = 0;
(workspace?.projects || []).forEach(project => {
const type = String(project?.type || 'core');
projectByType[type] = (projectByType[type] || 0) + 1;
if (project?.active !== false) activeProjects += 1;
});
const fixedAssetRows = this._buildFixedAssetRows(workspace);
const activeFixedAssetRows = fixedAssetRows.filter(item => item.active !== false);
const counterpartiesByRole = {};
const counterpartiesByResearch = {};
let activeCounterparties = 0;
(workspace?.counterparties || []).forEach(item => {
const role = String(item?.role || 'other');
counterpartiesByRole[role] = (counterpartiesByRole[role] || 0) + 1;
const research = String(item?.research_mode || 'manual');
counterpartiesByResearch[research] = (counterpartiesByResearch[research] || 0) + 1;
if (item?.active !== false) activeCounterparties += 1;
});
const rulesByTrigger = {};
let enabledRules = 0;
let autoApplyRules = 0;
(workspace?.rules || []).forEach(rule => {
const trigger = String(rule?.trigger_type || 'description');
rulesByTrigger[trigger] = (rulesByTrigger[trigger] || 0) + 1;
if (rule?.active !== false) enabledRules += 1;
if (rule?.active !== false && rule?.auto_apply) autoApplyRules += 1;
});
const missingImportantFields = this.IMPORTANT_IMPORT_FIELDS
.filter(field => !importFieldCounts[field])
.map(field => this.FACT_FIELD_LABELS[field] || field);
const queueConfig = workspace?.queueConfig || this._defaultQueueConfig();
const canonicalTochkaAccounts = this._canonicalTochkaAccounts();
const tochkaAccounts = canonicalTochkaAccounts.length > 0
? canonicalTochkaAccounts
: (Array.isArray(tochkaSnapshot?.accounts) ? tochkaSnapshot.accounts : []);
const tochkaTransactions = Array.isArray(bankTransactions) && bankTransactions.length > 0
? bankTransactions
: (Array.isArray(tochkaSnapshot?.transactions) ? tochkaSnapshot.transactions : []);
const fintabloAccounts = this._canonicalFintabloMoneybags(fintabloSnapshot);
const relationalTransactions = this._financeTransactionsToRows({
financeTransactions,
financeAccounts,
financeCategories,
financeDirections,
financeCounterparties,
workspace,
});
const fintabloTransactions = this._fintabloTransactionsToRows(fintabloSnapshot, tochkaAccounts);
const manualTransactions = this._manualTransactionsToBankRows(workspace);
const recurringTransactions = this._recurringTransactionsToBankRows(workspace);
const allTransactions = [
...manualTransactions,
...recurringTransactions,
...(relationalTransactions.length > 0
? relationalTransactions
: [...((tochkaSnapshot?.transactions || []).filter(item => item && typeof item === 'object')), ...fintabloTransactions]),
];
const txRows = this._buildTransactionRows(allTransactions, workspace, queueConfig, employees);
const bankAccountActivity = new Map();
txRows.forEach(item => {
const key = String(item.accountId || '');
if (!key) return;
const current = bankAccountActivity.get(key) || {
id: key,
accountId: key,
name: item.accountLabel || key,
transactionCount: 0,
lastTransactionDate: '',
};
current.transactionCount += 1;
if (!current.lastTransactionDate || String(item.date || '') > current.lastTransactionDate) current.lastTransactionDate = String(item.date || '');
bankAccountActivity.set(key, current);
});
const activityRows = Array.from(bankAccountActivity.values());
const workspaceAccountRows = (workspace?.accounts || []).map(account => {
const activity = this._matchWorkspaceAccountUsage(account, activityRows);
return {
id: String(account.id || ''),
name: account.name || String(account.id || ''),
type: account.type || '',
transactionCount: activity?.transactionCount || 0,
lastTransactionDate: activity?.lastTransactionDate || '',
note: account.note || '',
};
});
const bankAccountRows = tochkaAccounts.map(account => {
const activity = bankAccountActivity.get(String(account.accountId || '')) || null;
return {
id: String(account.accountId || ''),
name: account.displayName || String(account.accountId || ''),
transactionCount: activity?.transactionCount || 0,
lastTransactionDate: activity?.lastTransactionDate || '',
note: account.currentBalance != null ? `Баланс ${this.fmtRub(account.currentBalance)}` : '',
};
});
const fintabloAccountRows = fintabloAccounts.map(account => {
const activity = bankAccountActivity.get(String(account.id || '')) || null;
return {
id: String(account.id || ''),
name: account.displayName || String(account.id || ''),
transactionCount: activity?.transactionCount || 0,
lastTransactionDate: activity?.lastTransactionDate || '',
note: account.hideInTotal ? 'Скрыт в totals в FinTablo' : '',
};
});
const queuePreview = txRows.filter(item => !['manual', 'ignored'].includes(item.route)).slice(0, 18);
const autoCount = queuePreview.filter(item => item.route === 'auto').length;
const reviewCount = queuePreview.filter(item => ['review', 'draft'].includes(item.route)).length;
const unmatchedCount = queuePreview.filter(item => item.route === 'unmatched').length;
const confirmedCount = txRows.filter(item => item.route === 'manual').length;
const draftCount = txRows.filter(item => item.route === 'draft').length;
const transferCount = txRows.filter(item => item.kind === 'transfer').length;
const payrollMarkedRows = txRows.filter(item => item.kind === 'payroll');
const payrollCandidateRows = txRows.filter(item => item.isPayrollCandidate || item.kind === 'payroll');
const reports = this._buildManagementReports({
workspace,
employees,
timeEntries,
transactionsRows: txRows,
orders,
bankAccounts: tochkaAccounts,
tochkaSnapshot,
fintabloSnapshot,
});
return {
coverage: {
ordersRange: this._range(validOrderDates),
importRange: this._range(importDates),
timeRange: this._range(timeDates),
invalidOrderDates,
},
orders: {
total: (orders || []).length,
active: (orders || []).filter(order => !['deleted', 'cancelled'].includes(String(order?.status || ''))).length,
byStatus: this._topEntries(statusCounts, 20),
},
imports: {
total: (imports || []).length,
distinctDeals: distinctDeals.size,
totalRevenue: this._num(importFieldSums.fact_revenue),
totalCost: this._num(importFieldSums.fact_total),
topFields: this._topEntries(importFieldCounts, 10).map(([field, count]) => ({
key: field,
count,
label: this.FACT_FIELD_LABELS[field] || field,
})),
topCategories: this._topEntries(categoryCounts, 10).map(([name, count]) => ({ name, count })),
missingImportantFields,
},
employees: {
total: (employees || []).length,
byRole: this._topEntries(roles, 20),
payrollProfiles: this._topEntries(payrollProfiles, 20),
},
timeEntries: {
total: (timeEntries || []).length,
withOrderHours: Math.round(withOrderHours * 100) / 100,
withoutOrderHours: Math.round(withoutOrderHours * 100) / 100,
topWorkers: this._topEntries(topWorkers, 8),
topStages: this._topEntries(topStages, 8),
},
indirect: {
months: Object.keys(indirectMonths || {}).length,
range: this._monthRange(Object.keys(indirectMonths || {})),
},
sources: {
total: (workspace?.sources || []).length,
active: (workspace?.sources || []).filter(item => item.status === 'active').length,
planned: (workspace?.sources || []).filter(item => item.status === 'planned').length,
},
accounts: {
total: (workspace?.accounts || []).length,
bank: (workspace?.accounts || []).filter(item => item.type === 'bank').length,
cash: (workspace?.accounts || []).filter(item => item.type === 'cash').length,
rows: workspaceAccountRows,
},
categories: {
total: (workspace?.categories || []).length,
active: (workspace?.categories || []).filter(item => item.active !== false).length,
byGroup: categoryByGroup,
},
projects: {
total: (workspace?.projects || []).length,
active: activeProjects,
byType: this._topEntries(projectByType, 20),
},
fixedAssets: {
reportMonth: this._businessMonthFromDate(this._todayDateLocal()),
reportMonthLabel: this._formatBusinessMonth(this._businessMonthFromDate(this._todayDateLocal())),
total: fixedAssetRows.length,
active: activeFixedAssetRows.length,
historicalCost: this._roundMoney(activeFixedAssetRows.reduce((sum, item) => sum + this._num(item.purchase_cost), 0)),
paidAmount: this._roundMoney(activeFixedAssetRows.reduce((sum, item) => sum + this._num(item.paid_amount), 0)),
accumulatedAmortization: this._roundMoney(activeFixedAssetRows.reduce((sum, item) => sum + this._num(item.accumulated_amortization), 0)),
residualValue: this._roundMoney(activeFixedAssetRows.reduce((sum, item) => sum + this._num(item.residual_value), 0)),
currentMonthAmortization: this._roundMoney(activeFixedAssetRows.reduce((sum, item) => sum + this._num(item.current_month_amortization), 0)),
payableAmount: this._roundMoney(activeFixedAssetRows.reduce((sum, item) => sum + this._num(item.payable_amount), 0)),
overpaidAmount: this._roundMoney(activeFixedAssetRows.reduce((sum, item) => sum + this._num(item.overpaid_amount), 0)),
rows: fixedAssetRows,
},
counterparties: {
total: (workspace?.counterparties || []).length,
active: activeCounterparties,
byRole: this._topEntries(counterpartiesByRole, 20),
byResearch: this._topEntries(counterpartiesByResearch, 20),
},
rules: {
total: (workspace?.rules || []).length,
enabled: enabledRules,
autoApply: autoApplyRules,
byTrigger: this._topEntries(rulesByTrigger, 20),
},
tochka: {
syncedAt: tochkaSnapshot?.synced_at || null,
accounts: tochkaAccounts.length,
transactions: tochkaTransactions.length,
range: this._range((tochkaTransactions || []).map(item => item.date || item.occurred_on || null)),
latestTransactionDate: this._range((tochkaTransactions || []).map(item => item.date || item.occurred_on || null))[1],
accountRows: bankAccountRows,
},
fintablo: {
syncedAt: fintabloSnapshot?.synced_at || null,
accounts: fintabloAccounts.length,
transactions: relationalTransactions.filter(item => item?.source === 'fintablo').length || fintabloTransactions.length,
range: this._range((fintabloTransactions || []).map(item => item.date || null)),
latestTransactionDate: this._range((fintabloTransactions || []).map(item => item.date || null))[1],
accountRows: fintabloAccountRows,
},
automation: {
queuePreview,
autoCount,
reviewCount,
unmatchedCount,
},
transactions: {
total: txRows.length,
rows: txRows,
recentRows: txRows.slice(0, Math.min(120, txRows.length)),
confirmedCount,
draftCount,
reviewCount: txRows.filter(item => item.route === 'review').length,
unmatchedCount: txRows.filter(item => item.route === 'unmatched').length,
transferCount,
payrollCount: payrollMarkedRows.length,
manualCount: manualTransactions.length + recurringTransactions.length,
},
payroll: {
employeeCount: payrollEmployees,
salaryProfilesCount: this._topEntries(payrollProfiles, 20).length,
monthlyWhite: Math.round(monthlyWhite * 100) / 100,
monthlyBlack: Math.round(monthlyBlack * 100) / 100,
monthlyTotal: Math.round((monthlyWhite + monthlyBlack) * 100) / 100,
markedPaymentsCount: payrollMarkedRows.length,
markedPaymentsAmount: Math.round(payrollMarkedRows.reduce((sum, item) => sum + Math.abs(this._num(item.amount)), 0) * 100) / 100,
assignedPaymentsCount: payrollMarkedRows.filter(item => item.payrollEmployeeId).length,
unassignedPaymentsCount: payrollCandidateRows.filter(item => !item.payrollEmployeeId).length,
confirmedPaymentsCount: payrollMarkedRows.filter(item => item.route === 'manual').length,
rows: payrollCandidateRows.slice(0, 24),
},
queue: {
dailySyncEnabled: !!queueConfig.dailySyncEnabled,
autoApplyThreshold: this._clamp01(queueConfig.autoApplyThreshold, 0.85),
reviewThreshold: this._clamp01(queueConfig.reviewThreshold, 0.55),
researchEnabled: !!queueConfig.researchEnabled,
},
reports,
};
},
_buildTransactionRows(transactions, workspace, queueConfig, employees = []) {
const autoApplyThreshold = this._clamp01(queueConfig?.autoApplyThreshold, 0.85);
const reviewThreshold = this._clamp01(queueConfig?.reviewThreshold, 0.55);
const decisionMap = this._buildDecisionMap(workspace);
const rows = [...(transactions || [])]
.filter(item => item && typeof item === 'object')
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')))
.map(tx => {
const txKey = this._transactionKey(tx);
const suggestion = this._normalizeSuggestionForTransaction(tx, this._suggestTransaction(tx, workspace), workspace);
const decision = decisionMap.get(txKey) || null;
const employeeGuess = this._guessEmployeeForTransaction(tx, employees);
const persistedConfirmed = String(tx?.reviewStatus || '').trim().toLowerCase() === 'confirmed';
let kind = decision?.kind || tx.kindHint || (String(tx?.group || '') === 'transfer' ? 'transfer' : this._inferTransactionKind(tx, suggestion, workspace, employeeGuess));
let projectId = decision?.project_id || tx.projectHint || suggestion.projectId || '';
let projectLabel = decision?.project_label || '';
let categoryId = decision?.category_id || tx.categoryHint || suggestion.categoryId || '';
let counterpartyId = decision?.counterparty_id || tx.counterpartyHint || suggestion.counterpartyProfileId || '';
let payrollEmployeeId = decision?.payroll_employee_id || tx.employeeHint || (employeeGuess?.id != null ? String(employeeGuess.id) : '');
let transferAccountId = decision?.transfer_account_id || '';
if (kind === 'transfer') {
categoryId = categoryId || 'finance_transfers';
projectId = '';
} else if (kind === 'owner_money') {
categoryId = categoryId || 'finance_owner_money';
projectId = '';
} else if (kind === 'tax') {
categoryId = categoryId || suggestion.categoryId || 'taxes_usn';
} else if (kind === 'payroll') {
const payrollEmployee = payrollEmployeeId ? this._findById(employees, payrollEmployeeId) : employeeGuess;
categoryId = categoryId || this._employeePayrollCategoryId(payrollEmployee) || suggestion.categoryId || 'payroll_production';
projectId = projectId || suggestion.projectId || 'project_recycle_object';
}
const isManual = !!decision || persistedConfirmed;
const hasAssignment = !!(categoryId || projectId || counterpartyId || kind);
let route = 'unmatched';
if (kind === 'ignore') {
route = 'ignored';
} else if (decision?.confirmed || persistedConfirmed) {
route = 'manual';
} else if (isManual && hasAssignment) {
route = 'draft';
} else if (hasAssignment) {
route = (suggestion.autoApply && suggestion.confidence >= autoApplyThreshold) ? 'auto' : 'review';
if (suggestion.confidence < reviewThreshold) route = 'unmatched';
}
const selectedEmployee = payrollEmployeeId ? this._findById(employees, payrollEmployeeId) : null;
const isPayrollCandidate = this._looksLikePayrollTransaction(tx, suggestion, employeeGuess);
if (kind === 'payroll' && !payrollEmployeeId && route !== 'manual') route = 'review';
if (kind === 'transfer' && !transferAccountId && route === 'auto') route = 'review';
const reasons = [];
if (decision?.confirmed) reasons.push('подтверждено вручную');
else if (persistedConfirmed) reasons.push('подтверждено в finance ledger');
else if (isManual) reasons.push('черновик ручной разноски');
if (tx.recurringTemplateName && !decision?.confirmed) reasons.push(`автосписание "${tx.recurringTemplateName}"`);
if (decision?.note) reasons.push(decision.note);
else if (tx.noteHint) reasons.push(tx.noteHint);
if (suggestion.reasons?.length && !decision?.confirmed) reasons.push(...suggestion.reasons);
if (kind === 'transfer' && !transferAccountId) reasons.push('укажите второй счет');
if (kind === 'payroll' && !payrollEmployeeId) reasons.push('назначьте сотрудника');
return {
...tx,
txKey,
suggestion,
decision,
kind,
kindLabel: this.TRANSACTION_KIND_LABELS[kind] || 'Операция',
projectId,
projectName: this._resolveProjectName(projectId, workspace),
directionName: this._resolveProjectName(projectId, workspace),
projectLabel,
categoryId,
categoryName: this._resolveCategoryName(categoryId, workspace),
categoryGroup: this._findById(workspace?.categories, categoryId)?.group || '',
counterpartyId,
counterpartyProfileName: this._findById(workspace?.counterparties, counterpartyId)?.name || suggestion.counterpartyProfileName || '',
payrollEmployeeId,
payrollEmployeeName: selectedEmployee?.name || '',
transferAccountId,
transferAccountName: this._findById(workspace?.accounts, transferAccountId)?.name || '',
confirmed: !!decision?.confirmed || persistedConfirmed,
confidence: this._clamp01((decision?.confirmed || persistedConfirmed) ? 1 : suggestion.confidence, 0),
route,
routeLabel: this.TRANSACTION_ROUTE_LABELS[route] || 'Нужен разбор',
statusTone: this._routeTone(route),
amountLabel: `${tx.direction === 'out' ? '−' : '+'}${this.fmtRub(tx.amount)}`,
descriptionShort: this._shorten(tx.description || '', 110),
reasonSummary: reasons.length > 0 ? reasons.join(' · ') : 'Нового контрагента нужно дообучить',
isPayrollCandidate,
isTransferCandidate: this._looksLikeTransferTransaction(tx, suggestion),
linkedItems: [],
linkedToTxKey: '',
derivedCharges: [],
txBaseAmount: this._extractOperationBaseAmount(tx),
txCommissionAmount: this._extractOperationCommissionAmount(tx),
};
});
return this._attachLinkedAdjustments(rows, workspace);
},
_routeTone(route) {
if (['manual', 'auto'].includes(route)) return 'ok';
if (['draft', 'review'].includes(route)) return 'warn';
return 'muted';
},
_normalizeSuggestionForTransaction(tx, suggestion, workspace) {
const next = suggestion ? { ...suggestion, reasons: [...(suggestion.reasons || [])] } : {
projectId: '',
projectName: '',
categoryId: '',
categoryName: '',
counterpartyProfileId: '',
counterpartyProfileName: '',
confidence: 0.35,
autoApply: false,
reasons: [],
};
const category = this._findById(workspace?.categories, next.categoryId);
const text = this._transactionText(tx);
if (text.includes('фонд благотворитель')) {
next.categoryId = 'other_charity';
next.projectId = next.projectId || '';
next.categoryName = this._resolveCategoryName(next.categoryId, workspace);
next.projectName = this._resolveProjectName(next.projectId, workspace);
next.confidence = Math.max(next.confidence || 0, 0.9);
next.autoApply = false;
next.reasons.push('благотворительный фонд связан с конкретным приходом');
return next;
}
if (text.includes('фонд налоги')) {
next.categoryId = 'taxes_orders';
next.projectId = next.projectId || '';
next.categoryName = this._resolveCategoryName(next.categoryId, workspace);
next.projectName = this._resolveProjectName(next.projectId, workspace);
next.confidence = Math.max(next.confidence || 0, 0.9);
next.autoApply = false;
next.kind = 'tax';
next.reasons.push('налоговый фонд связан с конкретным приходом');
return next;
}
if (String(tx?.direction || '') === 'out' && category?.group === 'income') {
if (text.includes('комис') || text.includes('эквайр')) {
next.categoryId = 'commercial_marketplace_fees';
next.projectId = next.projectId || 'project_online_store';
next.categoryName = this._resolveCategoryName(next.categoryId, workspace);
next.projectName = this._resolveProjectName(next.projectId, workspace);
next.confidence = Math.min(next.confidence || 0.6, 0.68);
next.autoApply = false;
next.reasons.push('канал продаж упомянут в расходе, трактую как комиссию');
} else if (text.includes('покупка товара') || text.includes('ozon') || text.includes('озон')) {
next.categoryId = 'direct_materials';
next.projectId = 'project_recycle_object';
next.categoryName = this._resolveCategoryName(next.categoryId, workspace);
next.projectName = this._resolveProjectName(next.projectId, workspace);
next.counterpartyProfileId = '';
next.counterpartyProfileName = '';
next.confidence = 0.58;
next.autoApply = false;
next.reasons.push('расход с Ozon похож на закупку, а не на выручку маркетплейса');
} else {
next.categoryId = '';
next.projectId = '';
next.categoryName = '';
next.projectName = '';
next.confidence = Math.min(next.confidence || 0.5, 0.49);
next.autoApply = false;
next.reasons.push('упоминание канала в расходе не считаю доходом автоматически');
}
}
return next;
},
_attachLinkedAdjustments(rows, workspace) {
const byAmount = new Map();
const rowsByKey = new Map();
rows.forEach(row => {
rowsByKey.set(String(row.txKey || ''), row);
if (String(row.direction || '') !== 'in') return;
const key = `${row.date || ''}::${this._moneyKey(row.amount)}`;
if (!byAmount.has(key)) byAmount.set(key, []);
byAmount.get(key).push(row);
});
rows.forEach(row => {
if (!row) return;
const baseAmount = this._extractOperationBaseAmount(row);
if (!baseAmount || !row.date || row.direction === 'in') return;
const candidates = byAmount.get(`${row.date}::${this._moneyKey(baseAmount)}`) || [];
const linked = candidates.find(candidate => {
if (candidate.txKey === row.txKey) return false;
if (row.dealId && candidate.dealId) return String(row.dealId) === String(candidate.dealId);
return true;
}) || null;
if (!linked) return;
row.linkedToTxKey = linked.txKey;
if (!row.projectId && linked.projectId) {
row.projectId = linked.projectId;
row.projectName = linked.projectName;
row.directionName = linked.directionName;
}
if (!row.projectLabel && linked.projectLabel) {
row.projectLabel = linked.projectLabel;
}
row.reasonSummary = [row.reasonSummary, `связано с приходом ${linked.amountLabel}`].filter(Boolean).join(' · ');
linked.linkedItems.push({
txKey: row.txKey,
categoryName: row.categoryName || row.kindLabel,
amountLabel: row.amountLabel,
description: row.descriptionShort || row.description || '',
});
});
rows.forEach(row => {
if (!row || row.direction !== 'in') return;
const embeddedCommission = this._extractOperationCommissionAmount(row);
if (embeddedCommission > 0) {
row.derivedCharges.push({
label: 'Комиссия канала',
amountLabel: `−${this.fmtRub(embeddedCommission)}`,
});
}
});
return rows;
},
_extractOperationBaseAmount(tx) {
const text = String(tx?.description || '').replace(/\u00a0/g, ' ');
const rubMatch = text.match(/с операции на сумму\s+([\d\s]+(?:[.,]\d+)?)\s*rub/i);
if (rubMatch) return this._num(String(rubMatch[1]).replace(/\s+/g, '').replace(',', '.'));
return 0;
},
_extractOperationCommissionAmount(tx) {
const text = String(tx?.description || '').replace(/\u00a0/g, ' ');
const match = text.match(/сумма комиссии\s+([\d\s]+(?:[.,]\d+)?)/i);
if (!match) return 0;
return this._num(String(match[1]).replace(/\s+/g, '').replace(',', '.'));
},
_moneyKey(value) {
return Math.round(this._num(value) * 100);
},
_suggestTransaction(tx, workspace) {
const activeRules = (workspace?.rules || []).filter(item => item && item.active !== false);
let best = null;
activeRules.forEach(rule => {
const match = this._matchRuleToTransaction(rule, tx, workspace);
if (!match) return;
if (!best || match.confidence > best.confidence) best = match;
});
if (best) return best;
const recurringFallback = this._matchRecurringTemplate(tx, workspace);
if (recurringFallback) return recurringFallback;
const historyFallback = this._matchHistoryDecision(tx, workspace);
if (historyFallback) return historyFallback;
const profileFallback = this._matchCounterpartyProfile(tx, workspace);
if (profileFallback) return profileFallback;
return this._systemFallbackSuggestion(tx, workspace);
},
_matchHistoryDecision(tx, workspace) {
const txKey = this._transactionKey(tx);
const txText = this._transactionText(tx);
const txInn = this._digitsOnly(tx.counterpartyInn);
let best = null;
(workspace?.transactionDecisions || []).forEach(item => {
if (!item || item.confirmed !== true || String(item.tx_key || '') === txKey) return;
if (!item.category_id && !item.project_id && !item.counterparty_id) return;
let score = 0;
let reason = '';
const knownInn = this._digitsOnly(item.counterparty_inn);
if (knownInn && txInn && knownInn === txInn) {
score = 0.9;
reason = `история по ИНН ${knownInn}`;
} else {
const knownName = this._normalizeText(item.counterparty_name || '');
if (knownName && txText.includes(knownName)) {
score = 0.8;
reason = `история по контрагенту "${item.counterparty_name}"`;
}
}
if (!score) return;
const candidate = {
projectId: item.project_id || '',
projectName: this._resolveProjectName(item.project_id, workspace),
categoryId: item.category_id || '',
categoryName: this._resolveCategoryName(item.category_id, workspace),
counterpartyProfileId: item.counterparty_id || '',
counterpartyProfileName: this._findById(workspace?.counterparties, item.counterparty_id)?.name || '',
kind: item.kind || '',
confidence: score,
autoApply: false,
reasons: ['история ручного подтверждения', reason].filter(Boolean),
};
if (!best || candidate.confidence > best.confidence) best = candidate;
});
return best;
},
_matchRecurringTemplate(tx, workspace) {
const targetKind = String(tx?.direction || '') === 'in' ? 'income' : 'expense';
const txText = this._transactionText(tx);
let best = null;
this._normalizeRecurringTransactions(workspace?.recurringTransactions).forEach(item => {
if (!item || item.active === false || item.kind !== targetKind) return;
if (!item.category_id && !item.project_id && !item.note) return;
const accountMatched = this._accountScopeMatches(item.account_id, tx, workspace);
const amountDelta = Math.abs(this._moneyKey(item.amount) - this._moneyKey(tx.amount));
const amountBase = Math.max(this._moneyKey(item.amount), this._moneyKey(tx.amount), 1);
let score = 0;
let amountMatched = false;
let exactAmount = false;
const reasons = [`шаблон автосписания "${item.name}"`];
if (amountDelta === 0) {
score += 0.2;
amountMatched = true;
exactAmount = true;
reasons.push('та же сумма');
} else if (amountDelta <= 100) {
score += 0.14;
amountMatched = true;
reasons.push('сумма почти совпадает');
} else if ((amountDelta / amountBase) <= 0.03) {
score += 0.1;
amountMatched = true;
reasons.push('схожая сумма');
}
if (accountMatched) {
score += 0.16;
reasons.push('тот же счет');
}
const textMatch = this._recurringTemplateTextMatch(item, txText);
if (textMatch.exact) {
score += 0.54;
reasons.push(textMatch.reason);
} else if (textMatch.tokenCount >= 2) {
score += 0.34;
reasons.push(textMatch.reason);
} else if (textMatch.tokenCount === 1 && (accountMatched || amountMatched)) {
score += 0.18;
reasons.push(textMatch.reason);
}
if (!textMatch.matched) return;
if (!amountMatched && !accountMatched) return;
const confidence = this._clamp01(score, 0);
if (confidence < 0.62) return;
const candidate = {
projectId: item.project_id || '',
projectName: this._resolveProjectName(item.project_id, workspace),
categoryId: item.category_id || '',
categoryName: this._resolveCategoryName(item.category_id, workspace),
counterpartyProfileId: '',
counterpartyProfileName: '',
kind: item.kind === 'income' ? 'income' : 'expense',
confidence,
autoApply: !!(textMatch.exact && exactAmount && accountMatched),
reasons,
};
if (!best || candidate.confidence > best.confidence) best = candidate;
});
return best;
},
_recurringTemplateTextMatch(template, txText) {
const candidates = [
{ value: template?.counterparty_name, label: 'контрагент' },
{ value: template?.name, label: 'шаблон' },
{ value: template?.description, label: 'описание' },
].map(item => ({
raw: String(item.value || '').trim(),
normalized: this._normalizeText(item.value || ''),
label: item.label,
})).filter(item => item.normalized.length >= 4);
for (const item of candidates) {
if (txText.includes(item.normalized)) {
return {
matched: true,
exact: true,
tokenCount: this._matchableTokens(item.normalized).length,
reason: `${item.label}: "${item.raw}"`,
};
}
}
let best = { matched: false, exact: false, tokenCount: 0, reason: '' };
candidates.forEach(item => {
const matchedTokens = this._matchableTokens(item.normalized).filter(token => txText.includes(token));
if (matchedTokens.length > best.tokenCount) {
best = {
matched: matchedTokens.length > 0,
exact: false,
tokenCount: matchedTokens.length,
reason: `слова: ${matchedTokens.slice(0, 3).join(', ')}`,
};
}
});
return best;
},
_matchRuleToTransaction(rule, tx, workspace) {
if (!rule || rule.active === false) return null;
if (!this._accountScopeMatches(rule.account_scope, tx, workspace)) return null;
const text = this._transactionText(tx);
const triggerWords = this._splitList(rule.trigger);
const matchedWords = triggerWords.filter(word => text.includes(this._normalizeText(word)));
const linkedProfile = this._findById(workspace?.counterparties, rule.counterparty_id);
const profileMatch = linkedProfile ? this._profileMatchDetails(linkedProfile, tx) : null;
const ruleTrigger = String(rule.trigger_type || 'description');
let matched = false;
if (ruleTrigger === 'description' || ruleTrigger === 'keyword_bundle') {
matched = matchedWords.length > 0;
} else if (ruleTrigger === 'counterparty') {
matched = !!(profileMatch?.matched || matchedWords.length > 0);
} else if (ruleTrigger === 'counterparty_account') {
matched = !!(profileMatch?.matched || matchedWords.length > 0);
} else if (ruleTrigger === 'inn') {
const ruleInn = this._digitsOnly(rule.trigger);
const txInn = this._digitsOnly(tx.counterpartyInn);
matched = !!(ruleInn && txInn && ruleInn === txInn);
if (!matched && profileMatch?.matched && profileMatch.exactInn) matched = true;
}
if (!matched) return null;
const projectId = rule.project_id || linkedProfile?.default_project_id || '';
const categoryId = rule.category_id || linkedProfile?.default_category_id || '';
return {
projectId,
projectName: this._resolveProjectName(projectId, workspace),
categoryId,
categoryName: this._resolveCategoryName(categoryId, workspace),
counterpartyProfileId: linkedProfile?.id || '',
counterpartyProfileName: linkedProfile?.name || '',
confidence: this._clamp01(rule.confidence, 0.7),
autoApply: !!rule.auto_apply,
reasons: [
`правило "${rule.name}"`,
profileMatch?.reason || (matchedWords.length > 0 ? `ключи: ${matchedWords.slice(0, 2).join(', ')}` : 'точное совпадение'),
].filter(Boolean),
};
},
_matchCounterpartyProfile(tx, workspace) {
const profiles = (workspace?.counterparties || []).filter(item => item && item.active !== false);
let best = null;
profiles.forEach(profile => {
const match = this._profileMatchDetails(profile, tx);
if (!match?.matched) return;
const categoryId = profile.default_category_id || '';
const projectId = profile.default_project_id || '';
const candidate = {
projectId,
projectName: this._resolveProjectName(projectId, workspace),
categoryId,
categoryName: this._resolveCategoryName(categoryId, workspace),
counterpartyProfileId: profile.id,
counterpartyProfileName: profile.name,
confidence: this._clamp01(match.score, 0.64),
autoApply: false,
reasons: [match.reason || `профиль "${profile.name}"`],
};
if (!best || candidate.confidence > best.confidence) best = candidate;
});
return best;
},
_systemFallbackSuggestion(tx, workspace) {
const text = this._transactionText(tx);
const direction = String(tx.direction || '');
if (text.includes('фонд благотворитель')) {
return {
projectId: '',
projectName: 'Благотворительность',
categoryId: 'other_charity',
categoryName: this._resolveCategoryName('other_charity', workspace),
counterpartyProfileId: '',
counterpartyProfileName: '',
confidence: 0.92,
autoApply: false,
reasons: ['1% уходит в благотворительность от каждого прихода'],
};
}
if (text.includes('фонд налоги')) {
return {
projectId: '',
projectName: 'Налоги по оплатам',
categoryId: 'taxes_orders',
categoryName: this._resolveCategoryName('taxes_orders', workspace),
counterpartyProfileId: 'cp_tax_treasury',
counterpartyProfileName: 'Налоговая / казначейство',
kind: 'tax',
confidence: 0.91,
autoApply: false,
reasons: ['налоговый фонд, привязанный к конкретному приходу'],
};
}
if (text.includes('держател') && text.includes('платежных карт') && text.includes('терминал')) {
return {
projectId: 'project_online_store',
projectName: this._resolveProjectName('project_online_store', workspace),
categoryId: 'income_online_store',
categoryName: this._resolveCategoryName('income_online_store', workspace),
counterpartyProfileId: 'cp_online_acquiring',
counterpartyProfileName: 'Интернет-эквайринг / ЮMoney',
confidence: 0.88,
autoApply: false,
reasons: ['эквайринг / терминал интернет-магазина'],
};
}
if (text.includes('точка чеки') || text.includes('касса') || text.includes('чеки')) {
return {
projectId: 'project_online_store',
projectName: this._resolveProjectName('project_online_store', workspace),
categoryId: 'commercial_marketplace_fees',
categoryName: this._resolveCategoryName('commercial_marketplace_fees', workspace),
counterpartyProfileId: '',
counterpartyProfileName: '',
confidence: 0.81,
autoApply: false,
reasons: ['расход на чеки / кассу онлайн-канала'],
};
}
if (text.includes('перевод собственных средств')) {
return {
projectId: '',
projectName: 'Вне проекта',
categoryId: 'finance_owner_money',
categoryName: this._resolveCategoryName('finance_owner_money', workspace),
counterpartyProfileId: '',
counterpartyProfileName: '',
confidence: 0.72,
autoApply: false,
reasons: ['перевод собственных средств'],
};
}
if (text.includes('осфр') || text.includes('страховые взносы') || text.includes('сфр')) {
return {
projectId: '',
projectName: 'Налоговый контур',
categoryId: 'taxes_payroll',
categoryName: this._resolveCategoryName('taxes_payroll', workspace),
counterpartyProfileId: 'cp_social_funds',
counterpartyProfileName: 'Соцфонды / ОСФР',
confidence: 0.84,
autoApply: false,
reasons: ['системный шаблон по фондам'],
};
}
if (text.includes('ифнс') || text.includes('казначейств') || text.includes('енп') || (text.includes('налог') && direction === 'out')) {
return {
projectId: '',
projectName: 'Налоговый контур',
categoryId: 'taxes_usn',
categoryName: this._resolveCategoryName('taxes_usn', workspace),
counterpartyProfileId: 'cp_tax_treasury',
counterpartyProfileName: 'Налоговая / казначейство',
confidence: 0.82,
autoApply: false,
reasons: ['системный шаблон по налогам'],
};
}
if (text.includes('зп') || text.includes('зарплат')) {
return {
projectId: 'project_recycle_object',
projectName: this._resolveProjectName('project_recycle_object', workspace),
categoryId: 'payroll_production',
categoryName: this._resolveCategoryName('payroll_production', workspace),
counterpartyProfileId: '',
counterpartyProfileName: '',
confidence: tx.sourceKind === 'cash' ? 0.76 : 0.62,
autoApply: false,
reasons: ['системный шаблон по зарплате'],
};
}
if ((text.includes('ozon') || text.includes('wildberries') || text.includes('маркетплейс')) && direction === 'in') {
return {
projectId: 'project_marketplaces',
projectName: this._resolveProjectName('project_marketplaces', workspace),
categoryId: 'income_marketplaces',
categoryName: this._resolveCategoryName('income_marketplaces', workspace),
counterpartyProfileId: 'cp_marketplaces',
counterpartyProfileName: 'Маркетплейсы / платформы',
confidence: 0.78,
autoApply: false,
reasons: ['системный шаблон по каналу маркетплейсов'],
};
}
if ((text.includes('ozon') || text.includes('озон')) && direction === 'out' && text.includes('покупка товара')) {
return {
projectId: 'project_recycle_object',
projectName: this._resolveProjectName('project_recycle_object', workspace),
categoryId: 'direct_materials',
categoryName: this._resolveCategoryName('direct_materials', workspace),
counterpartyProfileId: '',
counterpartyProfileName: '',
confidence: 0.62,
autoApply: false,
reasons: ['Ozon в расходе читаю как закупку товара'],
};
}
return {
projectId: '',
projectName: '',
categoryId: '',
categoryName: '',
counterpartyProfileId: '',
counterpartyProfileName: '',
confidence: 0.35,
autoApply: false,
reasons: ['новый или неразмеченный сценарий'],
};
},
_profileMatchDetails(profile, tx) {
if (!profile) return null;
const txText = this._transactionText(tx);
const profileName = this._normalizeText(profile.name);
const txInn = this._digitsOnly(tx.counterpartyInn);
const profileInn = this._digitsOnly(profile.inn);
if (profileInn && txInn && profileInn === txInn) {
return { matched: true, score: 0.92, exactInn: true, reason: `ИНН ${profileInn}` };
}
if (profileName && txText.includes(profileName)) {
return { matched: true, score: 0.8, exactInn: false, reason: `название "${profile.name}"` };
}
const hints = this._splitList(profile.match_hint);
const matchedHints = hints.filter(hint => txText.includes(this._normalizeText(hint)));
if (matchedHints.length > 0) {
return {
matched: true,
score: matchedHints.length > 1 ? 0.76 : 0.68,
exactInn: false,
reason: `подсказки: ${matchedHints.slice(0, 2).join(', ')}`,
};
}
return { matched: false, score: 0, exactInn: false, reason: '' };
},
_accountScopeMatches(scope, tx, workspace) {
const value = String(scope || 'any');
if (!value || value === 'any') return true;
if (value === 'bank_any') return String(tx.sourceKind || '') === 'bank';
if (value === 'cash_any') return String(tx.sourceKind || '') === 'cash';
const account = this._findById(workspace?.accounts, value);
if (!account) return false;
if (String(account.id || '') && String(account.id || '') === String(tx.accountId || '')) return true;
if (this._normalizeText(account?.name || '') && this._normalizeText(account?.name || '') === this._normalizeText(tx.accountLabel || '')) return true;
const txDigits = this._digitsOnly(tx.accountId);
const workspaceDigits = this._digitsOnly(`${account.id || ''} ${account.name || ''} ${account.note || ''} ${account.external_ref || ''}`);
if (!txDigits || !workspaceDigits) return false;
return txDigits.includes(workspaceDigits.slice(-4)) || workspaceDigits.includes(txDigits.slice(-4));
},
_resolveProjectName(projectId, workspace) {
if (!projectId) return '';
return this._findById(workspace?.projects, projectId)?.name || '';
},
_resolveCategoryName(categoryId, workspace) {
if (!categoryId) return '';
return this._findById(workspace?.categories, categoryId)?.name || '';
},
_findById(list, id) {
return (Array.isArray(list) ? list : []).find(item => String(item?.id || '') === String(id || '')) || null;
},
_transactionText(tx) {
return this._normalizeText([
tx.counterpartyName,
tx.counterpartyInn,
tx.description,
tx.accountLabel,
tx.accountId,
].filter(Boolean).join(' '));
},
_normalizeText(value) {
return String(value || '')
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[«»"'`]/g, '')
.replace(/\s+/g, ' ')
.trim();
},
_matchableTokens(value) {
return Array.from(new Set(
this._normalizeText(value)
.split(/[^a-zа-я0-9]+/g)
.map(item => item.trim())
.filter(item => item.length >= 4 || /^\d{3,}$/.test(item))
));
},
_digitsOnly(value) {
return String(value || '').replace(/\D+/g, '');
},
_shorten(value, max = 80) {
const text = String(value || '').trim();
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 1)).trim()}…`;
},
_renderAccountRow(account) {
return `
${this._accountTypeOptions(account.type)}
${this._sourceOptions(account.source_id)}
${this._accountStatusOptions(account.status)}
✕
`;
},
_renderCategoryRow(category) {
return `
${this._categoryGroupOptions(category.group)}
${this._sourceOptions(category.source_id)}
✕
`;
},
_renderProjectRow(project) {
return `
${this._projectTypeOptions(project.type)}
${this._categoryOptions(project.default_income_category_id, true, '—')}
✕
`;
},
_renderCounterpartyRow(item) {
return `
${this._counterpartyRoleOptions(item.role)}
${this._projectOptions(item.default_project_id, true, '—')}
${this._categoryOptions(item.default_category_id, true, '—')}
${this._researchModeOptions(item.research_mode)}
✕
`;
},
_renderRuleRow(rule) {
return `
${this._ruleTriggerOptions(rule.trigger_type)}
${this._ruleAccountScopeOptions(rule.account_scope)}
${this._counterpartyOptions(rule.counterparty_id, true, '—')}
${this._projectOptions(rule.project_id, true, '—')}
${this._categoryOptions(rule.category_id, true, '—')}
✕
`;
},
_renderManualComposer(mode) {
const isTransfer = mode === 'transfer';
const today = new Date().toISOString().slice(0, 10);
const heading = mode === 'income' ? 'Новое поступление' : (isTransfer ? 'Новый перевод' : 'Новое списание');
const helper = isTransfer
? 'Добавьте внутренний перевод между счетами, чтобы он не смешивался с расходами.'
: 'Быстрый ручной ввод для наличных, корректировок и операций вне банковской выписки.';
return `
`;
},
_groupOperationsByDay(rows = []) {
const groups = [];
const map = new Map();
(Array.isArray(rows) ? rows : []).forEach(item => {
const dateKey = this._parseBusinessDate(item?.date) || '__no_date__';
let group = map.get(dateKey);
if (!group) {
const labels = this._describeOperationDay(dateKey);
group = {
dateKey,
title: labels.title,
subtitle: labels.subtitle,
count: 0,
income: 0,
expense: 0,
net: 0,
items: [],
};
map.set(dateKey, group);
groups.push(group);
}
const amount = this._roundMoney(item?.amount);
const direction = String(item?.direction || '');
if (direction === 'in') group.income += amount;
if (direction === 'out') group.expense += amount;
group.net += direction === 'in' ? amount : (direction === 'out' ? -amount : 0);
group.items.push(item);
group.count += 1;
});
return groups.map(group => ({
...group,
countLabel: `${group.count} ${this._ruPlural(group.count, 'операция', 'операции', 'операций')}`,
incomeLabel: `+${this.fmtRub(group.income)}`,
expenseLabel: `−${this.fmtRub(group.expense)}`,
netLabel: this._formatSignedRub(group.net),
netTone: group.net > 0 ? 'in' : (group.net < 0 ? 'out' : 'neutral'),
}));
},
_describeOperationDay(dateValue) {
const raw = this._parseBusinessDate(dateValue);
if (!raw) {
return {
title: 'Без даты',
subtitle: 'Дата не указана',
};
}
const date = this._dateFromBusinessDate(raw);
if (!date) {
return {
title: raw,
subtitle: 'Нераспознанная дата',
};
}
const today = this._todayDateLocal();
const todayRaw = this._businessDateFromDate(today);
const yesterdayRaw = this._businessDateFromDate(this._shiftLocalDays(today, -1));
const fullDate = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
if (raw === todayRaw) {
return {
title: 'Сегодня',
subtitle: fullDate,
};
}
if (raw === yesterdayRaw) {
return {
title: 'Вчера',
subtitle: fullDate,
};
}
return {
title: date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }),
subtitle: `${date.toLocaleDateString('ru-RU', { weekday: 'long' })} · ${fullDate}`,
};
},
_renderOperationDayGroup(group, selectedSet = new Set(), focusedKey = '') {
if (!group) return '';
const dayKeys = (Array.isArray(group.items) ? group.items : []).map(item => String(item?.txKey || '')).filter(Boolean);
const selectedCount = dayKeys.filter(key => selectedSet.has(key)).length;
const allSelected = dayKeys.length > 0 && selectedCount === dayKeys.length;
const daySelectionLabel = allSelected
? 'Снять день'
: (selectedCount > 0 ? `Добрать день (${selectedCount}/${dayKeys.length})` : 'Выбрать день');
return `
${this._esc(group.title || 'Без даты')}
${this._esc(group.countLabel || '')}
${selectedCount > 0 ? `
${this._esc(`В пачке ${selectedCount}/${dayKeys.length}`)}
` : ''}
${this._esc(group.subtitle || '')}
Вход
${this._esc(group.incomeLabel || `+${this.fmtRub(0)}`)}
Выход
${this._esc(group.expenseLabel || `−${this.fmtRub(0)}`)}
Итог
${this._esc(group.netLabel || this.fmtRub(0))}
${this._esc(daySelectionLabel)}
${(Array.isArray(group.items) ? group.items : []).map(item => this._renderOperationListItem(item, selectedSet, focusedKey)).join('')}
`;
},
_renderOperationListItem(item, selectedSet = new Set(), focusedKey = '') {
const txKey = String(item.txKey || '');
const isSelected = selectedSet.has(txKey);
const isFocused = txKey && txKey === String(focusedKey || '');
const routeChipTone = item.statusTone === 'ok' ? 'ok' : (item.statusTone === 'warn' ? 'warn' : 'muted');
const directionLabel = item.kind === 'transfer'
? 'Перевод'
: item.kind === 'owner_money'
? 'Собственник'
: (item.direction === 'in' ? 'Поступление' : 'Списание');
const amountTone = item.direction === 'in' ? 'finance-op-item__amount--in' : 'finance-op-item__amount--out';
const hasProject = !!(item.projectName || item.projectLabel);
const hasCategory = !!item.categoryName;
const projectLabel = hasProject ? (item.projectName || item.projectLabel) : 'Нужно направление';
const categoryLabel = hasCategory ? item.categoryName : 'Нужна статья';
const missingLabel = this._operationNeedsLabel(item);
const accountShort = this._shorten(item.accountLabel || 'Без счета', 32);
const linkedCount = (item.derivedCharges || []).length + (item.linkedItems || []).length;
const itemClasses = [
'finance-op-item',
isFocused ? 'finance-op-item--active' : '',
isSelected ? 'finance-op-item--selected' : '',
`finance-op-item--${this._escAttr(item.statusTone || 'muted')}`,
].filter(Boolean).join(' ');
return `
${this._esc(item.routeLabel || 'Без маршрута')}
${this._esc(item.date || '—')}
${this._esc(accountShort)}
${this._esc(item.counterpartyName || 'Без контрагента')}
${this._esc(item.descriptionShort || 'Без описания')}
${this._esc(item.amountLabel)}
${this._esc(directionLabel)}
Статья
${this._esc(categoryLabel)}
Направление
${this._esc(projectLabel)}
Что осталось
${this._esc(missingLabel)}
Связки
${linkedCount > 0 ? this._esc(`Связано ${String(linkedCount)}`) : 'Нет'}
`;
},
_renderOperationInspector(item, context = {}) {
const txKey = String(item.txKey || '');
const isSelected = !!context?.isSelected;
const focusIndex = Math.max(0, Number(context?.index) || 0);
const focusTotal = Math.max(0, Number(context?.total) || 0);
const hasPrev = focusIndex > 0;
const hasNext = focusIndex < Math.max(0, focusTotal - 1);
const directionLabel = item.kind === 'transfer'
? 'Перевод'
: item.kind === 'owner_money'
? 'Собственник'
: (item.direction === 'in' ? 'Поступление' : 'Списание');
const routeChipTone = item.statusTone === 'ok' ? 'ok' : (item.statusTone === 'warn' ? 'warn' : 'muted');
const detailTone = item.statusTone === 'ok' ? 'ok' : (item.statusTone === 'warn' ? 'warn' : 'muted');
const linkedMarkup = [
...(item.derivedCharges || []).map(charge => `${this._esc(charge.label)} ${this._esc(charge.amountLabel)} `),
...(item.linkedItems || []).map(linked => `${this._esc(linked.categoryName || 'Связанный расход')} ${this._esc(linked.amountLabel || '')} `),
].join('');
const amountTone = item.direction === 'in' ? 'finance-op-panel__amount--in' : 'finance-op-panel__amount--out';
const reasonSummary = item.reasonSummary || 'Без подсказки';
const projectLabel = item.projectName || item.projectLabel || 'Без направления';
const currentKindLabel = this.TRANSACTION_KIND_LABELS[item.kind] || directionLabel;
const currentCategoryLabel = item.categoryName || 'Нужна статья';
const currentProjectLabel = item.projectName || item.projectLabel || 'Нужно направление';
const reviewStatus = item.confirmed ? 'Уже подтверждено' : this._operationNeedsLabel(item);
const noteValue = item.decision?.note || '';
const suggestionLabel = this._describeSuggestion(item.suggestion);
const hasSuggestion = this._hasMeaningfulSuggestion(item.suggestion);
const manualDirection = String(item.direction || 'out') === 'in' ? 'in' : 'out';
return `
${this._esc(item.routeLabel || 'Без маршрута')}
${isSelected ? 'В пачке ' : ''}
${this._esc(item.date || '—')}
${this._esc(item.accountLabel || 'Без счета')}
${this._esc(item.counterpartyName || 'Без контрагента')}
${this._esc(item.descriptionShort || 'Без описания')}
${this._esc(item.amountLabel)}
${this._esc(currentKindLabel)}
Статья сейчас
${this._esc(currentCategoryLabel)}
Направление сейчас
${this._esc(currentProjectLabel)}
Что осталось
${this._esc(reviewStatus)}
← Предыдущая
${this._esc(String(focusIndex + 1))} из ${this._esc(String(focusTotal || 0))} на экране
Следующая →
Подсказка системы
${this._esc(suggestionLabel)}
${this._esc(reasonSummary)}
Применить подсказку
Подсказку + дальше
${this._renderOperationQuickPresets('single', txKey)}
${linkedMarkup ? `
${linkedMarkup}
` : ''}
${item.manualId ? `
Ручная операция
Сырые реквизиты. Открывай только если нужно поправить источник.
Сохранить ручную
` : ''}
Разнести вручную
Статья, направление, заказ и заметка. Только то, что реально нужно на каждый день.
Быстрые действия
Подтвердить, превратить в перевод, зарплату или скрыть без лишних шагов.
Подтверждено
Сохранить и дальше
Сделать переводом
Сделать ЗП
Скрыть
${item.manualId
? `Удалить ручную `
: `Сбросить разметку `}
`;
},
_renderTransactionRow(item, selectedSet = new Set()) {
const directionLabel = item.kind === 'transfer'
? 'Перевод'
: item.kind === 'owner_money'
? 'Собственник'
: (item.direction === 'in' ? 'Поступление' : 'Списание');
const routeChipTone = item.statusTone === 'ok' ? 'ok' : (item.statusTone === 'warn' ? 'warn' : 'muted');
const linkedMarkup = [
...(item.derivedCharges || []).map(charge => `${this._esc(charge.label)} ${this._esc(charge.amountLabel)} `),
...(item.linkedItems || []).map(linked => `${this._esc(linked.categoryName || 'Связанный расход')} ${this._esc(linked.amountLabel || '')} `),
].join('');
const isSelected = selectedSet.has(String(item.txKey || ''));
const rowClasses = [
'finance-op-card',
`finance-op-card--${this._escAttr(item.statusTone || 'muted')}`,
isSelected ? 'finance-op-card--selected' : '',
].filter(Boolean).join(' ');
const accountLabel = item.accountLabel || 'Без счета';
const projectLabel = item.projectName || item.projectLabel || 'Без направления';
const reasonSummary = item.reasonSummary || 'Без подсказки';
const noteValue = item.decision?.note || '';
const amountTone = item.direction === 'in' ? 'finance-op-card__amount--in' : 'finance-op-card__amount--out';
return `
${this._esc(item.counterpartyName || 'Без контрагента')}
${this._esc(item.descriptionShort || 'Без описания')}
${this._esc(reasonSummary)}
${linkedMarkup ? `
${linkedMarkup}
` : ''}
${this._esc(item.amountLabel)}
Статья
${this._categoryOptions(item.categoryId, true, 'Без статьи')}
Направление
${this._projectOptions(item.projectId, true, 'Без направления')}
Заказ / сделка
Заметка
${item.kind === 'transfer'
? `
Счет перевода
${this._accountOptions(item.transferAccountId, true, 'Счет перевода')}
`
: `
`}
`;
},
_renderPayrollRow(item) {
return `
${this._esc(item.routeLabel)}
${this._esc(item.date || '—')}
${this._esc(item.counterpartyName || 'Без контрагента')}
${this._esc(item.descriptionShort || 'Без описания')}
Счёт: ${this._esc(item.accountLabel || '—')}
${this._esc(item.amountLabel)}
${this._categoryOptions(item.categoryId, true, 'Без статьи')}
${this._projectOptions(item.projectId, true, 'Без направления')}
${this._esc(item.payrollEmployeeName || 'Без сотрудника')}
${this._esc(item.reasonSummary)}
Подтверждено
Подтвердить
Это перевод
Сброс
`;
},
_renderAccountToolRow(account, usedRows) {
const activity = this._matchWorkspaceAccountUsage(account, usedRows);
return `
${this._accountTypeOptions(account.type)}
${this._accountStatusOptions(account.status)}
${this._esc(activity ? `${activity.transactionCount} · ${activity.lastTransactionDate || '—'}` : '—')}
✕
`;
},
_renderProjectToolRow(project) {
return `
${this._projectTypeOptions(project.type)}
✕
`;
},
_renderFixedAssetReportRow(item) {
const statusMarkup = this._fixedAssetStatusChip(item.status);
const assetLabel = item.vendor_name
? `${item.name} · ${item.vendor_name}`
: item.name;
return `
${this._esc(assetLabel)}
${this._esc(item.note || item.type_label || 'Без комментария')}
${this._esc(item.project_name || 'Без направления')}
${this._esc(this.fmtRub(item.purchase_cost || 0))}
${this._esc(this.fmtRub(item.current_month_amortization || 0))}
${this._esc(this.fmtRub(item.accumulated_amortization || 0))}
${this._esc(this.fmtRub(item.residual_value || 0))}
${this._esc(this.fmtRub(item.payable_amount || 0))}
${statusMarkup}
`;
},
_renderFixedAssetToolRow(item, computed = null) {
const statusMarkup = computed ? this._fixedAssetStatusChip(computed.status) : this._fixedAssetStatusChip('planned');
const restText = computed
? `${this.fmtRub(computed.residual_value || 0)} · долг ${this.fmtRub(computed.payable_amount || 0)}`
: '—';
return `
${this._fixedAssetTypeOptions(item.asset_type)}
${this._projectOptions(item.project_id, true, 'Без направления')}
${this._esc(restText)}
${statusMarkup}
✕
`;
},
_renderCategoryToolRow(category) {
return `
${this._categoryGroupOptions(category.group)}
✕
`;
},
_renderRuleToolRow(rule) {
return `
${this._categoryOptions(rule.category_id, true, '—')}
${this._projectOptions(rule.project_id, true, '—')}
✕
`;
},
_renderRecurringRow(item) {
return `
${this._accountOptions(item.account_id, true, 'Счет')}
Списание
Пополнение
${this._categoryOptions(item.category_id, true, 'Статья')}
${this._projectOptions(item.project_id, true, 'Без направления')}
✕
`;
},
_filterOperationRows(rows) {
const filters = this.ui.operations || {};
const search = this._normalizeText(filters.search || '');
const dateWindow = this._resolveOperationsDateWindow(filters);
return (rows || []).filter(item => {
if (!this._operationQueueMatches(item, filters.queue || 'all')) return false;
if (!filters.show_hidden_accounts && !this._isOperationAccountVisible(item)) return false;
if (filters.account && String(item.accountId || '') !== String(filters.account)) return false;
if (filters.category && String(item.categoryId || '') !== String(filters.category)) return false;
if (filters.direction && String(item.projectId || '') !== String(filters.direction)) return false;
if (dateWindow.startDate && String(item.date || '') < dateWindow.startDate) return false;
if (dateWindow.endDate && String(item.date || '') > dateWindow.endDate) return false;
if (search) {
const hay = this._normalizeText([
item.counterpartyName,
item.description,
item.accountLabel,
item.categoryName,
item.projectName,
item.projectLabel,
item.reasonSummary,
].filter(Boolean).join(' '));
if (!hay.includes(search)) return false;
}
return true;
});
},
_operationQueueMatches(item, queue) {
const value = String(queue || 'all');
if (!value || value === 'all') return true;
if (value === 'review') return ['review', 'unmatched', 'draft'].includes(String(item?.route || ''));
if (value === 'manual') return String(item?.route || '') === 'manual' || !!item?.manualId;
if (value === 'auto') return String(item?.route || '') === 'auto';
if (value === 'transfers') return String(item?.kind || '') === 'transfer';
if (value === 'payroll') return String(item?.kind || '') === 'payroll';
return true;
},
_operationQueueCount(queue, rows) {
return (rows || []).filter(item => this._operationQueueMatches(item, queue)).length;
},
_renderOperationQueueTab(queue, label, rows, selected) {
const count = this._operationQueueCount(queue, rows);
return `
${this._esc(label)}
${this._esc(String(count))}
`;
},
_renderOperationsPeriodPresets(selected) {
return this._periodPresetItems().map(item => `
${this._esc(item.shortLabel || item.label)}
`).join('');
},
_operationsQueueLabel(queue) {
const labels = {
all: 'Все операции',
review: 'На проверке',
manual: 'Ручные',
auto: 'Авторазнос',
transfers: 'Переводы',
payroll: 'Зарплаты',
};
return labels[String(queue || 'all')] || 'Все операции';
},
_operationNeedsLabel(item) {
const missingParts = [
!item?.categoryName ? 'статья' : '',
!(item?.projectName || item?.projectLabel) ? 'направление' : '',
].filter(Boolean);
return missingParts.length > 0 ? `Нужно: ${missingParts.join(' + ')}` : 'Можно подтверждать';
},
_renderOperationsContextBar({ filters = {}, visibleRows = [], filteredRows = [], dateWindow = {} } = {}) {
const pills = [];
pills.push(`${this._esc(this._operationsQueueLabel(filters.queue || 'all'))} `);
if (String(filters.period || 'all') !== 'all') {
pills.push(this._renderOperationsFilterChip(dateWindow.label || 'Период', 'period'));
}
if (filters.account) {
const accountLabel = (this.summary?.transactions?.rows || []).find(item => String(item?.accountId || '') === String(filters.account || ''))?.accountLabel || filters.account;
pills.push(this._renderOperationsFilterChip(accountLabel, 'account'));
}
if (filters.category) {
const categoryLabel = this._findById(this.workspace?.categories, filters.category)?.name || 'Статья';
pills.push(this._renderOperationsFilterChip(categoryLabel, 'category'));
}
if (filters.direction) {
const directionLabel = filters.direction === 'in' ? 'Поступления' : (filters.direction === 'out' ? 'Списания' : filters.direction);
pills.push(this._renderOperationsFilterChip(directionLabel, 'direction'));
}
if (filters.show_hidden_accounts) {
pills.push(this._renderOperationsFilterChip('Скрытые счета', 'show_hidden_accounts'));
}
if (filters.search) {
pills.push(this._renderOperationsFilterChip(`Поиск: ${this._shorten(filters.search, 26)}`, 'search', 'finance-context-pill--search'));
}
const hasActiveFilters = pills.length > 1;
return `
${this._esc(String(visibleRows.length))} на экране
${this._esc(String(filteredRows.length))} в текущем срезе
${this._esc(String(this.summary?.transactions?.reviewCount || 0))} на проверке
${this._esc(String(this.summary?.transactions?.manualCount || 0))} ручных
история: ${this._esc(this._formatRange(this.summary?.tochka?.range))}
${pills.join('')}
${hasActiveFilters ? 'Сбросить все фильтры ' : ''}
`;
},
_renderOperationsFilterChip(label, field, extraClass = '') {
return `
`;
},
_resolveOperationsDateWindow(filters = {}) {
const preset = String(filters.period || 'all').trim() || 'all';
const today = this._todayDateLocal();
let startDate = '';
let endDate = '';
let label = 'Вся история';
if (preset === 'today') {
startDate = this._businessDateFromDate(today);
endDate = startDate;
label = 'Сегодня';
} else if (preset === 'yesterday') {
startDate = this._businessDateFromDate(this._shiftLocalDays(today, -1));
endDate = startDate;
label = 'Вчера';
} else if (preset === 'last7') {
startDate = this._businessDateFromDate(this._shiftLocalDays(today, -6));
endDate = this._businessDateFromDate(today);
label = 'Последние 7 дней';
} else if (preset === 'week') {
startDate = this._businessDateFromDate(this._startOfWeek(today));
endDate = this._businessDateFromDate(today);
label = 'Эта неделя';
} else if (preset === 'month') {
startDate = this._businessDateFromDate(this._startOfMonth(today));
endDate = this._businessDateFromDate(today);
label = 'Этот месяц';
} else if (preset === 'quarter') {
startDate = this._businessDateFromDate(this._startOfQuarter(today));
endDate = this._businessDateFromDate(today);
label = 'Этот квартал';
} else if (preset === 'year') {
startDate = this._businessDateFromDate(this._startOfYear(today));
endDate = this._businessDateFromDate(today);
label = 'Этот год';
} else if (preset === 'custom') {
startDate = this._parseBusinessDate(filters.start_date) || '';
endDate = this._parseBusinessDate(filters.end_date) || '';
if (startDate && endDate && startDate > endDate) {
[startDate, endDate] = [endDate, startDate];
}
if (startDate && endDate) label = startDate === endDate ? startDate : `${startDate} → ${endDate}`;
else if (startDate) label = `с ${startDate}`;
else if (endDate) label = `до ${endDate}`;
else label = 'Свой период';
}
return { preset, startDate, endDate, label };
},
_todayDateLocal() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0);
},
_businessDateFromDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
_businessMonthFromDate(date) {
const raw = this._businessDateFromDate(date);
return raw ? raw.slice(0, 7) : '';
},
_dateFromBusinessDate(value) {
const raw = this._parseBusinessDate(value);
if (!raw) return null;
return new Date(Number(raw.slice(0, 4)), Number(raw.slice(5, 7)) - 1, Number(raw.slice(8, 10)), 12, 0, 0, 0);
},
_businessMonthDistance(startMonth, endMonth) {
const start = this._parseBusinessMonth(startMonth);
const end = this._parseBusinessMonth(endMonth);
if (!start || !end) return null;
const startIndex = Number(start.slice(0, 4)) * 12 + Number(start.slice(5, 7)) - 1;
const endIndex = Number(end.slice(0, 4)) * 12 + Number(end.slice(5, 7)) - 1;
return endIndex - startIndex;
},
_shiftLocalDays(date, days) {
const next = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0, 0);
next.setDate(next.getDate() + Number(days || 0));
return next;
},
_startOfWeek(date) {
const day = date.getDay();
const diff = (day + 6) % 7;
return this._shiftLocalDays(date, -diff);
},
_startOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1, 12, 0, 0, 0);
},
_startOfQuarter(date) {
const quarterMonth = Math.floor(date.getMonth() / 3) * 3;
return new Date(date.getFullYear(), quarterMonth, 1, 12, 0, 0, 0);
},
_startOfYear(date) {
return new Date(date.getFullYear(), 0, 1, 12, 0, 0, 0);
},
_resolveWorkspaceAccountForOperation(item) {
if (!item) return null;
const direct = this._findById(this.workspace?.accounts, item.accountId);
if (direct) return direct;
return (this.workspace?.accounts || []).find(account => {
const externalRef = String(account?.external_ref || '').trim();
const txDigits = this._digitsOnly([item.accountId, item.accountLabel, item.bankNumber].filter(Boolean).join(' '));
const accountDigits = this._digitsOnly(`${account?.id || ''} ${account?.name || ''} ${account?.note || ''} ${externalRef}`);
if (txDigits && accountDigits && txDigits.includes(accountDigits)) return true;
if (txDigits && accountDigits && accountDigits.slice(-4) && txDigits.includes(accountDigits.slice(-4))) return true;
return this._normalizeText(account?.name || '') === this._normalizeText(item.accountLabel || '');
}) || null;
},
_isOperationAccountVisible(item) {
const account = this._resolveWorkspaceAccountForOperation(item);
if (!account) return true;
if (account.status === 'archived') return false;
return account.show_in_money !== false;
},
_matchWorkspaceAccountUsage(account, usedRows) {
const accountDigits = this._digitsOnly(`${account?.name || ''} ${account?.note || ''}`);
return (usedRows || []).find(item => {
const itemDigits = this._digitsOnly(`${item?.id || ''} ${item?.accountId || ''} ${item?.name || ''}`);
if (accountDigits && itemDigits && accountDigits.slice(-4) && itemDigits.includes(accountDigits.slice(-4))) return true;
return String(item.name || '') === String(account?.name || '');
}) || null;
},
_sourceOptions(selected) {
return (this.workspace?.sources || []).map(source => `
${this._esc(source.name)}
`).join('');
},
_accountTypeOptions(selected) {
return Object.entries(this.ACCOUNT_TYPE_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_accountStatusOptions(selected) {
return Object.entries(this.ACCOUNT_STATUS_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_fixedAssetTypeOptions(selected) {
return Object.entries(this.FIXED_ASSET_TYPE_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_categoryGroupOptions(selected) {
return Object.entries(this.GROUP_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_projectTypeOptions(selected) {
return Object.entries(this.PROJECT_TYPE_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_counterpartyRoleOptions(selected) {
return Object.entries(this.COUNTERPARTY_ROLE_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_researchModeOptions(selected) {
return Object.entries(this.RESEARCH_MODE_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_ruleTriggerOptions(selected) {
return Object.entries(this.RULE_TRIGGER_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_ruleAccountScopeOptions(selected) {
const genericOptions = [
{ value: 'any', label: 'Любой счет' },
{ value: 'bank_any', label: 'Любой банк' },
{ value: 'cash_any', label: 'Любые наличные' },
];
const actualAccounts = (this.workspace?.accounts || []).map(account => ({
value: String(account.id || ''),
label: account.name || account.id || 'Счет',
}));
return [...genericOptions, ...actualAccounts].map(option => `
${this._esc(option.label)}
`).join('');
},
_projectOptions(selected, allowBlank = false, blankLabel = '—') {
const options = [];
if (allowBlank) {
options.push(`${this._esc(blankLabel)} `);
}
(this.workspace?.projects || []).forEach(project => {
options.push(`
${this._esc(project.name)}
`);
});
return options.join('');
},
_batchProjectOptions(selected) {
const value = selected == null ? '__keep__' : String(selected);
const options = [
`Направление: не менять `,
`Без направления `,
];
(this.workspace?.projects || []).forEach(project => {
options.push(`
${this._esc(project.name)}
`);
});
return options.join('');
},
_categoryOptions(selected, allowBlank = false, blankLabel = '—') {
const options = [];
if (allowBlank) {
options.push(`${this._esc(blankLabel)} `);
}
(this.workspace?.categories || []).forEach(category => {
options.push(`
${this._esc(category.name)}
`);
});
return options.join('');
},
_batchCategoryOptions(selected) {
const value = selected == null ? '__keep__' : String(selected);
const options = [
`Статья: не менять `,
`Очистить статью `,
];
(this.workspace?.categories || []).forEach(category => {
options.push(`
${this._esc(category.name)}
`);
});
return options.join('');
},
_counterpartyOptions(selected, allowBlank = false, blankLabel = '—') {
const options = [];
if (allowBlank) {
options.push(`${this._esc(blankLabel)} `);
}
(this.workspace?.counterparties || []).forEach(item => {
options.push(`
${this._esc(item.name)}
`);
});
return options.join('');
},
_transactionKindOptions(selected) {
return Object.entries(this.TRANSACTION_KIND_LABELS).map(([value, label]) => `
${this._esc(label)}
`).join('');
},
_employeeOptions(selected, allowBlank = false, blankLabel = '—') {
const options = [];
if (allowBlank) {
options.push(`${this._esc(blankLabel)} `);
}
(this.data?.employees || [])
.filter(item => item && item.is_active !== false)
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru'))
.forEach(employee => {
options.push(`
${this._esc(employee.name || `Сотрудник ${employee.id}`)}
`);
});
return options.join('');
},
_accountOptions(selected, allowBlank = false, blankLabel = '—') {
const options = [];
if (allowBlank) {
options.push(`${this._esc(blankLabel)} `);
}
(this.workspace?.accounts || []).forEach(account => {
options.push(`
${this._esc(account.name || account.id || 'Счёт')}
`);
});
return options.join('');
},
_accountFilterOptions(selected) {
const options = ['Все счета '];
const seen = new Set();
(this.summary?.transactions?.rows || []).forEach(account => {
const key = String(account?.accountId || '');
if (!key || seen.has(key)) return;
if (!this.ui?.operations?.show_hidden_accounts && !this._isOperationAccountVisible(account)) return;
seen.add(key);
options.push(`${this._esc(account.accountLabel || key)} `);
});
return options.join('');
},
_categoryFilterOptions(selected) {
const options = ['Все статьи '];
(this.workspace?.categories || []).forEach(category => {
options.push(`${this._esc(category.name)} `);
});
return options.join('');
},
_directionFilterOptions(selected) {
const options = ['Все направления '];
(this.workspace?.projects || []).forEach(project => {
options.push(`${this._esc(project.name)} `);
});
return options.join('');
},
_periodPresetItems() {
return [
{ value: 'today', label: 'Сегодня', shortLabel: 'Сегодня' },
{ value: 'yesterday', label: 'Вчера', shortLabel: 'Вчера' },
{ value: 'last7', label: 'Последние 7 дней', shortLabel: '7 дней' },
{ value: 'week', label: 'Эта неделя', shortLabel: 'Неделя' },
{ value: 'month', label: 'Этот месяц', shortLabel: 'Месяц' },
{ value: 'quarter', label: 'Этот квартал', shortLabel: 'Квартал' },
{ value: 'year', label: 'Этот год', shortLabel: 'Год' },
{ value: 'all', label: 'Вся история', shortLabel: 'Все время' },
{ value: 'custom', label: 'Свой период', shortLabel: 'Свой период' },
];
},
_periodFilterOptions(selected) {
const items = this._periodPresetItems();
return items.map(item => `
${this._esc(item.label)}
`).join('');
},
_reportMonthOptions(selected) {
return (this.summary?.reports?.availableMonths || []).map(item => `
${this._esc(item.label)}
`).join('');
},
_orderDatalistOptions() {
return (this.data?.orders || [])
.filter(item => item && !['deleted', 'cancelled'].includes(String(item.status || '')))
.sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')))
.slice(0, 300)
.map(order => {
const name = String(order.order_name || order.name || `Заказ ${order.id}`).trim();
const status = String(order.status || '').trim();
const label = status ? `${name} · ${status}` : name;
return ` `;
})
.join('');
},
_statusChip(status, kind) {
const tone = status === 'active' ? 'ok' : (status === 'planned' ? 'warn' : 'muted');
const labels = kind === 'source' ? this.SOURCE_STATUS_LABELS : this.ACCOUNT_STATUS_LABELS;
return `${this._esc(labels[status] || status || '—')} `;
},
_coverageItem(label, range, note) {
return `
${this._esc(label)}
${this._esc(range || '—')}
${this._esc(note || '—')}
`;
},
_reportMetricRow(label, value, emphasize = false) {
const numeric = this._roundMoney(value);
const displayValue = numeric < 0 ? `−${this.fmtRub(Math.abs(numeric))}` : this.fmtRub(numeric);
return `
${emphasize ? `${this._esc(label)} ` : this._esc(label)}
${emphasize ? `${this._esc(displayValue)} ` : this._esc(displayValue)}
`;
},
_structureCard(title, text) {
return `
${this._esc(title)}
${this._esc(text)}
`;
},
_pill(text) {
return `${this._esc(text)} `;
},
_fixedAssetStatusChip(status) {
const tone = status === 'amortized' ? 'ok' : (status === 'active' ? 'warn' : 'muted');
const labels = {
active: 'Амортизируется',
amortized: 'Погашен',
planned: 'Еще не стартовал',
};
return `${this._esc(labels[status] || status || '—')} `;
},
_showLoading(message) {
const loading = document.getElementById('finance-loading');
const error = document.getElementById('finance-error');
const content = document.getElementById('finance-content');
if (loading) {
loading.style.display = '';
loading.textContent = message || 'Загрузка...';
}
if (error) error.style.display = 'none';
if (content) content.style.display = 'none';
},
_showError(message) {
const loading = document.getElementById('finance-loading');
const error = document.getElementById('finance-error');
const content = document.getElementById('finance-content');
if (loading) loading.style.display = 'none';
if (content) content.style.display = 'none';
if (error) {
error.style.display = '';
error.textContent = message || 'Ошибка';
}
},
_parseBusinessDate(value) {
const raw = String(value || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const year = Number(raw.slice(0, 4));
if (!Number.isFinite(year) || year < 2020 || year > 2100) return null;
return raw;
},
_parseBusinessMonth(value) {
const raw = String(value || '').slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(raw)) {
const date = this._parseBusinessDate(value);
return date ? date.slice(0, 7) : null;
}
const year = Number(raw.slice(0, 4));
const month = Number(raw.slice(5, 7));
if (!Number.isFinite(year) || year < 2020 || year > 2100) return null;
if (!Number.isFinite(month) || month < 1 || month > 12) return null;
return raw;
},
_formatRange(range) {
if (!Array.isArray(range) || !range[0] || !range[1]) return 'Нет данных';
return range[0] === range[1] ? range[0] : `${range[0]} → ${range[1]}`;
},
_formatMonthRange(range) {
if (!Array.isArray(range) || !range[0] || !range[1]) return 'Нет данных';
return range[0] === range[1] ? range[0] : `${range[0]} → ${range[1]}`;
},
_formatPercent(value) {
return `${Math.round(this._clamp01(value, 0) * 100)}%`;
},
_range(values = []) {
const list = (values || []).filter(Boolean).sort();
if (list.length === 0) return [null, null];
return [list[0], list[list.length - 1]];
},
_monthRange(values = []) {
const list = (values || []).filter(Boolean).sort();
if (list.length === 0) return [null, null];
return [list[0], list[list.length - 1]];
},
_formatBusinessMonth(value) {
const raw = this._parseBusinessMonth(value);
if (!raw) return 'текущий месяц';
const date = new Date(Number(raw.slice(0, 4)), Number(raw.slice(5, 7)) - 1, 1, 12, 0, 0, 0);
return date.toLocaleString('ru-RU', { month: 'long', year: 'numeric' });
},
_topEntries(obj, limit = 10) {
return Object.entries(obj || {}).sort((a, b) => (b[1] || 0) - (a[1] || 0)).slice(0, limit);
},
_extractStageLabel(taskDescription = '') {
const match = String(taskDescription || '').match(/"stage_label":"([^"]+)"/);
return match ? match[1] : 'Без стадии';
},
_splitList(value) {
return Array.from(new Set(
String(value || '')
.split(/[,;\n]/g)
.map(item => item.trim())
.filter(Boolean)
));
},
_uid(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
},
_num(value) {
const num = Number(value);
return Number.isFinite(num) ? num : 0;
},
_roundMoney(value) {
return Math.round(this._num(value) * 100) / 100;
},
_clamp01(value, fallback = 0) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
if (num < 0) return 0;
if (num > 1) return 1;
return Math.round(num * 100) / 100;
},
fmtRub(value) {
if (typeof formatRub === 'function') return formatRub(Math.round(this._num(value) * 100) / 100);
const num = Math.round(this._num(value) * 100) / 100;
return `${num.toLocaleString('ru-RU')} ₽`;
},
_formatSignedRub(value) {
const num = this._roundMoney(value);
if (num > 0) return `+${this.fmtRub(num)}`;
if (num < 0) return `−${this.fmtRub(Math.abs(num))}`;
return this.fmtRub(0);
},
_ruPlural(value, one, few, many) {
const abs = Math.abs(Math.trunc(Number(value) || 0));
const mod10 = abs % 10;
const mod100 = abs % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few;
return many;
},
_esc(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
},
_escAttr(value) {
return this._esc(value).replace(/'/g, ''');
},
_escJs(value) {
return String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
},
};
if (typeof window !== 'undefined') {
window.Finance = Finance;
}