@fullqueso/mcp-bc-gastos 1.26.0 → 1.27.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/CHANGELOG.md +16 -0
- package/config/bank-gl-map.json +12 -10
- package/lib/bc-client.js +32 -0
- package/package.json +1 -1
- package/tools/financials/cash-flow-html.js +41 -13
- package/tools/financials/cash-flow.js +23 -111
- package/tools/financials/fx-cash.js +227 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [1.27.0] — 2026-06-05
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- **`get_cash_flow` — efecto cambiario revaluado contra el SALDO REAL del banco + asiento sugerido (corrige v1.26.0).** FP detectó que el saldo VES del GL está inflado por **backlog de conciliación** (transacciones que el banco ya procesó pero BC no postea). Ej FQ88: GL cuenta 18250 = 12.44M VES vs saldo real del banco = 1.19M VES. Por eso el `fx_adjustment` de v1.26.0 (sobre GL, −$2,290) **sobrestimaba** la pérdida real. Ahora se revalúa el **saldo real** (`BankAccReconciliation.StatementEndingBalance`, en VES) y se aísla el backlog.
|
|
7
|
+
- **Matemática robusta:** por cuenta, `implied_rate = gl_ves/gl_usd` (tasa promedio histórica), `fx_real = stmt_ves × (1/rate_close − 1/implied_rate)` — escala por el saldo **real**, no el GL inflado. `recon_gap = gl_ves − stmt_ves` se reporta aparte (backlog a postear, NO es FX).
|
|
8
|
+
- **3 buckets de cuentas VES:** (1) banco real con statement → entra al total y al **asiento sugerido**; (2) banco sin statement → provisional, flag, fuera del total; (3) ajuste/tránsito (lista explícita en config: 18275, 18297) → solo informativo. Flag de tasa implícita atípica.
|
|
9
|
+
- **Asiento sugerido de diferencial cambiario** (READ-ONLY texto, cuadra DR=CR): débito Diferencial Cambiario / crédito cada banco VES por su `fx_real`. Es la hoja de trabajo del cierre.
|
|
10
|
+
- Nuevo módulo compartido `tools/financials/fx-cash.js` (computeCashPosition, buildFxJournal, consolidateCashPositions) — reutilizable por get_cash_flow y, a futuro, por el paso de cierre-mensual (Fase 3B). Nuevo `bcClient.getBankStatementBalances` (OData V4 `BankAccReconciliation`, elige la statement ≤ fin de período). Completado el mapeo banco→GL de FQ88 en `config/bank-gl-map.json` (desde fq-239-audit) + lista `adjustment_accounts_ves`.
|
|
11
|
+
- El bloque `cash_position` cambia de forma: `bank_accounts`/`pending_no_statement`/`informational` + `totals` (solo bucket 1) + `fx_journal` + `gl_basis` (referencia v1.26.0). Insight separa "brecha de conciliación" de "efecto cambiario sobre caja real". **El FCF sigue intacto** (el FX es memo de balance).
|
|
12
|
+
- **Verificado contra BC real (FQ88, mayo 2026):** GL libro $34,880 / FX sobre GL −$2,290 (v1.26.0, sobrestimado) → **caja real $5,884 / FX real −$500.89** (correcto); brecha de conciliación 14.36M VES (~$25,897, backlog). Asiento cuadra ($500.89). FCF intacto.
|
|
13
|
+
|
|
14
|
+
### Pendiente (Fase 3B)
|
|
15
|
+
- `generate_fx_revaluation` en cierre-mensual: emitir el asiento de diferencial cambiario integrado al journal de cierre (reusa el módulo `fx-cash.js`).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
3
19
|
## [1.26.0] — 2026-06-05
|
|
4
20
|
|
|
5
21
|
### Added
|
package/config/bank-gl-map.json
CHANGED
|
@@ -41,18 +41,18 @@
|
|
|
41
41
|
},
|
|
42
42
|
"FQ88": {
|
|
43
43
|
"store_name": "FQ88 - La Candelaria",
|
|
44
|
-
"_nota": "GL
|
|
44
|
+
"_nota": "GL confirmados 2026-06-05 desde fq-239-audit/data/fq_bancos_all.json (consolidacion_por_banco).",
|
|
45
45
|
"accounts": {
|
|
46
|
-
"MN0001": { "gl":
|
|
47
|
-
"MN0002": { "gl":
|
|
48
|
-
"MN0003": { "gl":
|
|
49
|
-
"MN0004": { "gl":
|
|
50
|
-
"MN0005": { "gl":
|
|
51
|
-
"MN0007": { "gl":
|
|
52
|
-
"MN0028": { "gl":
|
|
46
|
+
"MN0001": { "gl": "18210", "name": "Bancamiga 0240 Bs.", "currency": "VES", "category": "bancos_nacionales" },
|
|
47
|
+
"MN0002": { "gl": "18220", "name": "Bancamiga 9379 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
|
|
48
|
+
"MN0003": { "gl": "18230", "name": "BDV 9127 Bs.", "currency": "VES", "category": "bancos_nacionales" },
|
|
49
|
+
"MN0004": { "gl": "18240", "name": "BDV 7191 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
|
|
50
|
+
"MN0005": { "gl": "18250", "name": "Bancrecer 2558 Bs.", "currency": "VES", "category": "bancos_nacionales" },
|
|
51
|
+
"MN0007": { "gl": "18270", "name": "BDV Pago Movil 5145", "currency": "VES", "category": "bancos_nacionales" },
|
|
52
|
+
"MN0028": { "gl": "18290", "name": "Ubii Bank", "currency": "VES", "category": "bancos_nacionales" },
|
|
53
53
|
"MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
|
|
54
54
|
"ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
|
|
55
|
-
"ME0005": { "gl":
|
|
55
|
+
"ME0005": { "gl": "18410", "name": "Zelle USD", "currency": "USD", "category": "bancos_extranjeros" }
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
},
|
|
@@ -89,5 +89,7 @@
|
|
|
89
89
|
"18499": "Total Bancos Extranjeros Divisas",
|
|
90
90
|
"18998": "Total Bancos",
|
|
91
91
|
"18999": "Total Caja y Bancos"
|
|
92
|
-
}
|
|
92
|
+
},
|
|
93
|
+
"_adjustment_accounts_nota": "Cuentas VES de ajuste/tránsito (NO son bancos reales, no tienen statement). Se tratan como informativas en la revaluación cambiaria (fuera del total de pérdida y del asiento sugerido). FP 2026-06-05.",
|
|
94
|
+
"adjustment_accounts_ves": ["18275", "18297"]
|
|
93
95
|
}
|
package/lib/bc-client.js
CHANGED
|
@@ -329,6 +329,38 @@ export class BCClient {
|
|
|
329
329
|
return this.apiCallAllPages(url, maxPages);
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
// Saldos REALES de banco (statement) por cuenta, vía BankAccReconciliation (OData V4).
|
|
333
|
+
// Por cada BankAccountNo elige la statement más reciente con StatementDate ≤ asOfDate
|
|
334
|
+
// (los bancos tienen varias statements semanales + cierre de mes). Devuelve un mapa
|
|
335
|
+
// { 'FQ88-MN0005': { ending: 1190029.52, statementDate, statementNo, stale } }.
|
|
336
|
+
async getBankStatementBalances(storeCode, asOfDate) {
|
|
337
|
+
const url = this.buildODataUrl(storeCode, 'BankAccReconciliation', {
|
|
338
|
+
$select: 'BankAccountNo,StatementNo,StatementDate,StatementEndingBalance',
|
|
339
|
+
});
|
|
340
|
+
const rows = await this.odataCallAllPages(url);
|
|
341
|
+
const list = rows.value || rows || [];
|
|
342
|
+
const ad = String(asOfDate);
|
|
343
|
+
const byAcct = {};
|
|
344
|
+
for (const r of list) {
|
|
345
|
+
const acct = r.BankAccountNo;
|
|
346
|
+
if (!acct) continue;
|
|
347
|
+
const date = r.StatementDate ? String(r.StatementDate) : '';
|
|
348
|
+
// Preferir statements ≤ asOfDate (la más reciente). Si no hay, usar la más antigua futura (stale).
|
|
349
|
+
const cur = byAcct[acct];
|
|
350
|
+
const within = date && date <= ad;
|
|
351
|
+
if (!cur) {
|
|
352
|
+
byAcct[acct] = { ending: r.StatementEndingBalance || 0, statementDate: date, statementNo: r.StatementNo, stale: !within };
|
|
353
|
+
} else if (within) {
|
|
354
|
+
// entre las ≤ asOfDate, quedarse con la fecha mayor
|
|
355
|
+
if (cur.stale || date > cur.statementDate) byAcct[acct] = { ending: r.StatementEndingBalance || 0, statementDate: date, statementNo: r.StatementNo, stale: false };
|
|
356
|
+
} else if (cur.stale && date < cur.statementDate) {
|
|
357
|
+
// todas futuras: quedarse con la más cercana (menor fecha futura)
|
|
358
|
+
byAcct[acct] = { ending: r.StatementEndingBalance || 0, statementDate: date, statementNo: r.StatementNo, stale: true };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return byAcct;
|
|
362
|
+
}
|
|
363
|
+
|
|
332
364
|
// Fetch revenue entries (40000-49999) - credit balance = income
|
|
333
365
|
async getRevenueEntries(companyId, startDate, endDate) {
|
|
334
366
|
return this.getGLEntries(companyId, startDate, endDate, '40000', '49999');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullqueso/mcp-bc-gastos",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, accounts receivable/payable, multi-payment draft visibility, payroll, inventory cost analysis, and manager reports - Full Queso franchise stores",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -271,24 +271,52 @@ function renderCapexCard(s) {
|
|
|
271
271
|
</div>`;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
// Posición de caja
|
|
274
|
+
// Posición de caja · conciliación · efecto cambiario (base saldo REAL del banco). NO afecta el FCF.
|
|
275
275
|
function renderCashPositionCard(s) {
|
|
276
276
|
const cp = s.cash_position;
|
|
277
|
-
if (!cp || !cp.
|
|
278
|
-
const
|
|
277
|
+
if (!cp || !cp.bank_accounts) return '';
|
|
278
|
+
const t = cp.totals;
|
|
279
279
|
const dev = cp.month_devaluation_pct != null ? ` · devaluación mes ${cp.month_devaluation_pct}%` : '';
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
280
|
+
const st = (store) => (store ? ` <span class="pnl-acct">${esc(store)}</span>` : '');
|
|
281
|
+
|
|
282
|
+
// Bucket 1 — bancos con statement: GL vs real, brecha, tasa implícita, fx_real
|
|
283
|
+
const bankRows = cp.bank_accounts.map((b) => {
|
|
284
|
+
const flag = b.flags && b.flags.length ? ` <span class="pnl-acct">⚠ ${esc(b.flags.join(','))}</span>` : '';
|
|
285
|
+
return `<div class="pnl-row sub">
|
|
286
|
+
<span class="pnl-label">${esc(b.account)} ${esc(b.name || '')}${st(b.store)}${flag}<br>
|
|
287
|
+
<span class="kpi-cell-sub">GL ${fmt(b.gl_ves)} → real ${fmt(b.stmt_ves)} VES · impl ${b.implied_rate || '—'} vs ${cp.rate_end}</span></span>
|
|
288
|
+
<span class="pnl-amt ${b.fx_real < 0 ? 'val-red' : 'val-green'}">${signed(b.fx_real)}</span>
|
|
289
|
+
</div>`;
|
|
290
|
+
}).join('');
|
|
291
|
+
|
|
292
|
+
// Bucket 3 — ajuste/tránsito (informativo)
|
|
293
|
+
const infoRows = (cp.informational || []).map((b) =>
|
|
294
|
+
`<div class="pnl-row sub"><span class="pnl-label">${esc(b.account)} ${esc(b.name || '')}${st(b.store)} <span class="pnl-acct">informativo</span></span><span class="pnl-amt">${signed(b.fx_theoretical)}</span></div>`
|
|
295
|
+
).join('');
|
|
296
|
+
// Bucket 2 — sin statement (provisional)
|
|
297
|
+
const pendRows = (cp.pending_no_statement || []).map((b) =>
|
|
298
|
+
`<div class="pnl-row sub"><span class="pnl-label">${esc(b.account)} ${esc(b.name || '')}${st(b.store)} <span class="pnl-acct">sin statement</span></span><span class="pnl-amt">${signed(b.fx_provisional)}</span></div>`
|
|
299
|
+
).join('');
|
|
300
|
+
|
|
301
|
+
// Asiento sugerido (solo bucket 1)
|
|
302
|
+
const j = cp.fx_journal;
|
|
303
|
+
const jRows = (j && j.rows || []).map((r) =>
|
|
304
|
+
`<div class="pnl-row sub"><span class="pnl-label">${esc(String(r.account))} <span class="pnl-acct">${esc(r.account_type)}</span></span><span class="pnl-amt">${r.debit_usd ? 'DR ' + signed(r.debit_usd) : 'CR ' + signed(r.credit_usd)}</span></div>`
|
|
305
|
+
).join('');
|
|
306
|
+
|
|
307
|
+
const reconUsd = cp.rate_end ? t.recon_gap_ves / cp.rate_end : 0;
|
|
283
308
|
return `<div class="card">
|
|
284
|
-
<div class="card-hd"><span class="card-icon">💱</span><span class="card-title">Posición de Caja · Efecto Cambiario (VES)</span></div>
|
|
309
|
+
<div class="card-hd"><span class="card-icon">💱</span><span class="card-title">Posición de Caja · Conciliación · Efecto Cambiario (VES)</span></div>
|
|
285
310
|
<div class="card-bd"><div class="pnl">
|
|
286
|
-
<div class="pnl-row"><span class="pnl-label">Caja VES
|
|
287
|
-
<div class="pnl-row"><span class="pnl-label">
|
|
288
|
-
<div class="pnl-row gross"><span class="pnl-label">=
|
|
289
|
-
<div class="pnl-
|
|
290
|
-
|
|
291
|
-
|
|
311
|
+
<div class="pnl-row"><span class="pnl-label">Caja VES real (statement, a ${cp.rate_end} VES/USD)</span><span class="pnl-amt">${signed(t.ves_real_usd)}</span></div>
|
|
312
|
+
<div class="pnl-row"><span class="pnl-label">Brecha de conciliación (backlog a postear, NO es FX)</span><span class="pnl-amt">${fmt(t.recon_gap_ves)} VES (~${signed(reconUsd)})</span></div>
|
|
313
|
+
<div class="pnl-row gross"><span class="pnl-label">= Efecto cambiario sobre caja real${dev}</span><span class="pnl-amt ${t.fx_real < 0 ? 'val-red' : 'val-green'}">${signed(t.fx_real)}</span></div>
|
|
314
|
+
<div class="pnl-row sub"><span class="pnl-label">(ref. libro GL: ${signed(cp.gl_basis.ves_book_usd)} · FX s/GL ${signed(cp.gl_basis.fx_on_gl)})</span><span class="pnl-amt"></span></div>
|
|
315
|
+
<div class="pnl-section-hd">Bancos VES (saldo real vs libro)</div>
|
|
316
|
+
${bankRows}
|
|
317
|
+
${pendRows ? `<div class="pnl-section-hd">Sin statement (provisional)</div>${pendRows}` : ''}
|
|
318
|
+
${infoRows ? `<div class="pnl-section-hd">Ajustes / Tránsito (informativo, fuera del total)</div>${infoRows}` : ''}
|
|
319
|
+
${jRows ? `<div class="pnl-section-hd">Asiento sugerido — Diferencial Cambiario ${j.balanced ? '✓ cuadra' : '✗'}</div>${jRows}` : ''}
|
|
292
320
|
</div></div>
|
|
293
321
|
</div>`;
|
|
294
322
|
}
|
|
@@ -39,6 +39,7 @@ import { round2 } from '../../utils/currency-converter.js';
|
|
|
39
39
|
import { logger } from '../../utils/logger.js';
|
|
40
40
|
import { fetchStoreFinancials, periodLabel, previousMonthRange } from './statements.js';
|
|
41
41
|
import { buildCashFlowHTML } from './cash-flow-html.js';
|
|
42
|
+
import { computeCashPosition, consolidateCashPositions } from './fx-cash.js';
|
|
42
43
|
|
|
43
44
|
const TAX_RATE = 0.34; // ISLR Venezuela estándar (estimado)
|
|
44
45
|
|
|
@@ -216,7 +217,7 @@ async function buildStoreCashFlow(bcClient, store, start, end, opts = {}) {
|
|
|
216
217
|
fetchStoreFinancials(bcClient, store, start, end),
|
|
217
218
|
fetchWorkingCapitalAndCapex(bcClient, store.companyId, start, end),
|
|
218
219
|
opts.includeCashPosition && opts.rates
|
|
219
|
-
?
|
|
220
|
+
? computeCashPosition(bcClient, store, start, end, opts.rates).catch((e) => { logger.warn(`[cash-flow] cash position failed: ${e.message}`); return null; })
|
|
220
221
|
: Promise.resolve(null),
|
|
221
222
|
]);
|
|
222
223
|
|
|
@@ -244,80 +245,8 @@ async function buildStoreCashFlow(bcClient, store, start, end, opts = {}) {
|
|
|
244
245
|
);
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
// Regla VES (confirmada por FP, estructura compartida entre tiendas):
|
|
250
|
-
// bancos VES = 18200–18299 ; caja VEB = 18110/18130/18160. Resto de 18xxx = USD.
|
|
251
|
-
// El saldo VES vive en additionalCurrency (VES); el USD libro (debit/credit) quedó "stale"
|
|
252
|
-
// a tasas históricas. fx_adjustment = USD_real (VES/tasa_fin) − USD_libro (< 0 si VES se devaluó).
|
|
253
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
-
|
|
255
|
-
const VES_CASH_ACCOUNTS = new Set(['18110', '18130', '18160']);
|
|
256
|
-
function isVesCashAccount(accountNumber) {
|
|
257
|
-
const n = parseInt(accountNumber, 10);
|
|
258
|
-
return (n >= 18200 && n <= 18299) || VES_CASH_ACCOUNTS.has(String(accountNumber));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
async function fetchCashPosition(bcClient, companyId, start, end, rates) {
|
|
262
|
-
const entries = await bcClient.getCashGLBalances(companyId, end).catch((e) => {
|
|
263
|
-
logger.warn(`[cash-flow] cash balances fetch failed: ${e.message}`);
|
|
264
|
-
return null;
|
|
265
|
-
});
|
|
266
|
-
if (entries == null) return null;
|
|
267
|
-
|
|
268
|
-
const rateEnd = (bcClient.getExchangeRateForDate(rates, end) || {}).rate || null;
|
|
269
|
-
const rateStart = (bcClient.getExchangeRateForDate(rates, start) || {}).rate || null;
|
|
270
|
-
if (!rateEnd) return null;
|
|
271
|
-
|
|
272
|
-
const warnings = [];
|
|
273
|
-
if (entries._truncated) warnings.push('G/L de caja/bancos truncado — saldos pueden estar incompletos.');
|
|
274
|
-
|
|
275
|
-
// Acumular por cuenta: USD libro (debit−credit) y VES (additionalCurrency debit−credit)
|
|
276
|
-
// (no se usa e.description como nombre: es la nota de la transacción, no el nombre de la cuenta)
|
|
277
|
-
const acc = {};
|
|
278
|
-
for (const e of entries) {
|
|
279
|
-
const a = e.accountNumber;
|
|
280
|
-
if (!acc[a]) acc[a] = { usd: 0, ves: 0 };
|
|
281
|
-
acc[a].usd += (e.debitAmount || 0) - (e.creditAmount || 0);
|
|
282
|
-
acc[a].ves += (e.additionalCurrencyDebitAmount || 0) - (e.additionalCurrencyCreditAmount || 0);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const vesAccounts = [];
|
|
286
|
-
let vesBook = 0, vesReal = 0, usdTotal = 0;
|
|
287
|
-
for (const [a, v] of Object.entries(acc)) {
|
|
288
|
-
const usdBook = round2(v.usd);
|
|
289
|
-
if (Math.abs(usdBook) < 0.005 && Math.abs(v.ves) < 0.5) continue; // saldo cero
|
|
290
|
-
if (isVesCashAccount(a)) {
|
|
291
|
-
const usdReal = round2(v.ves / rateEnd);
|
|
292
|
-
vesBook += usdBook;
|
|
293
|
-
vesReal += usdReal;
|
|
294
|
-
const n = parseInt(a, 10);
|
|
295
|
-
vesAccounts.push({
|
|
296
|
-
account: a, name: n >= 18200 && n <= 18299 ? 'Banco VES' : 'Caja VES',
|
|
297
|
-
usd_book: usdBook, ves_balance: round2(v.ves), usd_real: usdReal,
|
|
298
|
-
fx_adjustment: round2(usdReal - usdBook),
|
|
299
|
-
});
|
|
300
|
-
} else {
|
|
301
|
-
usdTotal += usdBook; // cuentas USD: tenencia física en USD → sin ajuste
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
vesAccounts.sort((x, y) => x.fx_adjustment - y.fx_adjustment); // mayor pérdida primero
|
|
305
|
-
|
|
306
|
-
vesBook = round2(vesBook);
|
|
307
|
-
vesReal = round2(vesReal);
|
|
308
|
-
usdTotal = round2(usdTotal);
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
rate_end: round2(rateEnd),
|
|
312
|
-
rate_start: rateStart ? round2(rateStart) : null,
|
|
313
|
-
month_devaluation_pct: rateStart && rateStart > 0 ? round2((rateEnd / rateStart - 1) * 100) : null,
|
|
314
|
-
ves: { book_usd: vesBook, real_usd: vesReal, fx_adjustment: round2(vesReal - vesBook), accounts: vesAccounts },
|
|
315
|
-
usd: { total_usd: usdTotal },
|
|
316
|
-
total_cash_book_usd: round2(vesBook + usdTotal),
|
|
317
|
-
total_cash_real_usd: round2(vesReal + usdTotal),
|
|
318
|
-
warnings,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
248
|
+
// La posición de caja + efecto cambiario (revaluación del saldo VES contra el saldo REAL del
|
|
249
|
+
// banco, aislando el backlog de conciliación) vive en ./fx-cash.js (módulo compartido).
|
|
321
250
|
|
|
322
251
|
// Buckets del balance general (Chart of Accounts real de BC), por rango de cuenta.
|
|
323
252
|
// REGLA UNIVERSAL de impacto en caja: delta = −Σ(debit − credit) sobre el rango.
|
|
@@ -497,18 +426,28 @@ function buildBridge({ ebit, tax, nopat, delta_ar, delta_taxes, delta_prepaid, d
|
|
|
497
426
|
function buildInsights({ ebit, delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor, da, capex, fcf, fcfPct, status, cash_position, warnings }) {
|
|
498
427
|
const insights = [];
|
|
499
428
|
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
if (
|
|
429
|
+
// Posición de caja: separar la BRECHA DE CONCILIACIÓN (backlog en VES, a postear) del
|
|
430
|
+
// EFECTO CAMBIARIO sobre la caja real (asiento de diferencial cambiario). El FX se mide
|
|
431
|
+
// sobre el saldo real del banco (statement), no sobre el GL inflado por el backlog.
|
|
432
|
+
if (cash_position && cash_position.totals) {
|
|
433
|
+
const t = cash_position.totals;
|
|
434
|
+
if (Math.abs(t.recon_gap_ves) >= 1000) {
|
|
435
|
+
const gapUsd = cash_position.rate_end ? t.recon_gap_ves / cash_position.rate_end : 0;
|
|
436
|
+
insights.push(
|
|
437
|
+
`Brecha de conciliación: el GL excede al saldo real del banco en ${fmtAbs(t.recon_gap_ves)} VES ` +
|
|
438
|
+
`(~$${fmtAbs(gapUsd)}) — transacciones por postear; no es efecto cambiario.`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (t.fx_real < 0 && Math.abs(t.fx_real) >= 1) {
|
|
506
442
|
const dev = cash_position.month_devaluation_pct != null ? ` (devaluación del mes ${cash_position.month_devaluation_pct}%)` : '';
|
|
507
443
|
insights.push(
|
|
508
|
-
`
|
|
509
|
-
`
|
|
444
|
+
`Efecto cambiario sobre caja VES real: −$${fmtAbs(t.fx_real)}${dev} — postear asiento de ` +
|
|
445
|
+
`diferencial cambiario. Caja VES real $${fmtAbs(t.ves_real_usd)} (vs $${fmtAbs(cash_position.gl_basis.ves_book_usd)} en libros GL).`
|
|
510
446
|
);
|
|
511
447
|
}
|
|
448
|
+
if (cash_position.pending_no_statement && cash_position.pending_no_statement.length) {
|
|
449
|
+
insights.push(`${cash_position.pending_no_statement.length} cuenta(s) VES sin statement — revaluación provisional, conciliar antes de postear.`);
|
|
450
|
+
}
|
|
512
451
|
}
|
|
513
452
|
|
|
514
453
|
// Mayor driver negativo entre los usos de capital de trabajo (7 líneas)
|
|
@@ -597,39 +536,12 @@ function consolidateCashFlow(storeObjs) {
|
|
|
597
536
|
delta_labor: sum((o) => o.working_capital.delta_labor || 0),
|
|
598
537
|
capex: sum((o) => o.capex.amount),
|
|
599
538
|
capex_accounts,
|
|
600
|
-
cash_position:
|
|
539
|
+
cash_position: consolidateCashPositions(storeObjs.map((o) => (o.cash_position ? { ...o.cash_position, _store: o.store_code } : null))),
|
|
601
540
|
warnings: allWarnings,
|
|
602
541
|
}
|
|
603
542
|
);
|
|
604
543
|
}
|
|
605
544
|
|
|
606
|
-
// Consolida la posición de caja (FX) sumando escalares y concatenando cuentas VES.
|
|
607
|
-
function consolidateCashPosition(storeObjs) {
|
|
608
|
-
const positions = storeObjs.map((o) => o.cash_position).filter(Boolean);
|
|
609
|
-
if (!positions.length) return null;
|
|
610
|
-
const sumP = (fn) => round2(positions.reduce((s, p) => s + fn(p), 0));
|
|
611
|
-
const accounts = [];
|
|
612
|
-
for (const o of storeObjs) {
|
|
613
|
-
if (!o.cash_position) continue;
|
|
614
|
-
for (const a of o.cash_position.ves.accounts) accounts.push({ ...a, store: o.store_code });
|
|
615
|
-
}
|
|
616
|
-
accounts.sort((x, y) => x.fx_adjustment - y.fx_adjustment);
|
|
617
|
-
const vesBook = sumP((p) => p.ves.book_usd);
|
|
618
|
-
const vesReal = sumP((p) => p.ves.real_usd);
|
|
619
|
-
const usdTotal = sumP((p) => p.usd.total_usd);
|
|
620
|
-
const ref = positions[0]; // tasa VES compartida entre tiendas
|
|
621
|
-
return {
|
|
622
|
-
rate_end: ref.rate_end,
|
|
623
|
-
rate_start: ref.rate_start,
|
|
624
|
-
month_devaluation_pct: ref.month_devaluation_pct,
|
|
625
|
-
ves: { book_usd: vesBook, real_usd: vesReal, fx_adjustment: round2(vesReal - vesBook), accounts },
|
|
626
|
-
usd: { total_usd: usdTotal },
|
|
627
|
-
total_cash_book_usd: round2(vesBook + usdTotal),
|
|
628
|
-
total_cash_real_usd: round2(vesReal + usdTotal),
|
|
629
|
-
warnings: [],
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
|
|
633
545
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
634
546
|
// Comparación MoM (compacta)
|
|
635
547
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// tools/financials/fx-cash.js
|
|
2
|
+
//
|
|
3
|
+
// Módulo compartido: posición de caja + efecto cambiario sobre la caja VES, revaluando
|
|
4
|
+
// contra el SALDO REAL del banco (StatementEndingBalance), no el saldo GL (que puede estar
|
|
5
|
+
// inflado por backlog de conciliación). Usado por get_cash_flow (vista) y, a futuro, por el
|
|
6
|
+
// paso de cierre-mensual (asiento). READ-ONLY. NO afecta el FCF.
|
|
7
|
+
//
|
|
8
|
+
// MATEMÁTICA (por cuenta VES, robusta al backlog):
|
|
9
|
+
// gl_usd = Σ(debit−credit) USD libro
|
|
10
|
+
// gl_ves = Σ(additionalCurrency d−c) VES libro (puede estar inflado)
|
|
11
|
+
// implied_rate = gl_ves / gl_usd tasa promedio histórica de posteo
|
|
12
|
+
// stmt_ves = StatementEndingBalance VES real (del banco)
|
|
13
|
+
// recon_gap = gl_ves − stmt_ves backlog pendiente de postear (NO es FX)
|
|
14
|
+
// real_usd = stmt_ves / rate_end valor USD real de la caja
|
|
15
|
+
// fx_real = stmt_ves × (1/rate_end − 1/implied_rate) ← pérdida cambiaria sobre lo REAL
|
|
16
|
+
//
|
|
17
|
+
// 3 buckets de cuentas VES:
|
|
18
|
+
// 1) banco real con statement → fx_real, ENTRA al total y al asiento.
|
|
19
|
+
// 2) banco real sin statement → provisional sobre GL, flag, NO entra al total.
|
|
20
|
+
// 3) ajuste/tránsito (lista en config) → solo informativo, NO entra al total.
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { round2 } from '../../utils/currency-converter.js';
|
|
25
|
+
import { logger } from '../../utils/logger.js';
|
|
26
|
+
|
|
27
|
+
// ── Config bank-gl-map (cargado una vez) ───────────────────────────────────────
|
|
28
|
+
let _bankMap = null;
|
|
29
|
+
function bankMap() {
|
|
30
|
+
if (_bankMap) return _bankMap;
|
|
31
|
+
const path = fileURLToPath(new URL('../../config/bank-gl-map.json', import.meta.url));
|
|
32
|
+
_bankMap = JSON.parse(readFileSync(path, 'utf-8'));
|
|
33
|
+
return _bankMap;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Cuentas VES de ajuste/tránsito (bucket 3) — informativas, fuera del total/asiento.
|
|
37
|
+
function adjustmentAccounts() {
|
|
38
|
+
return new Set(bankMap().adjustment_accounts_ves || []);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Mapa GL→banco para una tienda: { '18250': { bankCode: 'FQ88-MN0005', name: 'Bancrecer 2558 Bs.' } }
|
|
42
|
+
// Solo cuentas VES (el statement está en VES).
|
|
43
|
+
function glToBankForStore(storeCode) {
|
|
44
|
+
const store = (bankMap().stores || {})[storeCode];
|
|
45
|
+
const out = {};
|
|
46
|
+
if (!store) return out;
|
|
47
|
+
for (const [suffix, info] of Object.entries(store.accounts || {})) {
|
|
48
|
+
if (!info.gl || info.currency !== 'VES') continue;
|
|
49
|
+
out[String(info.gl)] = { bankCode: `${storeCode}-${suffix}`, name: info.name || '' };
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Regla VES (confirmada por FP): bancos VES = 18200–18299 ; caja VEB = 18110/18130/18160.
|
|
55
|
+
const VES_CASH_ACCOUNTS = new Set(['18110', '18130', '18160']);
|
|
56
|
+
export function isVesCashAccount(accountNumber) {
|
|
57
|
+
const n = parseInt(accountNumber, 10);
|
|
58
|
+
return (n >= 18200 && n <= 18299) || VES_CASH_ACCOUNTS.has(String(accountNumber));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const RATE_MIN = 350, RATE_MAX = 750; // banda sana para la tasa implícita (flag si está fuera)
|
|
62
|
+
|
|
63
|
+
// ── Cálculo principal ──────────────────────────────────────────────────────────
|
|
64
|
+
// store = { code, name, companyId, companyName }
|
|
65
|
+
export async function computeCashPosition(bcClient, store, start, end, rates) {
|
|
66
|
+
const [entries, statements] = await Promise.all([
|
|
67
|
+
bcClient.getCashGLBalances(store.companyId, end).catch((e) => { logger.warn(`[fx-cash] GL balances failed: ${e.message}`); return null; }),
|
|
68
|
+
bcClient.getBankStatementBalances(store.code, end).catch((e) => { logger.warn(`[fx-cash] statement balances failed: ${e.message}`); return null; }),
|
|
69
|
+
]);
|
|
70
|
+
if (entries == null) return null;
|
|
71
|
+
|
|
72
|
+
const rateEnd = (bcClient.getExchangeRateForDate(rates, end) || {}).rate || null;
|
|
73
|
+
const rateStart = (bcClient.getExchangeRateForDate(rates, start) || {}).rate || null;
|
|
74
|
+
if (!rateEnd) return null;
|
|
75
|
+
|
|
76
|
+
const warnings = [];
|
|
77
|
+
if (entries._truncated) warnings.push('G/L de caja/bancos truncado — saldos pueden estar incompletos.');
|
|
78
|
+
if (statements == null) warnings.push('Reconciliaciones bancarias no disponibles — sin saldo real; revaluación provisional sobre GL.');
|
|
79
|
+
|
|
80
|
+
const glToBank = glToBankForStore(store.code);
|
|
81
|
+
const adjustment = adjustmentAccounts();
|
|
82
|
+
const stmts = statements || {};
|
|
83
|
+
|
|
84
|
+
// Acumular saldos GL por cuenta (USD libro + VES additionalCurrency)
|
|
85
|
+
const acc = {};
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
const a = e.accountNumber;
|
|
88
|
+
if (!acc[a]) acc[a] = { usd: 0, ves: 0 };
|
|
89
|
+
acc[a].usd += (e.debitAmount || 0) - (e.creditAmount || 0);
|
|
90
|
+
acc[a].ves += (e.additionalCurrencyDebitAmount || 0) - (e.additionalCurrencyCreditAmount || 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const bank_accounts = []; // bucket 1
|
|
94
|
+
const pending_no_statement = []; // bucket 2
|
|
95
|
+
const informational = []; // bucket 3
|
|
96
|
+
let usdTotal = 0;
|
|
97
|
+
let glVesBookSum = 0, glVesAtRateSum = 0; // referencia estilo v1.26.0 (todas las VES sobre GL)
|
|
98
|
+
|
|
99
|
+
for (const [a, v] of Object.entries(acc)) {
|
|
100
|
+
const glUsd = round2(v.usd);
|
|
101
|
+
const glVes = round2(v.ves);
|
|
102
|
+
if (Math.abs(glUsd) < 0.005 && Math.abs(glVes) < 0.5) continue; // saldo cero
|
|
103
|
+
|
|
104
|
+
if (!isVesCashAccount(a)) { usdTotal += glUsd; continue; } // cuentas USD: sin ajuste
|
|
105
|
+
|
|
106
|
+
// Referencia GL (v1.26.0): revaluar el saldo GL completo
|
|
107
|
+
glVesBookSum += glUsd;
|
|
108
|
+
glVesAtRateSum += glVes / rateEnd;
|
|
109
|
+
|
|
110
|
+
const impliedRate = glVes !== 0 && glUsd > 0 ? round2(glVes / glUsd) : null;
|
|
111
|
+
const flags = [];
|
|
112
|
+
if (impliedRate != null && (impliedRate < RATE_MIN || impliedRate > RATE_MAX)) flags.push('tasa_implicita_atipica');
|
|
113
|
+
const info = glToBank[a];
|
|
114
|
+
const stmt = info ? stmts[info.bankCode] : null;
|
|
115
|
+
|
|
116
|
+
// fx sobre un saldo VES dado, a la tasa implícita de la cuenta
|
|
117
|
+
const fxOn = (vesBalance) => (impliedRate ? round2(vesBalance * (1 / rateEnd - 1 / impliedRate)) : 0);
|
|
118
|
+
|
|
119
|
+
if (adjustment.has(String(a))) {
|
|
120
|
+
// Bucket 3 — ajuste/tránsito: informativo
|
|
121
|
+
informational.push({ account: a, name: info ? info.name : 'Ajuste/Tránsito VES', gl_usd: glUsd, gl_ves: glVes, implied_rate: impliedRate, fx_theoretical: fxOn(glVes), flags: [...flags, 'ajuste_transito'] });
|
|
122
|
+
} else if (info && stmt) {
|
|
123
|
+
// Bucket 1 — banco real con statement: revaluar saldo REAL
|
|
124
|
+
const stmtVes = round2(stmt.ending || 0);
|
|
125
|
+
const realUsd = round2(stmtVes / rateEnd);
|
|
126
|
+
const fxReal = fxOn(stmtVes);
|
|
127
|
+
if (stmt.stale) flags.push('statement_desactualizado');
|
|
128
|
+
bank_accounts.push({
|
|
129
|
+
account: a, bank_code: info.bankCode, name: info.name,
|
|
130
|
+
gl_usd: glUsd, gl_ves: glVes, implied_rate: impliedRate,
|
|
131
|
+
stmt_ves: stmtVes, statement_date: stmt.statementDate || null,
|
|
132
|
+
recon_gap_ves: round2(glVes - stmtVes), real_usd: realUsd, fx_real: fxReal, flags,
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
// Bucket 2 — banco/caja sin statement: provisional sobre GL
|
|
136
|
+
pending_no_statement.push({ account: a, name: info ? info.name : 'VES sin statement', gl_usd: glUsd, gl_ves: glVes, implied_rate: impliedRate, fx_provisional: fxOn(glVes), flags: [...flags, 'sin_statement'] });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
bank_accounts.sort((x, y) => x.fx_real - y.fx_real);
|
|
141
|
+
pending_no_statement.sort((x, y) => x.fx_provisional - y.fx_provisional);
|
|
142
|
+
informational.sort((x, y) => x.fx_theoretical - y.fx_theoretical);
|
|
143
|
+
|
|
144
|
+
const sum = (arr, fn) => round2(arr.reduce((s, o) => s + fn(o), 0));
|
|
145
|
+
const totals = {
|
|
146
|
+
ves_real_usd: sum(bank_accounts, (o) => o.real_usd),
|
|
147
|
+
fx_real: sum(bank_accounts, (o) => o.fx_real),
|
|
148
|
+
recon_gap_ves: sum(bank_accounts, (o) => o.recon_gap_ves),
|
|
149
|
+
bank_book_usd: sum(bank_accounts, (o) => o.gl_usd),
|
|
150
|
+
pending_book_usd: sum(pending_no_statement, (o) => o.gl_usd),
|
|
151
|
+
informational_book_usd: sum(informational, (o) => o.gl_usd),
|
|
152
|
+
usd_accounts_usd: round2(usdTotal),
|
|
153
|
+
};
|
|
154
|
+
totals.total_cash_real_usd = round2(totals.ves_real_usd + usdTotal);
|
|
155
|
+
|
|
156
|
+
const fx_journal = buildFxJournal(bank_accounts);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
rate_end: round2(rateEnd),
|
|
160
|
+
rate_start: rateStart ? round2(rateStart) : null,
|
|
161
|
+
month_devaluation_pct: rateStart && rateStart > 0 ? round2((rateEnd / rateStart - 1) * 100) : null,
|
|
162
|
+
bank_accounts,
|
|
163
|
+
pending_no_statement,
|
|
164
|
+
informational,
|
|
165
|
+
usd: { total_usd: round2(usdTotal) },
|
|
166
|
+
totals,
|
|
167
|
+
gl_basis: { ves_book_usd: round2(glVesBookSum), ves_at_rate_usd: round2(glVesAtRateSum), fx_on_gl: round2(glVesAtRateSum - glVesBookSum) },
|
|
168
|
+
fx_journal,
|
|
169
|
+
warnings,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Asiento sugerido de diferencial cambiario (READ-ONLY texto) — solo bucket 1 ──
|
|
174
|
+
// DR Pérdida Diferencial Cambiario / CR cada banco VES (en USD) por su fx_real.
|
|
175
|
+
export function buildFxJournal(bankAccounts) {
|
|
176
|
+
const PNL_ACCOUNT = { account: 'Diferencial Cambiario', note: 'Pérdida/Ganancia por diferencial cambiario (P&L)' };
|
|
177
|
+
const rows = [];
|
|
178
|
+
let bankDebit = 0, bankCredit = 0;
|
|
179
|
+
for (const b of bankAccounts) {
|
|
180
|
+
if (Math.abs(b.fx_real) < 0.005) continue;
|
|
181
|
+
// pérdida (fx_real<0) → CR banco ; ganancia (fx_real>0) → DR banco
|
|
182
|
+
const debit = b.fx_real > 0 ? round2(b.fx_real) : 0;
|
|
183
|
+
const credit = b.fx_real < 0 ? round2(-b.fx_real) : 0;
|
|
184
|
+
bankDebit += debit; bankCredit += credit;
|
|
185
|
+
rows.push({ account: b.account, name: b.name, account_type: 'Banco (G/L)', debit_usd: debit, credit_usd: credit });
|
|
186
|
+
}
|
|
187
|
+
const net = round2(bankCredit - bankDebit); // >0 = pérdida neta
|
|
188
|
+
// Contrapartida P&L
|
|
189
|
+
rows.push({
|
|
190
|
+
account: PNL_ACCOUNT.account, name: PNL_ACCOUNT.note, account_type: 'G/L (P&L)',
|
|
191
|
+
debit_usd: net > 0 ? net : 0, credit_usd: net < 0 ? round2(-net) : 0,
|
|
192
|
+
});
|
|
193
|
+
const debitTotal = round2(rows.reduce((s, r) => s + r.debit_usd, 0));
|
|
194
|
+
const creditTotal = round2(rows.reduce((s, r) => s + r.credit_usd, 0));
|
|
195
|
+
return { rows, debit_total: debitTotal, credit_total: creditTotal, balanced: Math.abs(debitTotal - creditTotal) < 0.01, net_fx: net };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Consolida posiciones de varias tiendas (suma bucket 1/2/3 y rehace totales/asiento).
|
|
199
|
+
export function consolidateCashPositions(positions) {
|
|
200
|
+
const ps = positions.filter(Boolean);
|
|
201
|
+
if (!ps.length) return null;
|
|
202
|
+
const ref = ps[0];
|
|
203
|
+
const tag = (arr, code) => arr.map((o) => ({ ...o, store: code }));
|
|
204
|
+
const bank_accounts = ps.flatMap((p) => tag(p.bank_accounts, p._store)).sort((a, b) => a.fx_real - b.fx_real);
|
|
205
|
+
const pending_no_statement = ps.flatMap((p) => tag(p.pending_no_statement, p._store));
|
|
206
|
+
const informational = ps.flatMap((p) => tag(p.informational, p._store));
|
|
207
|
+
const sum = (fn) => round2(ps.reduce((s, p) => s + fn(p), 0));
|
|
208
|
+
const usdTotal = sum((p) => p.usd.total_usd);
|
|
209
|
+
const totals = {
|
|
210
|
+
ves_real_usd: sum((p) => p.totals.ves_real_usd),
|
|
211
|
+
fx_real: sum((p) => p.totals.fx_real),
|
|
212
|
+
recon_gap_ves: sum((p) => p.totals.recon_gap_ves),
|
|
213
|
+
bank_book_usd: sum((p) => p.totals.bank_book_usd),
|
|
214
|
+
pending_book_usd: sum((p) => p.totals.pending_book_usd),
|
|
215
|
+
informational_book_usd: sum((p) => p.totals.informational_book_usd),
|
|
216
|
+
usd_accounts_usd: round2(usdTotal),
|
|
217
|
+
};
|
|
218
|
+
totals.total_cash_real_usd = round2(totals.ves_real_usd + usdTotal);
|
|
219
|
+
return {
|
|
220
|
+
rate_end: ref.rate_end, rate_start: ref.rate_start, month_devaluation_pct: ref.month_devaluation_pct,
|
|
221
|
+
bank_accounts, pending_no_statement, informational,
|
|
222
|
+
usd: { total_usd: round2(usdTotal) }, totals,
|
|
223
|
+
gl_basis: { ves_book_usd: sum((p) => p.gl_basis.ves_book_usd), ves_at_rate_usd: sum((p) => p.gl_basis.ves_at_rate_usd), fx_on_gl: sum((p) => p.gl_basis.fx_on_gl) },
|
|
224
|
+
fx_journal: buildFxJournal(bank_accounts),
|
|
225
|
+
warnings: [...new Set(ps.flatMap((p) => p.warnings))],
|
|
226
|
+
};
|
|
227
|
+
}
|