@fullqueso/mcp-bc-gastos 1.28.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -17,3 +17,10 @@ BC_COMPANY_FQFR=guid-for-franquicias
17
17
 
18
18
  # Optional
19
19
  LOG_LEVEL=info
20
+
21
+ # Per-request BC fetch timeout (ms). A hung request is aborted + retried instead
22
+ # of blocking the MCP forever (BUG #1 fix). Default 90000. Raise for heavy reports.
23
+ BC_FETCH_TIMEOUT_MS=90000
24
+
25
+ # CRM rate endpoint (get_crm_rate) — opcional, default abajo
26
+ CRM_RATE_BASE=https://crm-rate.fullqueso.com/api/v1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.31.0] — 2026-07-01
4
+
5
+ ### Added — KPIs de nómina operativa: tienda vs eventos, activos vs pagados
6
+ Contratos existentes intactos (solo se agregan campos). Todo USD. Verificado contra BC real (mayo 2026, FQ01/FQ28/FQ88/FQFR).
7
+
8
+ - **`paid_employees` en `get_payroll_documents` (con `include_employee_count=true`).** Además de `summary.unique_employees`, ahora `summary.paid_employees`: lista agregada por empleado en el período con `{ employee_code, employee_name, cedula, cost_usd, lines, payroll_types:[...], classification }`. Permite cruzar activos vs pagados sin leer las líneas doc por doc.
9
+ - **Deduplicación por cédula.** `unique_employees` y `paid_employees` deduplican por `cedula` cuando existe (una persona puede tener un código SEMANAL y otro EVENTO → se consolidan costo y líneas en un registro). `summary.duplicate_codes: [{ cedula, codes:[...] }]` expone los casos multi-código para limpieza en BC. Si `cedula` es null se trata el código como persona única (no se fusiona por nombre). El consolidado (`store="all"`) re-deduplica también entre tiendas (misma cédula en dos tiendas = una persona).
10
+ - Resolución de cédula en 2 pasos: si un código tiene cédula en alguna línea, las líneas con cédula en blanco de ese código heredan la cédula (evita partir un código en dos personas). Verificado mayo 2026: 0 splits, 0 merges; 82 personas = 58 con cédula (1:1) + 24 códigos sin cédula en BC (data-quality, tratados como únicos).
11
+ - **Clasificación evento/operativo por empleado** (relevante FQ01): `classification` = `"evento"` (todos sus pagos EVENTO), `"operativo"` (ningún EVENTO — SEMANAL/VACACIONES/LIQUIDACION), `"mixto"` (tiene EVENTO y no-EVENTO). Evita marcar como "inactivo no declarado" a quien solo trabaja eventos.
12
+ - **Buckets de nómina tienda vs eventos** en `summary` (per-store y consolidado): `store_payroll` = Σ `cost_usd` de docs SEMANAL + VACACIONES + LIQUIDACION; `event_payroll` = Σ EVENTO. `total_cost_usd` se mantiene igual (gerencia queda solo ahí). Constantes `STORE_PAYROLL_TYPES`/`EVENT_PAYROLL_TYPES` en `tools/payroll/cost-model.js`.
13
+
14
+ ### Added — Denominador de ventas correcto para el KPI (get_sales_analysis / compare_sales_by_store)
15
+ - **`revenue_store`, `revenue_events`, `revenue_intercompany`, `revenue_non_sales` + `revenue_total`** en `summary` (y por tienda en `compare_sales_by_store`), además del `total_revenue_usd` actual (sin cambios). Identidad: `revenue_total = revenue_store + revenue_intercompany + revenue_events + revenue_non_sales`.
16
+ - **`revenue_store = revenue_total − intercompañía − eventos − no-venta`.** Se excluye del denominador de tienda la venta intercompañía, la venta de eventos y el ingreso **no-venta** (FX gains + rebajas de compra) — este último por recomendación aceptada por FP.
17
+ - **Cuentas configurables (no inline), por tienda** en `config/income-accounts.js`, verificadas contra el CoA real:
18
+ - `INTERCOMPANY_REVENUE_ACCOUNTS = ['40310']` (tiendas operativas; FQ01/FQ88, FQ28=0).
19
+ - `EVENT_REVENUE_ACCOUNTS = ['40180','40700']` (solo FQ01 con movimiento).
20
+ - `NON_SALES_REVENUE_ACCOUNTS = ['40580','40540','40550']` (dif. cambiario + descuentos/rebajas de compra).
21
+ - **FQFR** usa `FQFR_INTERCOMPANY_REVENUE_ACCOUNTS = ['40420','40430','40660','40930']` (royalties/fees del master de franquicia, NO 40310) vía `getRevenueClassification(storeCode)` → sus buckets salen correctos aunque el KPI no aplique.
22
+ - Lógica en `splitRevenueBuckets(glRevenue, total, storeCode)` (`utils/sales-aggregation.js`); `calculateSummary` ahora recibe `storeCode` (los dos tools de ventas lo pasan por tienda).
23
+ - **KPI** (lo calcula el consumidor cruzando ambos tools): `nomina_tienda_pct = store_payroll / revenue_store`; `nomina_eventos_pct = event_payroll / revenue_events` (**null si `revenue_events = 0`** → solo FQ01). **FQFR excluido por completo** del KPI (`KPI_PAYROLL_STORES = ['FQ01','FQ28','FQ88']`).
24
+ - Verificado mayo 2026 (2 fases, `tests/verify-payroll-sales-kpi.js`):
25
+ - **Estricto** (− interco − eventos), coincide con la validación de FP: FQ01 8.36% tienda / 31.02% eventos (rev_store 86,987.43); FQ28 10.22% (77,407.51); FQ88 8.75% (150,122.32).
26
+ - **Refinado** (además − no-venta): FQ01 8.36% (86,940.37); FQ28 10.25% (77,146.08); FQ88 8.80% (149,322.16).
27
+
28
+ ### Nota operativa
29
+ - **Reiniciar Claude Desktop** tras el publish para que los tools recarguen (G-002).
30
+
31
+ ---
32
+
33
+ ## [1.30.0] — 2026-07-01
34
+
35
+ ### Fixed
36
+ - **BUG #1 — `get_employees`/`get_payroll_documents` con `store="all"` podían colgar el MCP indefinidamente.** Causa raíz: `fetch()` no tenía timeout → una sola petición colgada nunca resolvía, y como el consolidado usaba `Promise.all`, una tienda colgada tumbaba todo el call (aparecía como "MCP no responde").
37
+ - **`lib/bc-client.js` — timeout por petición (AbortController).** Cada intento de `fetchWithRetry` aborta a los `BC_FETCH_TIMEOUT_MS` (default **90000**, env-tunable) y reintenta; al agotar reintentos lanza un error claro de timeout en vez de colgarse. Cubre token + todas las llamadas v2.0/OData/custom (afecta a las 54 tools; el default generoso no toca ninguna llamada legítima — todas las observadas <1s).
38
+ - **Consolidación multi-tienda resiliente.** `get_payroll_documents` y `get_employees` con `store="all"` ahora usan `Promise.allSettled`: una tienda que falle/timeoutee devuelve data parcial + un array `errors[]` por tienda, nunca cuelga ni bota el consolidado.
39
+ - Verificado: per-store discovery <500ms; el timeout aborta+reintenta (3x) y lanza error en ~6s con `BC_FETCH_TIMEOUT_MS=1`.
40
+
41
+ ### Changed / Added — KPIs de nómina operativa
42
+ - **#2/#3 — Costo total en USD por documento (`total_cost_usd`) robusto a drafts sin distribuir.** Nuevo módulo `tools/payroll/cost-model.js` como **fuente única de verdad del costo**. Regla (verificada contra 448 docs reales): `total_cost_usd = totalGrossUSD (>0) → si no, bonos-equivalente USD → si no, neto (MAX, no suma)`. El campo `cost_basis` (`gross`|`bonuses`|`net`) hace auditable de dónde salió el número, y `net_distributed:false` marca los drafts cuyo neto aún no se reparte por método de pago (elimina el parche `max(neto,bonos)`).
43
+ - **Por qué la suma `net_usd + net_ves/tasa` estaba mal:** VACACIONES/LIQUIDACION contabilizan el MISMO monto en `totalNetUSD` **y** `totalNetVES` → esa suma **duplica** el costo. `totalGrossUSD` es el costo real y ya está en el header (antes no se seleccionaba).
44
+ - Expuestos en cada documento: `total_cost_usd`, `cost_basis`, `net_distributed`, `total_gross_usd`, `total_deductions_usd`. Agregado `total_cost_usd` a `summary` y `consolidated`.
45
+ - **#4 — Enums de `payroll_type` corregidos a los valores reales de BC + `exclude_managerial`.** El enum viejo `[SEMANAL|QUINCENAL|MENSUAL]` estaba mal (QUINCENAL/MENSUAL no existen).
46
+ - `get_payroll_documents`: `SEMANAL, GERENCIAL, GERENCIAL_CIERRE, EVENTO, VACACIONES, LIQUIDACION`. Nuevo flag `exclude_managerial` → excluye GERENCIAL + GERENCIAL_CIERRE server-side (nómina operativa en un solo call).
47
+ - `get_employees`: `SEMANAL, GERENCIAL, EVENTO`. Nuevo flag `exclude_managerial` → excluye `payrollType GERENCIAL` + `employeeType Management`.
48
+ - **#5 — `summary.by_type` ahora trae costo, no solo conteo.** De `{ TIPO: n }` a `{ TIPO: { count, lines, cost_usd } }` (per-store y consolidado).
49
+ - **#6 — Headcount único de empleados pagados en el periodo.** Nuevo flag `include_employee_count` en `get_payroll_documents` → `summary.unique_employees` (y `consolidated.unique_employees`). Lee las líneas de los docs filtrados en lotes de OR (no doc-por-doc). Distingue el headcount real de `total_employees_lines` (que duplica al mismo empleado por cada corrida semanal).
50
+ - **#7 — Costo por empleado en USD en `get_payroll_lines`.** Expuesto `total_ves_equivalent` (campo de la API antes sin usar) y `cost_usd` por línea = `totalVESEquivalent / tasa` (la tasa se lee del header en 1 llamada extra). `summary` trae `total_cost_usd`, `total_ves_equivalent`, `payroll_type`, `status`, `exchange_rate`.
51
+ - **Nota (L-001):** la API `fqPayrollLines` **no** expone días/horas trabajados ni FTE por línea → no se pueden calcular KPIs de productividad laboral sin ampliar la extensión de BC. Documentado, no fabricado.
52
+
53
+ ### Nota operativa
54
+ - **Reiniciar Claude Desktop** tras el publish para que los tools recarguen (G-002).
55
+
56
+ ---
57
+
58
+ ## [1.29.0] — 2026-06-21
59
+
60
+ ### Added
61
+ - **`get_crm_rate` — tasa de cambio Bs/USD desde el CRM de Full Queso (tasa de referencia, hora Caracas).** Complementa a `get_exchange_rate` (que trae la tasa BCV contable vigente en BC): este tool consulta el endpoint del CRM `https://crm-rate.fullqueso.com` y expone tanto la tasa **USDT/Binance** (paralelo) como la **BCV** (oficial), histórica o del día.
62
+ - **Dos monedas con sinónimos:** `coin` acepta `USDT`/`BINANCE` (paralelo, mismo valor) y `VES`/`BCV` (oficial). La descripción dispara con "tasa USDT, tasa Binance, dólar Binance, tasa paralelo/paralela, tasa BCV, tasa oficial, tasa del día, tasa histórica".
63
+ - **Histórica vs del día automático:** con `date` (YYYY-MM-DD) → `GET /api/v1/history/price?coin={VES|USDT}&date=...`; sin `date` → `GET /api/v1/coin/{bcv|usdt}` (momento de la consulta).
64
+ - Maneja `400` (coin/date inválidos → error claro), `404` (sin registro → `price: null` + mensaje) y fallo de conexión. Precio redondeado a 2 decimales; respuesta incluye `queried_at_caracas` y `source`.
65
+ - Base URL configurable vía env **`CRM_RATE_BASE`** (default `https://crm-rate.fullqueso.com/api/v1`). El endpoint no requiere auth de BC → tool autónomo (ignora `bcClient`).
66
+ - Nuevo `tools/get-crm-rate.js`; registrado en `server.js` (import + lista de tools + switch).
67
+ - **Verificado contra el endpoint real (21-jun-2026):** USDT del día ≈798.35; BCV del día 612.43; histórica USDT 19-jun 805.40; histórica VES 19-jun **607.39** (coincide con el ejemplo del doc); 404 (1990) y coin inválido (DOGE) manejados. `node --check` OK.
68
+
69
+ ### Nota operativa
70
+ - **Reiniciar Claude Desktop** tras el publish para que el tool recargue (G-002).
71
+
72
+ ---
73
+
3
74
  ## [1.28.0] — 2026-06-12
