@fullqueso/mcp-bc-gastos 1.28.0 → 1.33.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,129 @@
1
+ // scripts/generate-tools-doc.mjs
2
+ // Regenera la sección "## Tools (N)" del README.md leyendo las definiciones
3
+ // REALES exportadas por tools/**/*.js (objetos *Tool con name/description/inputSchema).
4
+ // Uso: node scripts/generate-tools-doc.mjs → imprime el markdown
5
+ // node scripts/generate-tools-doc.mjs --write → reescribe README.md
6
+ // Así el README nunca vuelve a quedar stale: se regenera desde el código.
7
+
8
+ import { readdirSync, readFileSync, writeFileSync } from 'fs';
9
+ import { fileURLToPath, pathToFileURL } from 'url';
10
+ import { dirname, join, relative } from 'path';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, '..');
14
+ const TOOLS_DIR = join(ROOT, 'tools');
15
+ const README = join(ROOT, 'README.md');
16
+
17
+ const CATEGORY_LABELS = {
18
+ '': 'Expense Analysis & Core',
19
+ 'auditoria': 'Auditoría / POS Reconciliation',
20
+ 'cierre-mensual': 'Cierre Mensual Bancario',
21
+ 'cobranzas': 'Cobranzas (AR / AP)',
22
+ 'financials': 'Financial Statements & Cash Flow',
23
+ 'inventario': 'Inventario',
24
+ 'multi-payment': 'Draft Visibility (Multi-Payments + facturas sin postear)',
25
+ 'payroll': 'Nómina',
26
+ 'reports': 'Reports',
27
+ 'ventas': 'Ventas',
28
+ };
29
+
30
+ function jsFiles(dir) {
31
+ const out = [];
32
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
+ if (entry.isDirectory()) out.push(...jsFiles(join(dir, entry.name)));
34
+ else if (entry.name.endsWith('.js') && !entry.name.startsWith('.')) {
35
+ // index.js incluido: algunos tools se DEFINEN ahí (ej. financials/index.js);
36
+ // los re-exports duplicados se filtran después con dedup por name.
37
+ out.push(join(dir, entry.name));
38
+ }
39
+ }
40
+ return out.sort();
41
+ }
42
+
43
+ function firstSentence(text, max = 240) {
44
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
45
+ const dot = clean.indexOf('. ');
46
+ const s = dot > 30 ? clean.slice(0, dot + 1) : clean;
47
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s;
48
+ }
49
+
50
+ function paramsLine(schema) {
51
+ const props = schema?.properties || {};
52
+ const required = new Set(schema?.required || []);
53
+ const parts = Object.keys(props).map((k) => (required.has(k) ? `\`${k}\`*` : `\`${k}\``));
54
+ return parts.length ? `- Params: ${parts.join(', ')}` : '- Params: (ninguno)';
55
+ }
56
+
57
+ async function collectTools() {
58
+ const byCategory = new Map();
59
+ for (const file of jsFiles(TOOLS_DIR)) {
60
+ const rel = relative(TOOLS_DIR, file);
61
+ const category = rel.includes('/') ? rel.split('/')[0] : '';
62
+ let mod;
63
+ try {
64
+ mod = await import(pathToFileURL(file).href);
65
+ } catch (e) {
66
+ console.error(`WARN: no se pudo importar ${rel}: ${e.message}`);
67
+ continue;
68
+ }
69
+ for (const [key, val] of Object.entries(mod)) {
70
+ if (!key.endsWith('Tool')) continue;
71
+ if (!val || typeof val !== 'object' || !val.name || !val.description) continue;
72
+ if (!byCategory.has(category)) byCategory.set(category, []);
73
+ byCategory.get(category).push(val);
74
+ }
75
+ }
76
+ // dedup por name (algunos módulos re-exportan)
77
+ for (const [cat, tools] of byCategory) {
78
+ const seen = new Set();
79
+ byCategory.set(cat, tools.filter((t) => (seen.has(t.name) ? false : seen.add(t.name))));
80
+ }
81
+ return byCategory;
82
+ }
83
+
84
+ function buildMarkdown(byCategory) {
85
+ const order = Object.keys(CATEGORY_LABELS).filter((k) => byCategory.has(k));
86
+ for (const k of byCategory.keys()) if (!order.includes(k)) order.push(k); // dirs nuevos
87
+ const total = [...byCategory.values()].reduce((n, t) => n + t.length, 0);
88
+
89
+ const lines = [`## Tools (${total})`, '',
90
+ '_Sección generada por `scripts/generate-tools-doc.mjs` — no editar a mano._',
91
+ '_Regenerar con: `node scripts/generate-tools-doc.mjs --write`_', ''];
92
+ for (const cat of order) {
93
+ const tools = byCategory.get(cat);
94
+ const label = CATEGORY_LABELS[cat] || cat;
95
+ lines.push(`### ${label} (${tools.length} tool${tools.length !== 1 ? 's' : ''})`, '');
96
+ for (const t of tools.sort((a, b) => a.name.localeCompare(b.name))) {
97
+ lines.push(`#### ${t.name}`);
98
+ lines.push(firstSentence(t.description));
99
+ lines.push(paramsLine(t.inputSchema), '');
100
+ }
101
+ }
102
+ lines.push('_Los params marcados con `*` son requeridos. Detalle completo en `docs/tool_*.md`._', '');
103
+ return lines.join('\n');
104
+ }
105
+
106
+ function spliceReadme(md) {
107
+ const readme = readFileSync(README, 'utf8');
108
+ const lines = readme.split('\n');
109
+ const start = lines.findIndex((l) => /^## Tools/.test(l));
110
+ if (start === -1) throw new Error('No se encontró la sección "## Tools" en README.md');
111
+ let end = lines.length;
112
+ for (let i = start + 1; i < lines.length; i++) {
113
+ if (/^## /.test(lines[i])) { end = i; break; }
114
+ }
115
+ const next = [...lines.slice(0, start), ...md.split('\n'), ...lines.slice(end)];
116
+ writeFileSync(README, next.join('\n'));
117
+ return { start: start + 1, end, total_lines: next.length };
118
+ }
119
+
120
+ const byCategory = await collectTools();
121
+ const md = buildMarkdown(byCategory);
122
+
123
+ if (process.argv.includes('--write')) {
124
+ const info = spliceReadme(md);
125
+ const total = [...byCategory.values()].reduce((n, t) => n + t.length, 0);
126
+ console.log(`README.md actualizado: ${total} tools, sección en línea ${info.start} (antes terminaba en ${info.end}). Total ${info.total_lines} líneas.`);
127
+ } else {
128
+ console.log(md);
129
+ }
package/server.js CHANGED
@@ -33,6 +33,7 @@ import { accountTransactionsTool, handleAccountTransactions } from './tools/acco
33
33
  import { vendorTransactionsTool, handleVendorTransactions } from './tools/vendor-transactions.js';
34
34
  import { listVendorsTool, handleListVendors } from './tools/list-vendors.js';
35
35
  import { exchangeRateTool, handleGetExchangeRate } from './tools/get-exchange-rate.js';
36
+ import { crmRateTool, handleGetCrmRate } from './tools/get-crm-rate.js';
36
37
 
37
38
  // Auditoria tools (bank reconciliation)
38
39
  import { listBankAccountsTool, handleListBankAccounts } from './tools/auditoria/list-bank-accounts.js';
@@ -61,6 +62,7 @@ import {
61
62
  draftReceivablesTool, handleDraftReceivables,
62
63
  draftPayablesTool, handleDraftPayables,
63
64
  draftSummaryTool, handleDraftSummary,
65
+ unpostedInvoicesTool, handleUnpostedInvoices,
64
66
  } from './tools/multi-payment/index.js';
65
67
 
66
68
  // Payroll tools
@@ -103,10 +105,11 @@ import {
103
105
  reconcileWithBcTool, handleReconcileWithBc,
104
106
  } from './tools/cierre-mensual/index.js';
105
107
 
106
- // Financials (P&L / Financial Statements) + Cash Flow (FCF)
108
+ // Financials (P&L / Financial Statements) + Cash Flow (FCF) + Income Statement (Power BI)
107
109
  import {
108
110
  financialStatementsTool, handleFinancialStatements,
109
111
  cashFlowTool, handleCashFlow,
112
+ incomeStatementTool, handleIncomeStatement,
110
113
  } from './tools/financials/index.js';
111
114
 
112
115
  const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
@@ -138,6 +141,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
138
141
  vendorTransactionsTool,
139
142
  listVendorsTool,
140
143
  exchangeRateTool,
144
+ crmRateTool,
141
145
  // Auditoria (bank reconciliation)
142
146
  listBankAccountsTool,
143
147
  reconciliationStatusTool,
@@ -162,6 +166,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
162
166
  draftReceivablesTool,
163
167
  draftPayablesTool,
164
168
  draftSummaryTool,
169
+ unpostedInvoicesTool,
165
170
  // Payroll
166
171
  payrollDocumentsTool,
167
172
  payrollLinesTool,
@@ -189,9 +194,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
189
194
  getClosingQuestionnaireTool,
190
195
  getClosingMatchResultsTool,
191
196
  reconcileWithBcTool,
192
- // Financials (P&L / Financial Statements) + Cash Flow (FCF)
197
+ // Financials (P&L / Financial Statements) + Cash Flow (FCF) + Income Statement (Power BI)
193
198
  financialStatementsTool,
194
199
  cashFlowTool,
200
+ incomeStatementTool,
195
201
  ],
196
202
  };
