// =============================================
// Recycle Object — FinTablo Integration Page
// =============================================
const FinTablo = {
API_BASE: 'https://api.fintablo.ru',
AUTO_SYNC_STORAGE_KEY: 'ro_fintablo_last_auto_sync_at',
AUTO_SYNC_INTERVAL_MS: 24 * 60 * 60 * 1000,
_deals: [],
_orders: [],
_categories: {}, // id → { name, parentId, group }
_matchMap: {}, // dealId → orderId
_currentFilter: 'matched',
_openDealId: null,
// =============================================
// Category mapping (FinTablo category name → our fact field)
// =============================================
CATEGORY_KEYWORDS: {
'зарплата': 'fact_salary', 'зп': 'fact_salary', 'фот': 'fact_salary',
'материал': 'fact_materials', 'пластик': 'fact_materials', 'сырье': 'fact_materials',
'фурнитура': 'fact_hardware', 'фурнитур': 'fact_hardware',
'упаков': 'fact_packaging',
'доставка': 'fact_delivery', 'логистика': 'fact_delivery',
'печат': 'fact_printing', 'нанесение': 'fact_printing',
'молд': 'fact_molds', 'форма': 'fact_molds',
'коммер': 'fact_commercial', 'комисси': 'fact_commercial',
'благотвор': 'fact_charity', 'благотв': 'fact_charity',
'налог': 'fact_taxes', 'ндс': 'fact_taxes',
'выручка': 'fact_revenue', 'доход': 'fact_revenue',
},
FACT_LABELS: {
fact_salary: 'Зарплата',
fact_materials: 'Материалы',
fact_hardware: 'Фурнитура',
fact_packaging: 'Упаковка',
fact_delivery: 'Доставка',
fact_printing: 'Нанесение',
fact_molds: 'Молды',
fact_taxes: 'Налоги',
fact_commercial: 'Коммерческий отдел',
fact_charity: 'Благотворительность',
fact_other: 'Прочее',
fact_revenue: 'Выручка',
},
_getFieldBreakdownSummary(field, rows = []) {
const list = Array.isArray(rows) ? rows : [];
return list
.filter(row => (parseFloat(row?.amount) || 0) > 0)
.slice(0, 10)
.map(row => ({
amount: parseFloat(row.amount) || 0,
description: String(row.description || '').trim(),
category: String(row.category || '').trim(),
date: row.date || '',
}));
},
_descriptionMatchesKeyword(description = '', keyword = '') {
const text = String(description || '').toLowerCase();
if (!text || !keyword || !text.includes(keyword)) return false;
if (keyword === 'ндс' && /без\s+ндс/i.test(text)) return false;
return true;
},
// =============================================
// API helpers
// =============================================
_getApiKey() {
return localStorage.getItem('ro_fintablo_api_key') || '';
},
async _apiGet(path, params = {}) {
const key = this._getApiKey();
if (!key) throw new Error('API-ключ не настроен');
const url = new URL(this.API_BASE + path);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
}
const resp = await fetch(url.toString(), {
headers: { 'Authorization': 'Bearer ' + key }
});
if (resp.status === 401) throw new Error('Неверный API-ключ');
if (resp.status === 429) throw new Error('Превышен лимит запросов (300/мин)');
if (!resp.ok) throw new Error('Ошибка API: ' + resp.status);
const data = await resp.json();
if (data.status !== 200) throw new Error(data.statusText || 'API error');
return data;
},
// =============================================
// Load page
// =============================================
async load() {
const key = this._getApiKey();
if (!key) {
this._showSetup();
return;
}
await this.refresh();
},
_showSetup() {
document.getElementById('ft-setup').style.display = '';
document.getElementById('ft-deals-card').style.display = 'none';
document.getElementById('ft-loading').style.display = 'none';
document.getElementById('ft-error').style.display = 'none';
document.getElementById('ft-detail').style.display = 'none';
document.getElementById('ft-refresh-btn').style.display = 'none';
},
saveApiKey() {
const key = document.getElementById('ft-api-key').value.trim();
if (!key) { App.toast('Введите API-ключ'); return; }
localStorage.setItem('ro_fintablo_api_key', key);
localStorage.removeItem(this.AUTO_SYNC_STORAGE_KEY);
App.toast('Ключ сохранен');
this.load();
},
async refresh() {
document.getElementById('ft-setup').style.display = 'none';
document.getElementById('ft-deals-card').style.display = 'none';
document.getElementById('ft-error').style.display = 'none';
document.getElementById('ft-detail').style.display = 'none';
document.getElementById('ft-loading').style.display = '';
document.getElementById('ft-refresh-btn').style.display = '';
try {
// Load in parallel: deals (all pages), categories, our orders
const [deals, categories, orders] = await Promise.all([
this._loadAllDeals(),
this._loadCategories(),
loadOrders({}),
]);
this._deals = deals;
this._orders = orders || [];
// Build category map
this._categories = {};
(categories || []).forEach(c => {
this._categories[c.id] = c;
});
// Match deals to orders by name
this._matchDeals();
// Render
document.getElementById('ft-loading').style.display = 'none';
document.getElementById('ft-deals-card').style.display = '';
this.applyFilter();
} catch (err) {
document.getElementById('ft-loading').style.display = 'none';
document.getElementById('ft-error').style.display = '';
document.getElementById('ft-error-text').textContent = err.message;
console.error('FinTablo load error:', err);
}
},
async _loadAllDeals() {
// FinTablo returns up to 500 deals per page
let all = [];
let page = 1;
while (true) {
const data = await this._apiGet('/v1/deal', { page });
const items = data.items || [];
all = all.concat(items);
if (items.length < 500) break;
page++;
}
return all;
},
async _loadCategories() {
// Load both income and outcome categories
const [inc, out] = await Promise.all([
this._apiGet('/v1/category', { group: 'income' }),
this._apiGet('/v1/category', { group: 'outcome' }),
]);
return [...(inc.items || []), ...(out.items || [])];
},
// =============================================
// Match deals to orders by name
// =============================================
_matchDeals() {
this._matchMap = this._buildMatchMap(this._deals, this._orders);
},
_normalizeMatchName(value) {
return String(value || '')
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[«»"'`]/g, '')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
},
_extractDealId(value) {
const raw = String(value || '').trim();
if (!raw) return null;
if (/^\d+$/.test(raw)) return Number(raw);
const patterns = [
/[?&]dealId=(\d+)/i,
/\/deals?\/(\d+)(?:[/?#]|$)/i,
/\/(\d+)(?:[/?#]|$)/,
];
for (const pattern of patterns) {
const match = raw.match(pattern);
if (match && match[1]) return Number(match[1]);
}
return null;
},
_buildMatchMap(deals, orders) {
const matchMap = {};
const ordersByName = {};
const ordersByDealId = {};
(orders || []).forEach(o => {
const name = this._normalizeMatchName(o.order_name);
if (name) ordersByName[name] = o;
const explicitDealId = this._extractDealId(o.fintablo_link);
if (Number.isFinite(explicitDealId) && explicitDealId > 0) {
ordersByDealId[explicitDealId] = o;
}
});
(deals || []).forEach(d => {
const explicitOrder = ordersByDealId[Number(d.id)];
if (explicitOrder) {
matchMap[d.id] = explicitOrder;
return;
}
const dealName = this._normalizeMatchName(d.name);
if (ordersByName[dealName]) {
matchMap[d.id] = ordersByName[dealName];
}
});
return matchMap;
},
_getLastAutoSyncAt() {
const ts = parseInt(localStorage.getItem(this.AUTO_SYNC_STORAGE_KEY) || '0', 10);
return Number.isFinite(ts) ? ts : 0;
},
async autoSyncMatchedImports(opts = {}) {
const key = this._getApiKey();
if (!key) return { synced: 0, skipped: 'no_key' };
const force = !!opts.force;
const minIntervalMs = Number.isFinite(Number(opts.minIntervalMs))
? Math.max(0, Number(opts.minIntervalMs))
: this.AUTO_SYNC_INTERVAL_MS;
const lastSyncAt = this._getLastAutoSyncAt();
if (!force && lastSyncAt > 0 && (Date.now() - lastSyncAt) < minIntervalMs) {
return { synced: 0, skipped: 'fresh' };
}
const targetOrderIds = Array.isArray(opts.orderIds)
? new Set(opts.orderIds.map(id => Number(id)).filter(Number.isFinite))
: null;
try {
const [deals, categories, orders] = await Promise.all([
this._loadAllDeals(),
this._loadCategories(),
Array.isArray(opts.orders) ? Promise.resolve(opts.orders) : loadOrders({}),
]);
const categoriesMap = {};
(categories || []).forEach(c => {
categoriesMap[c.id] = c;
});
const matchMap = this._buildMatchMap(deals, orders || []);
const matchedDeals = (deals || []).filter(deal => {
const matchedOrder = matchMap[deal.id];
if (!matchedOrder) return false;
return !targetOrderIds || targetOrderIds.has(Number(matchedOrder.id));
});
if (!matchedDeals.length) {
localStorage.setItem(this.AUTO_SYNC_STORAGE_KEY, String(Date.now()));
return { synced: 0, matched: 0 };
}
this._deals = deals || [];
this._orders = orders || [];
this._categories = categoriesMap;
this._matchMap = matchMap;
const BATCH = 5;
let synced = 0;
for (let i = 0; i < matchedDeals.length; i += BATCH) {
const batch = matchedDeals.slice(i, i + BATCH);
const results = await Promise.all(batch.map(async deal => {
const matchedOrder = matchMap[deal.id];
if (!matchedOrder) return false;
const data = await this._apiGet('/v1/transaction', {
dealId: deal.id, pageSize: 1000,
});
const txns = data.items || [];
const id = await this._syncDealImport(deal, matchedOrder, txns, { silent: true });
return !!id;
}));
synced += results.filter(Boolean).length;
}
localStorage.setItem(this.AUTO_SYNC_STORAGE_KEY, String(Date.now()));
return { synced, matched: matchedDeals.length };
} catch (err) {
console.error('FinTablo autoSyncMatchedImports error:', err);
if (!opts.silent && typeof App?.toast === 'function') {
App.toast('Не удалось обновить ФинТабло: ' + err.message);
}
return { synced: 0, error: err.message };
}
},
// =============================================
// Render deals table
// =============================================
applyFilter() {
this._currentFilter = document.getElementById('ft-filter').value;
this._renderDeals();
},
_renderDeals() {
const filter = this._currentFilter;
let deals = this._deals;
if (filter === 'matched') {
deals = deals.filter(d => this._matchMap[d.id]);
} else if (filter === 'unmatched') {
deals = deals.filter(d => !this._matchMap[d.id]);
}
// Sort: matched first, then by amount desc
deals.sort((a, b) => {
const am = this._matchMap[a.id] ? 1 : 0;
const bm = this._matchMap[b.id] ? 1 : 0;
if (am !== bm) return bm - am;
return (b.amount || 0) - (a.amount || 0);
});
const tbody = document.getElementById('ft-deals-body');
document.getElementById('ft-deals-count').textContent =
`(${deals.length} из ${this._deals.length})`;
if (deals.length === 0) {
tbody.innerHTML = '
| Нет сделок |
';
return;
}
tbody.innerHTML = deals.map(d => {
const order = this._matchMap[d.id];
const orderName = order ? this._esc(order.order_name) : '—';
const status = order
? '✓'
: '—';
return `
| ${this._esc(d.name)} |
${orderName} |
${formatRub(d.amount || 0)} |
... |
... |
${status} |
`;
}).join('');
// Load transaction totals in background (batched)
this._loadDealTotals(deals);
},
async _loadDealTotals(deals) {
// Load transactions for visible deals in small batches to get totals
// Use batch of 5 concurrent requests to respect rate limits
const BATCH = 5;
for (let i = 0; i < deals.length; i += BATCH) {
const batch = deals.slice(i, i + BATCH);
await Promise.all(batch.map(async d => {
try {
const data = await this._apiGet('/v1/transaction', {
dealId: d.id, pageSize: 1000
});
const txns = data.items || [];
const income = txns.filter(t => t.group === 'income').reduce((s, t) => s + (t.value || 0), 0);
const outcome = txns.filter(t => t.group === 'outcome').reduce((s, t) => s + (t.value || 0), 0);
const outEl = document.getElementById('ft-deal-out-' + d.id);
const incEl = document.getElementById('ft-deal-inc-' + d.id);
if (outEl) outEl.innerHTML = outcome > 0
? `${formatRub(outcome)}` : '0';
if (incEl) incEl.innerHTML = income > 0
? `${formatRub(income)}` : '0';
const matchedOrder = this._matchMap[d.id];
if (matchedOrder) {
await this._syncDealImport(d, matchedOrder, txns, { silent: true });
}
} catch (e) {
console.warn('Failed to load totals for deal', d.id, e);
}
}));
}
},
// =============================================
// Deal detail (transactions)
// =============================================
async openDeal(dealId) {
this._openDealId = dealId;
const deal = this._deals.find(d => d.id === dealId);
if (!deal) return;
const detailEl = document.getElementById('ft-detail');
detailEl.style.display = '';
document.getElementById('ft-detail-title').textContent = 'Транзакции: ' + deal.name;
document.getElementById('ft-detail-loading').style.display = '';
document.getElementById('ft-detail-content').innerHTML = '';
// Scroll to detail
detailEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const data = await this._apiGet('/v1/transaction', {
dealId: dealId, pageSize: 1000
});
const txns = data.items || [];
document.getElementById('ft-detail-loading').style.display = 'none';
this._renderDetail(deal, txns);
} catch (err) {
document.getElementById('ft-detail-loading').style.display = 'none';
document.getElementById('ft-detail-content').innerHTML =
`${this._esc(err.message)}
`;
}
},
closeDetail() {
document.getElementById('ft-detail').style.display = 'none';
this._openDealId = null;
},
_renderDetail(deal, txns) {
const content = document.getElementById('ft-detail-content');
const order = this._matchMap[deal.id];
const scopedTxns = this._scopeTransactionsToOrder(txns, order);
// Split by group
const outcomes = scopedTxns.filter(t => t.group === 'outcome');
const incomes = scopedTxns.filter(t => t.group === 'income');
const transfers = scopedTxns.filter(t => t.group === 'transfer');
// Map expenses to our fact fields
const factSums = this._mapToFactFields(outcomes);
const totalIncome = incomes.reduce((s, t) => s + this._effectiveTransactionValue(t), 0);
const totalOutcome = outcomes.reduce((s, t) => s + this._effectiveTransactionValue(t), 0);
let html = '';
// Summary card
html += `
Доходы
${formatRub(totalIncome)}
Расходы
${formatRub(totalOutcome)}
Маржа
${formatRub(totalIncome - totalOutcome)}
`;
// Fact breakdown
html += `Разбивка расходов по категориям
`;
html += ``;
for (const [field, label] of Object.entries(this.FACT_LABELS)) {
if (field === 'fact_revenue') continue; // shown separately
const val = factSums[field] || 0;
const cls = val > 0 ? '' : 'text-muted';
html += `
${label}
${formatRub(val)}
`;
}
html += `
ИТОГО расходы
${formatRub(totalOutcome)}
`;
html += `
Выручка
${formatRub(totalIncome)}
`;
html += `
`;
// Apply button (only if matched to an order)
if (order) {
html += `
`;
}
// Transaction list
if (txns.length > 0) {
html += `Все транзакции (${txns.length})
`;
html += `
| Дата | Описание | Статья |
Сумма | Тип |
`;
// Sort by date desc
const sorted = [...scopedTxns].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
sorted.forEach(t => {
const cat = this._categories[t.categoryId];
const catName = cat ? cat.name : '';
const typeLabel = t.group === 'income' ? 'Доход'
: t.group === 'outcome' ? 'Расход'
: 'Перевод';
const valueColor = t.group === 'income' ? 'color:var(--success)' :
t.group === 'outcome' ? 'color:var(--danger)' : '';
const effectiveValue = this._effectiveTransactionValue(t);
const originalValue = this._num(t.value);
let valueHtml = formatRub(effectiveValue);
if (order && t._scoped_mode === 'split' && Math.abs(effectiveValue - originalValue) > 0.001) {
valueHtml = `${formatRub(effectiveValue)}из ${formatRub(originalValue)}
`;
} else if (order && t._scoped_mode === 'split_zero') {
valueHtml = `не относитсяиз ${formatRub(originalValue)}
`;
}
html += `
| ${t.date || ''} |
${this._esc(t.description || '')} |
${this._esc(catName)} |
${valueHtml} |
${typeLabel} |
`;
});
html += `
`;
}
content.innerHTML = html;
},
// =============================================
// Map FinTablo transactions to our fact fields
// =============================================
_mapToFactFields(outcomes) {
const result = {
fact_salary: 0, fact_materials: 0, fact_hardware: 0,
fact_packaging: 0, fact_delivery: 0, fact_printing: 0, fact_molds: 0,
fact_taxes: 0, fact_commercial: 0, fact_charity: 0, fact_other: 0, fact_revenue: 0,
};
const breakdown = {};
outcomes.forEach(t => {
const amount = this._effectiveTransactionValue(t);
if (amount <= 0) return;
const cat = this._categories[t.categoryId];
const catName = (cat ? cat.name : '').toLowerCase();
// Also check parent category name
const parentCat = cat && cat.parentId ? this._categories[cat.parentId] : null;
const parentName = (parentCat ? parentCat.name : '').toLowerCase();
const scopedLabel = String(t._scoped_label || '').trim();
const originalDescription = String(t.description || '').trim();
const description = [scopedLabel, originalDescription]
.filter(Boolean)
.join(' | ')
.toLowerCase();
let matchedField = null;
let matched = false;
for (const [keyword, field] of Object.entries(this.CATEGORY_KEYWORDS)) {
if (
catName.includes(keyword)
|| parentName.includes(keyword)
|| this._descriptionMatchesKeyword(description, keyword)
) {
result[field] += amount;
matchedField = field;
matched = true;
break;
}
}
if (!matched) {
result.fact_other += amount;
matchedField = 'fact_other';
}
if (matchedField) {
if (!Array.isArray(breakdown[matchedField])) breakdown[matchedField] = [];
breakdown[matchedField].push({
amount,
description: scopedLabel || originalDescription,
category: cat ? cat.name : (parentCat ? parentCat.name : ''),
date: t.date || t.created_at || '',
});
}
});
return {
totals: result,
breakdown,
};
},
_buildImportData(order, deal, txns) {
const scopedTxns = this._scopeTransactionsToOrder(txns, order);
const outcomes = scopedTxns.filter(t => t.group === 'outcome');
const incomes = scopedTxns.filter(t => t.group === 'income');
const mapped = this._mapToFactFields(outcomes);
const factSums = mapped.totals;
const totalIncome = incomes.reduce((s, t) => s + this._effectiveTransactionValue(t), 0);
const totalOutcome = outcomes.reduce((s, t) => s + this._effectiveTransactionValue(t), 0);
const splitApplied = scopedTxns.some(t => t._scoped_mode === 'split' || t._scoped_mode === 'split_zero');
return {
order_id: order.id,
period_start: null,
period_end: null,
fact_salary: factSums.fact_salary,
fact_materials: factSums.fact_materials,
fact_hardware: factSums.fact_hardware,
fact_packaging: factSums.fact_packaging,
fact_delivery: factSums.fact_delivery,
fact_printing: factSums.fact_printing,
fact_molds: factSums.fact_molds,
fact_taxes: factSums.fact_taxes,
fact_commercial: factSums.fact_commercial,
fact_charity: factSums.fact_charity,
fact_other: factSums.fact_other,
fact_total: totalOutcome,
fact_revenue: totalIncome,
raw_data: {
source: 'fintablo_api',
dealId: deal.id,
dealName: deal.name,
txnCount: txns.length,
splitApplied,
field_breakdown: Object.fromEntries(
Object.entries(mapped.breakdown || {}).map(([field, rows]) => [
field,
this._getFieldBreakdownSummary(field, rows),
])
),
},
source: 'api',
};
},
_scopeTransactionsToOrder(txns, order) {
return (txns || []).map(txn => {
const scoped = this._resolveTransactionScope(txn, order);
return {
...txn,
_scoped_value: scoped.value,
_scoped_mode: scoped.mode,
_scoped_original_value: scoped.originalValue,
_scoped_label: scoped.label || '',
};
});
},
_resolveTransactionScope(txn, order) {
const originalValue = this._num(txn && txn.value);
if (!order || originalValue <= 0) {
return { value: originalValue, mode: 'full', originalValue };
}
// Income belongs to the attached deal as a whole. Split parsing is only
// reliable for shared expenses; applying it to income can zero out the
// deal revenue even when the money is attached correctly in FinTablo.
if (String(txn?.group || '') === 'income') {
return { value: originalValue, mode: 'full', originalValue };
}
const allocations = this._extractSplitAllocations(txn && txn.description);
if (!allocations.length) {
return { value: originalValue, mode: 'full', originalValue };
}
const matched = this._findAllocationForOrder(order.order_name, allocations);
if (matched) {
return {
value: matched.amount,
mode: 'split',
originalValue,
label: matched.label,
};
}
return { value: 0, mode: 'split_zero', originalValue };
},
_extractSplitAllocations(description) {
const text = String(description || '');
const regex = /(\d[\d\s]*(?:[.,]\d+)?)\s*(?:\([^)]*\))?\s*[-–—]\s*(.*?)(?=(?:\s+\d[\d\s]*(?:[.,]\d+)?\s*(?:\([^)]*\))?\s*[-–—])|$)/g;
const allocations = [];
let match;
while ((match = regex.exec(text)) !== null) {
const amount = this._parseMoney(match[1]);
const label = String(match[2] || '').trim();
if (amount > 0 && label) {
allocations.push({ amount, label });
}
}
return allocations.length >= 2 ? allocations : [];
},
_findAllocationForOrder(orderName, allocations) {
const orderTokens = this._tokenizeMatchText(orderName);
const orderNorm = this._normalizeMatchText(orderName);
if (!orderTokens.length || !orderNorm) return null;
let best = null;
allocations.forEach(allocation => {
const labelNorm = this._normalizeMatchText(allocation.label);
const labelTokens = this._tokenizeMatchText(allocation.label);
if (!labelNorm || !labelTokens.length) return;
let overlap = 0;
orderTokens.forEach(orderToken => {
const hasToken = labelTokens.some(labelToken =>
labelToken === orderToken
|| labelToken.startsWith(orderToken)
|| orderToken.startsWith(labelToken)
);
if (hasToken) overlap += 1;
});
const phraseMatch = labelNorm.includes(orderNorm) || orderNorm.includes(labelNorm);
const minOverlap = orderTokens.length >= 2 ? 2 : 1;
if (!phraseMatch && overlap < minOverlap) return;
const score = overlap + (phraseMatch ? 1 : 0);
if (!best || score > best.score || (score === best.score && allocation.amount > best.amount)) {
best = { ...allocation, score };
}
});
return best;
},
_normalizeMatchText(value) {
return String(value || '')
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[^a-zа-я0-9]+/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
},
_tokenizeMatchText(value) {
const stopWords = new Set(['и', 'в', 'во', 'на', 'по', 'для', 'без', 'от', 'из', 'с', 'со', 'над', 'под']);
const tokens = this._normalizeMatchText(value)
.split(' ')
.map(token => this._stemMatchToken(token))
.filter(token => token && token.length >= 2 && !stopWords.has(token));
return Array.from(new Set(tokens));
},
_stemMatchToken(token) {
let stem = String(token || '');
const suffixes = ['иями', 'ями', 'ами', 'ов', 'ев', 'ей', 'ий', 'ый', 'ой', 'ам', 'ям', 'ах', 'ях', 'ом', 'ем', 'ы', 'и', 'а', 'я', 'о', 'е', 'у', 'ю'];
for (const suffix of suffixes) {
if (stem.length > suffix.length + 2 && stem.endsWith(suffix)) {
stem = stem.slice(0, -suffix.length);
break;
}
}
return stem;
},
_parseMoney(value) {
return parseFloat(String(value || '').replace(/\s+/g, '').replace(',', '.')) || 0;
},
_effectiveTransactionValue(txn) {
if (txn && Object.prototype.hasOwnProperty.call(txn, '_scoped_value')) {
return this._num(txn._scoped_value);
}
return this._num(txn && txn.value);
},
async _syncDealImport(deal, order, txns, opts = {}) {
if (!deal || !order) return null;
const importData = this._buildImportData(order, deal, txns);
const hasAnyMoney = [
importData.fact_salary, importData.fact_materials, importData.fact_hardware, importData.fact_packaging,
importData.fact_delivery, importData.fact_printing, importData.fact_molds, importData.fact_taxes, importData.fact_commercial,
importData.fact_charity,
importData.fact_other, importData.fact_revenue,
].some(v => (parseFloat(v) || 0) > 0);
if (!hasAnyMoney) return null;
const id = await saveFintabloImport(importData);
if (id && !opts.silent) {
App.toast('Данные применены к заказу "' + order.order_name + '"');
}
return id;
},
// =============================================
// Apply data to order (save to fintablo_imports)
// =============================================
async applyToOrder(dealId) {
const deal = this._deals.find(d => d.id === dealId);
const order = this._matchMap[dealId];
if (!deal || !order) {
App.toast('Заказ не найден');
return;
}
try {
// Fetch transactions again (fresh)
const data = await this._apiGet('/v1/transaction', {
dealId: dealId, pageSize: 1000
});
const txns = data.items || [];
const id = await this._syncDealImport(deal, order, txns);
if (id) {
return;
}
App.toast('Нет данных для сохранения');
} catch (err) {
App.toast('Ошибка: ' + err.message);
console.error('applyToOrder error:', err);
}
},
// =============================================
// Utils
// =============================================
_esc(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
},
_num(value) {
const num = Number(value);
return Number.isFinite(num) ? num : 0;
},
};
if (typeof window !== 'undefined') {
window.FinTablo = FinTablo;
}