4
75
 
5
76
  ### Added
@@ -25,6 +25,35 @@ export const COGS_ACCOUNTS = {
25
25
  57020: { name: 'Costos Diversos', nameEn: 'Diverse Costs' },
26
26
  };
27
27
 
28
+ // ── Revenue classification for the store-payroll KPI denominator ─────────────
29
+ // Verified against the real CoA (tests/discover-revenue-accounts.js, May 2026):
30
+ // 40310 "Ventas Materia Prima otras tiendas" = intercompany de tienda (FQ01/FQ88,
31
+ // FQ28=0); su costo par es 50110 (see LESSONS L-011).
32
+ // 40180 "Ventas Eventos - NEN" + 40700 "Ventas Eventos" = event sales (solo FQ01).
33
+ // 40580 "Ganancia por dif. cambiario" + 40540/40550 "Descuentos/Rebajas en Compras"
34
+ // = ingreso NO-venta → fuera del denominador de tienda (confirmado por FP).
35
+ // revenue_store = revenue_total − intercompany − events − non_sales.
36
+ export const INTERCOMPANY_REVENUE_ACCOUNTS = ['40310'];
37
+ export const EVENT_REVENUE_ACCOUNTS = ['40180', '40700'];
38
+ export const NON_SALES_REVENUE_ACCOUNTS = ['40580', '40540', '40550'];
39
+
40
+ // FQFR (master de franquicia): su intercompañía usa cuentas propias (royalties/fees),
41
+ // NO 40310, y no tiene ventas de evento de tienda. El KPI de nómina/ventas NO aplica a
42
+ // FQFR (ver KPI_PAYROLL_STORES) — esta clasificación es solo para que sus buckets de
43
+ // venta salgan correctos si alguien consulta ventas de FQFR.
44
+ export const FQFR_INTERCOMPANY_REVENUE_ACCOUNTS = ['40420', '40430', '40660', '40930'];
45
+
46
+ // Tiendas a las que aplica el KPI nómina/ventas (FQFR excluido por completo).
47
+ export const KPI_PAYROLL_STORES = ['FQ01', 'FQ28', 'FQ88'];
48
+
49
+ // Cuentas de clasificación de ingreso por tienda (interco/eventos/no-venta).
50
+ export function getRevenueClassification(storeCode) {
51
+ if (storeCode === 'FQFR') {
52
+ return { intercompany: FQFR_INTERCOMPANY_REVENUE_ACCOUNTS, events: [], nonSales: NON_SALES_REVENUE_ACCOUNTS };
53
+ }
54
+ return { intercompany: INTERCOMPANY_REVENUE_ACCOUNTS, events: EVENT_REVENUE_ACCOUNTS, nonSales: NON_SALES_REVENUE_ACCOUNTS };
55
+ }
56
+
28
57
  // Income group labels for breakdown (by 100-prefix: 40110 → 40100)