197
203
  });
@@ -234,6 +240,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
234
240
  case 'get_exchange_rate':
235
241
  result = await handleGetExchangeRate(bcClient, args);
236
242
  break;
243
+ case 'get_crm_rate':
244
+ result = await handleGetCrmRate(bcClient, args);
245
+ break;
237
246
  // Auditoria (bank reconciliation)
238
247
  case 'list_bank_accounts':
239
248
  result = await handleListBankAccounts(bcClient, args);
@@ -300,6 +309,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
300
309
  case 'get_draft_summary':
301
310
  result = await handleDraftSummary(bcClient, args);
302
311
  break;
312
+ case 'get_unposted_invoices':
313
+ result = await handleUnpostedInvoices(bcClient, args);
314
+ break;
303
315
  // Payroll
304
316
  case 'get_payroll_documents':
305
317
  result = await handlePayrollDocuments(bcClient, args);
@@ -378,6 +390,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
378
390
  case 'get_cash_flow':
379
391
  result = await handleCashFlow(bcClient, args);
380
392
  break;
393
+ case 'get_income_statement':
394
+ result = await handleIncomeStatement(bcClient, args);
395
+ break;
381
396
  default:
382
397
  return {
383
398
  content: [{ type: 'text', text: `Herramienta desconocida: ${name}` }],
@@ -0,0 +1,265 @@
1
+ // tools/financials/income-statement.js
2
+ //
3
+ // get_income_statement — Estado de resultados en el formato exacto del reporte Power BI
4
+ // "Income Statement by Month" (G/L Account Level 1). Agrega los General Ledger entries por
5
+ // bucket level-1 con la convención de signos de Power BI:
6
+ // amount = debitAmount − creditAmount
7
+ // → ingresos (crédito) salen NEGATIVOS, costos/gastos (débito) POSITIVOS,
8
+ // Total = Σ(debit − credit); Total NEGATIVO = ganancia.
9
+ //
10
+ // Resuelve la parte frágil que el skill hacía a mano: partir los gastos por las fronteras
11
+ // level-1 (nómina 71000 vs impuestos 74000, otros 74000 vs 90000). Aquí se hace desde los
12
+ // entries por cuenta, así que cuadra 1:1 con Power BI. La columna Ajustado (drafts como
13
+ // posteados) y la reclasificación de dividendos las agrega el skill sobre este posted.
14
+ //
15
+ // Consumido por skill-fq-resultados-mensual (build_income_statement.py). READ-ONLY.
16
+
17
+ import { resolveStores } from '../../config/company-config.js';
18
+ import { getDateRange } from '../../utils/date-helper.js';
19
+ import { round2 } from '../../utils/currency-converter.js';
20
+ import { logger } from '../../utils/logger.js';
21
+ import { periodLabel } from './statements.js';
22
+
23
+ // ── Estructura level-1 de Power BI ────────────────────────────────────────────
24
+ // Fuente: screenshots del reporte Power BI FQ28 (refresh 2026-05-04). CoA compartido
25
+ // FQ01/FQ28/FQ88. MANTENER SINCRONIZADO con
26
+ // skill-fq-resultados-mensual/references/income_statement_coa.json.
27
+ export const COA_PBI = {
28
+ sections: [
29
+ {
30
+ id: 'ingresos', total_account: '40001', total_name: 'TOTAL INGRESOS', range: [40000, 49999],
31
+ level1: [
32
+ { account: '40100', name: 'Ingreso Ventas', from: 40000, to: 40299 },
33
+ { account: '40300', name: 'Ingreso Facturación Directa', from: 40300, to: 40399 },
34
+ { account: '40400', name: 'Ingreso por Servicios', from: 40400, to: 40499 },
35
+ { account: '40500', name: 'Otros Ingresos', from: 40500, to: 40599 },
36
+ { account: '40600', name: 'Ingresos por Alquiler', from: 40600, to: 40899 },
37
+ { account: '40900', name: 'Ingresos Extraordinarios', from: 40900, to: 49999 },
38
+ ],
39
+ },
40
+ {
41
+ id: 'costo_ventas', total_account: '50001', total_name: 'TOTAL COSTO DE VENTAS', range: [50000, 59999],
42
+ level1: [
43
+ { account: '50100', name: 'Costo de Productos', from: 50000, to: 50999 },
44
+ { account: '51000', name: 'Otros Costos de Producción', from: 51000, to: 51999 },
45
+ { account: '52000', name: 'Costos Desechables y Empaques', from: 52000, to: 55999 },
46
+ { account: '56000', name: 'Fletes', from: 56000, to: 56999 },
47
+ { account: '57000', name: 'Mantenimiento y reparación de equipos cocina', from: 57000, to: 59999 },
48
+ ],
49
+ },
50
+ {
51
+ id: 'gastos_operativos', total_account: '60001', total_name: 'TOTAL GASTOS OPERATIVOS', range: [60000, 99999],
52
+ level1: [
53
+ { account: '60002', name: 'Gastos Planta Física', from: 60000, to: 61999 },
54
+ { account: '62000', name: 'gastos de logistica', from: 62000, to: 62999 },
55
+ { account: '63000', name: 'Gastos Mercadeo, Publicidad y Ventas', from: 63000, to: 63999 },
56
+ { account: '64000', name: 'Gastos Administrativos Tienda', from: 64000, to: 66999 },
57
+ { account: '67000', name: 'Egresos Financieros', from: 67000, to: 67999 },
58
+ { account: '68000', name: 'Gastos Servicios Contratados', from: 68000, to: 70999 },
59
+ { account: '71000', name: 'sueldos y salarios', from: 71000, to: 73999 },
60
+ { account: '74000', name: 'Impuestos y permisologias', from: 74000, to: 89999 },
61
+ { account: '90000', name: 'Otros Egresos', from: 90000, to: 99999 },
62
+ ],
63
+ },
64
+ ],
65
+ };
66
+
67
+ function sectionIdFor(acctNum) {
68
+ if (acctNum >= 40000 && acctNum <= 49999) return 'ingresos';
69
+ if (acctNum >= 50000 && acctNum <= 59999) return 'costo_ventas';
70
+ if (acctNum >= 60000 && acctNum <= 99999) return 'gastos_operativos';
71
+ return null;
72
+ }
73
+
74
+ function bucketFor(section, acctNum) {
75
+ for (const b of section.level1) {
76
+ if (acctNum >= b.from && acctNum <= b.to) return b;
77
+ }
78
+ return section.level1[section.level1.length - 1]; // fallback defensivo
79
+ }
80
+
81
+ // ── Agregación pura (testeable sin BC) ────────────────────────────────────────
82
+ // entries: [{ accountNumber, debitAmount, creditAmount, description? }]
83
+ // opts: { level = 1, accountNames = {} }
84
+ export function buildStatement(entries, { level = 1, accountNames = {} } = {}) {
85
+ const secDefById = Object.fromEntries(COA_PBI.sections.map((s) => [s.id, s]));
86
+ const acc = {};
87
+ for (const s of COA_PBI.sections) {
88
+ acc[s.id] = { total: 0, buckets: {} };
89
+ for (const b of s.level1) acc[s.id].buckets[b.account] = { amount: 0, children: {} };
90
+ }
91
+
92
+ for (const e of entries || []) {
93
+ const acctNum = parseInt(e.accountNumber, 10);
94
+ if (!Number.isFinite(acctNum)) continue;
95
+ const secId = sectionIdFor(acctNum);
96
+ if (!secId) continue;
97
+ const bucket = bucketFor(secDefById[secId], acctNum);
98
+ const amt = (e.debitAmount || 0) - (e.creditAmount || 0); // convención Power BI
99
+ const box = acc[secId];
100
+ box.total += amt;
101
+ box.buckets[bucket.account].amount += amt;
102
+ if (level >= 2) {
103
+ const key = String(e.accountNumber);
104
+ const ch = box.buckets[bucket.account].children;
105
+ if (!ch[key]) ch[key] = { account: key, name: accountNames[key] || e.description || `Cuenta ${key}`, amount: 0 };
106
+ ch[key].amount += amt;
107
+ }
108
+ }
109
+
110
+ const sections = [];
111
+ const accounts_map = {};
112
+ let grand = 0;
113
+ for (const s of COA_PBI.sections) {
114
+ const box = acc[s.id];
115
+ const accounts = [];
116
+ for (const b of s.level1) {
117
+ const bk = box.buckets[b.account];
118
+ const amount = round2(bk.amount);
119
+ const childArr = Object.values(bk.children)
120
+ .map((c) => ({ account: c.account, name: c.name, amount: round2(c.amount) }))
121
+ .sort((x, y) => x.account.localeCompare(y.account));
122
+ const row = { account: b.account, name: b.name, amount };
123
+ if (level >= 2) row.children = childArr;
124
+ accounts.push(row);
125
+
126
+ const mapEntry = { posted: amount };
127
+ if (level >= 2 && childArr.length) {
128
+ mapEntry.children = {};
129
+ for (const c of childArr) mapEntry.children[c.account] = { name: c.name, posted: c.amount };
130
+ }
131
+ accounts_map[b.account] = mapEntry;
132
+ }
133
+ const total = round2(box.total);
134
+ grand += total;
135
+ sections.push({ id: s.id, total_account: s.total_account, total_name: s.total_name, total, accounts });
136
+ }
137
+
138
+ return {
139
+ sign_convention: 'power_bi (amount = debit − credit): ingresos negativos, costos/gastos positivos, Total negativo = ganancia',
140
+ sections,
141
+ grand_total: round2(grand),
142
+ accounts_map,
143
+ };
144
+ }
145
+
146
+ // ── Orquestador ───────────────────────────────────────────────────────────────
147
+ export async function getIncomeStatement(bcClient, params = {}) {
148
+ const t0 = Date.now();
149
+ const period = params.period || 'last_month';
150
+ const level = Number(params.level) === 2 ? 2 : 1;
151
+
152
+ let storeCodes = params.stores && params.stores.length ? params.stores : (params.store ? [params.store] : ['all']);
153
+ const stores = resolveStores(storeCodes.includes('all') ? null : storeCodes);
154
+ const { start, end } = getDateRange(period, params.start_date, params.end_date, params.month);
155
+ const label = periodLabel(start, end);
156
+
157
+ logger.info(`[income_statement] stores=${stores.map((s) => s.code).join(',')} ${start}→${end} level=${level}`);
158
+
159
+ const fxTask = bcClient
160
+ .getExchangeRates(stores[0].code)
161
+ .then((rates) => bcClient.getExchangeRateForDate(rates, end))
162
+ .catch((err) => {
163
+ logger.warn(`[income_statement] exchange rate fetch failed: ${err.message}`);
164
+ return null;
165
+ });
166
+
167
+ const perStore = await Promise.all(
168
+ stores.map(async (s) => {
169
+ const [income, cogs, expenses] = await Promise.all([
170
+ bcClient.getGLEntries(s.companyId, start, end, '40000', '49999'),
171
+ bcClient.getGLEntries(s.companyId, start, end, '50000', '59999'),
172
+ bcClient.getGLEntries(s.companyId, start, end, '60000', '99999'),
173
+ ]);
174
+ let accountNames = {};
175
+ if (level >= 2) {
176
+ accountNames = await bcClient.getChartOfAccountNames(s.companyId).catch((err) => {
177
+ logger.warn(`[income_statement] CoA names fetch failed (${s.code}): ${err.message}`);
178
+ return {};
179
+ });
180
+ }
181
+ const statement = buildStatement([...income, ...cogs, ...expenses], { level, accountNames });
182
+ return {
183
+ code: s.code, name: s.name,
184
+ companyName: decodeURIComponent(s.companyName || '') || s.name,
185
+ statement,
186
+ };
187
+ })
188
+ );
189
+
190
+ const fx = await fxTask;
191
+
192
+ const storesOut = {};
193
+ for (const r of perStore) {
194
+ storesOut[r.code] = {
195
+ store_code: r.code,
196
+ store_name: r.name,
197
+ company_name: r.companyName,
198
+ ...r.statement,
199
+ };
200
+ }
201
+
202
+ return {
203
+ format: 'power_bi_income_statement',
204
+ level,
205
+ period: { start, end, label },
206
+ exchange_rate: fx ? { rate: round2(fx.rate), label: `1 USD = ${round2(fx.rate)} VES` } : null,
207
+ generated_at: new Date().toISOString(),
208
+ note:
209
+ 'Columna POSTED = Power BI tal cual (amount = debit − credit; ingresos negativos; Total negativo = ganancia). ' +
210
+ 'El skill-fq-resultados-mensual agrega la columna Ajustado (drafts como posteados vía get_unposted_invoices + ' +
211
+ 'nómina Draft) y la reclasificación de nómina gerencial a dividendos. `accounts_map` va listo para el data.json ' +
212
+ 'del generador (build_income_statement.py): clave = cuenta level-1, valor = { posted, children? }.',
213
+ stores: storesOut,
214
+ query_duration_ms: Date.now() - t0,
215
+ };
216
+ }
217
+
218
+ // ── Definición del tool + handler ─────────────────────────────────────────────
219
+ export const incomeStatementTool = {
220
+ name: 'get_income_statement',
221
+ description:
222
+ 'Estado de resultados (Income Statement) en el formato EXACTO del reporte Power BI "Income Statement by Month" ' +
223
+ '(G/L Account Level 1) para una tienda Full Queso (FQ01, FQ28, FQ88) y un mes. Agrega los General Ledger entries ' +
224
+ 'por cuenta level-1 con la convención de signos de Power BI (ingresos negativos, costos/gastos positivos, Total ' +
225
+ 'negativo = ganancia), listo para comparar 1:1 con Power BI. Devuelve las 3 secciones (40001 TOTAL INGRESOS, ' +
226
+ '50001 TOTAL COSTO DE VENTAS, 60001 TOTAL GASTOS OPERATIVOS) con sus cuentas level-1, totales y Total general, ' +
227
+ 'más un `accounts_map` listo para el generador Excel del skill. Con level=2 incluye las cuentas de posteo hijas ' +
228
+ 'con su nombre real de BC. Resuelve las fronteras 71000/74000 y 74000/90000 que get_financial_statements no separa. ' +
229
+ 'Úsalo para: income statement, estado de resultados level 1, resultado mensual comparable con Power BI, P&L por ' +
230
+ 'cuenta. La columna Ajustado (drafts) la agrega el skill. READ-ONLY.',
231
+ inputSchema: {
232
+ type: 'object',
233
+ properties: {
234
+ store: {
235
+ type: 'string',
236
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
237
+ description: 'Tienda a analizar (una sola, recomendado). Alternativa a `stores`.',
238
+ },
239
+ stores: {
240
+ type: 'array',
241
+ items: { type: 'string', enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'] },
242
+ description: 'Tiendas a analizar. Default: ["all"]. Para el income statement de una tienda usar `store`.',
243
+ },
244
+ period: {
245
+ type: 'string',
246
+ enum: ['last_month', 'this_month', 'specific_month', 'custom'],
247
+ description: 'Período. Default: last_month. Para un mes exacto usar period="specific_month" + month.',
248
+ default: 'last_month',
249
+ },
250
+ month: { type: 'string', description: 'Mes "YYYY-MM" (con period="specific_month").' },
251
+ start_date: { type: 'string', description: 'Fecha inicio "YYYY-MM-DD" (con period="custom").' },
252
+ end_date: { type: 'string', description: 'Fecha fin "YYYY-MM-DD" (con period="custom").' },
253
+ level: {
254
+ type: 'number',
255
+ enum: [1, 2],
256
+ description: 'Nivel de detalle: 1 = level-1 como Power BI (default); 2 = con cuentas de posteo hijas.',
257
+ default: 1,
258
+ },
259
+ },
260
+ },
261
+ };
262
+
263
+ export async function handleIncomeStatement(bcClient, args) {
264
+ return getIncomeStatement(bcClient, args || {});
265
+ }
@@ -8,6 +8,7 @@
8
8
  import { getFinancialStatements } from './statements.js';
9
9
 
10
10
  export { cashFlowTool, handleCashFlow } from './cash-flow.js';
11
+ export { incomeStatementTool, handleIncomeStatement } from './income-statement.js';
11
12
 
12
13
  export const financialStatementsTool = {
13
14
  name: 'get_financial_statements',
@@ -0,0 +1,109 @@
1
+ // tools/get-crm-rate.js
2
+ // Tasa de cambio Bs/USD desde el CRM de Full Queso (hora Caracas).
3
+ // Fuente: https://crm-rate.fullqueso.com
4
+ // - USDT == Binance == tasa paralelo
5
+ // - VES == BCV == tasa oficial
6
+ // Histórica (con fecha) o del día (sin fecha).
7
+
8
+ import { DateTime } from 'luxon';
9
+
10
+ const CRM_RATE_BASE =
11
+ process.env.CRM_RATE_BASE || 'https://crm-rate.fullqueso.com/api/v1';
12
+
13
+ // Normaliza el coin del usuario al símbolo del endpoint.
14
+ function normalizeCoin(raw) {
15
+ const c = String(raw || '').trim().toUpperCase();
16
+ if (['USDT', 'BINANCE', 'PARALELO', 'PARALELA'].includes(c)) {
17
+ return { history: 'USDT', live: 'usdt', label: 'USDT/Binance' };
18
+ }
19
+ if (['VES', 'BCV', 'OFICIAL', 'BS'].includes(c)) {
20
+ return { history: 'VES', live: 'bcv', label: 'BCV (oficial)' };
21
+ }
22
+ return null;
23
+ }
24
+
25
+ export const crmRateTool = {
26
+ name: 'get_crm_rate',
27
+ description:
28
+ 'Obtiene la tasa de cambio Bs/USD desde el CRM de Full Queso (hora Caracas). ' +
29
+ 'Dos monedas: USDT (equivalente a la tasa Binance / paralelo) y VES (tasa BCV oficial). ' +
30
+ 'Si se pasa una fecha (YYYY-MM-DD) retorna la tasa histórica guardada de ese día; ' +
31
+ 'si se omite la fecha retorna la tasa del momento de la consulta. ' +
32
+ 'Usar SIEMPRE que se pida: tasa USDT, tasa Binance, dólar Binance, tasa paralelo/paralela, ' +
33
+ 'tasa BCV, tasa oficial, tasa del día o tasa histórica de una fecha. ' +
34
+ 'Para tasa BCV vigente contable en Business Central usar get_exchange_rate; este tool es la tasa de referencia del CRM.',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ coin: {
39
+ type: 'string',
40
+ enum: ['USDT', 'BINANCE', 'VES', 'BCV'],
41
+ description:
42
+ 'Moneda/tasa. USDT y BINANCE son equivalentes (paralelo). VES y BCV son equivalentes (oficial BCV).',
43
+ },
44
+ date: {
45
+ type: 'string',
46
+ description:
47
+ 'Fecha histórica (YYYY-MM-DD), hora Caracas. Si se omite, devuelve la tasa actual.',
48
+ },
49
+ },
50
+ required: ['coin'],
51
+ },
52
+ };
53
+
54
+ export async function handleGetCrmRate(_bcClient, args) {
55
+ const coin = normalizeCoin(args.coin);
56
+ if (!coin) {
57
+ throw new Error(
58
+ `coin inválido: "${args.coin}". Válidos: USDT/BINANCE (paralelo) o VES/BCV (oficial).`
59
+ );
60
+ }
61
+
62
+ const isHistorical = !!args.date;
63
+ let url;
64
+ if (isHistorical) {
65
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(args.date)) {
66
+ throw new Error(`date inválida: "${args.date}". Formato esperado YYYY-MM-DD.`);
67
+ }
68
+ url = `${CRM_RATE_BASE}/history/price?coin=${coin.history}&date=${args.date}`;
69
+ } else {
70
+ url = `${CRM_RATE_BASE}/coin/${coin.live}`;
71
+ }
72
+
73
+ let res;
74
+ try {
75
+ res = await fetch(url, { headers: { Accept: 'application/json' } });
76
+ } catch (e) {
77
+ throw new Error(`No se pudo conectar al CRM de tasas (${CRM_RATE_BASE}): ${e.message}`);
78
+ }
79
+
80
+ if (res.status === 400) {
81
+ throw new Error('CRM 400: coin o date inválidos.');
82
+ }
83
+ if (res.status === 404) {
84
+ return {
85
+ coin: coin.label,
86
+ date: args.date || null,
87
+ price: null,
88
+ message: `No hay registro de tasa ${coin.label} para ${args.date}.`,
89
+ };
90
+ }
91
+ if (!res.ok) {
92
+ throw new Error(`CRM respondió ${res.status} en ${url}`);
93
+ }
94
+
95
+ const data = await res.json();
96
+ const nowCcs = DateTime.now().setZone('America/Caracas');
97
+ const price =
98
+ typeof data.price === 'number' ? Math.round(data.price * 100) / 100 : data.price;
99
+
100
+ return {
101
+ coin: coin.label,
102
+ type: isHistorical ? 'historica' : 'del_dia',
103
+ date: isHistorical ? args.date : nowCcs.toISODate(),
104
+ queried_at_caracas: nowCcs.toFormat('yyyy-MM-dd HH:mm:ss'),
105
+ price,
106
+ label: `1 USD = ${price} Bs (${coin.label})`,
107
+ source: url,
108
+ };
109
+ }
@@ -1,3 +1,4 @@
1
1
  export { draftReceivablesTool, handleDraftReceivables } from './draft-receivables.js';
2
2
  export { draftPayablesTool, handleDraftPayables } from './draft-payables.js';
3
3
  export { draftSummaryTool, handleDraftSummary } from './draft-summary.js';
4
+ export { unpostedInvoicesTool, handleUnpostedInvoices } from './unposted-invoices.js';