@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
package/server.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
// Only load .env if running from source (local development).
|
|
11
|
+
// When installed via npm/npx, env vars come from the MCP client config.
|
|
12
|
+
const envPath = join(__dirname, '.env');
|
|
13
|
+
if (existsSync(envPath)) {
|
|
14
|
+
const dotenv = await import('dotenv');
|
|
15
|
+
dotenv.config({ path: envPath });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import {
|
|
21
|
+
CallToolRequestSchema,
|
|
22
|
+
ListToolsRequestSchema,
|
|
23
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
24
|
+
|
|
25
|
+
import { BCClient } from './lib/bc-client.js';
|
|
26
|
+
import { expenseAnalysisTool, handleExpenseAnalysis } from './tools/expense-analysis.js';
|
|
27
|
+
import { efficiencyRatiosTool, handleEfficiencyRatios } from './tools/efficiency-ratios.js';
|
|
28
|
+
import { storeComparisonTool, handleStoreComparison } from './tools/store-comparison.js';
|
|
29
|
+
import { anomalyDetectionTool, handleAnomalyDetection } from './tools/anomaly-detection.js';
|
|
30
|
+
import { trendsTool, handleTrends } from './tools/trends.js';
|
|
31
|
+
import { expenseDetailsTool, handleExpenseDetails } from './tools/expense-details.js';
|
|
32
|
+
import { accountTransactionsTool, handleAccountTransactions } from './tools/account-transactions.js';
|
|
33
|
+
|
|
34
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
|
|
35
|
+
const bcClient = new BCClient();
|
|
36
|
+
|
|
37
|
+
const server = new Server(
|
|
38
|
+
{
|
|
39
|
+
name: pkg.name,
|
|
40
|
+
version: pkg.version,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
capabilities: {
|
|
44
|
+
tools: {},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// List all available tools
|
|
50
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
51
|
+
return {
|
|
52
|
+
tools: [
|
|
53
|
+
expenseAnalysisTool,
|
|
54
|
+
efficiencyRatiosTool,
|
|
55
|
+
storeComparisonTool,
|
|
56
|
+
anomalyDetectionTool,
|
|
57
|
+
trendsTool,
|
|
58
|
+
expenseDetailsTool,
|
|
59
|
+
accountTransactionsTool,
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Handle tool calls
|
|
65
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
66
|
+
const { name, arguments: args } = request.params;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
let result;
|
|
70
|
+
|
|
71
|
+
switch (name) {
|
|
72
|
+
case 'get_expense_analysis':
|
|
73
|
+
result = await handleExpenseAnalysis(bcClient, args);
|
|
74
|
+
break;
|
|
75
|
+
case 'get_efficiency_ratios':
|
|
76
|
+
result = await handleEfficiencyRatios(bcClient, args);
|
|
77
|
+
break;
|
|
78
|
+
case 'compare_stores':
|
|
79
|
+
result = await handleStoreComparison(bcClient, args);
|
|
80
|
+
break;
|
|
81
|
+
case 'detect_anomalies':
|
|
82
|
+
result = await handleAnomalyDetection(bcClient, args);
|
|
83
|
+
break;
|
|
84
|
+
case 'get_trends':
|
|
85
|
+
result = await handleTrends(bcClient, args);
|
|
86
|
+
break;
|
|
87
|
+
case 'get_expense_details':
|
|
88
|
+
result = await handleExpenseDetails(bcClient, args);
|
|
89
|
+
break;
|
|
90
|
+
case 'get_account_transactions':
|
|
91
|
+
result = await handleAccountTransactions(bcClient, args);
|
|
92
|
+
break;
|
|
93
|
+
default:
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: 'text', text: `Herramienta desconocida: ${name}` }],
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
102
|
+
};
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`Error ejecutando herramienta ${name}:`, error);
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: `Error: ${error.message}`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
isError: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function validateEnv() {
|
|
118
|
+
const required = [
|
|
119
|
+
'BC_API_BASE', 'BC_TENANT_ID', 'BC_ENVIRONMENT',
|
|
120
|
+
'BC_TOKEN_URL', 'BC_CLIENT_ID', 'BC_CLIENT_SECRET', 'BC_SCOPE',
|
|
121
|
+
'BC_COMPANY_FQ01',
|
|
122
|
+
];
|
|
123
|
+
const missing = required.filter((k) => !process.env[k]);
|
|
124
|
+
if (missing.length > 0) {
|
|
125
|
+
console.error(`Missing required environment variables: ${missing.join(', ')}`);
|
|
126
|
+
console.error('Set them in your MCP client config or .env file.');
|
|
127
|
+
console.error('See: https://github.com/Fullqueso/fullqueso-mcp-bc-gastos#installation');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
validateEnv();
|
|
134
|
+
const transport = new StdioServerTransport();
|
|
135
|
+
await server.connect(transport);
|
|
136
|
+
console.error('MCP Full Queso BC Gastos server running on stdio');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
main().catch((error) => {
|
|
140
|
+
console.error('Fatal error:', error);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { getExpenseCategory, getAccountName } from '../config/expense-accounts.js';
|
|
2
|
+
import { resolveStores } from '../config/company-config.js';
|
|
3
|
+
import { round2 } from '../utils/currency-converter.js';
|
|
4
|
+
|
|
5
|
+
export const accountTransactionsTool = {
|
|
6
|
+
name: 'get_account_transactions',
|
|
7
|
+
description:
|
|
8
|
+
'Listado completo de transacciones para una cuenta contable específica con balance running y nombre de proveedor. Ideal para auditar movimientos de una cuenta, ej: "Todas las transacciones de la cuenta 68210 en FQ28 en diciembre 2025". Sin límite de registros. Montos en USD.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
account_number: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
description: 'Número de cuenta contable (requerido), ej: "68210", "71100".',
|
|
15
|
+
},
|
|
16
|
+
store: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
enum: ['FQ01', 'FQ28', 'FQ88'],
|
|
19
|
+
description: 'Tienda a consultar.',
|
|
20
|
+
},
|
|
21
|
+
start_date: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Fecha inicio en formato YYYY-MM-DD.',
|
|
24
|
+
},
|
|
25
|
+
end_date: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Fecha fin en formato YYYY-MM-DD.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ['account_number', 'store', 'start_date', 'end_date'],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function handleAccountTransactions(bcClient, args) {
|
|
35
|
+
const { account_number, store, start_date, end_date } = args;
|
|
36
|
+
|
|
37
|
+
if (!account_number || !store || !start_date || !end_date) {
|
|
38
|
+
throw new Error('Parámetros requeridos: account_number, store, start_date, end_date');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const stores = resolveStores([store]);
|
|
42
|
+
const storeInfo = stores[0];
|
|
43
|
+
|
|
44
|
+
// Fetch GL entries (critical) and vendor map (best-effort) in parallel
|
|
45
|
+
const [entries, vendorMap] = await Promise.all([
|
|
46
|
+
bcClient.getDetailedGLEntries(storeInfo.companyId, {
|
|
47
|
+
startDate: start_date,
|
|
48
|
+
endDate: end_date,
|
|
49
|
+
accountNumber: account_number,
|
|
50
|
+
}),
|
|
51
|
+
bcClient.buildVendorMap(storeInfo.companyId, start_date, end_date),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// Get category and account info
|
|
55
|
+
const category = getExpenseCategory(account_number);
|
|
56
|
+
const accountName = getAccountName(account_number);
|
|
57
|
+
|
|
58
|
+
// Build transactions with running balance and vendor info
|
|
59
|
+
let runningBalance = 0;
|
|
60
|
+
const transactions = entries
|
|
61
|
+
.sort((a, b) => {
|
|
62
|
+
// Sort chronologically ascending for running balance
|
|
63
|
+
if (a.postingDate !== b.postingDate) return a.postingDate.localeCompare(b.postingDate);
|
|
64
|
+
return a.entryNumber - b.entryNumber;
|
|
65
|
+
})
|
|
66
|
+
.map((entry) => {
|
|
67
|
+
const debit = entry.debitAmount || 0;
|
|
68
|
+
const credit = entry.creditAmount || 0;
|
|
69
|
+
const netAmount = debit - credit;
|
|
70
|
+
runningBalance += netAmount;
|
|
71
|
+
const vendor = vendorMap[entry.documentNumber] || null;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
posting_date: entry.postingDate,
|
|
75
|
+
entry_number: entry.entryNumber,
|
|
76
|
+
document_no: entry.documentNumber,
|
|
77
|
+
description: entry.description || '',
|
|
78
|
+
vendor_name: vendor?.vendor_name || null,
|
|
79
|
+
vendor_number: vendor?.vendor_number || null,
|
|
80
|
+
debit: round2(debit),
|
|
81
|
+
credit: round2(credit),
|
|
82
|
+
net_amount: round2(netAmount),
|
|
83
|
+
running_balance: round2(runningBalance),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Exchange rate
|
|
88
|
+
let exchangeRate = null;
|
|
89
|
+
try {
|
|
90
|
+
const rates = await bcClient.getExchangeRates(store);
|
|
91
|
+
exchangeRate = bcClient.getExchangeRateForDate(rates, end_date);
|
|
92
|
+
} catch (_) {
|
|
93
|
+
// optional
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const totalDebit = round2(transactions.reduce((s, t) => s + t.debit, 0));
|
|
97
|
+
const totalCredit = round2(transactions.reduce((s, t) => s + t.credit, 0));
|
|
98
|
+
const totalNet = round2(totalDebit - totalCredit);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
store: storeInfo.name,
|
|
102
|
+
store_code: store,
|
|
103
|
+
account: {
|
|
104
|
+
number: account_number,
|
|
105
|
+
name: accountName,
|
|
106
|
+
category: category?.key || null,
|
|
107
|
+
category_name: category?.name || null,
|
|
108
|
+
category_icon: category?.icon || null,
|
|
109
|
+
},
|
|
110
|
+
period: { start: start_date, end: end_date },
|
|
111
|
+
exchange_rate: exchangeRate ? {
|
|
112
|
+
rate: round2(exchangeRate.rate),
|
|
113
|
+
label: `1 USD = ${round2(exchangeRate.rate)} VES`,
|
|
114
|
+
} : null,
|
|
115
|
+
summary: {
|
|
116
|
+
total_transactions: transactions.length,
|
|
117
|
+
total_debit: totalDebit,
|
|
118
|
+
total_credit: totalCredit,
|
|
119
|
+
net_amount: totalNet,
|
|
120
|
+
net_amount_ves: exchangeRate ? round2(totalNet * exchangeRate.rate) : null,
|
|
121
|
+
final_balance: transactions.length > 0 ? transactions[transactions.length - 1].running_balance : 0,
|
|
122
|
+
},
|
|
123
|
+
transactions,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getDateRange, getPreviousPeriodRange } from '../utils/date-helper.js';
|
|
2
|
+
import { detectAnomalies } from '../lib/anomaly-detector.js';
|
|
3
|
+
|
|
4
|
+
export const anomalyDetectionTool = {
|
|
5
|
+
name: 'detect_anomalies',
|
|
6
|
+
description:
|
|
7
|
+
'Detecta anomalías en los gastos operacionales de Full Queso: gastos por encima de benchmarks, incrementos inusuales vs periodo anterior, concentración excesiva en una cuenta, y alertas de margen. Incluye severidad, causas posibles y acciones recomendadas.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
period: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'last_3_months', 'specific_month', 'custom'],
|
|
14
|
+
description: 'Periodo a analizar para detectar anomalías.',
|
|
15
|
+
default: 'last_month',
|
|
16
|
+
},
|
|
17
|
+
month: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Mes específico en formato YYYY-MM. Usar con period="specific_month".',
|
|
20
|
+
},
|
|
21
|
+
start_date: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Fecha inicio (YYYY-MM-DD). Requerido si period=custom',
|
|
24
|
+
},
|
|
25
|
+
end_date: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Fecha fin (YYYY-MM-DD). Requerido si period=custom',
|
|
28
|
+
},
|
|
29
|
+
stores: {
|
|
30
|
+
type: 'array',
|
|
31
|
+
items: { type: 'string', enum: ['FQ01', 'FQ28', 'FQ88', 'all'] },
|
|
32
|
+
description: 'Tiendas a analizar.',
|
|
33
|
+
default: ['all'],
|
|
34
|
+
},
|
|
35
|
+
sensitivity: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
enum: ['low', 'medium', 'high'],
|
|
38
|
+
description: 'Sensibilidad de detección. "high" detecta más anomalías.',
|
|
39
|
+
default: 'medium',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function handleAnomalyDetection(bcClient, args) {
|
|
46
|
+
const period = args.period || 'last_month';
|
|
47
|
+
const stores = args.stores || ['all'];
|
|
48
|
+
|
|
49
|
+
const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
|
|
50
|
+
const prevRange = getPreviousPeriodRange(dateRange.start, dateRange.end);
|
|
51
|
+
|
|
52
|
+
// Fetch current period
|
|
53
|
+
const currentData = await bcClient.getAllStoresFinancialData(stores, dateRange.start, dateRange.end);
|
|
54
|
+
|
|
55
|
+
// Fetch previous period for comparison
|
|
56
|
+
let previousData = null;
|
|
57
|
+
try {
|
|
58
|
+
previousData = await bcClient.getAllStoresFinancialData(stores, prevRange.start, prevRange.end);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// Previous period data is optional
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Exchange rate
|
|
64
|
+
const firstStoreCode = Object.keys(currentData)[0];
|
|
65
|
+
let exchangeRate = null;
|
|
66
|
+
try {
|
|
67
|
+
const rates = await bcClient.getExchangeRates(firstStoreCode);
|
|
68
|
+
exchangeRate = bcClient.getExchangeRateForDate(rates, dateRange.end);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// Optional
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Detect anomalies for each store
|
|
74
|
+
const results = {};
|
|
75
|
+
let totalAnomalies = 0;
|
|
76
|
+
let totalCritical = 0;
|
|
77
|
+
let totalWarnings = 0;
|
|
78
|
+
|
|
79
|
+
for (const [code, storeData] of Object.entries(currentData)) {
|
|
80
|
+
const prevStoreData = previousData ? previousData[code] : null;
|
|
81
|
+
results[code] = detectAnomalies(storeData, prevStoreData);
|
|
82
|
+
totalAnomalies += results[code].total_anomalies;
|
|
83
|
+
totalCritical += results[code].critical;
|
|
84
|
+
totalWarnings += results[code].warnings;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
period: {
|
|
89
|
+
current: { start: dateRange.start, end: dateRange.end },
|
|
90
|
+
previous: { start: prevRange.start, end: prevRange.end },
|
|
91
|
+
label: period,
|
|
92
|
+
},
|
|
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
|
+
summary: {
|
|
98
|
+
total_anomalies: totalAnomalies,
|
|
99
|
+
critical: totalCritical,
|
|
100
|
+
warnings: totalWarnings,
|
|
101
|
+
status: totalCritical > 0 ? '🚨 Requiere atención inmediata' : totalWarnings > 0 ? '⚠️ Monitorear' : '✅ Sin anomalías significativas',
|
|
102
|
+
},
|
|
103
|
+
by_store: results,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { getDateRange } from '../utils/date-helper.js';
|
|
2
|
+
import { calculateRatios } from '../lib/ratio-calculator.js';
|
|
3
|
+
|
|
4
|
+
export const efficiencyRatiosTool = {
|
|
5
|
+
name: 'get_efficiency_ratios',
|
|
6
|
+
description:
|
|
7
|
+
'Calcula ratios de eficiencia financiera de Full Queso: gastos/ingresos, nómina/ingresos, alquiler/ingresos, servicios/ingresos, marketing/ingresos y margen operativo. Compara contra benchmarks del sector restaurantes/deli.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
period: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'last_3_months', 'specific_month', 'custom'],
|
|
14
|
+
description: 'Periodo de análisis.',
|
|
15
|
+
default: 'last_month',
|
|
16
|
+
},
|
|
17
|
+
month: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Mes específico en formato YYYY-MM. Usar con period="specific_month".',
|
|
20
|
+
},
|
|
21
|
+
start_date: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Fecha inicio (YYYY-MM-DD). Requerido si period=custom',
|
|
24
|
+
},
|
|
25
|
+
end_date: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Fecha fin (YYYY-MM-DD). Requerido si period=custom',
|
|
28
|
+
},
|
|
29
|
+
stores: {
|
|
30
|
+
type: 'array',
|
|
31
|
+
items: { type: 'string', enum: ['FQ01', 'FQ28', 'FQ88', 'all'] },
|
|
32
|
+
description: 'Tiendas a analizar.',
|
|
33
|
+
default: ['all'],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function handleEfficiencyRatios(bcClient, args) {
|
|
40
|
+
const period = args.period || 'last_month';
|
|
41
|
+
const stores = args.stores || ['all'];
|
|
42
|
+
|
|
43
|
+
const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
|
|
44
|
+
|
|
45
|
+
const allStoresData = await bcClient.getAllStoresFinancialData(stores, dateRange.start, dateRange.end);
|
|
46
|
+
|
|
47
|
+
// Exchange rate
|
|
48
|
+
const firstStoreCode = Object.keys(allStoresData)[0];
|
|
49
|
+
let exchangeRate = null;
|
|
50
|
+
try {
|
|
51
|
+
const rates = await bcClient.getExchangeRates(firstStoreCode);
|
|
52
|
+
exchangeRate = bcClient.getExchangeRateForDate(rates, dateRange.end);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// Optional
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const results = {};
|
|
58
|
+
for (const [code, storeData] of Object.entries(allStoresData)) {
|
|
59
|
+
results[code] = calculateRatios(storeData);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Ranking by operating margin
|
|
63
|
+
const ranking = Object.entries(results)
|
|
64
|
+
.map(([code, r]) => ({
|
|
65
|
+
store: r.store,
|
|
66
|
+
store_code: code,
|
|
67
|
+
operating_margin_pct: r.main_ratios.operating_margin.value,
|
|
68
|
+
expense_to_income_pct: r.main_ratios.expense_to_income.value,
|
|
69
|
+
}))
|
|
70
|
+
.sort((a, b) => b.operating_margin_pct - a.operating_margin_pct);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
period: { start: dateRange.start, end: dateRange.end, label: period },
|
|
74
|
+
exchange_rate: exchangeRate ? {
|
|
75
|
+
rate: Math.round(exchangeRate.rate * 100) / 100,
|
|
76
|
+
label: `1 USD = ${Math.round(exchangeRate.rate * 100) / 100} VES`,
|
|
77
|
+
} : null,
|
|
78
|
+
by_store: results,
|
|
79
|
+
efficiency_ranking: ranking,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getDateRange } from '../utils/date-helper.js';
|
|
2
|
+
import { analyzeExpenses } from '../lib/expense-analyzer.js';
|
|
3
|
+
import { formatExpenseAnalysis } from '../lib/formatter.js';
|
|
4
|
+
|
|
5
|
+
export const expenseAnalysisTool = {
|
|
6
|
+
name: 'get_expense_analysis',
|
|
7
|
+
description:
|
|
8
|
+
'Análisis detallado de gastos operacionales de Full Queso por categoría (nómina, alquiler, servicios, marketing, etc.) con números de cuenta específicos. Incluye comparación contra benchmarks del sector y insights accionables. Montos en USD (moneda base BC).',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
period: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'last_3_months', 'specific_month', 'custom'],
|
|
15
|
+
description: 'Periodo de análisis. Usar "specific_month" con el parámetro month para un mes específico.',
|
|
16
|
+
default: 'last_month',
|
|
17
|
+
},
|
|
18
|
+
month: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Mes específico en formato YYYY-MM (ej: "2026-01"). Usar con period="specific_month".',
|
|
21
|
+
},
|
|
22
|
+
start_date: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Fecha inicio (YYYY-MM-DD). Requerido si period=custom',
|
|
25
|
+
},
|
|
26
|
+
end_date: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Fecha fin (YYYY-MM-DD). Requerido si period=custom',
|
|
29
|
+
},
|
|
30
|
+
stores: {
|
|
31
|
+
type: 'array',
|
|
32
|
+
items: { type: 'string', enum: ['FQ01', 'FQ28', 'FQ88', 'all'] },
|
|
33
|
+
description: 'Tiendas a analizar. Use ["all"] para todas.',
|
|
34
|
+
default: ['all'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export async function handleExpenseAnalysis(bcClient, args) {
|
|
41
|
+
const period = args.period || 'last_month';
|
|
42
|
+
const stores = args.stores || ['all'];
|
|
43
|
+
|
|
44
|
+
const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
|
|
45
|
+
|
|
46
|
+
// Fetch data for all requested stores
|
|
47
|
+
const allStoresData = await bcClient.getAllStoresFinancialData(stores, dateRange.start, dateRange.end);
|
|
48
|
+
|
|
49
|
+
// Fetch exchange rate for dual currency display
|
|
50
|
+
const firstStoreCode = Object.keys(allStoresData)[0];
|
|
51
|
+
let exchangeRate = null;
|
|
52
|
+
try {
|
|
53
|
+
const rates = await bcClient.getExchangeRates(firstStoreCode);
|
|
54
|
+
exchangeRate = bcClient.getExchangeRateForDate(rates, dateRange.end);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Exchange rate is optional
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const results = {};
|
|
60
|
+
for (const [code, storeData] of Object.entries(allStoresData)) {
|
|
61
|
+
results[code] = analyzeExpenses(storeData);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// If multiple stores, add combined summary
|
|
65
|
+
let combined = null;
|
|
66
|
+
const storeCodes = Object.keys(results);
|
|
67
|
+
if (storeCodes.length > 1) {
|
|
68
|
+
const totalIncome = storeCodes.reduce((s, c) => s + results[c].total_income, 0);
|
|
69
|
+
const totalExpenses = storeCodes.reduce((s, c) => s + results[c].total_expenses, 0);
|
|
70
|
+
combined = {
|
|
71
|
+
total_stores: storeCodes.length,
|
|
72
|
+
total_income: Math.round(totalIncome * 100) / 100,
|
|
73
|
+
total_expenses: Math.round(totalExpenses * 100) / 100,
|
|
74
|
+
operating_margin: Math.round((totalIncome - totalExpenses) * 100) / 100,
|
|
75
|
+
operating_margin_pct: totalIncome > 0 ? Math.round(((totalIncome - totalExpenses) / totalIncome) * 100 * 100) / 100 : 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
period: { start: dateRange.start, end: dateRange.end, label: period },
|
|
81
|
+
exchange_rate: exchangeRate ? {
|
|
82
|
+
rate: Math.round(exchangeRate.rate * 100) / 100,
|
|
83
|
+
label: `1 USD = ${Math.round(exchangeRate.rate * 100) / 100} VES`,
|
|
84
|
+
} : null,
|
|
85
|
+
by_store: results,
|
|
86
|
+
combined,
|
|
87
|
+
};
|
|
88
|
+
}
|