29
58
  export const INCOME_GROUP_LABELS = {
30
59
  40100: '40100_ingreso_ventas',
package/lib/bc-client.js CHANGED
@@ -18,6 +18,12 @@ export class BCClient {
18
18
  this._inflightRequests = new Map();
19
19
  this.STORE_DATA_TTL = 5 * 60 * 1000; // 5 minutes
20
20
  this.EXCHANGE_RATE_TTL = 15 * 60 * 1000; // 15 minutes
21
+
22
+ // Per-request hard timeout (ms). Without this a single hung fetch() never
23
+ // rejects → the whole tool call (and, via Promise.all consolidations, the
24
+ // MCP process) hangs forever. Generous default (all observed BC calls are
25
+ // <1s); tunable via env for unusually heavy OData reports. See L-015.
26
+ this.FETCH_TIMEOUT_MS = Number(process.env.BC_FETCH_TIMEOUT_MS) || 90000;
21
27
  }
22
28
 
23
29
  /**
@@ -135,8 +141,12 @@ export class BCClient {
135
141
 
136
142
  async fetchWithRetry(url, options, retries = 3) {
137
143
  for (let attempt = 1; attempt <= retries; attempt++) {
144
+ // Abort a hung request so it fails fast (and retries) instead of blocking
145
+ // forever. Each attempt gets its own controller/timer. See L-015.
146
+ const controller = new AbortController();
147
+ const timer = setTimeout(() => controller.abort(), this.FETCH_TIMEOUT_MS);
138
148
  try {
139
- const response = await fetch(url, options);
149
+ const response = await fetch(url, { ...options, signal: controller.signal });
140
150
  if (response.status === 429 && attempt < retries) {
141
151
  const retryAfter = response.headers.get('Retry-After');
142
152
  const delay = retryAfter ? parseInt(retryAfter) * 1000 : attempt * 3000;
@@ -146,10 +156,20 @@ export class BCClient {
146
156
  }
147
157
  return response;
148
158
  } catch (err) {
149
- if (attempt === retries) throw err;
159
+ const timedOut = err.name === 'AbortError';
160
+ if (attempt === retries) {
161
+ throw timedOut
162
+ ? new Error(`BC request timed out after ${this.FETCH_TIMEOUT_MS}ms (${retries} attempts): ${url}`)
163
+ : err;
164
+ }
150
165
  const delay = attempt * 2000;
151
- logger.warn(`Fetch failed (attempt ${attempt}/${retries}), retrying in ${delay}ms...`);
166
+ logger.warn(
167
+ `Fetch ${timedOut ? `timed out (>${this.FETCH_TIMEOUT_MS}ms)` : 'failed'} ` +
168
+ `(attempt ${attempt}/${retries}), retrying in ${delay}ms...`
169
+ );
152
170
  await new Promise((r) => setTimeout(r, delay));
171
+ } finally {
172
+ clearTimeout(timer);
153
173
  }
154
174
  }
155
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.28.0",
3
+ "version": "1.31.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": {
package/server.js CHANGED
@@ -33,6 +33,7 @@ import { accountTransactionsTool, handleAccountTransactions } from './tools/acco
33
33
  import { vendorTransactionsTool, handleVendorTransactions } from './tools/vendor-transactions.js';
34
34
  import { listVendorsTool, handleListVendors } from './tools/list-vendors.js';
35
35
  import { exchangeRateTool, handleGetExchangeRate } from './tools/get-exchange-rate.js';
36
+ import { crmRateTool, handleGetCrmRate } from './tools/get-crm-rate.js';
36
37
 
37
38
  // Auditoria tools (bank reconciliation)
38
39
  import { listBankAccountsTool, handleListBankAccounts } from './tools/auditoria/list-bank-accounts.js';
@@ -138,6 +139,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
138
139
  vendorTransactionsTool,
139
140
  listVendorsTool,
140
141
  exchangeRateTool,
142
+ crmRateTool,
141
143
  // Auditoria (bank reconciliation)
142
144
  listBankAccountsTool,
143
145
  reconciliationStatusTool,
@@ -234,6 +236,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
234
236
  case 'get_exchange_rate':
235
237
  result = await handleGetExchangeRate(bcClient, args);
236
238
  break;
239
+ case 'get_crm_rate':
240
+ result = await handleGetCrmRate(bcClient, args);
241
+ break;
237
242
  // Auditoria (bank reconciliation)
238
243
  case 'list_bank_accounts':
239
244
  result = await handleListBankAccounts(bcClient, args);
@@ -0,0 +1,109 @@
1
+ // tools/get-crm-rate.js
2
+ // Tasa de cambio Bs/USD desde el CRM de Full Queso (hora Caracas).
3
+ // Fuente: https://crm-rate.fullqueso.com
4
+ // - USDT == Binance == tasa paralelo
5
+ // - VES == BCV == tasa oficial
6
+ // Histórica (con fecha) o del día (sin fecha).
7
+
8
+ import { DateTime } from 'luxon';
9
+
10
+ const CRM_RATE_BASE =
11
+ process.env.CRM_RATE_BASE || 'https://crm-rate.fullqueso.com/api/v1';
12
+
13
+ // Normaliza el coin del usuario al símbolo del endpoint.
14
+ function normalizeCoin(raw) {
15
+ const c = String(raw || '').trim().toUpperCase();
16
+ if (['USDT', 'BINANCE', 'PARALELO', 'PARALELA'].includes(c)) {
17
+ return { history: 'USDT', live: 'usdt', label: 'USDT/Binance' };
18
+ }
19
+ if (['VES', 'BCV', 'OFICIAL', 'BS'].includes(c)) {
20
+ return { history: 'VES', live: 'bcv', label: 'BCV (oficial)' };
21
+ }
22
+ return null;
23
+ }
24
+
25
+ export const crmRateTool = {
26
+ name: 'get_crm_rate',
27
+ description:
28
+ 'Obtiene la tasa de cambio Bs/USD desde el CRM de Full Queso (hora Caracas). ' +
29
+ 'Dos monedas: USDT (equivalente a la tasa Binance / paralelo) y VES (tasa BCV oficial). ' +
30
+ 'Si se pasa una fecha (YYYY-MM-DD) retorna la tasa histórica guardada de ese día; ' +
31
+ 'si se omite la fecha retorna la tasa del momento de la consulta. ' +
32
+ 'Usar SIEMPRE que se pida: tasa USDT, tasa Binance, dólar Binance, tasa paralelo/paralela, ' +
33
+ 'tasa BCV, tasa oficial, tasa del día o tasa histórica de una fecha. ' +
34
+ 'Para tasa BCV vigente contable en Business Central usar get_exchange_rate; este tool es la tasa de referencia del CRM.',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ coin: {
39
+ type: 'string',
40
+ enum: ['USDT', 'BINANCE', 'VES', 'BCV'],
41
+ description:
42
+ 'Moneda/tasa. USDT y BINANCE son equivalentes (paralelo). VES y BCV son equivalentes (oficial BCV).',
43
+ },
44
+ date: {
45
+ type: 'string',
46
+ description:
47
+ 'Fecha histórica (YYYY-MM-DD), hora Caracas. Si se omite, devuelve la tasa actual.',
48
+ },
49
+ },
50
+ required: ['coin'],
51
+ },
52
+ };
53
+
54
+ export async function handleGetCrmRate(_bcClient, args) {
55
+ const coin = normalizeCoin(args.coin);
56
+ if (!coin) {
57
+ throw new Error(
58
+ `coin inválido: "${args.coin}". Válidos: USDT/BINANCE (paralelo) o VES/BCV (oficial).`
59
+ );
60
+ }
61
+
62
+ const isHistorical = !!args.date;
63
+ let url;
64
+ if (isHistorical) {
65
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(args.date)) {
66
+ throw new Error(`date inválida: "${args.date}". Formato esperado YYYY-MM-DD.`);
67
+ }
68
+ url = `${CRM_RATE_BASE}/history/price?coin=${coin.history}&date=${args.date}`;
69
+ } else {
70
+ url = `${CRM_RATE_BASE}/coin/${coin.live}`;
71
+ }
72
+
73
+ let res;
74
+ try {
75
+ res = await fetch(url, { headers: { Accept: 'application/json' } });
76
+ } catch (e) {
77
+ throw new Error(`No se pudo conectar al CRM de tasas (${CRM_RATE_BASE}): ${e.message}`);
78
+ }
79
+
80
+ if (res.status === 400) {
81
+ throw new Error('CRM 400: coin o date inválidos.');
82
+ }
83
+ if (res.status === 404) {
84
+ return {
85
+ coin: coin.label,
86
+ date: args.date || null,
87
+ price: null,
88
+ message: `No hay registro de tasa ${coin.label} para ${args.date}.`,
89
+ };
90
+ }
91
+ if (!res.ok) {
92
+ throw new Error(`CRM respondió ${res.status} en ${url}`);
93
+ }
94
+
95
+ const data = await res.json();
96
+ const nowCcs = DateTime.now().setZone('America/Caracas');
97
+ const price =
98
+ typeof data.price === 'number' ? Math.round(data.price * 100) / 100 : data.price;
99
+
100
+ return {
101
+ coin: coin.label,
102
+ type: isHistorical ? 'historica' : 'del_dia',
103
+ date: isHistorical ? args.date : nowCcs.toISODate(),
104
+ queried_at_caracas: nowCcs.toFormat('yyyy-MM-dd HH:mm:ss'),
105
+ price,
106
+ label: `1 USD = ${price} Bs (${coin.label})`,
107
+ source: url,
108
+ };
109
+ }
@@ -0,0 +1,51 @@
1
+ // Single source of truth for "how much did this payroll document cost", in USD.
2
+ //
3
+ // Grounded in 448 real BC documents (tests/validate-payroll-cost-model.js):
4
+ // - Undistributed Drafts: totalGrossUSD = 0, totalNet* = 0, only totalBonuses* carries value.
5
+ // - Posted / Accrued docs: totalGrossUSD is populated and equals the real cost.
6
+ // - VACACIONES / LIQUIDACION: net is double-booked (the same amount lands in BOTH
7
+ // totalNetUSD *and* totalNetVES) → `net_usd + net_ves/rate`
8
+ // DOUBLE-COUNTS. Never use that sum as the cost.
9
+ //
10
+ // Rule (in order): cost = totalGrossUSD (>0) → else bonuses-equivalent → else net (MAX, not sum).
11
+ // `cost_basis` records which branch was used so the number is auditable, and
12
+ // `net_distributed` flags drafts whose net hasn't been split across payment methods yet.
13
+
14
+ const EPS = 0.005;
15
+
16
+ // Payroll-cost buckets for the store-vs-events KPI. Managerial (GERENCIAL /
17
+ // GERENCIAL_CIERRE) is neither → stays only in total_cost_usd.
18
+ export const STORE_PAYROLL_TYPES = ['SEMANAL', 'VACACIONES', 'LIQUIDACION'];
19
+ export const EVENT_PAYROLL_TYPES = ['EVENTO'];
20
+
21
+ export function round2(n) {
22
+ return Math.round(n * 100) / 100;
23
+ }
24
+
25
+ export function computeDocCost(doc) {
26
+ const rate = doc.exchangeRate || 0;
27
+ const grossUsd = doc.totalGrossUSD || 0;
28
+ const bonusUsdEq = (doc.totalBonusesUSD || 0) + (rate ? (doc.totalBonusesVES || 0) / rate : 0);
29
+ // max() (not sum) guards against net booked in both currencies (the double-count above).
30
+ const netUsdEq = Math.max(doc.totalNetUSD || 0, rate ? (doc.totalNetVES || 0) / rate : 0);
31
+
32
+ let cost, basis;
33
+ if (grossUsd > EPS) {
34
+ cost = grossUsd;
35
+ basis = 'gross';
36
+ } else if (bonusUsdEq > EPS) {
37
+ cost = bonusUsdEq;
38
+ basis = 'bonuses';
39
+ } else {
40
+ cost = netUsdEq;
41
+ basis = 'net';
42
+ }
43
+
44
+ const netDistributed = (doc.totalNetUSD || 0) !== 0 || (doc.totalNetVES || 0) !== 0;
45
+
46
+ return {
47
+ total_cost_usd: round2(cost),
48
+ cost_basis: basis, // 'gross' | 'bonuses' | 'net' — which field the cost came from
49
+ net_distributed: netDistributed, // false = Draft not yet split across payment methods (#3)
50
+ };
51
+ }
@@ -1,10 +1,14 @@
1
1
  import { resolveStores } from '../../config/company-config.js';
2
2
  import { logger } from '../../utils/logger.js';
3
3
 
4
+ // Real employee payrollType values in BC (discovered, not assumed — see L-001):
5
+ // EVENTO, GERENCIAL, SEMANAL. (QUINCENAL/MENSUAL never existed.)
6
+ const EMPLOYEE_PAYROLL_TYPES = ['SEMANAL', 'GERENCIAL', 'EVENTO'];
7
+
4
8
  export const employeesTool = {
5
9
  name: 'get_employees',
6
10
  description:
7
- 'Lista empleados de una tienda con datos de nomina: tipo, status, salarios base, bonos predeterminados, fechas. Filtros por status, tipo de nomina, busqueda por nombre.',
11
+ 'Lista empleados de una tienda con datos de nomina: tipo, status, salarios base, bonos predeterminados, fechas. Filtros por status, tipo de nomina, y exclude_managerial para headcount operativo. Soporta multi-tienda (resiliente: una tienda que falle no tumba el consolidado).',
8
12
  inputSchema: {
9
13
  type: 'object',
10
14
  properties: {
@@ -20,8 +24,13 @@ export const employeesTool = {
20
24
  },
21
25
  payroll_type: {
22
26
  type: 'string',
23
- enum: ['SEMANAL', 'QUINCENAL', 'MENSUAL'],
24
- description: 'Filtrar por tipo de nomina.',
27
+ enum: EMPLOYEE_PAYROLL_TYPES,
28
+ description: 'Filtrar por tipo de nomina. Valores reales de empleado: SEMANAL, GERENCIAL, EVENTO.',
29
+ },
30
+ exclude_managerial: {
31
+ type: 'boolean',
32
+ description:
33
+ 'Excluir empleados gerenciales (payrollType GERENCIAL / employeeType Management) → headcount operativo. Default: false.',
25
34
  },
26
35
  employee_search: {
27
36
  type: 'string',
@@ -46,10 +55,19 @@ export async function handleEmployees(bcClient, args) {
46
55
  return processStore(bcClient, stores[0], args);
47
56
  }
48
57
 
49
- const results = await Promise.all(
58
+ // Resilient consolidation: a single store hanging/failing must not take down
59
+ // the whole "all" response (root cause behind BUG #1). See L-015 / L-016.
60
+ const settled = await Promise.allSettled(
50
61
  stores.map((s) => processStore(bcClient, s, args))
51
62
  );
52
63
 
64
+ const results = [];
65
+ const errors = [];
66
+ settled.forEach((r, i) => {
67
+ if (r.status === 'fulfilled') results.push(r.value);
68
+ else errors.push({ store: stores[i].code, error: String(r.reason?.message || r.reason) });
69
+ });
70
+
53
71
  const consolidated = {
54
72
  total_employees: 0,
55
73
  by_status: {},
@@ -70,7 +88,9 @@ export async function handleEmployees(bcClient, args) {
70
88
  }
71
89
  }
72
90
 
73
- return { stores: results, consolidated };
91
+ const out = { stores: results, consolidated };
92
+ if (errors.length) out.errors = errors;
93
+ return out;
74
94
  }
75
95
 
76
96
  async function processStore(bcClient, storeInfo, args) {
@@ -79,6 +99,10 @@ async function processStore(bcClient, storeInfo, args) {
79
99
 
80
100
  const filters = [`companyCode eq '${storeInfo.code}'`, `status eq '${status}'`];
81
101
  if (args.payroll_type) filters.push(`payrollType eq '${args.payroll_type}'`);
102
+ if (args.exclude_managerial) {
103
+ filters.push(`payrollType ne 'GERENCIAL'`);
104
+ filters.push(`employeeType ne 'Management'`);
105
+ }
82
106
  if (args.employee_code) filters.push(`employeeCode eq '${args.employee_code}'`);
83
107
 
84
108
  const url = bcClient.buildPayrollApiUrl(companyId, 'fqEmployees', {
@@ -1,10 +1,17 @@
1
1
  import { resolveStores } from '../../config/company-config.js';
2
2
  import { logger } from '../../utils/logger.js';
3
+ import { computeDocCost, round2, STORE_PAYROLL_TYPES, EVENT_PAYROLL_TYPES } from './cost-model.js';
4
+
5
+ // Real document payrollType values in BC (discovered, not assumed — see LESSONS L-001):
6
+ // EVENTO, GERENCIAL, GERENCIAL_CIERRE, LIQUIDACION, SEMANAL, VACACIONES
7
+ // The old enum [SEMANAL|QUINCENAL|MENSUAL] was wrong (QUINCENAL/MENSUAL don't exist).
8
+ const DOC_PAYROLL_TYPES = ['SEMANAL', 'GERENCIAL', 'GERENCIAL_CIERRE', 'EVENTO', 'VACACIONES', 'LIQUIDACION'];
9
+ const MANAGERIAL_TYPES = ['GERENCIAL', 'GERENCIAL_CIERRE'];
3
10
 
4
11
  export const payrollDocumentsTool = {
5
12
  name: 'get_payroll_documents',
6
13
  description:
7
- 'Lista documentos de nomina (headers) con filtros por periodo, tipo, status. Muestra totales por documento, cantidad de empleados, tasas de cambio. Soporta multi-tienda.',
14
+ 'Lista documentos de nomina (headers) con filtros por periodo, tipo, status. Devuelve costo total en USD por documento (total_cost_usd, robusto a drafts sin distribuir), buckets store_payroll/event_payroll, tasas de cambio, y un summary con costo por tipo. Con include_employee_count agrega empleados unicos y pagados (dedup por cedula) para cruzar activos vs pagados. Soporta multi-tienda y excluir gerencia (exclude_managerial) para KPIs de nomina operativa.',
8
15
  inputSchema: {
9
16
  type: 'object',
10
17
  properties: {
@@ -19,8 +26,14 @@ export const payrollDocumentsTool = {
19
26
  },
20
27
  payroll_type: {
21
28
  type: 'string',
22
- enum: ['SEMANAL', 'QUINCENAL', 'MENSUAL'],
23
- description: 'Filtrar por tipo de nomina.',
29
+ enum: DOC_PAYROLL_TYPES,
30
+ description:
31
+ 'Filtrar por tipo de documento de nomina. Valores reales: SEMANAL, GERENCIAL, GERENCIAL_CIERRE, EVENTO, VACACIONES, LIQUIDACION.',
32
+ },
33
+ exclude_managerial: {
34
+ type: 'boolean',
35
+ description:
36
+ 'Excluir nomina gerencial (GERENCIAL + GERENCIAL_CIERRE) en un solo call → deja la nomina operativa. Default: false.',
24
37
  },
25
38
  status: {
26
39
  type: 'string',
@@ -35,6 +48,11 @@ export const payrollDocumentsTool = {
35
48
  type: 'string',
36
49
  description: 'Fecha fin (YYYY-MM-DD) para filtrar por documentDate.',
37
50
  },
51
+ include_employee_count: {
52
+ type: 'boolean',
53
+ description:
54
+ 'Calcular empleados unicos + lista paid_employees (dedup por cedula, con costo/lineas/tipos/clasificacion) y duplicate_codes. Requiere leer las lineas de los docs filtrados (consultas extra, en lotes). Default: false.',
55
+ },
38
56
  summary_only: {
39
57
  type: 'boolean',
40
58
  description: 'Solo resumen (sin lista de documentos). Default: false.',
@@ -52,38 +70,82 @@ export async function handlePayrollDocuments(bcClient, args) {
52
70
  const summaryOnly = args.summary_only || false;
53
71
 
54
72
  if (stores.length === 1) {
55
- return processStore(bcClient, stores[0], args, summaryOnly);
73
+ const r = await processStore(bcClient, stores[0], args, summaryOnly);
74
+ delete r.summary._paid_contribs;
75
+ return r;
56
76
  }
57
77
 
58
- const results = await Promise.all(
78
+ // Resilient consolidation: one store failing/timing out must NOT hang or nuke
79
+ // the whole "all" response (root cause behind BUG #1). See L-015 / L-016.
80
+ const settled = await Promise.allSettled(
59
81
  stores.map((s) => processStore(bcClient, s, args, summaryOnly))
60
82
  );
61
83
 
84
+ const results = [];
85
+ const errors = [];
86
+ settled.forEach((r, i) => {
87
+ if (r.status === 'fulfilled') results.push(r.value);
88
+ else errors.push({ store: stores[i].code, error: String(r.reason?.message || r.reason) });
89
+ });
90
+
62
91
  const consolidated = {
63
92
  total_documents: 0,
64
93
  total_employees_lines: 0,
94
+ total_cost_usd: 0,
95
+ store_payroll: 0,
96
+ event_payroll: 0,
65
97
  total_net_usd: 0,
66
98
  total_net_ves: 0,
67
99
  by_status: { Draft: 0, Posted: 0 },
68
100
  by_type: {},
69
101
  };
70
102
 
103
+ const allContribs = [];
104
+ let anyEmployeeCount = false;
105
+
71
106
  for (const r of results) {
72
107
  consolidated.total_documents += r.summary.total_documents;
73
108
  consolidated.total_employees_lines += r.summary.total_employees_lines;
109
+ consolidated.total_cost_usd += r.summary.total_cost_usd;
110
+ consolidated.store_payroll += r.summary.store_payroll;
111
+ consolidated.event_payroll += r.summary.event_payroll;
74
112
  consolidated.total_net_usd += r.summary.total_net_usd;
75
113
  consolidated.total_net_ves += r.summary.total_net_ves;
76
114
  consolidated.by_status.Draft += r.summary.by_status.Draft || 0;
77
115
  consolidated.by_status.Posted += r.summary.by_status.Posted || 0;
78
- for (const [type, count] of Object.entries(r.summary.by_type)) {
79
- consolidated.by_type[type] = (consolidated.by_type[type] || 0) + count;
116
+ for (const [type, agg] of Object.entries(r.summary.by_type)) {
117
+ const c = consolidated.by_type[type] || { count: 0, lines: 0, cost_usd: 0 };
118
+ c.count += agg.count;
119
+ c.lines += agg.lines;
120
+ c.cost_usd = round2(c.cost_usd + agg.cost_usd);
121
+ consolidated.by_type[type] = c;
122
+ }
123
+ if (r.summary._paid_contribs) {
124
+ anyEmployeeCount = true;
125
+ allContribs.push(...r.summary._paid_contribs);
80
126
  }
81
127
  }
82
128
 
129
+ consolidated.total_cost_usd = round2(consolidated.total_cost_usd);
130
+ consolidated.store_payroll = round2(consolidated.store_payroll);
131
+ consolidated.event_payroll = round2(consolidated.event_payroll);
83
132
  consolidated.total_net_usd = round2(consolidated.total_net_usd);
84
133
  consolidated.total_net_ves = round2(consolidated.total_net_ves);
85
134
 
86
- return { stores: results, consolidated };
135
+ if (anyEmployeeCount) {
136
+ // Re-dedup across stores too (same cédula in two stores = one person).
137
+ const agg = aggregatePaidEmployees(allContribs);
138
+ consolidated.unique_employees = agg.unique_employees;
139
+ consolidated.paid_employees = agg.paid_employees;
140
+ consolidated.duplicate_codes = agg.duplicate_codes;
141
+ }
142
+
143
+ // Strip the internal contribution list from per-store summaries before returning.
144
+ for (const r of results) delete r.summary._paid_contribs;
145
+
146
+ const out = { stores: results, consolidated };
147
+ if (errors.length) out.errors = errors;
148
+ return out;
87
149
  }
88
150
 
89
151
  async function processStore(bcClient, storeInfo, args, summaryOnly) {
@@ -92,12 +154,16 @@ async function processStore(bcClient, storeInfo, args, summaryOnly) {
92
154
  const filters = [];
93
155
  if (args.period_code) filters.push(`periodCode eq '${args.period_code}'`);
94
156
  if (args.payroll_type) filters.push(`payrollType eq '${args.payroll_type}'`);
157
+ if (args.exclude_managerial) {
158
+ for (const t of MANAGERIAL_TYPES) filters.push(`payrollType ne '${t}'`);
159
+ }
95
160
  if (args.status) filters.push(`status eq '${args.status}'`);
96
161
  if (args.start_date) filters.push(`documentDate ge ${args.start_date}`);
97
162
  if (args.end_date) filters.push(`documentDate le ${args.end_date}`);
98
163
 
99
164
  const params = {
100
- $select: 'documentNo,periodCode,payrollType,companyCode,documentDate,postingDate,paymentDate,status,lineCount,totalBonusesVES,totalBonusesUSD,totalNetUSD,totalNetVES,exchangeRate,createdBy,postedBy,createdDateTime,postedDateTime',
165
+ $select:
166
+ 'documentNo,periodCode,payrollType,companyCode,documentDate,postingDate,paymentDate,status,lineCount,totalGrossUSD,totalDeductionsUSD,totalBonusesVES,totalBonusesUSD,totalNetUSD,totalNetVES,exchangeRate,createdBy,postedBy,createdDateTime,postedDateTime',
101
167
  $orderby: 'documentDate desc',
102
168
  };
103
169
  if (filters.length > 0) params.$filter = filters.join(' and ');
@@ -110,6 +176,9 @@ async function processStore(bcClient, storeInfo, args, summaryOnly) {
110
176
  const summary = {
111
177
  total_documents: data.length,
112
178
  total_employees_lines: 0,
179
+ total_cost_usd: 0,
180
+ store_payroll: 0,
181
+ event_payroll: 0,
113
182
  total_net_usd: 0,
114
183
  total_net_ves: 0,
115
184
  by_status: { Draft: 0, Posted: 0 },
@@ -122,12 +191,19 @@ async function processStore(bcClient, storeInfo, args, summaryOnly) {
122
191
  const lineCount = doc.lineCount || 0;
123
192
  const netUsd = doc.totalNetUSD || 0;
124
193
  const netVes = doc.totalNetVES || 0;
194
+ const cost = computeDocCost(doc);
125
195
 
126
196
  summary.total_employees_lines += lineCount;
197
+ summary.total_cost_usd += cost.total_cost_usd;
127
198
  summary.total_net_usd += netUsd;
128
199
  summary.total_net_ves += netVes;
129
200
  summary.by_status[doc.status] = (summary.by_status[doc.status] || 0) + 1;
130
- summary.by_type[doc.payrollType] = (summary.by_type[doc.payrollType] || 0) + 1;
201
+
202
+ const typeAgg = summary.by_type[doc.payrollType] || { count: 0, lines: 0, cost_usd: 0 };
203
+ typeAgg.count += 1;
204
+ typeAgg.lines += lineCount;
205
+ typeAgg.cost_usd = round2(typeAgg.cost_usd + cost.total_cost_usd);
206
+ summary.by_type[doc.payrollType] = typeAgg;
131
207
 
132
208
  if (!summaryOnly) {
133
209
  documents.push({
@@ -140,6 +216,11 @@ async function processStore(bcClient, storeInfo, args, summaryOnly) {
140
216
  payment_date: doc.paymentDate,
141
217
  status: doc.status,
142
218
  line_count: lineCount,
219
+ total_cost_usd: cost.total_cost_usd,
220
+ cost_basis: cost.cost_basis,
221
+ net_distributed: cost.net_distributed,
222
+ total_gross_usd: round2(doc.totalGrossUSD || 0),
223
+ total_deductions_usd: round2(doc.totalDeductionsUSD || 0),
143
224
  total_bonuses_ves: round2(doc.totalBonusesVES || 0),
144
225
  total_bonuses_usd: round2(doc.totalBonusesUSD || 0),
145
226
  total_net_usd: round2(netUsd),
@@ -153,9 +234,28 @@ async function processStore(bcClient, storeInfo, args, summaryOnly) {
153
234
  }
154
235
  }
155
236
 
237
+ summary.total_cost_usd = round2(summary.total_cost_usd);
156
238
  summary.total_net_usd = round2(summary.total_net_usd);
157
239
  summary.total_net_ves = round2(summary.total_net_ves);
158
240
 
241
+ // #4 (buckets) — tienda vs eventos (gerencia queda solo en total_cost_usd).
242
+ summary.store_payroll = round2(
243
+ STORE_PAYROLL_TYPES.reduce((s, t) => s + (summary.by_type[t]?.cost_usd || 0), 0)
244
+ );
245
+ summary.event_payroll = round2(
246
+ EVENT_PAYROLL_TYPES.reduce((s, t) => s + (summary.by_type[t]?.cost_usd || 0), 0)
247
+ );
248
+
249
+ // #6/#1/#2/#3 — empleados pagados (dedup por cedula) en el periodo (opt-in).
250
+ if (args.include_employee_count) {
251
+ const contribs = await fetchPaidContribs(bcClient, companyId, data);
252
+ const agg = aggregatePaidEmployees(contribs);
253
+ summary.unique_employees = agg.unique_employees;
254
+ summary.paid_employees = agg.paid_employees;
255
+ summary.duplicate_codes = agg.duplicate_codes;
256
+ summary._paid_contribs = contribs; // internal, stripped before return
257
+ }
258
+
159
259
  const result = {
160
260
  store: storeInfo.code,
161
261
  store_name: storeInfo.name,
@@ -167,6 +267,90 @@ async function processStore(bcClient, storeInfo, args, summaryOnly) {
167
267
  return result;
168
268
  }
169
269
 
170
- function round2(n) {
171
- return Math.round(n * 100) / 100;
270
+ // Per-line contributions (one per employee-line) with the document's payrollType
271
+ // and exchangeRate, so cost_usd = totalVESEquivalent / rate. Lines only filter by
272
+ // documentNo → batch in OR-chunks to avoid N calls.
273
+ async function fetchPaidContribs(bcClient, companyId, docs, chunkSize = 15) {
274
+ const docMap = {};
275
+ for (const d of docs) docMap[d.documentNo] = { payrollType: d.payrollType, rate: d.exchangeRate || 0 };
276
+ const documentNos = docs.map((d) => d.documentNo);
277
+
278
+ const contribs = [];
279
+ for (let i = 0; i < documentNos.length; i += chunkSize) {
280
+ const chunk = documentNos.slice(i, i + chunkSize);
281
+ const filter = chunk.map((no) => `documentNo eq '${no}'`).join(' or ');
282
+ const url = bcClient.buildPayrollApiUrl(companyId, 'fqPayrollLines', {
283
+ $filter: filter,
284
+ $select: 'documentNo,employeeCode,employeeName,cedulaDeIdentidad,totalVESEquivalent',
285
+ });
286
+ const lines = await bcClient.apiCallAllPages(url);
287
+ for (const l of lines) {
288
+ const dm = docMap[l.documentNo] || {};
289
+ const rate = dm.rate || 0;
290
+ contribs.push({
291
+ employee_code: l.employeeCode,
292
+ employee_name: l.employeeName,
293
+ cedula: l.cedulaDeIdentidad,
294
+ payroll_type: dm.payrollType,
295
+ cost_usd: rate ? (l.totalVESEquivalent || 0) / rate : 0,
296
+ });
297
+ }
298
+ }
299
+ return contribs;
300
+ }
301
+
302
+ // Aggregate line contributions into one record per PERSON, deduped by cédula.
303
+ // A person with a SEMANAL code and an EVENTO code (same cédula) collapses into one
304
+ // record; their codes are surfaced in duplicate_codes for BC cleanup. When cédula
305
+ // is null/empty the code is treated as a unique person (never merge by name).
306
+ export function aggregatePaidEmployees(contribs) {
307
+ // Pass 1: learn each code's cédula from ANY line that carries one, so a line
308
+ // with a blank cédula still collapses into that person (avoids splitting one
309
+ // code into two when the cédula is only populated on some weeks).
310
+ const codeToCedula = {};
311
+ for (const c of contribs) {
312
+ const ced = c.cedula != null && String(c.cedula).trim() ? String(c.cedula).trim() : null;
313
+ if (ced && c.employee_code && !codeToCedula[c.employee_code]) codeToCedula[c.employee_code] = ced;
314
+ }
315
+
316
+ const byPerson = new Map();
317
+ for (const c of contribs) {
318
+ const cedula =
319
+ (c.cedula != null && String(c.cedula).trim() ? String(c.cedula).trim() : null) ||
320
+ codeToCedula[c.employee_code] ||
321
+ null;
322
+ const key = cedula ? `ced:${cedula}` : `code:${c.employee_code}`;
323
+ let p = byPerson.get(key);
324
+ if (!p) {
325
+ p = { employee_code: c.employee_code, employee_name: c.employee_name || null, cedula, cost_usd: 0, lines: 0, types: new Set(), codes: new Set() };
326
+ byPerson.set(key, p);
327
+ }
328
+ p.cost_usd += c.cost_usd || 0;
329
+ p.lines += 1;
330
+ if (c.payroll_type) p.types.add(c.payroll_type);
331
+ if (c.employee_code) p.codes.add(c.employee_code);
332
+ if (!p.employee_name && c.employee_name) p.employee_name = c.employee_name;
333
+ }
334
+
335
+ const paid_employees = [];
336
+ const duplicate_codes = [];
337
+ for (const p of byPerson.values()) {
338
+ const codes = [...p.codes].sort();
339
+ const hasEvento = p.types.has('EVENTO');
340
+ const hasNonEvento = [...p.types].some((t) => t !== 'EVENTO');
341
+ const classification = hasEvento && hasNonEvento ? 'mixto' : hasEvento ? 'evento' : 'operativo';
342
+ paid_employees.push({
343
+ employee_code: p.employee_code,
344
+ employee_name: p.employee_name,
345
+ cedula: p.cedula,
346
+ cost_usd: round2(p.cost_usd),
347
+ lines: p.lines,
348
+ payroll_types: [...p.types].sort(),
349
+ classification,
350
+ });
351
+ if (p.cedula && codes.length > 1) duplicate_codes.push({ cedula: p.cedula, codes });
352
+ }
353
+
354
+ paid_employees.sort((a, b) => b.cost_usd - a.cost_usd);
355
+ return { unique_employees: byPerson.size, paid_employees, duplicate_codes };
172
356
  }
@@ -1,10 +1,11 @@
1
1
  import { resolveStores } from '../../config/company-config.js';
2
2
  import { logger } from '../../utils/logger.js';
3
+ import { round2 } from './cost-model.js';
3
4
 
4
5
  export const payrollLinesTool = {
5
6
  name: 'get_payroll_lines',
6
7
  description:
7
- 'Detalle de nomina por empleado para un documento especifico. Muestra bonos, pagos por metodo (cash VES, banco VES, cash USD), totales, y balance. Requiere document_no.',
8
+ 'Detalle de nomina por empleado para un documento. Muestra bonos, pagos por metodo (cash VES, banco VES, cash USD), y el costo por empleado en USD (cost_usd, derivado de totalVESEquivalent a la tasa del documento) → base para "costo por persona". Requiere document_no. Nota: la API de nomina NO expone dias/horas trabajados ni FTE por linea.',
8
9
  inputSchema: {
9
10
  type: 'object',
10
11
  properties: {
@@ -42,39 +43,57 @@ export async function handlePayrollLines(bcClient, args) {
42
43
  const filters = [`documentNo eq '${document_no}'`];
43
44
  if (args.employee_code) filters.push(`employeeCode eq '${args.employee_code}'`);
44
45
 
45
- const url = bcClient.buildPayrollApiUrl(companyId, 'fqPayrollLines', {
46
+ // Header holds the exchangeRate (lines don't) → needed for per-employee cost_usd.
47
+ const headerUrl = bcClient.buildPayrollApiUrl(companyId, 'fqPayrollDocuments', {
48
+ $filter: `documentNo eq '${document_no}'`,
49
+ $select: 'documentNo,payrollType,status,documentDate,exchangeRate',
50
+ });
51
+
52
+ const linesUrl = bcClient.buildPayrollApiUrl(companyId, 'fqPayrollLines', {
46
53
  $filter: filters.join(' and '),
47
- $select: 'documentNo,lineNo,employeeCode,employeeName,cedulaDeIdentidad,transportBonusVES,productivityBonusUSD,extraRedobleUSD,specialDateBonusUSD,foodBonusUSD,payCashVES,payBankVES,payCashUSD,totalBonusesVES,totalBonusesUSD,totalPayVES,totalPayUSD,balanceDifference',
54
+ $select:
55
+ 'documentNo,lineNo,employeeCode,employeeName,cedulaDeIdentidad,transportBonusVES,productivityBonusUSD,extraRedobleUSD,specialDateBonusUSD,foodBonusUSD,payCashVES,payBankVES,payCashUSD,totalBonusesVES,totalBonusesUSD,totalVESEquivalent,totalPayVES,totalPayUSD,balanceDifference',
48
56
  $orderby: 'lineNo',
49
57
  });
50
58
 
51
- const data = await bcClient.apiCallAllPages(url);
52
- logger.info(`${storeInfo.code}: ${data.length} payroll lines for ${document_no}`);
59
+ const [headerRows, data] = await Promise.all([
60
+ bcClient.apiCallAllPages(headerUrl),
61
+ bcClient.apiCallAllPages(linesUrl),
62
+ ]);
63
+ const header = headerRows[0] || null;
64
+ const rate = header?.exchangeRate || 0;
65
+ logger.info(`${storeInfo.code}: ${data.length} payroll lines for ${document_no} (rate=${rate || 'n/a'})`);
53
66
 
54
- let lines = data.map((line) => ({
55
- document_no: line.documentNo,
56
- line_no: line.lineNo,
57
- employee_code: line.employeeCode,
58
- employee_name: line.employeeName,
59
- cedula: line.cedulaDeIdentidad || null,
60
- bonuses: {
61
- transport_ves: round2(line.transportBonusVES || 0),
62
- productivity_usd: round2(line.productivityBonusUSD || 0),
63
- extra_redoble_usd: round2(line.extraRedobleUSD || 0),
64
- special_date_usd: round2(line.specialDateBonusUSD || 0),
65
- food_usd: round2(line.foodBonusUSD || 0),
66
- },
67
- payments: {
68
- cash_ves: round2(line.payCashVES || 0),
69
- bank_ves: round2(line.payBankVES || 0),
70
- cash_usd: round2(line.payCashUSD || 0),
71
- },
72
- total_bonuses_ves: round2(line.totalBonusesVES || 0),
73
- total_bonuses_usd: round2(line.totalBonusesUSD || 0),
74
- total_pay_ves: round2(line.totalPayVES || 0),
75
- total_pay_usd: round2(line.totalPayUSD || 0),
76
- balance_difference: round2(line.balanceDifference || 0),
77
- }));
67
+ let lines = data.map((line) => {
68
+ const vesEquivalent = round2(line.totalVESEquivalent || 0);
69
+ return {
70
+ document_no: line.documentNo,
71
+ line_no: line.lineNo,
72
+ employee_code: line.employeeCode,
73
+ employee_name: line.employeeName,
74
+ cedula: line.cedulaDeIdentidad || null,
75
+ bonuses: {
76
+ transport_ves: round2(line.transportBonusVES || 0),
77
+ productivity_usd: round2(line.productivityBonusUSD || 0),
78
+ extra_redoble_usd: round2(line.extraRedobleUSD || 0),
79
+ special_date_usd: round2(line.specialDateBonusUSD || 0),
80
+ food_usd: round2(line.foodBonusUSD || 0),
81
+ },
82
+ payments: {
83
+ cash_ves: round2(line.payCashVES || 0),
84
+ bank_ves: round2(line.payBankVES || 0),
85
+ cash_usd: round2(line.payCashUSD || 0),
86
+ },
87
+ total_bonuses_ves: round2(line.totalBonusesVES || 0),
88
+ total_bonuses_usd: round2(line.totalBonusesUSD || 0),
89
+ total_ves_equivalent: vesEquivalent,
90
+ // Per-employee cost in USD = full pay (VES-equivalent) at the document rate.
91
+ cost_usd: rate ? round2(vesEquivalent / rate) : null,
92
+ total_pay_ves: round2(line.totalPayVES || 0),
93
+ total_pay_usd: round2(line.totalPayUSD || 0),
94
+ balance_difference: round2(line.balanceDifference || 0),
95
+ };
96
+ });
78
97
 
79
98
  // Client-side name search
80
99
  if (args.employee_search) {
@@ -85,7 +104,12 @@ export async function handlePayrollLines(bcClient, args) {
85
104
  // Summary totals
86
105
  const summary = {
87
106
  document_no,
107
+ payroll_type: header?.payrollType || null,
108
+ status: header?.status || null,
109
+ exchange_rate: rate || null,
88
110
  total_employees: lines.length,
111
+ total_cost_usd: 0,
112
+ total_ves_equivalent: 0,
89
113
  total_bonuses_ves: 0,
90
114
  total_bonuses_usd: 0,
91
115
  total_pay_ves: 0,
@@ -97,6 +121,8 @@ export async function handlePayrollLines(bcClient, args) {
97
121
  };
98
122
 
99
123
  for (const line of lines) {
124
+ summary.total_ves_equivalent += line.total_ves_equivalent;
125
+ if (line.cost_usd != null) summary.total_cost_usd += line.cost_usd;
100
126
  summary.total_bonuses_ves += line.total_bonuses_ves;
101
127
  summary.total_bonuses_usd += line.total_bonuses_usd;
102
128
  summary.total_pay_ves += line.total_pay_ves;
@@ -107,6 +133,8 @@ export async function handlePayrollLines(bcClient, args) {
107
133
  if (Math.abs(line.balance_difference) > 0.01) summary.employees_with_balance_diff++;
108
134
  }
109
135
 
136
+ summary.total_cost_usd = rate ? round2(summary.total_cost_usd) : null;
137
+ summary.total_ves_equivalent = round2(summary.total_ves_equivalent);
110
138
  summary.total_bonuses_ves = round2(summary.total_bonuses_ves);
111
139
  summary.total_bonuses_usd = round2(summary.total_bonuses_usd);
112
140
  summary.total_pay_ves = round2(summary.total_pay_ves);
@@ -122,7 +150,3 @@ export async function handlePayrollLines(bcClient, args) {
122
150
  lines,
123
151
  };
124
152
  }
125
-
126
- function round2(n) {
127
- return Math.round(n * 100) / 100;
128
- }
@@ -99,7 +99,7 @@ export async function handleSalesAnalysis(bcClient, args) {
99
99
  data.lines.map((l) => ({ ...l, storeCode: code, storeName: data.storeName }))
100
100
  );
101
101
 
102
- const storeSummary = calculateSummary(data.invoices, data.lines, exchangeRate, data.glRevenue);
102
+ const storeSummary = calculateSummary(data.invoices, data.lines, exchangeRate, data.glRevenue, code);
103
103
  byStoreResults.push({
104
104
  store_code: code,
105
105
  store_name: data.storeName,
@@ -59,7 +59,7 @@ export async function handleSalesStoreComparison(bcClient, args) {
59
59
  const storesSummaries = {};
60
60
 
61
61
  for (const [code, data] of Object.entries(allStoresData)) {
62
- const summary = calculateSummary(data.invoices, data.lines, exchangeRate, data.glRevenue);
62
+ const summary = calculateSummary(data.invoices, data.lines, exchangeRate, data.glRevenue, code);
63
63
  storesSummaries[data.storeName] = { summary };
64
64
 
65
65
  const storeResult = {
@@ -69,6 +69,10 @@ export async function handleSalesStoreComparison(bcClient, args) {
69
69
 
70
70
  if (showAll || requestedMetrics.includes('revenue')) {
71
71
  storeResult.revenue_usd = summary.total_revenue_usd;
72
+ storeResult.revenue_store = summary.revenue_store; // neto de interco, eventos y no-venta
73
+ storeResult.revenue_events = summary.revenue_events;
74
+ storeResult.revenue_intercompany = summary.revenue_intercompany;
75
+ storeResult.revenue_non_sales = summary.revenue_non_sales;
72
76
  storeResult.revenue_breakdown = summary.revenue_breakdown;
73
77
  storeResult.margin_usd = summary.total_margin_usd;
74
78
  storeResult.margin_pct = summary.margin_pct;
@@ -1,3 +1,5 @@
1
+ import { getRevenueClassification } from '../config/income-accounts.js';
2
+
1
3
  export function aggregateByField(lines, field) {
2
4
  const map = {};
3
5
  for (const line of lines) {
@@ -43,7 +45,7 @@ export function aggregateByDate(lines) {
43
45
  .sort((a, b) => a.date.localeCompare(b.date));
44
46
  }
45
47
 
46
- export function calculateSummary(invoices, lines, exchangeRate = null, glRevenue = null) {
48
+ export function calculateSummary(invoices, lines, exchangeRate = null, glRevenue = null, storeCode = null) {
47
49
  const totalTransactions = invoices.length;
48
50
  const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
49
51
  const totalCostUSD = lines.reduce((sum, l) => sum + (l.unitCostUSD || 0) * (l.quantity || 0), 0);
@@ -55,8 +57,18 @@ export function calculateSummary(invoices, lines, exchangeRate = null, glRevenue
55
57
  const marginPct = totalRevenueUSD && totalRevenueUSD !== 0
56
58
  ? (totalMarginUSD / totalRevenueUSD) * 100 : 0;
57
59
 
60
+ // Split revenue into the buckets that matter for the store-payroll KPI: store
61
+ // (own operation, the denominator) vs events (own ratio) vs intercompany (not a
62
+ // store sale) vs non-sales income (FX gains, purchase rebates). Store-aware.
63
+ const buckets = splitRevenueBuckets(glRevenue, totalRevenueUSD, storeCode);
64
+
58
65
  return {
59
66
  total_revenue_usd: totalRevenueUSD !== null ? Math.round(totalRevenueUSD * 100) / 100 : null,
67
+ revenue_total: buckets.revenue_total, // alias of total_revenue_usd (explicit)
68
+ revenue_store: buckets.revenue_store, // total − intercompany − events − non_sales
69
+ revenue_events: buckets.revenue_events,
70
+ revenue_intercompany: buckets.revenue_intercompany,
71
+ revenue_non_sales: buckets.revenue_non_sales, // FX gains + purchase rebates (excluded)
60
72
  total_transactions: totalTransactions,
61
73
  total_quantity: totalQuantity,
62
74
  avg_ticket_usd: avgTicketUSD !== null ? Math.round(avgTicketUSD * 100) / 100 : null,
@@ -68,6 +80,28 @@ export function calculateSummary(invoices, lines, exchangeRate = null, glRevenue
68
80
  };
69
81
  }
70
82
 
83
+ // revenue_store = total − intercompany − events − non_sales. Accounts per store
84
+ // from config (not inline). Identity: total = store + interco + events + non_sales.
85
+ export function splitRevenueBuckets(glRevenue, totalRevenueUSD, storeCode = null) {
86
+ if (!glRevenue || !glRevenue.breakdown || totalRevenueUSD === null) {
87
+ return { revenue_total: totalRevenueUSD ?? null, revenue_store: null, revenue_events: null, revenue_intercompany: null, revenue_non_sales: null };
88
+ }
89
+ const b = glRevenue.breakdown;
90
+ const sumAccts = (accts) => accts.reduce((s, a) => s + (b[a] || 0), 0);
91
+ const r2 = (n) => Math.round(n * 100) / 100;
92
+ const { intercompany, events, nonSales } = getRevenueClassification(storeCode);
93
+ const ic = sumAccts(intercompany);
94
+ const ev = sumAccts(events);
95
+ const ns = sumAccts(nonSales);
96
+ return {
97
+ revenue_total: r2(totalRevenueUSD),
98
+ revenue_intercompany: r2(ic),
99
+ revenue_events: r2(ev),
100
+ revenue_non_sales: r2(ns),
101
+ revenue_store: r2(totalRevenueUSD - ic - ev - ns),
102
+ };
103
+ }
104
+
71
105
  export function calculateGrowth(current, previous) {
72
106
  if (!previous || previous === 0) return null;
73
107
  return ((current - previous) / Math.abs(previous)) * 100;