@fullqueso/mcp-bc-gastos 1.23.1 → 1.25.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 +31 -0
- package/config/income-accounts.js +1 -1
- package/package.json +1 -1
- package/tools/financials/aggregator.js +12 -1
- package/tools/financials/cash-flow-html.js +9 -4
- package/tools/financials/cash-flow.js +147 -110
- package/tools/financials/html-template.js +48 -19
- package/tools/financials/statements.js +87 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [1.25.0] — 2026-06-05
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`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:
|
|
7
|
+
- Nuevo bloque `intercompany_sales: { revenue (40310), cost (50110), gross_margin:{amount,pct}, revenue_account, cost_account }`.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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).
|
|
10
|
+
- `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).
|
|
11
|
+
- Corregido el nombre de la cuenta 40310 en `config/income-accounts.js` (decía "Ventas Facturación Directa" → "Ventas Materia Prima otras tiendas").
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
14
|
+
### Pendiente (fases futuras)
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## [1.24.0] — 2026-06-05
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **`get_cash_flow` — working capital 100% desde net-change G/L (7 líneas) + D&A/EBITDA.** Se unificó todo el capital de trabajo sobre el Chart of Accounts real de BC, con la regla universal de impacto en caja `delta = −Σ(debit − credit)` (vale para activos: sube = uso, y pasivos: sube = fuente). Antes ΔAR/ΔAP venían del customer/vendor ledger y ΔInventario del item ledger (fuentes mezcladas); ahora las 7 líneas salen del balance general, misma base USD que el EBIT.
|
|
23
|
+
- **7 líneas de WC** (orden del bridge): ΔAR `15100–15199 + 15900–15998` · Δ Impuestos (activo) `15200–15299` · Δ Prepagados `16000–16999` · Δ Inventario `14000–14999` · ΔAP `22000–22999` · **Δ Impuestos por pagar `23000–23999`** (nueva) · **Δ Pasivos laborales `24000–25999`** (nueva). `net_wc_change` = suma de las 7.
|
|
24
|
+
- **CapEx** afinado a PP&E `11110–11190 + 12110–12240` (excluye `12900` depreciación). Implementado con **2 umbrella queries** (activos `11110–16999`, pasivos `22000–25999`) bucketizadas en código por rango de cuenta — `12900` se trae pero no matchea bucket → se descarta; `17xxx`/`18xxx` quedan fuera de rango. Reemplaza 6 consultas (3 G/L + 3 subledger) por 2.
|
|
25
|
+
- **D&A / EBITDA**: `get_financial_statements` ahora calcula D&A = `Σ(debit−credit)` en `58000–58999` (COGS) + `80000–89999` (OpEx) — filtrando las entries que ya trae (sin query nueva) — y expone `depreciation_amortization` + `ebitda` (`{amount, pct}`). EBIT no cambia; EBITDA = EBIT + D&A. `get_cash_flow` los hereda. Insight automático cuando D&A = 0 ("verificar si se registra depreciación en BC, cuentas 58100-58300 / 81000-82000"). Hoy D&A ≈ 0 → EBIT ≈ EBITDA.
|
|
26
|
+
- `fcf_bridge` pasa a **13 filas** (7 líneas WC). HTML: tarjeta de Capital de Trabajo con las 7 líneas; KPIs EBITDA + D&A; CapEx etiquetado `PP&E 11110-12240`; fila D&A + EBITDA en el P&L de `get_financial_statements`; fila EBITDA en el comparativo multi-tienda.
|
|
27
|
+
- **Verificado contra BC real (FQ88, mayo 2026):** CapEx ANTES `$8,760.92` (cuentas 151xx/152xx) → `$0.00` (sin movimientos PP&E). Cross-check G/L vs subledger casi idéntico (ΔInventario 8,643.73 → 8,644.31; ΔAP 10,559.77 → 10,594.77). Nuevas líneas: Δ Imp. por pagar `+$6,052.21`, Δ Pasivos laborales `+$1,294.00`. Bridge cuadra: NOPAT `$12,340.71` + ΔWC `$17,824.37` − CapEx `$0` = FCF `$30,165.08`. EBIT/EBITDA `$18,698.05` (D&A 0).
|
|
28
|
+
|
|
29
|
+
### Pendiente (Fase 2)
|
|
30
|
+
- **Efecto cambiario sobre caja VES** — línea reconciliadora bajo el FCF (`FCF + Efecto cambiario = Δ Caja real USD`) para reflejar la erosión en USD del saldo en bolívares por la devaluación diaria (~0.75–1%). Se estimará desde el saldo VES de las cuentas 18xxx-VES × tasas. No incluido en esta versión.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
3
34
|
## [1.23.1] — 2026-06-05
|
|
4
35
|
|
|
5
36
|
### Fixed
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullqueso/mcp-bc-gastos",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.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": {
|
|
@@ -289,10 +289,21 @@ export function calcScore(ratios) {
|
|
|
289
289
|
// generateInsights — array ordenado por severidad (critical → warning → success)
|
|
290
290
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
291
|
|
|
292
|
-
export function generateInsights(categories, ratios) {
|
|
292
|
+
export function generateInsights(categories, ratios, da = null) {
|
|
293
293
|
const insights = [];
|
|
294
294
|
const sevOrder = { critical: 0, warning: 1, success: 2 };
|
|
295
295
|
|
|
296
|
+
// D&A en cero — la depreciación periódica no se está contabilizando en BC
|
|
297
|
+
// (por eso EBIT ≈ EBITDA). Avisar para que se verifique el registro.
|
|
298
|
+
if (da === 0) {
|
|
299
|
+
insights.push({
|
|
300
|
+
type: 'warning',
|
|
301
|
+
category: 'depreciation',
|
|
302
|
+
message: 'D&A en cero — verificar si se registra depreciación en BC (cuentas 58100-58300 y 81000-82000).',
|
|
303
|
+
action: 'Confirmar con contabilidad si falta contabilizar la depreciación periódica del período.',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
296
307
|
// Margen operativo
|
|
297
308
|
const op = ratios.operating_margin_pct;
|
|
298
309
|
if (op.status === 'critical') {
|
|
@@ -208,9 +208,11 @@ function renderKpis(s) {
|
|
|
208
208
|
const fcfCls = statusValClass(s.fcf.status);
|
|
209
209
|
return `<div class="kpi-grid cf-kpis">
|
|
210
210
|
${cell('EBIT', signed(s.ebit.amount), `${s.ebit.pct}%`)}
|
|
211
|
+
${cell('D&A', signed(s.da ? s.da.amount : 0), 'dep. 58xxx+8xxxx')}
|
|
212
|
+
${cell('EBITDA', signed(s.ebitda ? s.ebitda.amount : s.ebit.amount), `${s.ebitda ? s.ebitda.pct : s.ebit.pct}%`)}
|
|
211
213
|
${cell('NOPAT', signed(s.nopat.amount), `tax ${signed(-s.tax_estimated.amount)}`)}
|
|
212
214
|
${cell('CFO', signed(s.cfo.amount), `${s.cfo.pct}%`)}
|
|
213
|
-
${cell('CapEx', signed(-s.capex.amount), 'PP&E 11110-
|
|
215
|
+
${cell('CapEx', signed(-s.capex.amount), 'PP&E 11110-12240')}
|
|
214
216
|
${cell('FCF', signed(s.fcf.amount), statusWord(s.fcf.status), fcfCls)}
|
|
215
217
|
${cell('FCF Margin', `${s.fcf.pct}%`, '% ingresos', fcfCls)}
|
|
216
218
|
</div>`;
|
|
@@ -236,10 +238,12 @@ function renderWorkingCapital(s) {
|
|
|
236
238
|
<div class="card-bd">
|
|
237
239
|
<div class="wc">
|
|
238
240
|
${row('Δ Cobranzas (AR)', wc.delta_ar)}
|
|
239
|
-
${row('Δ
|
|
240
|
-
${row('Δ Inventario', wc.delta_inventory)}
|
|
241
|
-
${row('Δ Impuestos corrientes', wc.delta_taxes || 0)}
|
|
241
|
+
${row('Δ Impuestos (activo)', wc.delta_taxes || 0)}
|
|
242
242
|
${row('Δ Prepagados', wc.delta_prepaid || 0)}
|
|
243
|
+
${row('Δ Inventario', wc.delta_inventory)}
|
|
244
|
+
${row('Δ Proveedores (AP)', wc.delta_ap)}
|
|
245
|
+
${row('Δ Impuestos por pagar', wc.delta_taxes_payable || 0)}
|
|
246
|
+
${row('Δ Pasivos laborales', wc.delta_labor || 0)}
|
|
243
247
|
<div class="wc-row total">
|
|
244
248
|
<span class="wc-label">Cambio neto WC</span>
|
|
245
249
|
<span class="wc-arrow ${wc.net_wc_change >= 0 ? 'up' : 'down'}">${wc.net_wc_change >= 0 ? '↑' : '↓'}</span>
|
|
@@ -279,6 +283,7 @@ function renderStoresComparison(result) {
|
|
|
279
283
|
|
|
280
284
|
const body = [
|
|
281
285
|
row('EBIT', (c) => signed(c.ebit.amount)),
|
|
286
|
+
row('EBITDA', (c) => signed(c.ebitda ? c.ebitda.amount : c.ebit.amount)),
|
|
282
287
|
row('NOPAT', (c) => signed(c.nopat.amount)),
|
|
283
288
|
row('CFO', (c) => `<span class="${c.cfo.amount >= 0 ? 'val-green' : 'val-red'}">${signed(c.cfo.amount)}</span>`),
|
|
284
289
|
row('CapEx', (c) => signed(-c.capex.amount)),
|
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
// tools/financials/cash-flow.js
|
|
2
2
|
//
|
|
3
3
|
// Tool get_cash_flow — Free Cash Flow (FCF) por método indirecto para tiendas FQ.
|
|
4
|
-
// Reutiliza el bloque P&L/EBIT de get_financial_statements
|
|
5
|
-
// le agrega impuesto estimado, capital de trabajo (
|
|
6
|
-
// CFO, FCF, bridge tipo waterfall e insights. READ-ONLY.
|
|
4
|
+
// Reutiliza el bloque P&L/EBIT/D&A/EBITDA de get_financial_statements
|
|
5
|
+
// (fetchStoreFinancials) y le agrega impuesto estimado, capital de trabajo (7 líneas),
|
|
6
|
+
// CapEx, CFO, FCF, bridge tipo waterfall e insights. READ-ONLY.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// (
|
|
17
|
-
//
|
|
8
|
+
// WORKING CAPITAL — las 7 líneas salen de net-change G/L (balance general), misma
|
|
9
|
+
// base USD que el EBIT. REGLA UNIVERSAL de impacto en caja (+ = fuente, − = uso):
|
|
10
|
+
// delta = −Σ(debit − credit) sobre el rango de cuenta. Vale para activos (sube =
|
|
11
|
+
// uso) y pasivos (sube = fuente). Rangos (Chart of Accounts real de BC):
|
|
12
|
+
// delta_ar = 15100–15199 + 15900–15998 (cuentas por cobrar + otras CxC)
|
|
13
|
+
// delta_taxes = 15200–15299 (impuestos corrientes activo)
|
|
14
|
+
// delta_prepaid = 16000–16999 (anticipos y prepagados)
|
|
15
|
+
// delta_inventory = 14000–14999 (inventarios)
|
|
16
|
+
// delta_ap = 22000–22999 (cuentas por pagar)
|
|
17
|
+
// delta_taxes_payable = 23000–23999 (impuestos por pagar)
|
|
18
|
+
// delta_labor = 24000–25999 (pasivos laborales)
|
|
19
|
+
// net_wc_change = suma de las 7 ; nopat = ebit − tax ; cfo = nopat + net_wc_change ;
|
|
20
|
+
// fcf = cfo − capex.
|
|
18
21
|
//
|
|
19
|
-
// CAPEX =
|
|
20
|
-
// tangibles
|
|
21
|
-
//
|
|
22
|
+
// CAPEX = +Σ(debit − credit) en PP&E 11110–11190 (intangibles) + 12110–12240
|
|
23
|
+
// (tangibles). EXCLUYE 12900 (depreciación acumulada), 17000–17999 (inversiones
|
|
24
|
+
// corto plazo) y 18000–18999 (caja y bancos = posición de caja final).
|
|
25
|
+
//
|
|
26
|
+
// D&A / EBITDA: D&A = Σ(debit−credit) en 58000–58999 (COGS) + 80000–89999 (OpEx),
|
|
27
|
+
// calculada por get_financial_statements. EBITDA = EBIT + D&A (KPI; no es paso del
|
|
28
|
+
// bridge). Hoy D&A suele ser 0 (no se contabiliza depreciación) → EBIT ≈ EBITDA.
|
|
22
29
|
//
|
|
23
30
|
// Montos en USD (LCY = Additional Reporting Currency), igual que get_financial_statements.
|
|
24
31
|
|
|
@@ -44,8 +51,9 @@ export const cashFlowTool = {
|
|
|
44
51
|
description:
|
|
45
52
|
'Free Cash Flow (FCF) por método indirecto para una o más tiendas Full Queso (FQ01, FQ28, FQ88). ' +
|
|
46
53
|
'Parte del EBIT (igual que get_financial_statements), resta impuesto estimado (ISLR 34%), ajusta por capital de trabajo ' +
|
|
47
|
-
'(
|
|
48
|
-
'caja operativo (CFO), y resta CapEx (PP&E
|
|
54
|
+
'(7 líneas desde net-change G/L: Δ cuentas por cobrar, Δ impuestos corrientes, Δ prepagados, Δ inventario, Δ cuentas por pagar, ' +
|
|
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 NOPAT, margen FCF, status ' +
|
|
49
57
|
'(healthy/positive_low/negative_watch/' +
|
|
50
58
|
'negative_critical), bridge tipo waterfall e insights automáticos. Multi-tienda calcula consolidado. Con render_html=true ' +
|
|
51
59
|
'genera un dashboard HTML (lo guarda en ~/Downloads y retorna la ruta). Úsalo cuando el usuario pida flujo de caja, ' +
|
|
@@ -209,11 +217,15 @@ async function buildStoreCashFlow(bcClient, store, start, end) {
|
|
|
209
217
|
cogs: financials.cogs.total,
|
|
210
218
|
gross_margin_amount: financials.gross_margin.amount,
|
|
211
219
|
ebit: financials.operating_income.amount,
|
|
220
|
+
da: financials.depreciation_amortization.amount,
|
|
221
|
+
ebitda: financials.ebitda.amount,
|
|
212
222
|
delta_ar: wc.delta_ar,
|
|
213
|
-
delta_ap: wc.delta_ap,
|
|
214
|
-
delta_inventory: wc.delta_inventory,
|
|
215
223
|
delta_taxes: wc.delta_taxes,
|
|
216
224
|
delta_prepaid: wc.delta_prepaid,
|
|
225
|
+
delta_inventory: wc.delta_inventory,
|
|
226
|
+
delta_ap: wc.delta_ap,
|
|
227
|
+
delta_taxes_payable: wc.delta_taxes_payable,
|
|
228
|
+
delta_labor: wc.delta_labor,
|
|
217
229
|
capex: wc.capex,
|
|
218
230
|
capex_accounts: wc.capex_accounts,
|
|
219
231
|
warnings: wc.warnings,
|
|
@@ -221,91 +233,90 @@ async function buildStoreCashFlow(bcClient, store, start, end) {
|
|
|
221
233
|
);
|
|
222
234
|
}
|
|
223
235
|
|
|
224
|
-
//
|
|
236
|
+
// Buckets del balance general (Chart of Accounts real de BC), por rango de cuenta.
|
|
237
|
+
// REGLA UNIVERSAL de impacto en caja: delta = −Σ(debit − credit) sobre el rango.
|
|
238
|
+
// - Activo que sube (debit) → −Σ < 0 = uso de caja.
|
|
239
|
+
// - Pasivo que sube (credit) → −Σ > 0 = fuente de caja.
|
|
240
|
+
// CapEx es la excepción: capex = +Σ(debit − credit) sobre PP&E (un débito = inversión).
|
|
241
|
+
// Montos en USD (LCY = Additional Reporting Currency), consistentes con el EBIT.
|
|
242
|
+
const ASSET_BUCKETS = [
|
|
243
|
+
{ key: 'capex', ranges: [[11110, 11190], [12110, 12240]] }, // PP&E (excluye 12900 deprec.)
|
|
244
|
+
{ key: 'delta_inventory', ranges: [[14000, 14999]] }, // Inventarios
|
|
245
|
+
{ key: 'delta_ar', ranges: [[15100, 15199], [15900, 15998]] }, // Cuentas por cobrar + otras CxC
|
|
246
|
+
{ key: 'delta_taxes', ranges: [[15200, 15299]] }, // Impuestos corrientes (activo)
|
|
247
|
+
{ key: 'delta_prepaid', ranges: [[16000, 16999]] }, // Anticipos y prepagados
|
|
248
|
+
];
|
|
249
|
+
const LIAB_BUCKETS = [
|
|
250
|
+
{ key: 'delta_ap', ranges: [[22000, 22999]] }, // Cuentas por pagar
|
|
251
|
+
{ key: 'delta_taxes_payable', ranges: [[23000, 23999]] }, // Impuestos por pagar (pasivo)
|
|
252
|
+
{ key: 'delta_labor', ranges: [[24000, 25999]] }, // Pasivos laborales
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
function bucketOf(accountNumber, buckets) {
|
|
256
|
+
const n = parseInt(accountNumber, 10);
|
|
257
|
+
for (const b of buckets) for (const [lo, hi] of b.ranges) if (n >= lo && n <= hi) return b.key;
|
|
258
|
+
return null; // 12900 (deprec.), gaps, etc. → fuera de todo bucket = descartado
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Capital de trabajo + CapEx desde 2 umbrella queries G/L (activos + pasivos),
|
|
262
|
+
// bucketizadas en código por rango de cuenta. Reemplaza el enfoque mixto
|
|
263
|
+
// subledger/G/L: ahora TODO el WC sale del balance general (net-change G/L),
|
|
264
|
+
// misma base USD que el EBIT. AR/Inventario/AP ya no usan customer/vendor/item
|
|
265
|
+
// ledger — todo es net change de las cuentas de balance.
|
|
225
266
|
async function fetchWorkingCapitalAndCapex(bcClient, companyId, start, end) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
});
|
|
234
|
-
const apUrl = bcClient.buildBetaApiUrl(companyId, 'vendorLedgerEntries', {
|
|
235
|
-
$filter: periodFilter,
|
|
236
|
-
$select: 'postingDate,documentType,amountLocalCurrency',
|
|
237
|
-
});
|
|
238
|
-
const ileUrl = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
|
|
239
|
-
$filter: periodFilter,
|
|
240
|
-
$select: 'postingDate,entryType,quantity,costAmountActual',
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// Rangos G/L del Chart of Accounts real de BC (activos 10000–19999):
|
|
244
|
-
// CapEx → PP&E 11110–12899 (excluye 12900 depreciación acumulada)
|
|
245
|
-
// Impuestos corrientes → 15210–15280 (IVA crédito, retenciones, anticipos ISLR)
|
|
246
|
-
// Prepagados → 16110–16900 (anticipos a proveedores, alquileres/otros prepagados)
|
|
247
|
-
// AR (15110–15199) e Inventario (14000–14999) ya se calculan vía subledger
|
|
248
|
-
// (customer/item ledger) — NO consultarlos aquí para no duplicar.
|
|
249
|
-
// Excluidos del FCF: 17000–17999 (inversiones corto plazo) y 18000–18999 (caja/bancos).
|
|
250
|
-
const [arEntries, apEntries, ileEntries, capexEntries, taxEntries, prepaidEntries] = await Promise.all([
|
|
251
|
-
bcClient.apiCallAllPages(arUrl).catch((e) => { logger.warn(`[cash-flow] AR fetch failed: ${e.message}`); return null; }),
|
|
252
|
-
bcClient.apiCallAllPages(apUrl).catch((e) => { logger.warn(`[cash-flow] AP fetch failed: ${e.message}`); return null; }),
|
|
253
|
-
bcClient.apiCallAllPages(ileUrl).catch((e) => { logger.warn(`[cash-flow] ILE fetch failed: ${e.message}`); return null; }),
|
|
254
|
-
bcClient.getGLEntries(companyId, start, end, '11110', '12899').catch((e) => { logger.warn(`[cash-flow] CapEx fetch failed: ${e.message}`); return null; }),
|
|
255
|
-
bcClient.getGLEntries(companyId, start, end, '15210', '15280').catch((e) => { logger.warn(`[cash-flow] Impuestos fetch failed: ${e.message}`); return null; }),
|
|
256
|
-
bcClient.getGLEntries(companyId, start, end, '16110', '16900').catch((e) => { logger.warn(`[cash-flow] Prepagados fetch failed: ${e.message}`); return null; }),
|
|
267
|
+
// Umbrella de activos 11110–16999: cubre CapEx (11xxx–12xxx), inventario (14xxx),
|
|
268
|
+
// AR (151xx/159xx), impuestos activo (152xx) y prepagados (16xxx). 12900 cae dentro
|
|
269
|
+
// pero no matchea bucket → se descarta. 17xxx (inversiones) y 18xxx (caja) quedan fuera.
|
|
270
|
+
// Umbrella de pasivos 22000–25999: AP (22xxx), impuestos por pagar (23xxx), laborales (24-25xxx).
|
|
271
|
+
const [assetEntries, liabEntries] = await Promise.all([
|
|
272
|
+
bcClient.getGLEntries(companyId, start, end, '11110', '16999').catch((e) => { logger.warn(`[cash-flow] activos G/L fetch failed: ${e.message}`); return null; }),
|
|
273
|
+
bcClient.getGLEntries(companyId, start, end, '22000', '25999').catch((e) => { logger.warn(`[cash-flow] pasivos G/L fetch failed: ${e.message}`); return null; }),
|
|
257
274
|
]);
|
|
258
275
|
|
|
259
276
|
const warnings = [];
|
|
277
|
+
const raw = { capex: 0, delta_inventory: 0, delta_ar: 0, delta_taxes: 0, delta_prepaid: 0, delta_ap: 0, delta_taxes_payable: 0, delta_labor: 0 };
|
|
278
|
+
const capex_accounts = {};
|
|
260
279
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// costAmountActual ya es el costo total de la entrada (no multiplicar por quantity).
|
|
273
|
-
let delta_inventory = 0;
|
|
274
|
-
if (ileEntries == null) warnings.push('Item ledger no disponible — Δinventario asumido en 0.');
|
|
275
|
-
else {
|
|
276
|
-
delta_inventory = round2(-ileEntries.reduce((s, e) => s + (e.costAmountActual || 0), 0));
|
|
277
|
-
if (ileEntries._truncated) warnings.push('Item ledger truncado (muchas entradas) — Δinventario puede estar incompleto.');
|
|
280
|
+
if (assetEntries == null) {
|
|
281
|
+
warnings.push('G/L de activos (11110–16999) no disponible — CapEx/ΔAR/ΔImpuestos/ΔPrepagados/ΔInventario asumidos en 0.');
|
|
282
|
+
} else {
|
|
283
|
+
for (const e of assetEntries) {
|
|
284
|
+
const key = bucketOf(e.accountNumber, ASSET_BUCKETS);
|
|
285
|
+
if (!key) continue;
|
|
286
|
+
const net = (e.debitAmount || 0) - (e.creditAmount || 0);
|
|
287
|
+
raw[key] += net;
|
|
288
|
+
if (key === 'capex') capex_accounts[e.accountNumber] = round2((capex_accounts[e.accountNumber] || 0) + net);
|
|
289
|
+
}
|
|
290
|
+
if (assetEntries._truncated) warnings.push('G/L de activos truncado (muchas entradas) — WC de activos puede estar incompleto.');
|
|
278
291
|
}
|
|
279
292
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
let delta_taxes = 0;
|
|
283
|
-
if (taxEntries == null) warnings.push('G/L de impuestos corrientes (15210–15280) no disponible — ΔImpuestos asumido en 0.');
|
|
284
|
-
else delta_taxes = round2(-taxEntries.reduce((s, e) => s + ((e.debitAmount || 0) - (e.creditAmount || 0)), 0));
|
|
285
|
-
|
|
286
|
-
// ΔPrepagados — impacto en caja = −Σ(debit−credit) G/L 16110–16900
|
|
287
|
-
let delta_prepaid = 0;
|
|
288
|
-
if (prepaidEntries == null) warnings.push('G/L de prepagados (16110–16900) no disponible — ΔPrepagados asumido en 0.');
|
|
289
|
-
else delta_prepaid = round2(-prepaidEntries.reduce((s, e) => s + ((e.debitAmount || 0) - (e.creditAmount || 0)), 0));
|
|
290
|
-
|
|
291
|
-
// CapEx — net change (debit−credit) en PP&E 11110–12899 (excluye 12900 depreciación,
|
|
292
|
-
// 17xxx inversiones corto plazo y 18xxx bancos)
|
|
293
|
-
let capex = 0;
|
|
294
|
-
const capex_accounts = {};
|
|
295
|
-
if (capexEntries == null) {
|
|
296
|
-
warnings.push('G/L de PP&E (activos fijos 11110–12899) no disponible — CapEx asumido en 0.');
|
|
293
|
+
if (liabEntries == null) {
|
|
294
|
+
warnings.push('G/L de pasivos (22000–25999) no disponible — ΔAP/ΔImpuestos x pagar/ΔPasivos laborales asumidos en 0.');
|
|
297
295
|
} else {
|
|
298
|
-
for (const e of
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
296
|
+
for (const e of liabEntries) {
|
|
297
|
+
const key = bucketOf(e.accountNumber, LIAB_BUCKETS);
|
|
298
|
+
if (!key) continue;
|
|
299
|
+
raw[key] += (e.debitAmount || 0) - (e.creditAmount || 0);
|
|
302
300
|
}
|
|
303
|
-
|
|
304
|
-
// limpiar cuentas con neto 0
|
|
305
|
-
for (const k of Object.keys(capex_accounts)) if (capex_accounts[k] === 0) delete capex_accounts[k];
|
|
301
|
+
if (liabEntries._truncated) warnings.push('G/L de pasivos truncado (muchas entradas) — WC de pasivos puede estar incompleto.');
|
|
306
302
|
}
|
|
307
303
|
|
|
308
|
-
|
|
304
|
+
// Aplicar signos: CapEx = +Σ(debit−credit); cada delta de WC = −Σ(debit−credit).
|
|
305
|
+
const capex = round2(raw.capex);
|
|
306
|
+
for (const k of Object.keys(capex_accounts)) if (capex_accounts[k] === 0) delete capex_accounts[k];
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
delta_ar: round2(-raw.delta_ar),
|
|
310
|
+
delta_taxes: round2(-raw.delta_taxes),
|
|
311
|
+
delta_prepaid: round2(-raw.delta_prepaid),
|
|
312
|
+
delta_inventory: round2(-raw.delta_inventory),
|
|
313
|
+
delta_ap: round2(-raw.delta_ap),
|
|
314
|
+
delta_taxes_payable: round2(-raw.delta_taxes_payable),
|
|
315
|
+
delta_labor: round2(-raw.delta_labor),
|
|
316
|
+
capex,
|
|
317
|
+
capex_accounts,
|
|
318
|
+
warnings,
|
|
319
|
+
};
|
|
309
320
|
}
|
|
310
321
|
|
|
311
322
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -321,12 +332,22 @@ function assembleCashFlow(meta, c) {
|
|
|
321
332
|
const tax = ebit > 0 ? round2(ebit * TAX_RATE) : 0;
|
|
322
333
|
const nopat = round2(ebit - tax);
|
|
323
334
|
|
|
335
|
+
// D&A / EBITDA — EBIT no cambia (D&A ya está en COGS/OpEx); EBITDA la re-suma.
|
|
336
|
+
const da = round2(c.da || 0);
|
|
337
|
+
const ebitda = round2(c.ebitda != null ? c.ebitda : ebit + da);
|
|
338
|
+
const ebitdaPct = income > 0 ? round2((ebitda / income) * 100) : 0;
|
|
339
|
+
|
|
340
|
+
// Working capital — las 7 líneas desde net-change G/L (regla −Σ(debit−credit)).
|
|
324
341
|
const delta_ar = round2(c.delta_ar);
|
|
325
|
-
const delta_ap = round2(c.delta_ap);
|
|
326
|
-
const delta_inventory = round2(c.delta_inventory);
|
|
327
342
|
const delta_taxes = round2(c.delta_taxes || 0);
|
|
328
343
|
const delta_prepaid = round2(c.delta_prepaid || 0);
|
|
329
|
-
const
|
|
344
|
+
const delta_inventory = round2(c.delta_inventory);
|
|
345
|
+
const delta_ap = round2(c.delta_ap);
|
|
346
|
+
const delta_taxes_payable = round2(c.delta_taxes_payable || 0);
|
|
347
|
+
const delta_labor = round2(c.delta_labor || 0);
|
|
348
|
+
const net_wc_change = round2(
|
|
349
|
+
delta_ar + delta_taxes + delta_prepaid + delta_inventory + delta_ap + delta_taxes_payable + delta_labor
|
|
350
|
+
);
|
|
330
351
|
|
|
331
352
|
const cfo = round2(nopat + net_wc_change);
|
|
332
353
|
const cfoPct = income > 0 ? round2((cfo / income) * 100) : 0;
|
|
@@ -336,8 +357,9 @@ function assembleCashFlow(meta, c) {
|
|
|
336
357
|
const fcfPct = income > 0 ? round2((fcf / income) * 100) : 0;
|
|
337
358
|
|
|
338
359
|
const status = fcfStatus(fcf, fcfPct, ebit);
|
|
339
|
-
const
|
|
340
|
-
const
|
|
360
|
+
const wc = { delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor };
|
|
361
|
+
const bridge = buildBridge({ ebit, tax, nopat, ...wc, cfo, capex, fcf });
|
|
362
|
+
const insights = buildInsights({ ebit, ...wc, da, capex, fcf, fcfPct, status, warnings: c.warnings || [] });
|
|
341
363
|
|
|
342
364
|
return {
|
|
343
365
|
store_code: meta.store_code,
|
|
@@ -346,9 +368,11 @@ function assembleCashFlow(meta, c) {
|
|
|
346
368
|
cogs: { total: round2(c.cogs) },
|
|
347
369
|
gross_margin: { amount: round2(c.gross_margin_amount), pct: gmPct },
|
|
348
370
|
ebit: { amount: ebit, pct: ebitPct },
|
|
371
|
+
da: { amount: da },
|
|
372
|
+
ebitda: { amount: ebitda, pct: ebitdaPct },
|
|
349
373
|
tax_estimated: { amount: tax, rate: TAX_RATE, note: 'Estimado ISLR 34%' },
|
|
350
374
|
nopat: { amount: nopat },
|
|
351
|
-
working_capital: {
|
|
375
|
+
working_capital: { ...wc, net_wc_change },
|
|
352
376
|
cfo: { amount: cfo, pct: cfoPct },
|
|
353
377
|
capex: { amount: capex, accounts: c.capex_accounts || {} },
|
|
354
378
|
fcf: { amount: fcf, pct: fcfPct, status },
|
|
@@ -363,33 +387,37 @@ function fcfStatus(fcf, fcfPct, ebit) {
|
|
|
363
387
|
return fcf > ebit * -0.2 ? 'negative_watch' : 'negative_critical';
|
|
364
388
|
}
|
|
365
389
|
|
|
366
|
-
function buildBridge({ ebit, tax, nopat, delta_ar,
|
|
390
|
+
function buildBridge({ ebit, tax, nopat, delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor, cfo, capex, fcf }) {
|
|
367
391
|
const flow = (label, amount) => ({ label, amount: round2(amount), type: amount >= 0 ? 'source' : 'use' });
|
|
368
392
|
return [
|
|
369
393
|
{ label: 'EBIT', amount: ebit, type: 'source' },
|
|
370
394
|
{ label: 'Impuesto est.', amount: round2(-tax), type: 'use' },
|
|
371
395
|
{ label: 'NOPAT', amount: nopat, type: 'subtotal' },
|
|
372
396
|
flow('Δ Cob. (AR)', delta_ar),
|
|
373
|
-
flow('Δ
|
|
374
|
-
flow('Δ Inventario', delta_inventory),
|
|
375
|
-
flow('Δ Impuestos', delta_taxes),
|
|
397
|
+
flow('Δ Impuestos (activo)', delta_taxes),
|
|
376
398
|
flow('Δ Prepagados', delta_prepaid),
|
|
399
|
+
flow('Δ Inventario', delta_inventory),
|
|
400
|
+
flow('Δ Prov. (AP)', delta_ap),
|
|
401
|
+
flow('Δ Imp. x pagar', delta_taxes_payable),
|
|
402
|
+
flow('Δ Pasivos laborales', delta_labor),
|
|
377
403
|
{ label: 'CFO', amount: cfo, type: 'subtotal' },
|
|
378
404
|
{ label: 'CapEx', amount: round2(-capex), type: 'use' },
|
|
379
405
|
{ label: 'FCF', amount: fcf, type: 'total' },
|
|
380
406
|
];
|
|
381
407
|
}
|
|
382
408
|
|
|
383
|
-
function buildInsights({ ebit, delta_ar,
|
|
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 }) {
|
|
384
410
|
const insights = [];
|
|
385
411
|
|
|
386
|
-
// Mayor driver negativo entre los usos de capital de trabajo
|
|
412
|
+
// Mayor driver negativo entre los usos de capital de trabajo (7 líneas)
|
|
387
413
|
const uses = [
|
|
388
414
|
{ key: 'delta_inventory', label: 'Inventario', val: delta_inventory },
|
|
389
415
|
{ key: 'delta_ar', label: 'Cobranza (AR)', val: delta_ar },
|
|
390
416
|
{ key: 'delta_ap', label: 'Pagos a proveedores (AP)', val: delta_ap },
|
|
391
417
|
{ key: 'delta_taxes', label: 'Impuestos corrientes', val: delta_taxes },
|
|
392
418
|
{ key: 'delta_prepaid', label: 'Prepagados', val: delta_prepaid },
|
|
419
|
+
{ key: 'delta_taxes_payable', label: 'Impuestos por pagar', val: delta_taxes_payable },
|
|
420
|
+
{ key: 'delta_labor', label: 'Pasivos laborales', val: delta_labor },
|
|
393
421
|
].filter((u) => u.val < 0).sort((a, b) => a.val - b.val);
|
|
394
422
|
if (uses.length) {
|
|
395
423
|
insights.push(`${uses[0].label} es el mayor uso de caja del período ($${fmtAbs(uses[0].val)}).`);
|
|
@@ -400,9 +428,14 @@ function buildInsights({ ebit, delta_ar, delta_ap, delta_inventory, delta_taxes,
|
|
|
400
428
|
insights.push(`Cobranza lenta presiona caja: ΔAR −$${fmtAbs(delta_ar)} (>20% del EBIT).`);
|
|
401
429
|
}
|
|
402
430
|
|
|
403
|
-
//
|
|
431
|
+
// D&A en cero — depreciación posiblemente no contabilizada en BC
|
|
432
|
+
if (da === 0) {
|
|
433
|
+
insights.push('D&A en cero — verificar si se registra depreciación en BC (cuentas 58100-58300 y 81000-82000).');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// CapEx — solo si hay net change real en PP&E (11110–12240)
|
|
404
437
|
if (capex > 0) {
|
|
405
|
-
insights.push(`CapEx $${fmtAbs(capex)} en PP&E (activos fijos 11110–
|
|
438
|
+
insights.push(`CapEx $${fmtAbs(capex)} en PP&E (activos fijos 11110–12240) detectado.`);
|
|
406
439
|
} else if (capex < 0) {
|
|
407
440
|
insights.push(`Desinversión neta en PP&E $${fmtAbs(capex)} (fuente de caja).`);
|
|
408
441
|
}
|
|
@@ -450,11 +483,15 @@ function consolidateCashFlow(storeObjs) {
|
|
|
450
483
|
cogs: sum((o) => o.cogs.total),
|
|
451
484
|
gross_margin_amount: sum((o) => o.gross_margin.amount),
|
|
452
485
|
ebit: sum((o) => o.ebit.amount),
|
|
486
|
+
da: sum((o) => (o.da ? o.da.amount : 0)),
|
|
487
|
+
ebitda: sum((o) => (o.ebitda ? o.ebitda.amount : 0)),
|
|
453
488
|
delta_ar: sum((o) => o.working_capital.delta_ar),
|
|
454
|
-
delta_ap: sum((o) => o.working_capital.delta_ap),
|
|
455
|
-
delta_inventory: sum((o) => o.working_capital.delta_inventory),
|
|
456
489
|
delta_taxes: sum((o) => o.working_capital.delta_taxes || 0),
|
|
457
490
|
delta_prepaid: sum((o) => o.working_capital.delta_prepaid || 0),
|
|
491
|
+
delta_inventory: sum((o) => o.working_capital.delta_inventory),
|
|
492
|
+
delta_ap: sum((o) => o.working_capital.delta_ap),
|
|
493
|
+
delta_taxes_payable: sum((o) => o.working_capital.delta_taxes_payable || 0),
|
|
494
|
+
delta_labor: sum((o) => o.working_capital.delta_labor || 0),
|
|
458
495
|
capex: sum((o) => o.capex.amount),
|
|
459
496
|
capex_accounts,
|
|
460
497
|
warnings: allWarnings,
|
|
@@ -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
|
}
|
|
@@ -222,12 +226,33 @@ function renderPnlCard(s) {
|
|
|
222
226
|
${expenseRows}
|
|
223
227
|
<div class="pnl-row total"><span class="pnl-label">(−) Total Gastos Operativos</span><span class="pnl-amt">$${fmt(s.expenses.total)}</span></div>
|
|
224
228
|
|
|
225
|
-
<div class="pnl-row operating"><span class="pnl-label">= Utilidad Operativa <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>
|
|
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>
|
|
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>
|
|
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)}
|
|
226
233
|
</div>
|
|
227
234
|
</div>
|
|
228
235
|
</div>`;
|
|
229
236
|
}
|
|
230
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
|
+
|
|
231
256
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
232
257
|
// Barras horizontales de desglose de gastos
|
|
233
258
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -264,6 +289,8 @@ function renderExpenseBars(s) {
|
|
|
264
289
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
265
290
|
|
|
266
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;
|
|
267
294
|
const order = [
|
|
268
295
|
['gross_margin_pct', 'Margen Bruto'],
|
|
269
296
|
['operating_margin_pct', 'Margen Operativo'],
|
|
@@ -274,9 +301,9 @@ function renderRatiosCard(s) {
|
|
|
274
301
|
['marketing_pct', 'Marketing'],
|
|
275
302
|
];
|
|
276
303
|
const rows = order
|
|
277
|
-
.filter(([k]) =>
|
|
304
|
+
.filter(([k]) => ratios[k])
|
|
278
305
|
.map(([k, label]) => {
|
|
279
|
-
const r =
|
|
306
|
+
const r = ratios[k];
|
|
280
307
|
return `<div class="ratio-row">
|
|
281
308
|
<span class="ratio-name">${esc(label)}</span>
|
|
282
309
|
<span class="ratio-val ${valClass(r.status)}">${r.value}%</span>
|
|
@@ -287,7 +314,7 @@ function renderRatiosCard(s) {
|
|
|
287
314
|
.join('');
|
|
288
315
|
|
|
289
316
|
return `<div class="card">
|
|
290
|
-
<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>
|
|
291
318
|
<div class="card-bd"><div class="ratios">${rows}</div></div>
|
|
292
319
|
</div>`;
|
|
293
320
|
}
|
|
@@ -312,13 +339,15 @@ function renderMultiComparisonTable(result) {
|
|
|
312
339
|
${cols.map((c) => `<div class="ctbl-cell">${fn(c)}</div>`).join('')}
|
|
313
340
|
</div>`;
|
|
314
341
|
|
|
342
|
+
const core = (c) => c.core || c;
|
|
315
343
|
const body = [
|
|
316
344
|
row('Ingresos', (c) => `$${fmt(c.income.total)}`),
|
|
345
|
+
row('Vtas. intercompañía', (c) => `$${fmt(c.intercompany_sales ? c.intercompany_sales.revenue : 0)}`),
|
|
317
346
|
row('COGS', (c) => `$${fmt(c.cogs.total)}`),
|
|
318
|
-
row('
|
|
347
|
+
row('MB core', (c) => `<span class="${valClass(core(c).ratios.gross_margin_pct.status)}">${core(c).gross_margin.pct}%</span>`),
|
|
319
348
|
row('Gastos Op.', (c) => `$${fmt(c.expenses.total)}`),
|
|
320
|
-
row('
|
|
321
|
-
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'),
|
|
322
351
|
].join('');
|
|
323
352
|
|
|
324
353
|
return `<div class="card">
|
|
@@ -39,9 +39,56 @@ function buildStoreFinancials(storeCode, storeName, incomeEntries, cogsEntries,
|
|
|
39
39
|
const opAmount = round2(gmAmount - expenses.total);
|
|
40
40
|
const opPct = income.total > 0 ? round2((opAmount / income.total) * 100) : 0;
|
|
41
41
|
|
|
42
|
+
// D&A — depreciación/amortización ya contenida en COGS (58000-58999) y OpEx
|
|
43
|
+
// (80000-89999). Se calcula filtrando las entries ya traídas (sin query nueva).
|
|
44
|
+
// EBIT NO cambia (D&A ya está restada); EBITDA = EBIT + D&A la re-suma.
|
|
45
|
+
const da = computeDepreciation(cogsEntries, expenseEntries);
|
|
46
|
+
const ebitdaAmount = round2(opAmount + da);
|
|
47
|
+
const ebitdaPct = income.total > 0 ? round2((ebitdaAmount / income.total) * 100) : 0;
|
|
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.
|
|
42
89
|
const ratios = calcRatios(income, cogs, expenses);
|
|
43
90
|
const score = calcScore(ratios);
|
|
44
|
-
const insights = generateInsights(expenses.categories,
|
|
91
|
+
const insights = generateInsights(expenses.categories, coreRatios, da);
|
|
45
92
|
|
|
46
93
|
return {
|
|
47
94
|
store_code: storeCode,
|
|
@@ -51,12 +98,33 @@ function buildStoreFinancials(storeCode, storeName, incomeEntries, cogsEntries,
|
|
|
51
98
|
gross_margin: { amount: gmAmount, pct: gmPct },
|
|
52
99
|
expenses,
|
|
53
100
|
operating_income: { amount: opAmount, pct: opPct },
|
|
101
|
+
depreciation_amortization: { amount: da },
|
|
102
|
+
ebitda: { amount: ebitdaAmount, pct: ebitdaPct },
|
|
103
|
+
intercompany_sales,
|
|
104
|
+
core,
|
|
54
105
|
ratios,
|
|
55
106
|
insights,
|
|
56
107
|
score,
|
|
57
108
|
};
|
|
58
109
|
}
|
|
59
110
|
|
|
111
|
+
// D&A = Σ(debit − credit) sobre 58000-58999 (en COGS) + 80000-89999 (en OpEx).
|
|
112
|
+
// Mismas entries del P&L — no requiere consulta adicional a BC.
|
|
113
|
+
function computeDepreciation(cogsEntries, expenseEntries) {
|
|
114
|
+
const inRange = (acct, lo, hi) => { const n = parseInt(acct, 10); return n >= lo && n <= hi; };
|
|
115
|
+
let da = 0;
|
|
116
|
+
for (const e of cogsEntries) if (inRange(e.accountNumber, 58000, 58999)) da += (e.debitAmount || 0) - (e.creditAmount || 0);
|
|
117
|
+
for (const e of expenseEntries) if (inRange(e.accountNumber, 80000, 89999)) da += (e.debitAmount || 0) - (e.creditAmount || 0);
|
|
118
|
+
return round2(da);
|
|
119
|
+
}
|
|
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
|
+
|
|
60
128
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
129
|
// fetchStoreFinancials — 3 queries BC en paralelo para una tienda
|
|
62
130
|
// Devuelve { financials, raw } (raw se usa para consolidar sin re-consultar BC)
|
|
@@ -202,27 +270,33 @@ function buildComparison(current, previous, prevPeriod) {
|
|
|
202
270
|
|
|
203
271
|
const varPct = (cur, prev) => (prev !== 0 ? round2(((cur - prev) / Math.abs(prev)) * 100) : null);
|
|
204
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
|
+
|
|
205
278
|
return {
|
|
206
279
|
previous_period: prevPeriod,
|
|
280
|
+
basis: 'core',
|
|
207
281
|
income: {
|
|
208
282
|
current: current.income.total,
|
|
209
283
|
previous: previous.income.total,
|
|
210
284
|
variance_pct: varPct(current.income.total, previous.income.total),
|
|
211
285
|
},
|
|
212
286
|
gross_margin_pct: {
|
|
213
|
-
current:
|
|
214
|
-
previous:
|
|
215
|
-
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),
|
|
216
290
|
},
|
|
217
291
|
operating_income: {
|
|
218
|
-
current:
|
|
219
|
-
previous:
|
|
220
|
-
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),
|
|
221
295
|
},
|
|
222
296
|
operating_margin_pct: {
|
|
223
|
-
current:
|
|
224
|
-
previous:
|
|
225
|
-
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),
|
|
226
300
|
},
|
|
227
301
|
expenses: {
|
|
228
302
|
current: current.expenses.total,
|
|
@@ -230,9 +304,9 @@ function buildComparison(current, previous, prevPeriod) {
|
|
|
230
304
|
variance_pct: varPct(current.expenses.total, previous.expenses.total),
|
|
231
305
|
},
|
|
232
306
|
score: {
|
|
233
|
-
current:
|
|
234
|
-
previous:
|
|
235
|
-
variance:
|
|
307
|
+
current: curCore.score,
|
|
308
|
+
previous: prevCore.score,
|
|
309
|
+
variance: curCore.score - prevCore.score,
|
|
236
310
|
},
|
|
237
311
|
};
|
|
238
312
|
}
|