@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.
- package/.env.example +7 -0
- package/CHANGELOG.md +127 -0
- package/README.md +219 -66
- package/config/bank-gl-map.json +4 -2
- package/config/income-accounts.js +29 -0
- package/lib/bc-client.js +37 -3
- package/package.json +1 -1
- package/scripts/generate-tools-doc.mjs +129 -0
- package/server.js +17 -2
- package/tools/financials/income-statement.js +265 -0
- package/tools/financials/index.js +1 -0
- package/tools/get-crm-rate.js +109 -0
- package/tools/multi-payment/index.js +1 -0
- package/tools/multi-payment/unposted-invoices.js +224 -0
- package/tools/payroll/cost-model.js +51 -0
- package/tools/payroll/employees.js +29 -5
- package/tools/payroll/payroll-documents.js +196 -12
- package/tools/payroll/payroll-lines.js +57 -33
- package/tools/ventas/sales-analysis.js +1 -1
- package/tools/ventas/sales-store-comparison.js +5 -1
- package/utils/sales-aggregation.js +35 -1
|
@@ -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';
|