@fullqueso/mcp-bc-gastos 1.27.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.
Files changed (64) hide show
  1. package/.env.example +8 -0
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +3 -1
  4. package/config/company-config.js +8 -1
  5. package/config/income-accounts.js +29 -0
  6. package/lib/bc-client.js +23 -3
  7. package/package.json +1 -1
  8. package/server.js +5 -0
  9. package/tools/account-transactions.js +1 -1
  10. package/tools/anomaly-detection.js +1 -1
  11. package/tools/auditoria/bank-ledger-entries.js +1 -1
  12. package/tools/auditoria/bank-reconciliation-report.js +1 -1
  13. package/tools/auditoria/find-potential-matches.js +1 -1
  14. package/tools/auditoria/gl-account-entries.js +1 -1
  15. package/tools/auditoria/list-bank-accounts.js +1 -1
  16. package/tools/auditoria/pm-receipts.js +1 -1
  17. package/tools/auditoria/reconcile-pos-sales.js +1 -1
  18. package/tools/auditoria/reconciliation-status.js +1 -1
  19. package/tools/auditoria/suggest-journal-entries.js +1 -1
  20. package/tools/auditoria/unmatched-ledger-entries.js +1 -1
  21. package/tools/auditoria/unmatched-statement-lines.js +1 -1
  22. package/tools/cierre-mensual/generate-closing-journal.js +1 -1
  23. package/tools/cierre-mensual/get-match-results.js +1 -1
  24. package/tools/cierre-mensual/get-questionnaire.js +1 -1
  25. package/tools/cierre-mensual/reconcile-with-bc.js +1 -1
  26. package/tools/cierre-mensual/start-month-closing.js +1 -1
  27. package/tools/cierre-mensual/submit-answers.js +1 -1
  28. package/tools/cobranzas/collection-status.js +1 -1
  29. package/tools/cobranzas/customer-balances.js +1 -1
  30. package/tools/cobranzas/customer-ledger.js +1 -1
  31. package/tools/cobranzas/customer-list.js +1 -1
  32. package/tools/cobranzas/open-payables.js +1 -1
  33. package/tools/cobranzas/open-receivables.js +1 -1
  34. package/tools/cobranzas/vendor-ledger.js +1 -1
  35. package/tools/efficiency-ratios.js +1 -1
  36. package/tools/expense-analysis.js +1 -1
  37. package/tools/expense-details.js +1 -1
  38. package/tools/financials/cash-flow.js +1 -1
  39. package/tools/financials/index.js +3 -3
  40. package/tools/get-crm-rate.js +109 -0
  41. package/tools/get-exchange-rate.js +1 -1
  42. package/tools/inventario/inventory-by-location.js +1 -1
  43. package/tools/inventario/inventory-change.js +1 -1
  44. package/tools/inventario/inventory-levels.js +1 -1
  45. package/tools/inventario/item-card.js +1 -1
  46. package/tools/inventario/item-cost-trend.js +1 -1
  47. package/tools/inventario/item-ledger-entries.js +1 -1
  48. package/tools/inventario/item-value-entries.js +1 -1
  49. package/tools/list-vendors.js +1 -1
  50. package/tools/multi-payment/draft-payables.js +1 -1
  51. package/tools/multi-payment/draft-receivables.js +1 -1
  52. package/tools/multi-payment/draft-summary.js +1 -1
  53. package/tools/payroll/cost-model.js +51 -0
  54. package/tools/payroll/employees.js +30 -6
  55. package/tools/payroll/payroll-documents.js +197 -13
  56. package/tools/payroll/payroll-lines.js +58 -34
  57. package/tools/reports/manager-report.js +1 -1
  58. package/tools/trends.js +1 -1
  59. package/tools/vendor-transactions.js +1 -1
  60. package/tools/ventas/item-sales-detail.js +1 -1
  61. package/tools/ventas/product-performance.js +1 -1
  62. package/tools/ventas/sales-analysis.js +2 -2
  63. package/tools/ventas/sales-store-comparison.js +5 -1
  64. package/utils/sales-aggregation.js +35 -1
