@fullqueso/mcp-bc-gastos 1.24.0 → 1.26.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 +28 -0
- package/config/income-accounts.js +1 -1
- package/lib/bc-client.js +13 -0
- package/package.json +1 -1
- package/tools/financials/cash-flow-html.js +25 -0
- package/tools/financials/cash-flow.js +152 -21
- package/tools/financials/html-template.js +45 -18
- package/tools/financials/statements.js +68 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [1.26.0] — 2026-06-05
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`get_cash_flow` — posición de caja + efecto cambiario sobre la caja VES (Fase 2 FX).** Los bancos/caja en bolívares reciben depósitos que se dolarizan a la tasa del día, pero el VES se devalúa ~0.75–1%/día y BC **no postea revaluación** → el USD en libros sobrestima la caja real. Nuevo bloque `cash_position` que revalúa el saldo VES a la tasa de fin de período. **No afecta el FCF** (queda 100% operativo; el efecto cambiario es un memo de balance).
|
|
7
|
+
- **Fuente:** `generalLedgerEntries` trae ambas monedas por cuenta — `debitAmount/creditAmount` = USD (libro) y `additionalCurrencyDebitAmount/additionalCurrencyCreditAmount` = VES (saldo físico). Nuevo método `bcClient.getCashGLBalances(companyId, asOfDate)` (acumulado a fecha, cuentas 18xxx, ambas monedas).
|
|
8
|
+
- **Métrica:** por cada cuenta VES, `usd_real = ves_balance / rate_end`; `fx_adjustment = usd_real − usd_book` (< 0 cuando el VES se devaluó). `cash_position` expone `ves {book_usd, real_usd, fx_adjustment, accounts[]}`, `usd {total_usd}`, `total_cash_book_usd`, `total_cash_real_usd`, `rate_end`, `month_devaluation_pct`. Insight automático cuantificando la sobrestimación.
|
|
9
|
+
- **Regla VES (estructural, confirmada por FP):** bancos VES = `18200–18299`; caja VEB = `18110/18130/18160`; resto de 18xxx = USD (sin ajuste, tenencia física en USD). Captura todas las cuentas VES sin mapear banco→GL por cuenta.
|
|
10
|
+
- Nuevo param `include_cash_position` (default `true`). Card HTML "Posición de Caja · Efecto Cambiario (VES)" en single/multi/consolidado. Solo se calcula para el período actual (es saldo a fin de período).
|
|
11
|
+
- **Verificado contra BC real (FQ88, 31-may-2026):** caja VES libro **$34,873** → real **$32,583** (a 554.43 VES/USD) → ajuste **−$2,290** (6.57%); caja total libro $35,310 → real $33,020; devaluación del mes **13.25%**. La regla estructural capturó las 10 cuentas VES (2 más que el sondeo manual: 18275, 18297). FCF intacto en $30,165.08.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [1.25.0] — 2026-06-05
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **`get_financial_statements` — separación de ventas intercompañía (40310/50110) → P&L "core" comparable.** La cuenta **40310 "Ventas Materia Prima otras tiendas"** (venta de insumos a otras tiendas de la marca, bajo margen) y su costo directo **50110 "Costo Directo Intercompañía"** inflaban el ingreso y distorsionaban el margen del local, rompiendo la comparabilidad con tiendas que no venden a otras (p.ej. FQ28). Ahora se separan:
|
|
19
|
+
- Nuevo bloque `intercompany_sales: { revenue (40310), cost (50110), gross_margin:{amount,pct}, revenue_account, cost_account }`.
|
|
20
|
+
- Nuevo bloque `core` = P&L excluyendo intercompañía (`income`, `cogs`, `gross_margin`, `operating_income`, `ebitda`, `ratios`, `score`), calculado sobre `coreIncome = income − 40310` y `coreCogs = cogs − 50110`. **Core es la métrica principal** del reporte (comparable entre tiendas); el total se mantiene como cifra actual.
|
|
21
|
+
- Campos top-level (`income`, `cogs`, `gross_margin`, `operating_income`, `ebitda`, `ratios`, `score`) **siguen siendo TOTAL** (actual) → `get_cash_flow` no cambia (la venta intercompañía es caja real; su FCF la sigue incluyendo).
|
|
22
|
+
- `insights` y la comparación MoM (`buildComparison`) pasan a basarse en **core**. HTML: score ring + KPIs de margen + ratios + comparativo multi-tienda sobre core; tarjeta del P&L agrega memo "Ventas a otras tiendas (intercompañía)" + subtotal "= CORE (comparable)" (solo si hay ventas intercompañía en el período).
|
|
23
|
+
- Corregido el nombre de la cuenta 40310 en `config/income-accounts.js` (decía "Ventas Facturación Directa" → "Ventas Materia Prima otras tiendas").
|
|
24
|
+
- **Verificado contra BC real (mayo 2026):** FQ28 (no vende a otras) core == total (39.42%); FQ01 GM% 38.86→**41.34** core; FQ88 GM% 41.25→**42.24** core y OM% 11.12→**8.49** core (las ventas intercompañía estaban inflando el margen operativo — el core revela la rentabilidad real). Reconcilia: core + intercompañía == total en las 3 tiendas.
|
|
25
|
+
|
|
26
|
+
### Pendiente (fases futuras)
|
|
27
|
+
- **Efecto cambiario sobre caja VES** (Fase 2): línea reconciliadora bajo el FCF. La brecha entre el margen intercompañía reportado (26–33%) y el nominal (5–9%) es la pérdida cambiaria del insumo (tasa Binance) no contabilizada en 50110 — se conecta con este tema.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
3
31
|
## [1.24.0] — 2026-06-05
|
|
4
32
|
|
|
5
33
|
### Changed
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export const REVENUE_ACCOUNTS = {
|
|
6
6
|
40110: { name: 'Ventas Productos (Pedidos)', nameEn: 'Product Sales (Orders)' },
|
|
7
7
|
40160: { name: 'Ventas Productos (Órdenes)', nameEn: 'Product Sales (Orders)' },
|
|
8
|
-
40310: { name: 'Ventas
|
|
8
|
+
40310: { name: 'Ventas Materia Prima otras tiendas', nameEn: 'Raw Material Sales to Other Stores (intercompany)' },
|
|
9
9
|
40450: { name: 'Ingresos Varios Pedidos', nameEn: 'Misc Order Revenue' },
|
|
10
10
|
40460: { name: 'Ingresos Varios Órdenes', nameEn: 'Misc Order Revenue' },
|
|
11
11
|
40520: { name: 'Ventas Diarias', nameEn: 'Daily Sales' },
|
package/lib/bc-client.js
CHANGED
|
@@ -316,6 +316,19 @@ export class BCClient {
|
|
|
316
316
|
return this.apiCallAllPages(url);
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
// Saldos acumulados (a fecha) de cuentas con AMBAS monedas: debit/creditAmount = USD (LCY)
|
|
320
|
+
// y additionalCurrency*Amount = VES (moneda adicional). Para revaluación de caja VES.
|
|
321
|
+
// postingDate le asOfDate (acumulado desde el inicio = saldo a la fecha).
|
|
322
|
+
async getCashGLBalances(companyId, asOfDate, accountMin = '18000', accountMax = '18999', maxPages = 60) {
|
|
323
|
+
const ed = this._sanitizeOData(asOfDate);
|
|
324
|
+
const url = this.buildApiUrl(companyId, 'generalLedgerEntries', {
|
|
325
|
+
$filter: `postingDate le ${ed} and accountNumber ge '${accountMin}' and accountNumber le '${accountMax}'`,
|
|
326
|
+
$select: 'accountNumber,description,debitAmount,creditAmount,additionalCurrencyDebitAmount,additionalCurrencyCreditAmount',
|
|
327
|
+
$orderby: 'postingDate desc',
|
|
328
|
+
});
|
|
329
|
+
return this.apiCallAllPages(url, maxPages);
|
|
330
|
+
}
|
|
331
|
+
|
|
319
332
|
// Fetch revenue entries (40000-49999) - credit balance = income
|
|
320
333
|
async getRevenueEntries(companyId, startDate, endDate) {
|
|
321
334
|
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.26.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": {
|
|
@@ -38,6 +38,7 @@ ${renderWaterfall(s.fcf_bridge)}
|
|
|
38
38
|
${renderKpis(s)}
|
|
39
39
|
${renderWorkingCapital(s)}
|
|
40
40
|
${renderCapexCard(s)}
|
|
41
|
+
${renderCashPositionCard(s)}
|
|
41
42
|
${comparison ? renderComparisonCard({ [code]: comparison }, result) : ''}
|
|
42
43
|
`;
|
|
43
44
|
return renderShell(`${s.store_code} · Cash Flow · ${result.period.label}`, header, body, result);
|
|
@@ -67,6 +68,7 @@ function renderMulti(result) {
|
|
|
67
68
|
${renderWaterfall(s.fcf_bridge)}
|
|
68
69
|
${renderKpis(s)}
|
|
69
70
|
${renderWorkingCapital(s)}
|
|
71
|
+
${renderCashPositionCard(s)}
|
|
70
72
|
</div>
|
|
71
73
|
</details>`;
|
|
72
74
|
})
|
|
@@ -78,6 +80,7 @@ ${renderStoresComparison(result)}
|
|
|
78
80
|
${con ? renderWaterfall(con.fcf_bridge) : ''}
|
|
79
81
|
${con ? renderKpis(con) : ''}
|
|
80
82
|
${con ? renderWorkingCapital(con) : ''}
|
|
83
|
+
${con ? renderCashPositionCard(con) : ''}
|
|
81
84
|
${result.comparison ? renderComparisonCard(result.comparison, result) : ''}
|
|
82
85
|
<div class="section-label">Detalle por tienda</div>
|
|
83
86
|
${blocks}
|
|
@@ -268,6 +271,28 @@ function renderCapexCard(s) {
|
|
|
268
271
|
</div>`;
|
|
269
272
|
}
|
|
270
273
|
|
|
274
|
+
// Posición de caja + efecto cambiario sobre la caja VES (no afecta el FCF).
|
|
275
|
+
function renderCashPositionCard(s) {
|
|
276
|
+
const cp = s.cash_position;
|
|
277
|
+
if (!cp || !cp.ves || !cp.ves.accounts.length) return '';
|
|
278
|
+
const adj = cp.ves.fx_adjustment;
|
|
279
|
+
const dev = cp.month_devaluation_pct != null ? ` · devaluación mes ${cp.month_devaluation_pct}%` : '';
|
|
280
|
+
const acctRows = cp.ves.accounts
|
|
281
|
+
.map((a) => `<div class="pnl-row sub"><span class="pnl-label">${esc(a.account)} ${esc(a.name || '')}${a.store ? ` <span class="pnl-acct">${esc(a.store)}</span>` : ''}</span><span class="pnl-amt ${a.fx_adjustment < 0 ? 'val-red' : ''}">${signed(a.fx_adjustment)}</span></div>`)
|
|
282
|
+
.join('');
|
|
283
|
+
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>
|
|
285
|
+
<div class="card-bd"><div class="pnl">
|
|
286
|
+
<div class="pnl-row"><span class="pnl-label">Caja VES — libro (USD)</span><span class="pnl-amt">${signed(cp.ves.book_usd)}</span></div>
|
|
287
|
+
<div class="pnl-row"><span class="pnl-label">Caja VES — real (a ${cp.rate_end} VES/USD)</span><span class="pnl-amt">${signed(cp.ves.real_usd)}</span></div>
|
|
288
|
+
<div class="pnl-row gross"><span class="pnl-label">= Ajuste cambiario no contabilizado${dev}</span><span class="pnl-amt ${adj < 0 ? 'val-red' : 'val-green'}">${signed(adj)}</span></div>
|
|
289
|
+
<div class="pnl-section-hd">Por cuenta VES</div>
|
|
290
|
+
${acctRows}
|
|
291
|
+
<div class="pnl-row total"><span class="pnl-label">Caja total — libro ${signed(cp.total_cash_book_usd)} → real</span><span class="pnl-amt">${signed(cp.total_cash_real_usd)}</span></div>
|
|
292
|
+
</div></div>
|
|
293
|
+
</div>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
271
296
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
297
|
// Comparativo de tiendas (multi)
|
|
273
298
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -53,7 +53,9 @@ export const cashFlowTool = {
|
|
|
53
53
|
'Parte del EBIT (igual que get_financial_statements), resta impuesto estimado (ISLR 34%), ajusta por capital de trabajo ' +
|
|
54
54
|
'(7 líneas desde net-change G/L: Δ cuentas por cobrar, Δ impuestos corrientes, Δ prepagados, Δ inventario, Δ cuentas por pagar, ' +
|
|
55
55
|
'Δ impuestos por pagar, Δ pasivos laborales) para obtener el flujo de caja operativo (CFO), y resta CapEx (PP&E 11110-12240) ' +
|
|
56
|
-
'para el FCF. Reporta también D&A y EBITDA (EBIT + D&A). Incluye
|
|
56
|
+
'para el FCF. Reporta también D&A y EBITDA (EBIT + D&A). Incluye además la posición de caja con efecto cambiario: revalúa ' +
|
|
57
|
+
'el saldo de las cuentas de banco/caja en bolívares (VES) a la tasa de fin de período y muestra cuánto sobrestiman los libros ' +
|
|
58
|
+
'la caja real por la devaluación no contabilizada (no afecta el FCF). Incluye NOPAT, margen FCF, status ' +
|
|
57
59
|
'(healthy/positive_low/negative_watch/' +
|
|
58
60
|
'negative_critical), bridge tipo waterfall e insights automáticos. Multi-tienda calcula consolidado. Con render_html=true ' +
|
|
59
61
|
'genera un dashboard HTML (lo guarda en ~/Downloads y retorna la ruta). Úsalo cuando el usuario pida flujo de caja, ' +
|
|
@@ -81,6 +83,11 @@ export const cashFlowTool = {
|
|
|
81
83
|
description: 'Si true, incluye comparación con el mes anterior (MoM). Default: false.',
|
|
82
84
|
default: false,
|
|
83
85
|
},
|
|
86
|
+
include_cash_position: {
|
|
87
|
+
type: 'boolean',
|
|
88
|
+
description: 'Si true (default), calcula la posición de caja y el efecto cambiario sobre la caja VES (revaluación del saldo en bolívares a la tasa de fin de período). No afecta el FCF.',
|
|
89
|
+
default: true,
|
|
90
|
+
},
|
|
84
91
|
render_html: {
|
|
85
92
|
type: 'boolean',
|
|
86
93
|
description: 'Si true, genera el dashboard HTML, lo guarda en ~/Downloads y retorna la ruta. Default: false.',
|
|
@@ -112,12 +119,22 @@ export async function getCashFlow(bcClient, params = {}) {
|
|
|
112
119
|
|
|
113
120
|
const compare = !!params.compare_previous;
|
|
114
121
|
const prevRange = compare ? previousMonthRange(start, end) : null;
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
const includeCashPosition = params.include_cash_position !== false; // default true
|
|
123
|
+
|
|
124
|
+
// Tipo de cambio VES: serie completa (compartida); se obtiene una vez y se pasa a las
|
|
125
|
+
// tiendas (la posición de caja la usa para revaluar el saldo VES a la tasa de fin de mes).
|
|
126
|
+
const rates = await bcClient.getExchangeRates(stores[0].code).catch((err) => {
|
|
127
|
+
logger.warn(`[cash-flow] exchange rate fetch failed: ${err.message}`);
|
|
128
|
+
return null;
|
|
129
|
+
});
|
|
130
|
+
const fx = rates ? bcClient.getExchangeRateForDate(rates, end) : null;
|
|
131
|
+
|
|
132
|
+
// ── Fetch en paralelo: cash flow actual + previo por tienda ──
|
|
133
|
+
// La posición de caja (FX) solo se calcula para el período actual (es saldo a fin de período).
|
|
117
134
|
const tasks = [];
|
|
118
135
|
for (const s of stores) {
|
|
119
136
|
tasks.push(
|
|
120
|
-
buildStoreCashFlow(bcClient, s, start, end)
|
|
137
|
+
buildStoreCashFlow(bcClient, s, start, end, { rates, includeCashPosition })
|
|
121
138
|
.then((cf) => ({ kind: 'current', code: s.code, cf }))
|
|
122
139
|
.catch((err) => ({ kind: 'current', code: s.code, error: err.message, store: s }))
|
|
123
140
|
);
|
|
@@ -125,24 +142,14 @@ export async function getCashFlow(bcClient, params = {}) {
|
|
|
125
142
|
if (compare) {
|
|
126
143
|
for (const s of stores) {
|
|
127
144
|
tasks.push(
|
|
128
|
-
buildStoreCashFlow(bcClient, s, prevRange.start, prevRange.end)
|
|
145
|
+
buildStoreCashFlow(bcClient, s, prevRange.start, prevRange.end, { rates, includeCashPosition: false })
|
|
129
146
|
.then((cf) => ({ kind: 'previous', code: s.code, cf }))
|
|
130
147
|
.catch((err) => ({ kind: 'previous', code: s.code, error: err.message }))
|
|
131
148
|
);
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
|
|
135
|
-
const
|
|
136
|
-
.getExchangeRates(stores[0].code)
|
|
137
|
-
.then((rates) => bcClient.getExchangeRateForDate(rates, end))
|
|
138
|
-
.catch((err) => {
|
|
139
|
-
logger.warn(`[cash-flow] exchange rate fetch failed: ${err.message}`);
|
|
140
|
-
return null;
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const settled = await Promise.all([fxTask, ...tasks]);
|
|
144
|
-
const fx = settled[0];
|
|
145
|
-
const fetched = settled.slice(1);
|
|
152
|
+
const fetched = await Promise.all(tasks);
|
|
146
153
|
|
|
147
154
|
// ── Partición current / previous ──
|
|
148
155
|
const storesOut = {};
|
|
@@ -201,13 +208,16 @@ export async function getCashFlow(bcClient, params = {}) {
|
|
|
201
208
|
// Cash flow de una tienda
|
|
202
209
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
203
210
|
|
|
204
|
-
async function buildStoreCashFlow(bcClient, store, start, end) {
|
|
211
|
+
async function buildStoreCashFlow(bcClient, store, start, end, opts = {}) {
|
|
205
212
|
logger.info(`[cash-flow] ${store.code}: ${start} → ${end}`);
|
|
206
213
|
|
|
207
|
-
// P&L base (income/cogs/EBIT) en paralelo con working capital + capex
|
|
208
|
-
const [{ financials }, wc] = await Promise.all([
|
|
214
|
+
// P&L base (income/cogs/EBIT) en paralelo con working capital + capex + posición de caja (FX)
|
|
215
|
+
const [{ financials }, wc, cashPosition] = await Promise.all([
|
|
209
216
|
fetchStoreFinancials(bcClient, store, start, end),
|
|
210
217
|
fetchWorkingCapitalAndCapex(bcClient, store.companyId, start, end),
|
|
218
|
+
opts.includeCashPosition && opts.rates
|
|
219
|
+
? fetchCashPosition(bcClient, store.companyId, start, end, opts.rates)
|
|
220
|
+
: Promise.resolve(null),
|
|
211
221
|
]);
|
|
212
222
|
|
|
213
223
|
return assembleCashFlow(
|
|
@@ -228,11 +238,87 @@ async function buildStoreCashFlow(bcClient, store, start, end) {
|
|
|
228
238
|
delta_labor: wc.delta_labor,
|
|
229
239
|
capex: wc.capex,
|
|
230
240
|
capex_accounts: wc.capex_accounts,
|
|
241
|
+
cash_position: cashPosition,
|
|
231
242
|
warnings: wc.warnings,
|
|
232
243
|
}
|
|
233
244
|
);
|
|
234
245
|
}
|
|
235
246
|
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
// Posición de caja + efecto cambiario (devaluación VES) — READ-ONLY, NO afecta el FCF.
|
|
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
|
+
}
|
|
321
|
+
|
|
236
322
|
// Buckets del balance general (Chart of Accounts real de BC), por rango de cuenta.
|
|
237
323
|
// REGLA UNIVERSAL de impacto en caja: delta = −Σ(debit − credit) sobre el rango.
|
|
238
324
|
// - Activo que sube (debit) → −Σ < 0 = uso de caja.
|
|
@@ -359,7 +445,8 @@ function assembleCashFlow(meta, c) {
|
|
|
359
445
|
const status = fcfStatus(fcf, fcfPct, ebit);
|
|
360
446
|
const wc = { delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor };
|
|
361
447
|
const bridge = buildBridge({ ebit, tax, nopat, ...wc, cfo, capex, fcf });
|
|
362
|
-
const
|
|
448
|
+
const cash_position = c.cash_position || null;
|
|
449
|
+
const insights = buildInsights({ ebit, ...wc, da, capex, fcf, fcfPct, status, cash_position, warnings: c.warnings || [] });
|
|
363
450
|
|
|
364
451
|
return {
|
|
365
452
|
store_code: meta.store_code,
|
|
@@ -376,6 +463,7 @@ function assembleCashFlow(meta, c) {
|
|
|
376
463
|
cfo: { amount: cfo, pct: cfoPct },
|
|
377
464
|
capex: { amount: capex, accounts: c.capex_accounts || {} },
|
|
378
465
|
fcf: { amount: fcf, pct: fcfPct, status },
|
|
466
|
+
cash_position,
|
|
379
467
|
fcf_bridge: bridge,
|
|
380
468
|
insights,
|
|
381
469
|
};
|
|
@@ -406,9 +494,23 @@ function buildBridge({ ebit, tax, nopat, delta_ar, delta_taxes, delta_prepaid, d
|
|
|
406
494
|
];
|
|
407
495
|
}
|
|
408
496
|
|
|
409
|
-
function buildInsights({ ebit, delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor, da, capex, fcf, fcfPct, status, warnings }) {
|
|
497
|
+
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 }) {
|
|
410
498
|
const insights = [];
|
|
411
499
|
|
|
500
|
+
// Efecto cambiario sobre caja VES — el saldo en libros sobrestima la caja real porque
|
|
501
|
+
// los bolívares no se han revaluado a la tasa de cierre (devaluación no contabilizada).
|
|
502
|
+
if (cash_position && cash_position.ves.book_usd > 0) {
|
|
503
|
+
const adj = cash_position.ves.fx_adjustment;
|
|
504
|
+
const pct = round2((adj / cash_position.ves.book_usd) * 100);
|
|
505
|
+
if (adj < 0 && Math.abs(adj) >= 1) {
|
|
506
|
+
const dev = cash_position.month_devaluation_pct != null ? ` (devaluación del mes ${cash_position.month_devaluation_pct}%)` : '';
|
|
507
|
+
insights.push(
|
|
508
|
+
`Caja VES sobrestimada $${fmtAbs(adj)} (${Math.abs(pct)}%) por devaluación no contabilizada — ` +
|
|
509
|
+
`valor real $${fmtAbs(cash_position.total_cash_real_usd)} vs $${fmtAbs(cash_position.total_cash_book_usd)} en libros${dev}.`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
412
514
|
// Mayor driver negativo entre los usos de capital de trabajo (7 líneas)
|
|
413
515
|
const uses = [
|
|
414
516
|
{ key: 'delta_inventory', label: 'Inventario', val: delta_inventory },
|
|
@@ -453,6 +555,7 @@ function buildInsights({ ebit, delta_ar, delta_taxes, delta_prepaid, delta_inven
|
|
|
453
555
|
}
|
|
454
556
|
|
|
455
557
|
insights.push(...warnings);
|
|
558
|
+
if (cash_position && cash_position.warnings) insights.push(...cash_position.warnings);
|
|
456
559
|
|
|
457
560
|
if (insights.length === 0) {
|
|
458
561
|
insights.push('Flujo de caja dentro de rangos normales.');
|
|
@@ -494,11 +597,39 @@ function consolidateCashFlow(storeObjs) {
|
|
|
494
597
|
delta_labor: sum((o) => o.working_capital.delta_labor || 0),
|
|
495
598
|
capex: sum((o) => o.capex.amount),
|
|
496
599
|
capex_accounts,
|
|
600
|
+
cash_position: consolidateCashPosition(storeObjs),
|
|
497
601
|
warnings: allWarnings,
|
|
498
602
|
}
|
|
499
603
|
);
|
|
500
604
|
}
|
|
501
605
|
|
|
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
|
+
|
|
502
633
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
503
634
|
// Comparación MoM (compacta)
|
|
504
635
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -28,7 +28,7 @@ export function buildHTML(result) {
|
|
|
28
28
|
function renderSingle(result, code) {
|
|
29
29
|
const store = result.stores[code];
|
|
30
30
|
const comparison = result.comparison ? result.comparison[code] : null;
|
|
31
|
-
const headerHtml = renderHeader(store.store_code, store.store_name, store.score, result, comparison);
|
|
31
|
+
const headerHtml = renderHeader(store.store_code, store.store_name, store.core ? store.core.score : store.score, result, comparison);
|
|
32
32
|
const body = `
|
|
33
33
|
${renderTldr(store.insights)}
|
|
34
34
|
${renderKpiGrid(store)}
|
|
@@ -46,7 +46,7 @@ ${comparison ? renderComparisonCard({ [code]: comparison }, result) : ''}
|
|
|
46
46
|
|
|
47
47
|
function renderMulti(result) {
|
|
48
48
|
const con = result.consolidated;
|
|
49
|
-
const headerHtml = renderHeader('FQ', con ? con.store_name : 'Consolidado Full Queso', con ? con.score : 0, result, null);
|
|
49
|
+
const headerHtml = renderHeader('FQ', con ? con.store_name : 'Consolidado Full Queso', con ? (con.core ? con.core.score : con.score) : 0, result, null);
|
|
50
50
|
|
|
51
51
|
const storeBlocks = Object.keys(result.stores)
|
|
52
52
|
.map((code) => {
|
|
@@ -55,7 +55,7 @@ function renderMulti(result) {
|
|
|
55
55
|
<summary>
|
|
56
56
|
<span class="sd-icon">▸</span>
|
|
57
57
|
<span class="sd-name">${esc(s.store_name)}</span>
|
|
58
|
-
<span class="sd-meta">$${fmt(s.income.total)} · MB ${s.gross_margin.pct}% · MO ${s.operating_income.pct}% · ${scoreBadge(s.score)}</span>
|
|
58
|
+
<span class="sd-meta">$${fmt(s.income.total)} · MB ${(s.core || s).gross_margin.pct}% · MO ${(s.core || s).operating_income.pct}% · ${scoreBadge((s.core || s).score)}</span>
|
|
59
59
|
</summary>
|
|
60
60
|
<div class="sd-body">
|
|
61
61
|
${renderKpiGrid(s)}
|
|
@@ -158,26 +158,30 @@ function insightGlyph(type) {
|
|
|
158
158
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
159
|
|
|
160
160
|
function renderKpiGrid(s) {
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
161
|
+
// Márgenes sobre CORE (comparable entre tiendas); ingresos = total real.
|
|
162
|
+
const c = s.core || s;
|
|
163
|
+
const cr = c.ratios || s.ratios;
|
|
164
|
+
const gmStatus = cr.gross_margin_pct.status;
|
|
165
|
+
const opStatus = cr.operating_margin_pct.status;
|
|
166
|
+
const e2iPct = cr.expense_to_income_pct.value;
|
|
167
|
+
const hasInterco = s.intercompany_sales && s.intercompany_sales.revenue > 0;
|
|
164
168
|
|
|
165
169
|
return `<div class="kpi-grid">
|
|
166
170
|
<div class="kpi-cell">
|
|
167
171
|
<span class="kpi-cell-v">$${fmt(s.income.total)}</span>
|
|
168
|
-
<span class="kpi-cell-l">Ingresos</span>
|
|
172
|
+
<span class="kpi-cell-l">Ingresos${hasInterco ? `<br><span class="kpi-cell-sub">core $${fmt(c.income.total)}</span>` : ''}</span>
|
|
169
173
|
</div>
|
|
170
174
|
<div class="kpi-cell">
|
|
171
|
-
<span class="kpi-cell-v ${valClass(gmStatus)}">${
|
|
172
|
-
<span class="kpi-cell-l">Margen Bruto<br><span class="kpi-cell-sub">$${fmt(
|
|
175
|
+
<span class="kpi-cell-v ${valClass(gmStatus)}">${c.gross_margin.pct}%</span>
|
|
176
|
+
<span class="kpi-cell-l">Margen Bruto (core)<br><span class="kpi-cell-sub">$${fmt(c.gross_margin.amount)}</span></span>
|
|
173
177
|
</div>
|
|
174
178
|
<div class="kpi-cell">
|
|
175
|
-
<span class="kpi-cell-v ${valClass(opStatus)}">${
|
|
176
|
-
<span class="kpi-cell-l">Margen Operativo<br><span class="kpi-cell-sub">$${fmt(
|
|
179
|
+
<span class="kpi-cell-v ${valClass(opStatus)}">${c.operating_income.pct}%</span>
|
|
180
|
+
<span class="kpi-cell-l">Margen Operativo (core)<br><span class="kpi-cell-sub">$${fmt(c.operating_income.amount)}</span></span>
|
|
177
181
|
</div>
|
|
178
182
|
<div class="kpi-cell">
|
|
179
183
|
<span class="kpi-cell-v">$${fmt(s.expenses.total)}</span>
|
|
180
|
-
<span class="kpi-cell-l">Gastos Op.<br><span class="kpi-cell-sub">${e2iPct}% de ingresos</span></span>
|
|
184
|
+
<span class="kpi-cell-l">Gastos Op.<br><span class="kpi-cell-sub">${e2iPct}% de ingresos core</span></span>
|
|
181
185
|
</div>
|
|
182
186
|
</div>`;
|
|
183
187
|
}
|
|
@@ -225,11 +229,30 @@ function renderPnlCard(s) {
|
|
|
225
229
|
<div class="pnl-row operating"><span class="pnl-label">= Utilidad Operativa (EBIT) <span class="pnl-pct ${valClass(s.ratios.operating_margin_pct.status)}">${s.operating_income.pct}%</span></span><span class="pnl-amt ${valClass(s.ratios.operating_margin_pct.status)}">$${fmt(s.operating_income.amount)}</span></div>
|
|
226
230
|
${s.ebitda ? `<div class="pnl-row sub"><span class="pnl-label">(+) Depreciación y Amortización</span><span class="pnl-amt">$${fmt(s.depreciation_amortization.amount)}</span></div>
|
|
227
231
|
<div class="pnl-row gross"><span class="pnl-label">= EBITDA <span class="pnl-pct">${s.ebitda.pct}%</span></span><span class="pnl-amt">$${fmt(s.ebitda.amount)}</span></div>` : ''}
|
|
232
|
+
${renderCoreBlock(s)}
|
|
228
233
|
</div>
|
|
229
234
|
</div>
|
|
230
235
|
</div>`;
|
|
231
236
|
}
|
|
232
237
|
|
|
238
|
+
// Bloque intercompañía + CORE (solo si hay ventas a otras tiendas en el período).
|
|
239
|
+
function renderCoreBlock(s) {
|
|
240
|
+
const ic = s.intercompany_sales;
|
|
241
|
+
const c = s.core;
|
|
242
|
+
if (!ic || !c || !(ic.revenue > 0)) return '';
|
|
243
|
+
return `
|
|
244
|
+
<div class="pnl-section-hd">Ventas a otras tiendas (intercompañía)</div>
|
|
245
|
+
<div class="pnl-row sub"><span class="pnl-label">Ventas Materia Prima <span class="pnl-acct">${esc(ic.revenue_account)}</span></span><span class="pnl-amt">$${fmt(ic.revenue)}</span></div>
|
|
246
|
+
<div class="pnl-row sub"><span class="pnl-label">(−) Costo Directo Intercompañía <span class="pnl-acct">${esc(ic.cost_account)}</span></span><span class="pnl-amt">$${fmt(ic.cost)}</span></div>
|
|
247
|
+
<div class="pnl-row sub"><span class="pnl-label">= Margen intercompañía <span class="pnl-pct">${ic.gross_margin.pct}%</span></span><span class="pnl-amt">$${fmt(ic.gross_margin.amount)}</span></div>
|
|
248
|
+
|
|
249
|
+
<div class="pnl-section-hd">Core (comparable, sin intercompañía)</div>
|
|
250
|
+
<div class="pnl-row sub"><span class="pnl-label">Ingresos core</span><span class="pnl-amt">$${fmt(c.income.total)}</span></div>
|
|
251
|
+
<div class="pnl-row sub"><span class="pnl-label">(−) COGS core</span><span class="pnl-amt">$${fmt(c.cogs.total)}</span></div>
|
|
252
|
+
<div class="pnl-row gross"><span class="pnl-label">= Margen Bruto core <span class="pnl-pct ${valClass(c.ratios.gross_margin_pct.status)}">${c.gross_margin.pct}%</span></span><span class="pnl-amt ${valClass(c.ratios.gross_margin_pct.status)}">$${fmt(c.gross_margin.amount)}</span></div>
|
|
253
|
+
<div class="pnl-row operating"><span class="pnl-label">= Utilidad Operativa core <span class="pnl-pct ${valClass(c.ratios.operating_margin_pct.status)}">${c.operating_income.pct}%</span></span><span class="pnl-amt ${valClass(c.ratios.operating_margin_pct.status)}">$${fmt(c.operating_income.amount)}</span></div>`;
|
|
254
|
+
}
|
|
255
|
+
|
|
233
256
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
234
257
|
// Barras horizontales de desglose de gastos
|
|
235
258
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -266,6 +289,8 @@ function renderExpenseBars(s) {
|
|
|
266
289
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
267
290
|
|
|
268
291
|
function renderRatiosCard(s) {
|
|
292
|
+
// Ratios sobre CORE (consistente con el score core del ring).
|
|
293
|
+
const ratios = s.core ? s.core.ratios : s.ratios;
|
|
269
294
|
const order = [
|
|
270
295
|
['gross_margin_pct', 'Margen Bruto'],
|
|
271
296
|
['operating_margin_pct', 'Margen Operativo'],
|
|
@@ -276,9 +301,9 @@ function renderRatiosCard(s) {
|
|
|
276
301
|
['marketing_pct', 'Marketing'],
|
|
277
302
|
];
|
|
278
303
|
const rows = order
|
|
279
|
-
.filter(([k]) =>
|
|
304
|
+
.filter(([k]) => ratios[k])
|
|
280
305
|
.map(([k, label]) => {
|
|
281
|
-
const r =
|
|
306
|
+
const r = ratios[k];
|
|
282
307
|
return `<div class="ratio-row">
|
|
283
308
|
<span class="ratio-name">${esc(label)}</span>
|
|
284
309
|
<span class="ratio-val ${valClass(r.status)}">${r.value}%</span>
|
|
@@ -289,7 +314,7 @@ function renderRatiosCard(s) {
|
|
|
289
314
|
.join('');
|
|
290
315
|
|
|
291
316
|
return `<div class="card">
|
|
292
|
-
<div class="card-hd"><span class="card-icon">📈</span><span class="card-title">Ratios vs Benchmark</span></div>
|
|
317
|
+
<div class="card-hd"><span class="card-icon">📈</span><span class="card-title">Ratios vs Benchmark (core)</span></div>
|
|
293
318
|
<div class="card-bd"><div class="ratios">${rows}</div></div>
|
|
294
319
|
</div>`;
|
|
295
320
|
}
|
|
@@ -314,13 +339,15 @@ function renderMultiComparisonTable(result) {
|
|
|
314
339
|
${cols.map((c) => `<div class="ctbl-cell">${fn(c)}</div>`).join('')}
|
|
315
340
|
</div>`;
|
|
316
341
|
|
|
342
|
+
const core = (c) => c.core || c;
|
|
317
343
|
const body = [
|
|
318
344
|
row('Ingresos', (c) => `$${fmt(c.income.total)}`),
|
|
345
|
+
row('Vtas. intercompañía', (c) => `$${fmt(c.intercompany_sales ? c.intercompany_sales.revenue : 0)}`),
|
|
319
346
|
row('COGS', (c) => `$${fmt(c.cogs.total)}`),
|
|
320
|
-
row('
|
|
347
|
+
row('MB core', (c) => `<span class="${valClass(core(c).ratios.gross_margin_pct.status)}">${core(c).gross_margin.pct}%</span>`),
|
|
321
348
|
row('Gastos Op.', (c) => `$${fmt(c.expenses.total)}`),
|
|
322
|
-
row('
|
|
323
|
-
row('Score', (c) => scoreBadge(c.score), 'em'),
|
|
349
|
+
row('MO core', (c) => `<span class="${valClass(core(c).ratios.operating_margin_pct.status)}">${core(c).operating_income.pct}%</span>`, 'em'),
|
|
350
|
+
row('Score core', (c) => scoreBadge(core(c).score), 'em'),
|
|
324
351
|
].join('');
|
|
325
352
|
|
|
326
353
|
return `<div class="card">
|
|
@@ -46,9 +46,49 @@ function buildStoreFinancials(storeCode, storeName, incomeEntries, cogsEntries,
|
|
|
46
46
|
const ebitdaAmount = round2(opAmount + da);
|
|
47
47
|
const ebitdaPct = income.total > 0 ? round2((ebitdaAmount / income.total) * 100) : 0;
|
|
48
48
|
|
|
49
|
+
// ── Intercompañía: ventas de materia prima a otras tiendas (40310) y su costo
|
|
50
|
+
// directo (50110). Bajo margen (manejo 5-9%); se separan para una métrica core
|
|
51
|
+
// comparable entre tiendas (las que no venden a otras tienen 40310 = 0). ──
|
|
52
|
+
const intercoRevenue = round2(sumIncomeAccount(incomeEntries, '40310'));
|
|
53
|
+
const intercoCost = round2(cogs.accounts['50110'] || 0);
|
|
54
|
+
const intercoGm = round2(intercoRevenue - intercoCost);
|
|
55
|
+
const intercoGmPct = intercoRevenue > 0 ? round2((intercoGm / intercoRevenue) * 100) : 0;
|
|
56
|
+
const intercompany_sales = {
|
|
57
|
+
revenue: intercoRevenue,
|
|
58
|
+
cost: intercoCost,
|
|
59
|
+
gross_margin: { amount: intercoGm, pct: intercoGmPct },
|
|
60
|
+
revenue_account: '40310',
|
|
61
|
+
cost_account: '50110',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── CORE = P&L excluyendo intercompañía (comparable entre tiendas). El margen,
|
|
65
|
+
// ratios y score core son la métrica principal del reporte; el total queda como
|
|
66
|
+
// cifra actual. EBIT total NO cambia (get_cash_flow lo sigue usando). ──
|
|
67
|
+
const coreIncome = round2(income.total - intercoRevenue);
|
|
68
|
+
const coreCogs = round2(cogs.total - intercoCost);
|
|
69
|
+
const coreGm = round2(coreIncome - coreCogs);
|
|
70
|
+
const coreGmPct = coreIncome > 0 ? round2((coreGm / coreIncome) * 100) : 0;
|
|
71
|
+
const coreOp = round2(coreGm - expenses.total);
|
|
72
|
+
const coreOpPct = coreIncome > 0 ? round2((coreOp / coreIncome) * 100) : 0;
|
|
73
|
+
const coreEbitda = round2(coreOp + da);
|
|
74
|
+
const coreEbitdaPct = coreIncome > 0 ? round2((coreEbitda / coreIncome) * 100) : 0;
|
|
75
|
+
const coreRatios = calcRatios({ total: coreIncome }, { total: coreCogs }, expenses);
|
|
76
|
+
const coreScore = calcScore(coreRatios);
|
|
77
|
+
const core = {
|
|
78
|
+
income: { total: coreIncome },
|
|
79
|
+
cogs: { total: coreCogs },
|
|
80
|
+
gross_margin: { amount: coreGm, pct: coreGmPct },
|
|
81
|
+
operating_income: { amount: coreOp, pct: coreOpPct },
|
|
82
|
+
ebitda: { amount: coreEbitda, pct: coreEbitdaPct },
|
|
83
|
+
ratios: coreRatios,
|
|
84
|
+
score: coreScore,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Ratios/score TOTAL (compat + base del cash flow). Insights sobre CORE — más
|
|
88
|
+
// representativo de la rentabilidad real de la operación del local.
|
|
49
89
|
const ratios = calcRatios(income, cogs, expenses);
|
|
50
90
|
const score = calcScore(ratios);
|
|
51
|
-
const insights = generateInsights(expenses.categories,
|
|
91
|
+
const insights = generateInsights(expenses.categories, coreRatios, da);
|
|
52
92
|
|
|
53
93
|
return {
|
|
54
94
|
store_code: storeCode,
|
|
@@ -60,6 +100,8 @@ function buildStoreFinancials(storeCode, storeName, incomeEntries, cogsEntries,
|
|
|
60
100
|
operating_income: { amount: opAmount, pct: opPct },
|
|
61
101
|
depreciation_amortization: { amount: da },
|
|
62
102
|
ebitda: { amount: ebitdaAmount, pct: ebitdaPct },
|
|
103
|
+
intercompany_sales,
|
|
104
|
+
core,
|
|
63
105
|
ratios,
|
|
64
106
|
insights,
|
|
65
107
|
score,
|
|
@@ -76,6 +118,13 @@ function computeDepreciation(cogsEntries, expenseEntries) {
|
|
|
76
118
|
return round2(da);
|
|
77
119
|
}
|
|
78
120
|
|
|
121
|
+
// Σ(credit − debit) de una cuenta de ingreso específica (40000-49999 = saldo crédito).
|
|
122
|
+
function sumIncomeAccount(incomeEntries, accountNumber) {
|
|
123
|
+
let sum = 0;
|
|
124
|
+
for (const e of incomeEntries) if (e.accountNumber === accountNumber) sum += (e.creditAmount || 0) - (e.debitAmount || 0);
|
|
125
|
+
return sum;
|
|
126
|
+
}
|
|
127
|
+
|
|
79
128
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
129
|
// fetchStoreFinancials — 3 queries BC en paralelo para una tienda
|
|
81
130
|
// Devuelve { financials, raw } (raw se usa para consolidar sin re-consultar BC)
|
|
@@ -221,27 +270,33 @@ function buildComparison(current, previous, prevPeriod) {
|
|
|
221
270
|
|
|
222
271
|
const varPct = (cur, prev) => (prev !== 0 ? round2(((cur - prev) / Math.abs(prev)) * 100) : null);
|
|
223
272
|
|
|
273
|
+
// Comparar sobre CORE (margen/EBIT/score) para datos comparables entre tiendas;
|
|
274
|
+
// income se compara sobre total (ingreso real). Fallback a total si falta `core`.
|
|
275
|
+
const curCore = current.core || current;
|
|
276
|
+
const prevCore = previous.core || previous;
|
|
277
|
+
|
|
224
278
|
return {
|
|
225
279
|
previous_period: prevPeriod,
|
|
280
|
+
basis: 'core',
|
|
226
281
|
income: {
|
|
227
282
|
current: current.income.total,
|
|
228
283
|
previous: previous.income.total,
|
|
229
284
|
variance_pct: varPct(current.income.total, previous.income.total),
|
|
230
285
|
},
|
|
231
286
|
gross_margin_pct: {
|
|
232
|
-
current:
|
|
233
|
-
previous:
|
|
234
|
-
variance_pts: round2(
|
|
287
|
+
current: curCore.gross_margin.pct,
|
|
288
|
+
previous: prevCore.gross_margin.pct,
|
|
289
|
+
variance_pts: round2(curCore.gross_margin.pct - prevCore.gross_margin.pct),
|
|
235
290
|
},
|
|
236
291
|
operating_income: {
|
|
237
|
-
current:
|
|
238
|
-
previous:
|
|
239
|
-
variance_pct: varPct(
|
|
292
|
+
current: curCore.operating_income.amount,
|
|
293
|
+
previous: prevCore.operating_income.amount,
|
|
294
|
+
variance_pct: varPct(curCore.operating_income.amount, prevCore.operating_income.amount),
|
|
240
295
|
},
|
|
241
296
|
operating_margin_pct: {
|
|
242
|
-
current:
|
|
243
|
-
previous:
|
|
244
|
-
variance_pts: round2(
|
|
297
|
+
current: curCore.operating_income.pct,
|
|
298
|
+
previous: prevCore.operating_income.pct,
|
|
299
|
+
variance_pts: round2(curCore.operating_income.pct - prevCore.operating_income.pct),
|
|
245
300
|
},
|
|
246
301
|
expenses: {
|
|
247
302
|
current: current.expenses.total,
|
|
@@ -249,9 +304,9 @@ function buildComparison(current, previous, prevPeriod) {
|
|
|
249
304
|
variance_pct: varPct(current.expenses.total, previous.expenses.total),
|
|
250
305
|
},
|
|
251
306
|
score: {
|
|
252
|
-
current:
|
|
253
|
-
previous:
|
|
254
|
-
variance:
|
|
307
|
+
current: curCore.score,
|
|
308
|
+
previous: prevCore.score,
|
|
309
|
+
variance: curCore.score - prevCore.score,
|
|
255
310
|
},
|
|
256
311
|
};
|
|
257
312
|
}
|