@fullqueso/mcp-bc-gastos 1.24.0 → 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 CHANGED
@@ -1,5 +1,21 @@
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
+
3
19
  ## [1.24.0] — 2026-06-05
4
20
 
5
21
  ### 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 Facturación Directa', nameEn: 'Direct Invoice Sales' },
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.24.0",
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": {
@@ -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
- const gmStatus = s.ratios.gross_margin_pct.status;
162
- const opStatus = s.ratios.operating_margin_pct.status;
163
- const e2iPct = s.ratios.expense_to_income_pct.value;
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)}">${s.gross_margin.pct}%</span>
172
- <span class="kpi-cell-l">Margen Bruto<br><span class="kpi-cell-sub">$${fmt(s.gross_margin.amount)}</span></span>
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)}">${s.operating_income.pct}%</span>
176
- <span class="kpi-cell-l">Margen Operativo<br><span class="kpi-cell-sub">$${fmt(s.operating_income.amount)}</span></span>
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">(&minus;) 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">(&minus;) 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]) => s.ratios[k])
304
+ .filter(([k]) => ratios[k])
280
305
  .map(([k, label]) => {
281
- const r = s.ratios[k];
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">&#x1F4C8;</span><span class="card-title">Ratios vs Benchmark</span></div>
317
+ <div class="card-hd"><span class="card-icon">&#x1F4C8;</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('Margen Bruto', (c) => `<span class="${valClass(c.ratios.gross_margin_pct.status)}">${c.gross_margin.pct}%</span>`),
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('Margen Oper.', (c) => `<span class="${valClass(c.ratios.operating_margin_pct.status)}">${c.operating_income.pct}%</span>`, 'em'),
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, ratios, da);
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: current.gross_margin.pct,
233
- previous: previous.gross_margin.pct,
234
- variance_pts: round2(current.gross_margin.pct - previous.gross_margin.pct),
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: current.operating_income.amount,
238
- previous: previous.operating_income.amount,
239
- variance_pct: varPct(current.operating_income.amount, previous.operating_income.amount),
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: current.operating_income.pct,
243
- previous: previous.operating_income.pct,
244
- variance_pts: round2(current.operating_income.pct - previous.operating_income.pct),
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: current.score,
253
- previous: previous.score,
254
- variance: current.score - previous.score,
307
+ current: curCore.score,
308
+ previous: prevCore.score,
309
+ variance: curCore.score - prevCore.score,
255
310
  },
256
311
  };
257
312
  }