@@ -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
+ }
@@ -10,7 +10,7 @@ export const exchangeRateTool = {
10
10
  properties: {
11
11
  store: {
12
12
  type: 'string',
13
- enum: ['FQ01', 'FQ28', 'FQ88'],
13
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
14
14
  description: 'Tienda a consultar.',
15
15
  },
16
16
  date: {
@@ -11,7 +11,7 @@ export const inventoryByLocationTool = {
11
11
  properties: {
12
12
  store: {
13
13
  type: 'string',
14
- enum: ['FQ01', 'FQ28', 'FQ88'],
14
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
15
15
  description: 'Tienda a consultar.',
16
16
  },
17
17
  location_code: {
@@ -11,7 +11,7 @@ export const inventoryChangeTool = {
11
11
  properties: {
12
12
  store: {
13
13
  type: 'string',
14
- enum: ['FQ01', 'FQ28', 'FQ88'],
14
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
15
15
  description: 'Tienda a consultar.',
16
16
  },
17
17
  period: {
@@ -11,7 +11,7 @@ export const inventoryLevelsTool = {
11
11
  properties: {
12
12
  store: {
13
13
  type: 'string',
14
- enum: ['FQ01', 'FQ28', 'FQ88'],
14
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
15
15
  description: 'Tienda a consultar.',
16
16
  },
17
17
  item_category: {
@@ -11,7 +11,7 @@ export const itemCardTool = {
11
11
  properties: {
12
12
  store: {
13
13
  type: 'string',
14
- enum: ['FQ01', 'FQ28', 'FQ88'],
14
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
15
15
  description: 'Tienda a consultar.',
16
16
  },
17
17
  item_number: {
@@ -10,7 +10,7 @@ export const itemCostTrendTool = {
10
10
  properties: {
11
11
  store: {
12
12
  type: 'string',
13
- enum: ['FQ01', 'FQ28', 'FQ88'],
13
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
14
14
  description: 'Tienda a consultar.',
15
15
  },
16
16
  item_number: {
@@ -10,7 +10,7 @@ export const itemLedgerEntriesTool = {
10
10
  properties: {
11
11
  store: {
12
12
  type: 'string',
13
- enum: ['FQ01', 'FQ28', 'FQ88'],
13
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
14
14
  description: 'Tienda a consultar.',
15
15
  },
16
16
  item_number: {
@@ -10,7 +10,7 @@ export const itemValueEntriesTool = {
10
10
  properties: {
11
11
  store: {
12
12
  type: 'string',
13
- enum: ['FQ01', 'FQ28', 'FQ88'],
13
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
14
14
  description: 'Tienda a consultar.',
15
15
  },
16
16
  item_number: {
@@ -12,7 +12,7 @@ export const listVendorsTool = {
12
12
  properties: {
13
13
  store: {
14
14
  type: 'string',
15
- enum: ['FQ01', 'FQ28', 'FQ88'],
15
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
16
16
  description: 'Tienda a consultar.',
17
17
  },
18
18
  start_date: {
@@ -12,7 +12,7 @@ export const draftPayablesTool = {
12
12
  properties: {
13
13
  store: {
14
14
  type: 'string',
15
- enum: ['FQ01', 'FQ28', 'FQ88', 'all'],
15
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'],
16
16
  description: 'Tienda(s) a consultar.',
17
17
  },
18
18
  status_filter: {
@@ -12,7 +12,7 @@ export const draftReceivablesTool = {
12
12
  properties: {
13
13
  store: {
14
14
  type: 'string',
15
- enum: ['FQ01', 'FQ28', 'FQ88', 'all'],
15
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'],
16
16
  description: 'Tienda(s) a consultar.',
17
17
  },
18
18
  status_filter: {
@@ -10,7 +10,7 @@ export const draftSummaryTool = {
10
10
  properties: {
11
11
  store: {
12
12
  type: 'string',
13
- enum: ['FQ01', 'FQ28', 'FQ88', 'all'],
13
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'],
14
14
  description: 'Tienda(s) a consultar.',
15
15
  },
16
16
  },
@@ -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,16 +1,20 @@
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: {
11
15
  store: {
12
16
  type: 'string',
13
- enum: ['FQ01', 'FQ28', 'FQ88', 'all'],
17
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'],
14
18
  description: 'Tienda(s) a consultar.',
15
19
  },
16
20
  status: {
@@ -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', {