@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.
- package/.env.example +18 -0
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/config/benchmarks.js +135 -0
- package/config/company-config.js +39 -0
- package/config/expense-accounts.js +199 -0
- package/config/income-accounts.js +40 -0
- package/lib/anomaly-detector.js +223 -0
- package/lib/bc-client.js +361 -0
- package/lib/expense-analyzer.js +144 -0
- package/lib/formatter.js +77 -0
- package/lib/ratio-calculator.js +114 -0
- package/lib/trend-analyzer.js +195 -0
- package/package.json +59 -0
- package/server.js +142 -0
- package/tools/account-transactions.js +125 -0
- package/tools/anomaly-detection.js +105 -0
- package/tools/efficiency-ratios.js +81 -0
- package/tools/expense-analysis.js +88 -0
- package/tools/expense-details.js +179 -0
- package/tools/store-comparison.js +179 -0
- package/tools/trends.js +84 -0
- package/utils/currency-converter.js +30 -0
- package/utils/date-helper.js +107 -0
- package/utils/logger.js +18 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { getDateRange } from '../utils/date-helper.js';
|
|
2
|
+
import { getExpenseCategory, getAccountName, EXPENSE_CATEGORIES } from '../config/expense-accounts.js';
|
|
3
|
+
import { resolveStores } from '../config/company-config.js';
|
|
4
|
+
import { round2 } from '../utils/currency-converter.js';
|
|
5
|
+
|
|
6
|
+
export const expenseDetailsTool = {
|
|
7
|
+
name: 'get_expense_details',
|
|
8
|
+
description:
|
|
9
|
+
'Drill-down de transacciones individuales de gastos operacionales. Muestra cada movimiento contable con fecha, documento, descripción, proveedor (vendor), cuenta, monto y categoría. Ideal para investigar el detalle detrás de un resumen de gastos. Montos en USD.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
store: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
enum: ['FQ01', 'FQ28', 'FQ88'],
|
|
16
|
+
description: 'Tienda a consultar.',
|
|
17
|
+
},
|
|
18
|
+
period: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'last_3_months', 'specific_month', 'custom'],
|
|
21
|
+
description: 'Periodo de consulta. Usar "specific_month" con month para un mes específico.',
|
|
22
|
+
default: 'last_month',
|
|
23
|
+
},
|
|
24
|
+
month: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Mes específico en formato YYYY-MM (ej: "2025-12"). Usar con period="specific_month".',
|
|
27
|
+
},
|
|
28
|
+
start_date: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Fecha inicio (YYYY-MM-DD). Requerido si period=custom.',
|
|
31
|
+
},
|
|
32
|
+
end_date: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Fecha fin (YYYY-MM-DD). Requerido si period=custom.',
|
|
35
|
+
},
|
|
36
|
+
category: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
enum: [
|
|
39
|
+
'planta_fisica', 'alquiler_equipos', 'logistica', 'marketing',
|
|
40
|
+
'administrativos', 'seguros', 'bancarios', 'servicios_contratados',
|
|
41
|
+
'nomina', 'otros',
|
|
42
|
+
],
|
|
43
|
+
description: 'Filtrar por categoría de gasto (opcional).',
|
|
44
|
+
},
|
|
45
|
+
account_number: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Filtrar por número de cuenta específico, ej: "68210" (opcional).',
|
|
48
|
+
},
|
|
49
|
+
min_amount: {
|
|
50
|
+
type: 'number',
|
|
51
|
+
description: 'Monto mínimo en USD para filtrar transacciones (opcional).',
|
|
52
|
+
},
|
|
53
|
+
limit: {
|
|
54
|
+
type: 'number',
|
|
55
|
+
description: 'Máximo de transacciones a retornar. Default: 50.',
|
|
56
|
+
default: 50,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
required: ['store'],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export async function handleExpenseDetails(bcClient, args) {
|
|
64
|
+
const store = args.store;
|
|
65
|
+
const period = args.period || 'last_month';
|
|
66
|
+
const limit = args.limit || 50;
|
|
67
|
+
const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
|
|
68
|
+
|
|
69
|
+
const stores = resolveStores([store]);
|
|
70
|
+
const storeInfo = stores[0];
|
|
71
|
+
|
|
72
|
+
// Determine account range filters
|
|
73
|
+
let accountMin = '60000';
|
|
74
|
+
let accountMax = '99999';
|
|
75
|
+
let specificAccount = null;
|
|
76
|
+
|
|
77
|
+
if (args.account_number) {
|
|
78
|
+
specificAccount = args.account_number;
|
|
79
|
+
} else if (args.category) {
|
|
80
|
+
const catConfig = EXPENSE_CATEGORIES[args.category];
|
|
81
|
+
if (!catConfig) {
|
|
82
|
+
throw new Error(`Categoría desconocida: ${args.category}. Opciones: ${Object.keys(EXPENSE_CATEGORIES).join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
accountMin = String(catConfig.accountRange.min);
|
|
85
|
+
accountMax = String(catConfig.accountRange.max);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fetch GL entries
|
|
89
|
+
const filters = {
|
|
90
|
+
startDate: dateRange.start,
|
|
91
|
+
endDate: dateRange.end,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (specificAccount) {
|
|
95
|
+
filters.accountNumber = specificAccount;
|
|
96
|
+
} else {
|
|
97
|
+
filters.accountMin = accountMin;
|
|
98
|
+
filters.accountMax = accountMax;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fetch GL entries (critical) and vendor map (best-effort) in parallel
|
|
102
|
+
// buildVendorMap is resilient — returns {} if vendor endpoints fail
|
|
103
|
+
const [entries, vendorMap] = await Promise.all([
|
|
104
|
+
bcClient.getDetailedGLEntries(storeInfo.companyId, filters),
|
|
105
|
+
bcClient.buildVendorMap(storeInfo.companyId, dateRange.start, dateRange.end),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
// Map entries with category info, vendor info, and compute expense amount
|
|
109
|
+
let transactions = entries.map((entry) => {
|
|
110
|
+
const category = getExpenseCategory(entry.accountNumber);
|
|
111
|
+
const expenseAmount = (entry.debitAmount || 0) - (entry.creditAmount || 0);
|
|
112
|
+
const vendor = vendorMap[entry.documentNumber] || null;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
posting_date: entry.postingDate,
|
|
116
|
+
document_no: entry.documentNumber,
|
|
117
|
+
description: entry.description || '',
|
|
118
|
+
vendor_name: vendor?.vendor_name || null,
|
|
119
|
+
vendor_number: vendor?.vendor_number || null,
|
|
120
|
+
account_number: entry.accountNumber,
|
|
121
|
+
account_name: getAccountName(entry.accountNumber),
|
|
122
|
+
debit: round2(entry.debitAmount || 0),
|
|
123
|
+
credit: round2(entry.creditAmount || 0),
|
|
124
|
+
amount_usd: round2(expenseAmount),
|
|
125
|
+
category: category?.key || 'otros',
|
|
126
|
+
category_name: category?.name || 'Otros Gastos',
|
|
127
|
+
category_icon: category?.icon || '',
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Apply category filter if account_number was not specified but category was
|
|
132
|
+
if (args.category && !args.account_number) {
|
|
133
|
+
transactions = transactions.filter((t) => t.category === args.category);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Apply min_amount filter
|
|
137
|
+
if (args.min_amount) {
|
|
138
|
+
transactions = transactions.filter((t) => Math.abs(t.amount_usd) >= args.min_amount);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const totalBeforeLimit = transactions.length;
|
|
142
|
+
|
|
143
|
+
// Apply limit
|
|
144
|
+
transactions = transactions.slice(0, limit);
|
|
145
|
+
|
|
146
|
+
// Summary
|
|
147
|
+
const totalAmount = transactions.reduce((sum, t) => sum + t.amount_usd, 0);
|
|
148
|
+
|
|
149
|
+
// Exchange rate
|
|
150
|
+
let exchangeRate = null;
|
|
151
|
+
try {
|
|
152
|
+
const rates = await bcClient.getExchangeRates(store);
|
|
153
|
+
exchangeRate = bcClient.getExchangeRateForDate(rates, dateRange.end);
|
|
154
|
+
} catch (_) {
|
|
155
|
+
// optional
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
store: storeInfo.name,
|
|
160
|
+
store_code: store,
|
|
161
|
+
period: { start: dateRange.start, end: dateRange.end, label: period },
|
|
162
|
+
filters_applied: {
|
|
163
|
+
category: args.category || null,
|
|
164
|
+
account_number: args.account_number || null,
|
|
165
|
+
min_amount: args.min_amount || null,
|
|
166
|
+
},
|
|
167
|
+
exchange_rate: exchangeRate ? {
|
|
168
|
+
rate: round2(exchangeRate.rate),
|
|
169
|
+
label: `1 USD = ${round2(exchangeRate.rate)} VES`,
|
|
170
|
+
} : null,
|
|
171
|
+
summary: {
|
|
172
|
+
total_transactions: totalBeforeLimit,
|
|
173
|
+
showing: transactions.length,
|
|
174
|
+
total_amount_usd: round2(totalAmount),
|
|
175
|
+
total_amount_ves: exchangeRate ? round2(totalAmount * exchangeRate.rate) : null,
|
|
176
|
+
},
|
|
177
|
+
transactions,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { getDateRange } from '../utils/date-helper.js';
|
|
2
|
+
import { analyzeExpenses, aggregateByCategory } from '../lib/expense-analyzer.js';
|
|
3
|
+
import { calculateRatios } from '../lib/ratio-calculator.js';
|
|
4
|
+
import { round2 } from '../utils/currency-converter.js';
|
|
5
|
+
|
|
6
|
+
export const storeComparisonTool = {
|
|
7
|
+
name: 'compare_stores',
|
|
8
|
+
description:
|
|
9
|
+
'Compara la eficiencia de gastos entre las tiendas de Full Queso (FQ01 Chacao, FQ28 Marqués, FQ88 Candelaria). Ranking por eficiencia, varianzas entre tiendas y oportunidades de ahorro identificadas.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
period: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'last_3_months', 'specific_month', 'custom'],
|
|
16
|
+
description: 'Periodo de comparación.',
|
|
17
|
+
default: 'last_month',
|
|
18
|
+
},
|
|
19
|
+
month: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Mes específico en formato YYYY-MM. Usar con period="specific_month".',
|
|
22
|
+
},
|
|
23
|
+
start_date: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Fecha inicio (YYYY-MM-DD). Requerido si period=custom',
|
|
26
|
+
},
|
|
27
|
+
end_date: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Fecha fin (YYYY-MM-DD). Requerido si period=custom',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function handleStoreComparison(bcClient, args) {
|
|
36
|
+
const period = args.period || 'last_month';
|
|
37
|
+
const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
|
|
38
|
+
|
|
39
|
+
// Fetch all 3 stores
|
|
40
|
+
const allStoresData = await bcClient.getAllStoresFinancialData(['all'], dateRange.start, dateRange.end);
|
|
41
|
+
|
|
42
|
+
// Exchange rate
|
|
43
|
+
const firstStoreCode = Object.keys(allStoresData)[0];
|
|
44
|
+
let exchangeRate = null;
|
|
45
|
+
try {
|
|
46
|
+
const rates = await bcClient.getExchangeRates(firstStoreCode);
|
|
47
|
+
exchangeRate = bcClient.getExchangeRateForDate(rates, dateRange.end);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// Optional
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Analyze each store
|
|
53
|
+
const storeAnalyses = {};
|
|
54
|
+
const storeRatios = {};
|
|
55
|
+
for (const [code, storeData] of Object.entries(allStoresData)) {
|
|
56
|
+
storeAnalyses[code] = analyzeExpenses(storeData);
|
|
57
|
+
storeRatios[code] = calculateRatios(storeData);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build comparison table
|
|
61
|
+
const comparison = Object.entries(storeAnalyses).map(([code, analysis]) => ({
|
|
62
|
+
store_code: code,
|
|
63
|
+
store_name: analysis.store,
|
|
64
|
+
total_revenue: analysis.total_revenue,
|
|
65
|
+
total_cogs: analysis.total_cogs,
|
|
66
|
+
gross_margin_pct: analysis.gross_margin_pct,
|
|
67
|
+
total_income: analysis.total_revenue,
|
|
68
|
+
total_expenses: analysis.total_expenses,
|
|
69
|
+
operating_margin: analysis.operating_margin,
|
|
70
|
+
operating_margin_pct: analysis.operating_margin_pct,
|
|
71
|
+
margin_status: analysis.margin_status,
|
|
72
|
+
categories: analysis.categories.reduce((obj, cat) => {
|
|
73
|
+
obj[cat.category] = {
|
|
74
|
+
amount: cat.total,
|
|
75
|
+
pct_of_income: cat.pct_of_income,
|
|
76
|
+
benchmark_status: cat.benchmark_status,
|
|
77
|
+
};
|
|
78
|
+
return obj;
|
|
79
|
+
}, {}),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// Ranking by efficiency (highest margin = most efficient)
|
|
83
|
+
const ranking = [...comparison].sort((a, b) => b.operating_margin_pct - a.operating_margin_pct);
|
|
84
|
+
|
|
85
|
+
// Identify savings opportunities (compare worst vs best in each category)
|
|
86
|
+
const savingsOpportunities = identifySavingsOpportunities(comparison, allStoresData);
|
|
87
|
+
|
|
88
|
+
// Cross-store insights
|
|
89
|
+
const crossInsights = generateCrossStoreInsights(comparison, ranking);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
period: { start: dateRange.start, end: dateRange.end, label: period },
|
|
93
|
+
exchange_rate: exchangeRate ? {
|
|
94
|
+
rate: Math.round(exchangeRate.rate * 100) / 100,
|
|
95
|
+
label: `1 USD = ${Math.round(exchangeRate.rate * 100) / 100} VES`,
|
|
96
|
+
} : null,
|
|
97
|
+
ranking,
|
|
98
|
+
comparison,
|
|
99
|
+
savings_opportunities: savingsOpportunities,
|
|
100
|
+
insights: crossInsights,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function identifySavingsOpportunities(comparison, allStoresData) {
|
|
105
|
+
const opportunities = [];
|
|
106
|
+
|
|
107
|
+
// For each expense category, find the store with lowest % of income
|
|
108
|
+
const categoryKeys = new Set();
|
|
109
|
+
for (const store of comparison) {
|
|
110
|
+
for (const key of Object.keys(store.categories)) {
|
|
111
|
+
categoryKeys.add(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const catKey of categoryKeys) {
|
|
116
|
+
const storesWithCat = comparison
|
|
117
|
+
.filter((s) => s.categories[catKey])
|
|
118
|
+
.map((s) => ({
|
|
119
|
+
store: s.store_name,
|
|
120
|
+
store_code: s.store_code,
|
|
121
|
+
pct: s.categories[catKey].pct_of_income,
|
|
122
|
+
amount: s.categories[catKey].amount,
|
|
123
|
+
income: s.total_income,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
if (storesWithCat.length < 2) continue;
|
|
127
|
+
|
|
128
|
+
const best = storesWithCat.reduce((min, s) => s.pct < min.pct ? s : min);
|
|
129
|
+
const worst = storesWithCat.reduce((max, s) => s.pct > max.pct ? s : max);
|
|
130
|
+
|
|
131
|
+
const gap = worst.pct - best.pct;
|
|
132
|
+
if (gap > 2 && worst.amount > 100) {
|
|
133
|
+
// Calculate potential savings if worst matched best's %
|
|
134
|
+
const potentialSaving = worst.income > 0
|
|
135
|
+
? round2(worst.income * (gap / 100))
|
|
136
|
+
: 0;
|
|
137
|
+
|
|
138
|
+
opportunities.push({
|
|
139
|
+
category: catKey,
|
|
140
|
+
best_store: best.store,
|
|
141
|
+
best_pct: best.pct,
|
|
142
|
+
worst_store: worst.store,
|
|
143
|
+
worst_pct: worst.pct,
|
|
144
|
+
gap_pct: round2(gap),
|
|
145
|
+
potential_monthly_saving: potentialSaving,
|
|
146
|
+
recommendation: `Si ${worst.store} alcanza el nivel de ${best.store} en ${catKey}, ahorro potencial de $${potentialSaving.toLocaleString()}/mes`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return opportunities.sort((a, b) => b.potential_monthly_saving - a.potential_monthly_saving);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function generateCrossStoreInsights(comparison, ranking) {
|
|
155
|
+
const insights = [];
|
|
156
|
+
|
|
157
|
+
if (ranking.length > 1) {
|
|
158
|
+
const best = ranking[0];
|
|
159
|
+
const worst = ranking[ranking.length - 1];
|
|
160
|
+
|
|
161
|
+
insights.push({
|
|
162
|
+
type: 'ranking',
|
|
163
|
+
message: `${best.store_name} es la tienda más eficiente con margen operativo de ${best.operating_margin_pct}%`,
|
|
164
|
+
detail: `Diferencia de ${round2(best.operating_margin_pct - worst.operating_margin_pct)} puntos vs ${worst.store_name}`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Total potential savings
|
|
168
|
+
if (worst.operating_margin_pct < best.operating_margin_pct - 5) {
|
|
169
|
+
const potentialIncrease = round2(worst.total_income * ((best.operating_margin_pct - worst.operating_margin_pct) / 100));
|
|
170
|
+
insights.push({
|
|
171
|
+
type: 'opportunity',
|
|
172
|
+
message: `Si ${worst.store_name} iguala la eficiencia de ${best.store_name}, margen adicional de $${potentialIncrease.toLocaleString()}/mes`,
|
|
173
|
+
detail: 'Revisar categorías con mayor diferencia entre tiendas.',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return insights;
|
|
179
|
+
}
|
package/tools/trends.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { getMonthlyRanges } from '../utils/date-helper.js';
|
|
2
|
+
import { analyzeTrends } from '../lib/trend-analyzer.js';
|
|
3
|
+
|
|
4
|
+
export const trendsTool = {
|
|
5
|
+
name: 'get_trends',
|
|
6
|
+
description:
|
|
7
|
+
'Análisis de tendencias históricas de gastos e ingresos de Full Queso. Muestra evolución mensual de los últimos 3-6 meses, tasas de crecimiento, estacionalidad y visualización de tendencias. Identifica patrones preocupantes o positivos.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
months: {
|
|
12
|
+
type: 'number',
|
|
13
|
+
description: 'Cantidad de meses a analizar (3-12).',
|
|
14
|
+
default: 6,
|
|
15
|
+
},
|
|
16
|
+
store: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
enum: ['FQ01', 'FQ28', 'FQ88'],
|
|
19
|
+
description: 'Tienda a analizar. Tendencias se calculan por tienda individual.',
|
|
20
|
+
default: 'FQ01',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function handleTrends(bcClient, args) {
|
|
27
|
+
const months = Math.min(Math.max(args.months || 6, 3), 12);
|
|
28
|
+
const storeCode = args.store || 'FQ01';
|
|
29
|
+
|
|
30
|
+
// Get monthly date ranges
|
|
31
|
+
const monthlyRanges = getMonthlyRanges(months);
|
|
32
|
+
|
|
33
|
+
// Exchange rate
|
|
34
|
+
let exchangeRate = null;
|
|
35
|
+
try {
|
|
36
|
+
const rates = await bcClient.getExchangeRates(storeCode);
|
|
37
|
+
exchangeRate = bcClient.getExchangeRateForDate(rates, monthlyRanges[monthlyRanges.length - 1].end);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// Optional
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fetch data for each month
|
|
43
|
+
const monthlyData = [];
|
|
44
|
+
for (const range of monthlyRanges) {
|
|
45
|
+
try {
|
|
46
|
+
const storeData = await bcClient.getStoreFinancialData(storeCode, range.start, range.end);
|
|
47
|
+
monthlyData.push({
|
|
48
|
+
label: range.label,
|
|
49
|
+
labelEs: range.labelEs,
|
|
50
|
+
storeData,
|
|
51
|
+
});
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// Skip months with no data
|
|
54
|
+
monthlyData.push({
|
|
55
|
+
label: range.label,
|
|
56
|
+
labelEs: range.labelEs,
|
|
57
|
+
storeData: {
|
|
58
|
+
storeCode,
|
|
59
|
+
storeName: storeCode,
|
|
60
|
+
period: { start: range.start, end: range.end },
|
|
61
|
+
expenses: [],
|
|
62
|
+
income: [],
|
|
63
|
+
totalIncome: 0,
|
|
64
|
+
totalExpenses: 0,
|
|
65
|
+
operatingMargin: 0,
|
|
66
|
+
operatingMarginPct: 0,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Analyze trends
|
|
73
|
+
const trends = analyzeTrends(monthlyData);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
store: storeCode,
|
|
77
|
+
months_analyzed: months,
|
|
78
|
+
exchange_rate: exchangeRate ? {
|
|
79
|
+
rate: Math.round(exchangeRate.rate * 100) / 100,
|
|
80
|
+
label: `1 USD = ${Math.round(exchangeRate.rate * 100) / 100} VES`,
|
|
81
|
+
} : null,
|
|
82
|
+
...trends,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Currency Converter - VES/USD
|
|
2
|
+
// Business Central LCY is USD; VES is the foreign currency
|
|
3
|
+
|
|
4
|
+
export function convertUSDtoVES(amountUSD, exchangeRate) {
|
|
5
|
+
if (!exchangeRate || exchangeRate === 0) return null;
|
|
6
|
+
return amountUSD * exchangeRate;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function convertVEStoUSD(amountVES, exchangeRate) {
|
|
10
|
+
if (!exchangeRate || exchangeRate === 0) return null;
|
|
11
|
+
return amountVES / exchangeRate;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatDualCurrency(amountUSD, exchangeRate) {
|
|
15
|
+
if (amountUSD === null || amountUSD === undefined) return 'N/A';
|
|
16
|
+
|
|
17
|
+
const usdStr = `$${Math.abs(amountUSD).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
18
|
+
|
|
19
|
+
if (exchangeRate) {
|
|
20
|
+
const amountVES = amountUSD * exchangeRate;
|
|
21
|
+
const vesStr = `Bs. ${Math.abs(amountVES).toLocaleString('es-VE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
22
|
+
return `${usdStr} (${vesStr})`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return usdStr;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function round2(value) {
|
|
29
|
+
return Math.round(value * 100) / 100;
|
|
30
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
|
|
3
|
+
export function getDateRange(period, startDate, endDate, month) {
|
|
4
|
+
const now = DateTime.now();
|
|
5
|
+
|
|
6
|
+
switch (period) {
|
|
7
|
+
case 'last_7_days':
|
|
8
|
+
return {
|
|
9
|
+
start: now.minus({ days: 7 }).toISODate(),
|
|
10
|
+
end: now.toISODate(),
|
|
11
|
+
};
|
|
12
|
+
case 'last_30_days':
|
|
13
|
+
return {
|
|
14
|
+
start: now.minus({ days: 30 }).toISODate(),
|
|
15
|
+
end: now.toISODate(),
|
|
16
|
+
};
|
|
17
|
+
case 'this_month':
|
|
18
|
+
return {
|
|
19
|
+
start: now.startOf('month').toISODate(),
|
|
20
|
+
end: now.toISODate(),
|
|
21
|
+
};
|
|
22
|
+
case 'last_month': {
|
|
23
|
+
const lastMonth = now.minus({ months: 1 });
|
|
24
|
+
return {
|
|
25
|
+
start: lastMonth.startOf('month').toISODate(),
|
|
26
|
+
end: lastMonth.endOf('month').toISODate(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
case 'last_3_months': {
|
|
30
|
+
return {
|
|
31
|
+
start: now.minus({ months: 3 }).startOf('month').toISODate(),
|
|
32
|
+
end: now.toISODate(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
case 'last_6_months': {
|
|
36
|
+
return {
|
|
37
|
+
start: now.minus({ months: 6 }).startOf('month').toISODate(),
|
|
38
|
+
end: now.toISODate(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
case 'specific_month': {
|
|
42
|
+
if (!month) throw new Error('month es requerido en formato YYYY-MM cuando period es "specific_month"');
|
|
43
|
+
const [year, mon] = month.split('-').map(Number);
|
|
44
|
+
const target = DateTime.fromObject({ year, month: mon });
|
|
45
|
+
return {
|
|
46
|
+
start: target.startOf('month').toISODate(),
|
|
47
|
+
end: target.endOf('month').toISODate(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
case 'custom':
|
|
51
|
+
if (!startDate || !endDate) {
|
|
52
|
+
throw new Error('start_date y end_date son requeridos cuando period es "custom"');
|
|
53
|
+
}
|
|
54
|
+
return { start: startDate, end: endDate };
|
|
55
|
+
default:
|
|
56
|
+
return {
|
|
57
|
+
start: now.minus({ days: 30 }).toISODate(),
|
|
58
|
+
end: now.toISODate(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getPreviousPeriodRange(start, end) {
|
|
64
|
+
const startDT = DateTime.fromISO(start);
|
|
65
|
+
const endDT = DateTime.fromISO(end);
|
|
66
|
+
const days = endDT.diff(startDT, 'days').days;
|
|
67
|
+
|
|
68
|
+
const prevEnd = startDT.minus({ days: 1 });
|
|
69
|
+
const prevStart = prevEnd.minus({ days: Math.round(days) });
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
start: prevStart.toISODate(),
|
|
73
|
+
end: prevEnd.toISODate(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getMonthlyRanges(months = 6) {
|
|
78
|
+
const now = DateTime.now();
|
|
79
|
+
const ranges = [];
|
|
80
|
+
|
|
81
|
+
for (let i = months - 1; i >= 0; i--) {
|
|
82
|
+
const target = now.minus({ months: i });
|
|
83
|
+
const isCurrentMonth = i === 0;
|
|
84
|
+
ranges.push({
|
|
85
|
+
label: target.toFormat('yyyy-MM'),
|
|
86
|
+
labelEs: target.setLocale('es').toFormat('LLLL yyyy'),
|
|
87
|
+
start: target.startOf('month').toISODate(),
|
|
88
|
+
end: isCurrentMonth ? now.toISODate() : target.endOf('month').toISODate(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return ranges;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatCurrency(amount, currency = 'USD') {
|
|
96
|
+
if (amount === null || amount === undefined) return 'N/A';
|
|
97
|
+
if (currency === 'VES') {
|
|
98
|
+
return `Bs. ${amount.toLocaleString('es-VE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
99
|
+
}
|
|
100
|
+
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function formatPercent(value) {
|
|
104
|
+
if (value === null || value === undefined) return 'N/A';
|
|
105
|
+
const sign = value > 0 ? '+' : '';
|
|
106
|
+
return `${sign}${value.toFixed(1)}%`;
|
|
107
|
+
}
|
package/utils/logger.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Logger utility - writes to stderr (MCP convention: stdout is for protocol)
|
|
2
|
+
|
|
3
|
+
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
4
|
+
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1;
|
|
5
|
+
|
|
6
|
+
function log(level, ...args) {
|
|
7
|
+
if (LOG_LEVELS[level] >= currentLevel) {
|
|
8
|
+
const prefix = `[${new Date().toISOString()}] [${level.toUpperCase()}]`;
|
|
9
|
+
console.error(prefix, ...args);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const logger = {
|
|
14
|
+
debug: (...args) => log('debug', ...args),
|
|
15
|
+
info: (...args) => log('info', ...args),
|
|
16
|
+
warn: (...args) => log('warn', ...args),
|
|
17
|
+
error: (...args) => log('error', ...args),
|
|
18
|
+
};
|