@fullqueso/mcp-bc-gastos 1.28.0 → 1.33.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.
@@ -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;