@fullqueso/mcp-bc-gastos 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ import { round2 } from '../utils/currency-converter.js';
2
+ import { aggregateByCategory } from './expense-analyzer.js';
3
+ import { BENCHMARKS } from '../config/benchmarks.js';
4
+
5
+ export function detectAnomalies(currentData, previousData) {
6
+ const anomalies = [];
7
+
8
+ const currentCats = aggregateByCategory(currentData.expenses);
9
+ const previousCats = previousData ? aggregateByCategory(previousData.expenses) : null;
10
+
11
+ const currentIncome = currentData.totalIncome;
12
+ const previousIncome = previousData ? previousData.totalIncome : null;
13
+
14
+ // 1. Check each category for anomalies
15
+ for (const [key, cat] of Object.entries(currentCats)) {
16
+ const prevCat = previousCats ? previousCats[key] : null;
17
+ const benchmark = BENCHMARKS[key];
18
+ const pctOfIncome = currentIncome > 0 ? (cat.total / currentIncome) * 100 : 0;
19
+
20
+ // Anomaly: significantly above benchmark
21
+ if (benchmark && pctOfIncome > benchmark.alert_high) {
22
+ anomalies.push({
23
+ severity: 'critical',
24
+ icon: '🚨',
25
+ category: cat.name,
26
+ category_key: key,
27
+ type: 'above_benchmark',
28
+ description: `${cat.name} representa ${round2(pctOfIncome)}% de ingresos (umbral crítico: ${benchmark.alert_high}%)`,
29
+ current_value: round2(cat.total),
30
+ current_pct: round2(pctOfIncome),
31
+ threshold: benchmark.alert_high,
32
+ possible_causes: getPossibleCauses(key, 'above_benchmark'),
33
+ recommended_actions: getRecommendedActions(key, 'above_benchmark'),
34
+ });
35
+ } else if (benchmark && pctOfIncome > benchmark.max) {
36
+ anomalies.push({
37
+ severity: 'warning',
38
+ icon: '⚠️',
39
+ category: cat.name,
40
+ category_key: key,
41
+ type: 'above_normal',
42
+ description: `${cat.name} representa ${round2(pctOfIncome)}% de ingresos (rango normal: ${benchmark.min}-${benchmark.max}%)`,
43
+ current_value: round2(cat.total),
44
+ current_pct: round2(pctOfIncome),
45
+ threshold: benchmark.max,
46
+ possible_causes: getPossibleCauses(key, 'above_normal'),
47
+ recommended_actions: getRecommendedActions(key, 'above_normal'),
48
+ });
49
+ }
50
+
51
+ // Anomaly: significant increase vs previous period
52
+ if (prevCat && prevCat.total > 0) {
53
+ const changePct = ((cat.total - prevCat.total) / prevCat.total) * 100;
54
+
55
+ if (changePct > 30) {
56
+ anomalies.push({
57
+ severity: changePct > 50 ? 'critical' : 'warning',
58
+ icon: changePct > 50 ? '🚨' : '⚠️',
59
+ category: cat.name,
60
+ category_key: key,
61
+ type: 'spike',
62
+ description: `${cat.name} aumentó ${round2(changePct)}% vs periodo anterior`,
63
+ current_value: round2(cat.total),
64
+ previous_value: round2(prevCat.total),
65
+ change_pct: round2(changePct),
66
+ change_amount: round2(cat.total - prevCat.total),
67
+ possible_causes: getPossibleCauses(key, 'spike'),
68
+ recommended_actions: getRecommendedActions(key, 'spike'),
69
+ });
70
+ }
71
+
72
+ // Unusual drop (could indicate missed payments)
73
+ if (changePct < -40 && cat.total > 0) {
74
+ anomalies.push({
75
+ severity: 'info',
76
+ icon: 'ℹ️',
77
+ category: cat.name,
78
+ category_key: key,
79
+ type: 'unusual_drop',
80
+ description: `${cat.name} disminuyó ${round2(Math.abs(changePct))}% vs periodo anterior`,
81
+ current_value: round2(cat.total),
82
+ previous_value: round2(prevCat.total),
83
+ change_pct: round2(changePct),
84
+ possible_causes: ['Pago no registrado', 'Cambio de proveedor', 'Reclasificación contable'],
85
+ recommended_actions: ['Verificar que todos los pagos estén registrados', 'Confirmar que no hay facturas pendientes'],
86
+ });
87
+ }
88
+ }
89
+
90
+ // Anomaly: check individual accounts within a category for concentration
91
+ const accountValues = Object.values(cat.accounts);
92
+ if (accountValues.length > 1) {
93
+ const maxAccount = accountValues.reduce((max, a) => a.total > max.total ? a : max);
94
+ const acctConcentration = cat.total > 0 ? (maxAccount.total / cat.total) * 100 : 0;
95
+
96
+ if (acctConcentration > 80 && cat.total > currentIncome * 0.05) {
97
+ anomalies.push({
98
+ severity: 'info',
99
+ icon: 'ℹ️',
100
+ category: cat.name,
101
+ category_key: key,
102
+ type: 'concentration',
103
+ description: `${maxAccount.name} (${maxAccount.accountNumber}) concentra ${round2(acctConcentration)}% de ${cat.name}`,
104
+ account: maxAccount.accountNumber,
105
+ account_name: maxAccount.name,
106
+ amount: round2(maxAccount.total),
107
+ possible_causes: ['Concentración normal para esta categoría', 'Cuenta cajón para gastos varios'],
108
+ recommended_actions: ['Verificar clasificación de subcuentas'],
109
+ });
110
+ }
111
+ }
112
+ }
113
+
114
+ // 2. Check income anomalies
115
+ if (previousIncome && previousIncome > 0) {
116
+ const incomeChange = ((currentIncome - previousIncome) / previousIncome) * 100;
117
+ if (incomeChange < -20) {
118
+ anomalies.push({
119
+ severity: 'critical',
120
+ icon: '🚨',
121
+ category: 'Ingresos',
122
+ category_key: 'income',
123
+ type: 'income_drop',
124
+ description: `Ingresos cayeron ${round2(Math.abs(incomeChange))}% vs periodo anterior`,
125
+ current_value: round2(currentIncome),
126
+ previous_value: round2(previousIncome),
127
+ change_pct: round2(incomeChange),
128
+ possible_causes: ['Baja en ventas', 'Días no operativos', 'Estacionalidad'],
129
+ recommended_actions: ['Revisar análisis de ventas detallado', 'Comparar con tendencia del mercado'],
130
+ });
131
+ }
132
+ }
133
+
134
+ // 3. Check operating margin
135
+ if (currentData.operatingMarginPct < 35) {
136
+ anomalies.push({
137
+ severity: 'critical',
138
+ icon: '🚨',
139
+ category: 'Margen Operativo',
140
+ category_key: 'margin',
141
+ type: 'low_margin',
142
+ description: `Margen operativo en ${round2(currentData.operatingMarginPct)}% (mínimo recomendado: 40%)`,
143
+ current_value: round2(currentData.operatingMarginPct),
144
+ threshold: 40,
145
+ possible_causes: ['Gastos excesivos', 'Caída de ingresos', 'Combinación de ambos'],
146
+ recommended_actions: ['Revisar las categorías de gasto más altas', 'Evaluar oportunidades de incremento de ingresos'],
147
+ });
148
+ }
149
+
150
+ // Sort by severity
151
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
152
+ anomalies.sort((a, b) => (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3));
153
+
154
+ return {
155
+ store: currentData.storeName,
156
+ store_code: currentData.storeCode,
157
+ period: currentData.period,
158
+ total_anomalies: anomalies.length,
159
+ critical: anomalies.filter((a) => a.severity === 'critical').length,
160
+ warnings: anomalies.filter((a) => a.severity === 'warning').length,
161
+ info: anomalies.filter((a) => a.severity === 'info').length,
162
+ anomalies,
163
+ };
164
+ }
165
+
166
+ function getPossibleCauses(categoryKey, anomalyType) {
167
+ const causes = {
168
+ nomina: {
169
+ above_benchmark: ['Incremento salarial reciente', 'Contrataciones nuevas', 'Pago de utilidades o prestaciones'],
170
+ above_normal: ['Horas extra elevadas', 'Bonificaciones especiales'],
171
+ spike: ['Pago de liquidaciones', 'Retroactivos', 'Ajuste salarial'],
172
+ },
173
+ planta_fisica: {
174
+ above_benchmark: ['Aumento de alquiler', 'Facturas de servicios atrasadas', 'Mantenimiento extraordinario'],
175
+ above_normal: ['Temporada de alto consumo eléctrico', 'Reparaciones'],
176
+ spike: ['Factura acumulada', 'Renovación de contrato', 'Reparación mayor'],
177
+ },
178
+ marketing: {
179
+ above_benchmark: ['Campaña publicitaria especial', 'Lanzamiento de producto'],
180
+ above_normal: ['Inversión en redes sociales', 'Evento promocional'],
181
+ spike: ['Campaña de temporada', 'Inversión puntual en publicidad'],
182
+ },
183
+ logistica: {
184
+ above_benchmark: ['Aumento de precio combustible', 'Reparación de vehículo'],
185
+ above_normal: ['Mayor volumen de delivery', 'Mantenimiento vehicular'],
186
+ spike: ['Reparación mayor', 'Compra de repuestos'],
187
+ },
188
+ default: {
189
+ above_benchmark: ['Gasto inusual en esta categoría', 'Posible reclasificación contable'],
190
+ above_normal: ['Incremento gradual', 'Nuevo proveedor más costoso'],
191
+ spike: ['Pago acumulado', 'Gasto extraordinario'],
192
+ },
193
+ };
194
+
195
+ return (causes[categoryKey] || causes.default)[anomalyType] || causes.default[anomalyType];
196
+ }
197
+
198
+ function getRecommendedActions(categoryKey, anomalyType) {
199
+ const actions = {
200
+ nomina: {
201
+ above_benchmark: ['Revisar estructura de personal', 'Evaluar automatización de procesos', 'Comparar con otras tiendas'],
202
+ above_normal: ['Controlar horas extra', 'Revisar productividad por empleado'],
203
+ spike: ['Verificar pagos especiales', 'Confirmar que no hay duplicados'],
204
+ },
205
+ planta_fisica: {
206
+ above_benchmark: ['Negociar renovación de alquiler', 'Buscar eficiencia energética', 'Comparar tarifas de servicios'],
207
+ above_normal: ['Implementar ahorro energético', 'Revisar contratos de mantenimiento'],
208
+ spike: ['Verificar facturación correcta', 'Revisar si es gasto recurrente o puntual'],
209
+ },
210
+ marketing: {
211
+ above_benchmark: ['Medir ROI de campañas', 'Priorizar canales más efectivos'],
212
+ above_normal: ['Evaluar efectividad de campañas activas'],
213
+ spike: ['Verificar resultados de la campaña', 'Comparar costo vs incremento en ventas'],
214
+ },
215
+ default: {
216
+ above_benchmark: ['Revisar detalle de transacciones', 'Buscar alternativas más económicas'],
217
+ above_normal: ['Monitorear tendencia', 'Solicitar cotizaciones competitivas'],
218
+ spike: ['Investigar causa del incremento', 'Verificar clasificación contable correcta'],
219
+ },
220
+ };
221
+
222
+ return (actions[categoryKey] || actions.default)[anomalyType] || actions.default[anomalyType];
223
+ }
@@ -0,0 +1,361 @@
1
+ import { logger } from '../utils/logger.js';
2
+ import { getExpenseCategory } from '../config/expense-accounts.js';
3
+ import { resolveStores } from '../config/company-config.js';
4
+
5
+ export class BCClient {
6
+ constructor() {
7
+ this.token = null;
8
+ this.tokenExpiry = null;
9
+ this.baseUrl = process.env.BC_API_BASE;
10
+ this.tenantId = process.env.BC_TENANT_ID;
11
+ this.environment = process.env.BC_ENVIRONMENT;
12
+ }
13
+
14
+ async getToken() {
15
+ if (this.token && this.tokenExpiry > Date.now()) {
16
+ return this.token;
17
+ }
18
+
19
+ logger.info('Fetching new BC access token...');
20
+ const response = await this.fetchWithRetry(process.env.BC_TOKEN_URL, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
23
+ body: new URLSearchParams({
24
+ client_id: process.env.BC_CLIENT_ID,
25
+ client_secret: process.env.BC_CLIENT_SECRET,
26
+ scope: process.env.BC_SCOPE,
27
+ grant_type: 'client_credentials',
28
+ }),
29
+ });
30
+
31
+ if (!response.ok) {
32
+ const errorText = await response.text();
33
+ throw new Error(`Token request failed (${response.status}): ${errorText}`);
34
+ }
35
+
36
+ const data = await response.json();
37
+ this.token = data.access_token;
38
+ this.tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
39
+ logger.info('Token obtained successfully');
40
+ return this.token;
41
+ }
42
+
43
+ buildApiUrl(companyId, endpoint, params = {}) {
44
+ const base = `${this.baseUrl}/${this.tenantId}/${this.environment}/api/v2.0/companies(${companyId})/${endpoint}`;
45
+ const searchParams = new URLSearchParams();
46
+
47
+ if (params.$filter) searchParams.set('$filter', params.$filter);
48
+ if (params.$top) searchParams.set('$top', String(params.$top));
49
+ if (params.$orderby) searchParams.set('$orderby', params.$orderby);
50
+ if (params.$select) searchParams.set('$select', params.$select);
51
+
52
+ const qs = searchParams.toString();
53
+ return qs ? `${base}?${qs}` : base;
54
+ }
55
+
56
+ async apiCall(url) {
57
+ const token = await this.getToken();
58
+ logger.debug(`API call: ${url}`);
59
+
60
+ const response = await this.fetchWithRetry(url, {
61
+ headers: { Authorization: `Bearer ${token}` },
62
+ });
63
+
64
+ if (!response.ok) {
65
+ const errorText = await response.text();
66
+ throw new Error(`BC API Error (${response.status}): ${errorText}`);
67
+ }
68
+
69
+ const data = await response.json();
70
+ return data.value || data;
71
+ }
72
+
73
+ async fetchWithRetry(url, options, retries = 3) {
74
+ for (let attempt = 1; attempt <= retries; attempt++) {
75
+ try {
76
+ return await fetch(url, options);
77
+ } catch (err) {
78
+ if (attempt === retries) throw err;
79
+ const delay = attempt * 2000;
80
+ logger.warn(`Fetch failed (attempt ${attempt}/${retries}), retrying in ${delay}ms...`);
81
+ await new Promise((r) => setTimeout(r, delay));
82
+ }
83
+ }
84
+ }
85
+
86
+ async apiCallAllPages(url) {
87
+ const token = await this.getToken();
88
+ let allResults = [];
89
+ let nextUrl = url;
90
+
91
+ while (nextUrl) {
92
+ logger.debug(`API call (paged): ${nextUrl}`);
93
+ const response = await this.fetchWithRetry(nextUrl, {
94
+ headers: { Authorization: `Bearer ${token}` },
95
+ });
96
+
97
+ if (!response.ok) {
98
+ const errorText = await response.text();
99
+ throw new Error(`BC API Error (${response.status}): ${errorText}`);
100
+ }
101
+
102
+ const data = await response.json();
103
+ allResults = allResults.concat(data.value || []);
104
+ nextUrl = data['@odata.nextLink'] || null;
105
+ }
106
+
107
+ return allResults;
108
+ }
109
+
110
+ // Fetch General Ledger Entries for a specific account range
111
+ async getGLEntries(companyId, startDate, endDate, accountMin, accountMax) {
112
+ const url = this.buildApiUrl(companyId, 'generalLedgerEntries', {
113
+ $filter: `postingDate ge ${startDate} and postingDate le ${endDate} and accountNumber ge '${accountMin}' and accountNumber le '${accountMax}'`,
114
+ $select: 'entryNumber,postingDate,documentNumber,accountNumber,description,debitAmount,creditAmount',
115
+ $orderby: 'postingDate desc',
116
+ });
117
+ return this.apiCallAllPages(url);
118
+ }
119
+
120
+ // Fetch revenue entries (40000-49999) - credit balance = income
121
+ async getRevenueEntries(companyId, startDate, endDate) {
122
+ return this.getGLEntries(companyId, startDate, endDate, '40000', '49999');
123
+ }
124
+
125
+ // Fetch COGS entries (50000-59999) - debit balance = cost of goods sold
126
+ async getCOGSEntries(companyId, startDate, endDate) {
127
+ return this.getGLEntries(companyId, startDate, endDate, '50000', '59999');
128
+ }
129
+
130
+ // Fetch expense entries (60000-99999) - debit balance = operating expenses
131
+ async getExpenseEntries(companyId, startDate, endDate) {
132
+ return this.getGLEntries(companyId, startDate, endDate, '60000', '99999');
133
+ }
134
+
135
+ // Fetch all financial data for a store
136
+ async getStoreFinancialData(storeCode, startDate, endDate) {
137
+ const stores = resolveStores([storeCode]);
138
+ const store = stores[0];
139
+
140
+ logger.info(`Fetching financial data for ${store.name}: ${startDate} to ${endDate}`);
141
+
142
+ const [revenueEntries, cogsEntries, expenseEntries] = await Promise.all([
143
+ this.getRevenueEntries(store.companyId, startDate, endDate),
144
+ this.getCOGSEntries(store.companyId, startDate, endDate),
145
+ this.getExpenseEntries(store.companyId, startDate, endDate),
146
+ ]);
147
+
148
+ // Revenue (40000-49999): credit balance accounts, income = credit - debit
149
+ const totalRevenue = revenueEntries.reduce(
150
+ (sum, e) => sum + ((e.creditAmount || 0) - (e.debitAmount || 0)),
151
+ 0
152
+ );
153
+
154
+ // COGS (50000-59999): debit balance accounts, cost = debit - credit
155
+ const totalCOGS = cogsEntries.reduce(
156
+ (sum, e) => sum + ((e.debitAmount || 0) - (e.creditAmount || 0)),
157
+ 0
158
+ );
159
+
160
+ // Categorize expense entries (60000-99999)
161
+ const categorizedExpenses = expenseEntries.map((entry) => {
162
+ const category = getExpenseCategory(entry.accountNumber);
163
+ return {
164
+ ...entry,
165
+ categoryKey: category?.key || 'otros',
166
+ categoryName: category?.name || 'Otros Gastos',
167
+ categoryIcon: category?.icon || '📦',
168
+ // Expenses are debit entries; net = debit - credit
169
+ expenseAmount: (entry.debitAmount || 0) - (entry.creditAmount || 0),
170
+ };
171
+ });
172
+
173
+ const totalExpenses = categorizedExpenses.reduce((sum, e) => sum + e.expenseAmount, 0);
174
+
175
+ // Financial summary
176
+ const grossMargin = totalRevenue - totalCOGS;
177
+ const grossMarginPct = totalRevenue > 0 ? (grossMargin / totalRevenue) * 100 : 0;
178
+ const operatingMargin = totalRevenue - totalCOGS - totalExpenses;
179
+ const operatingMarginPct = totalRevenue > 0 ? (operatingMargin / totalRevenue) * 100 : 0;
180
+
181
+ return {
182
+ storeCode: store.code,
183
+ storeName: store.name,
184
+ period: { start: startDate, end: endDate },
185
+ expenses: categorizedExpenses,
186
+ totalRevenue,
187
+ totalCOGS,
188
+ grossMargin,
189
+ grossMarginPct,
190
+ totalExpenses,
191
+ // totalIncome = totalRevenue (for backward compat with analyzers)
192
+ totalIncome: totalRevenue,
193
+ operatingMargin,
194
+ operatingMarginPct,
195
+ };
196
+ }
197
+
198
+ // Fetch financial data for all requested stores
199
+ async getAllStoresFinancialData(storeCodes, startDate, endDate) {
200
+ const stores = resolveStores(storeCodes);
201
+ const results = {};
202
+
203
+ // Fetch stores sequentially to avoid BC API rate limiting
204
+ for (const store of stores) {
205
+ results[store.code] = await this.getStoreFinancialData(store.code, startDate, endDate);
206
+ }
207
+
208
+ return results;
209
+ }
210
+
211
+ // Fetch exchange rates
212
+ async getExchangeRates(storeCode) {
213
+ const stores = resolveStores([storeCode]);
214
+ const url = this.buildApiUrl(stores[0].companyId, 'currencyExchangeRates', {
215
+ $filter: `currencyCode eq 'VES'`,
216
+ $orderby: 'startingDate desc',
217
+ });
218
+ return this.apiCallAllPages(url);
219
+ }
220
+
221
+ getExchangeRateForDate(rates, targetDate) {
222
+ for (const rate of rates) {
223
+ if (rate.startingDate <= targetDate) {
224
+ return {
225
+ rate: rate.exchangeRateAmount / rate.relationalExchangeRateAmount,
226
+ startingDate: rate.startingDate,
227
+ };
228
+ }
229
+ }
230
+ if (rates.length > 0) {
231
+ const oldest = rates[rates.length - 1];
232
+ return {
233
+ rate: oldest.exchangeRateAmount / oldest.relationalExchangeRateAmount,
234
+ startingDate: oldest.startingDate,
235
+ };
236
+ }
237
+ return null;
238
+ }
239
+
240
+ // Fetch posted purchase invoices (includes vendorName directly)
241
+ async getPurchaseInvoices(companyId, startDate, endDate) {
242
+ const url = this.buildApiUrl(companyId, 'purchaseInvoices', {
243
+ $filter: `invoiceDate ge ${startDate} and invoiceDate le ${endDate}`,
244
+ $select: 'number,vendorNumber,vendorName',
245
+ });
246
+ return this.apiCallAllPages(url);
247
+ }
248
+
249
+ // Fetch vendor ledger entries to map documentNumber -> vendor info
250
+ async getVendorLedgerEntries(companyId, startDate, endDate) {
251
+ const url = this.buildApiUrl(companyId, 'vendorLedgerEntries', {
252
+ $filter: `postingDate ge ${startDate} and postingDate le ${endDate}`,
253
+ $select: 'documentNumber,vendorNumber,description',
254
+ $orderby: 'postingDate desc',
255
+ });
256
+ return this.apiCallAllPages(url);
257
+ }
258
+
259
+ // Fetch vendors list for name lookup
260
+ async getVendors(companyId) {
261
+ const url = this.buildApiUrl(companyId, 'vendors', {
262
+ $select: 'number,displayName',
263
+ });
264
+ return this.apiCallAllPages(url);
265
+ }
266
+
267
+ // Build a map of documentNumber -> { vendor_number, vendor_name }
268
+ // Strategy: purchaseInvoices first (has vendorName directly), then vendorLedgerEntries fallback
269
+ // Resilient: returns empty map if all vendor endpoints are unavailable
270
+ async buildVendorMap(companyId, startDate, endDate) {
271
+ // Strategy 1: purchaseInvoices — includes posted invoices with vendorName
272
+ try {
273
+ const invoices = await this.getPurchaseInvoices(companyId, startDate, endDate);
274
+ if (invoices.length > 0) {
275
+ const map = {};
276
+ for (const inv of invoices) {
277
+ if (inv.number && !map[inv.number]) {
278
+ map[inv.number] = {
279
+ vendor_number: inv.vendorNumber || null,
280
+ vendor_name: inv.vendorName || inv.vendorNumber || null,
281
+ };
282
+ }
283
+ }
284
+ logger.info(`Vendor map built from purchaseInvoices: ${Object.keys(map).length} documents mapped`);
285
+ return map;
286
+ }
287
+ logger.info('purchaseInvoices returned 0 results, trying vendorLedgerEntries...');
288
+ } catch (err) {
289
+ logger.warn(`purchaseInvoices failed (${err.message}), trying vendorLedgerEntries...`);
290
+ }
291
+
292
+ // Strategy 2: vendorLedgerEntries + vendors (cross-reference)
293
+ try {
294
+ const [vendorEntries, vendors] = await Promise.all([
295
+ this.getVendorLedgerEntries(companyId, startDate, endDate),
296
+ this.getVendors(companyId),
297
+ ]);
298
+
299
+ const vendorNames = {};
300
+ for (const v of vendors) {
301
+ vendorNames[v.number] = v.displayName;
302
+ }
303
+
304
+ const map = {};
305
+ for (const entry of vendorEntries) {
306
+ if (entry.documentNumber && !map[entry.documentNumber]) {
307
+ map[entry.documentNumber] = {
308
+ vendor_number: entry.vendorNumber,
309
+ vendor_name: vendorNames[entry.vendorNumber] || entry.vendorNumber,
310
+ };
311
+ }
312
+ }
313
+
314
+ logger.info(`Vendor map built from vendorLedgerEntries: ${Object.keys(map).length} documents mapped`);
315
+ return map;
316
+ } catch (err) {
317
+ logger.warn('All vendor lookup strategies failed, continuing without vendor info:', err.message);
318
+ return {};
319
+ }
320
+ }
321
+
322
+ // Fetch detailed GL entries with flexible filters for drill-down
323
+ async getDetailedGLEntries(companyId, filters = {}) {
324
+ const conditions = [];
325
+
326
+ if (filters.startDate) conditions.push(`postingDate ge ${filters.startDate}`);
327
+ if (filters.endDate) conditions.push(`postingDate le ${filters.endDate}`);
328
+ if (filters.accountNumber) {
329
+ conditions.push(`accountNumber eq '${filters.accountNumber}'`);
330
+ } else if (filters.accountMin && filters.accountMax) {
331
+ conditions.push(`accountNumber ge '${filters.accountMin}'`);
332
+ conditions.push(`accountNumber le '${filters.accountMax}'`);
333
+ }
334
+
335
+ const params = {
336
+ $filter: conditions.length > 0 ? conditions.join(' and ') : undefined,
337
+ $select: 'entryNumber,postingDate,documentNumber,accountNumber,description,debitAmount,creditAmount',
338
+ $orderby: 'postingDate desc',
339
+ };
340
+
341
+ if (filters.top) params.$top = String(filters.top);
342
+
343
+ const url = this.buildApiUrl(companyId, 'generalLedgerEntries', params);
344
+ return filters.top ? this.apiCall(url) : this.apiCallAllPages(url);
345
+ }
346
+
347
+ async getCompanies() {
348
+ const url = `${this.baseUrl}/${this.tenantId}/${this.environment}/api/v2.0/companies`;
349
+ return this.apiCall(url);
350
+ }
351
+
352
+ async testConnection() {
353
+ await this.getToken();
354
+ const companies = await this.getCompanies();
355
+ return {
356
+ authenticated: true,
357
+ companiesFound: companies.length,
358
+ companies: companies.map((c) => ({ id: c.id, name: c.name })),
359
+ };
360
+ }
361
+ }