@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.
@@ -0,0 +1,224 @@
1
+ // tools/multi-payment/unposted-invoices.js
2
+ // Facturas de compra y venta SIN POSTEAR en Business Central (status Draft /
3
+ // In Review en api/v2.0 purchaseInvoices y salesInvoices). A diferencia de los
4
+ // tools de multi-payment (settlement de facturas ya posteadas), estos documentos
5
+ // NO están en el GL todavía → son los ajustes de "Cierre Parcial" del P&L:
6
+ // compras draft → +COGS/gasto estimado; ventas draft → +ingreso estimado.
7
+ // Consumido por skill-fq-resultados-mensual.
8
+
9
+ import { resolveStores } from '../../config/company-config.js';
10
+ import { logger } from '../../utils/logger.js';
11
+
12
+ const round2 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
13
+
14
+ export const unpostedInvoicesTool = {
15
+ name: 'get_unposted_invoices',
16
+ description:
17
+ 'Facturas de compra y/o venta SIN POSTEAR en Business Central (status Draft o In Review). ' +
18
+ 'Estos documentos NO están en el GL: son la base del ajuste de CIERRE PARCIAL del P&L ' +
19
+ '(compras draft → costo/gasto estimado; ventas draft → ingreso estimado). ' +
20
+ 'NO confundir con get_draft_payables/receivables (Multi-Payments de cobro/pago sobre ' +
21
+ 'facturas ya posteadas). Filtra por rango de postingDate, separa ventas intercompañía (IC-*) ' +
22
+ 'y totaliza por moneda. Úsalo para: cierre parcial, resultados con drafts, facturas sin ' +
23
+ 'postear, "qué falta por postear del mes". READ-ONLY.',
24
+ inputSchema: {
25
+ type: 'object',
26
+ properties: {
27
+ store: {
28
+ type: 'string',
29
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'],
30
+ description: 'Tienda(s) a consultar.',
31
+ },
32
+ type: {
33
+ type: 'string',
34
+ enum: ['purchases', 'sales', 'both'],
35
+ description: 'Tipo de facturas. Default: both.',
36
+ },
37
+ start_date: {
38
+ type: 'string',
39
+ description: 'Fecha inicio YYYY-MM-DD (filtra por postingDate). Opcional.',
40
+ },
41
+ end_date: {
42
+ type: 'string',
43
+ description: 'Fecha fin YYYY-MM-DD (filtra por postingDate). Opcional.',
44
+ },
45
+ include_in_review: {
46
+ type: 'boolean',
47
+ description: "Incluir status 'In Review' además de 'Draft'. Default: true.",
48
+ },
49
+ summary_only: {
50
+ type: 'boolean',
51
+ description: 'Solo totales, sin lista de facturas. Default: false.',
52
+ },
53
+ },
54
+ required: ['store'],
55
+ },
56
+ };
57
+
58
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
59
+
60
+ function buildStatusFilter(includeInReview) {
61
+ return includeInReview
62
+ ? "(status eq 'Draft' or status eq 'In Review')"
63
+ : "status eq 'Draft'";
64
+ }
65
+
66
+ function buildFilter(args) {
67
+ const parts = [buildStatusFilter(args.include_in_review !== false)];
68
+ if (args.start_date) {
69
+ if (!DATE_RE.test(args.start_date)) throw new Error(`start_date inválida: ${args.start_date}`);
70
+ parts.push(`postingDate ge ${args.start_date}`);
71
+ }
72
+ if (args.end_date) {
73
+ if (!DATE_RE.test(args.end_date)) throw new Error(`end_date inválida: ${args.end_date}`);
74
+ parts.push(`postingDate le ${args.end_date}`);
75
+ }
76
+ return parts.join(' and ');
77
+ }
78
+
79
+ function mapInvoice(inv, kind) {
80
+ return {
81
+ number: inv.number,
82
+ status: inv.status,
83
+ counterparty_number: kind === 'purchases' ? inv.vendorNumber : inv.customerNumber,
84
+ counterparty_name: kind === 'purchases' ? inv.vendorName : inv.customerName,
85
+ invoice_date: inv.invoiceDate,
86
+ posting_date: inv.postingDate,
87
+ currency: inv.currencyCode || 'LCY',
88
+ total_excl_tax: round2(inv.totalAmountExcludingTax ?? 0),
89
+ total_incl_tax: round2(inv.totalAmountIncludingTax ?? 0),
90
+ };
91
+ }
92
+
93
+ function summarize(invoices, kind) {
94
+ const by_currency = {};
95
+ const non_ic_by_currency = {};
96
+ const ic = { count: 0, by_currency: {} };
97
+ for (const inv of invoices) {
98
+ by_currency[inv.currency] = round2((by_currency[inv.currency] || 0) + inv.total_excl_tax);
99
+ const isIC = kind === 'sales' && String(inv.counterparty_number || '').toUpperCase().startsWith('IC-');
100
+ if (isIC) {
101
+ ic.count += 1;
102
+ ic.by_currency[inv.currency] = round2((ic.by_currency[inv.currency] || 0) + inv.total_excl_tax);
103
+ } else if (kind === 'sales') {
104
+ non_ic_by_currency[inv.currency] = round2((non_ic_by_currency[inv.currency] || 0) + inv.total_excl_tax);
105
+ }
106
+ }
107
+ const summary = {
108
+ count: invoices.length,
109
+ by_currency,
110
+ mixed_currency: Object.keys(by_currency).length > 1,
111
+ by_status: invoices.reduce((acc, i) => { acc[i.status] = (acc[i.status] || 0) + 1; return acc; }, {}),
112
+ };
113
+ if (kind === 'sales') {
114
+ summary.intercompany = ic;
115
+ summary.non_ic_by_currency = non_ic_by_currency;
116
+ }
117
+ return summary;
118
+ }
119
+
120
+ function fmtByCurrency(byCurrency) {
121
+ const parts = Object.entries(byCurrency).map(([cur, amt]) => {
122
+ const n = amt.toLocaleString('en-US', { minimumFractionDigits: 2 });
123
+ return cur === 'VES' ? `Bs ${n}` : `$${n}${cur === 'LCY' ? '' : ` ${cur}`}`;
124
+ });
125
+ return parts.join(' + ') || '0';
126
+ }
127
+
128
+ function mergeByCurrency(target, source) {
129
+ for (const [cur, amt] of Object.entries(source || {})) {
130
+ target[cur] = round2((target[cur] || 0) + amt);
131
+ }
132
+ }
133
+
134
+ async function fetchKind(bcClient, companyId, kind, filter) {
135
+ const endpoint = kind === 'purchases' ? 'purchaseInvoices' : 'salesInvoices';
136
+ const select = kind === 'purchases'
137
+ ? 'number,status,vendorNumber,vendorName,invoiceDate,postingDate,currencyCode,totalAmountExcludingTax,totalAmountIncludingTax'
138
+ : 'number,status,customerNumber,customerName,invoiceDate,postingDate,currencyCode,totalAmountExcludingTax,totalAmountIncludingTax';
139
+ const url = bcClient.buildApiUrl(companyId, endpoint, {
140
+ $filter: filter,
141
+ $select: select,
142
+ $orderby: 'postingDate desc',
143
+ });
144
+ const rows = await bcClient.apiCallAllPages(url);
145
+ return rows.map((r) => mapInvoice(r, kind));
146
+ }
147
+
148
+ async function processStore(bcClient, store, args, filter) {
149
+ const type = args.type || 'both';
150
+ const wantP = type === 'purchases' || type === 'both';
151
+ const wantS = type === 'sales' || type === 'both';
152
+
153
+ const [purchases, sales] = await Promise.all([
154
+ wantP ? fetchKind(bcClient, store.companyId, 'purchases', filter) : Promise.resolve(null),
155
+ wantS ? fetchKind(bcClient, store.companyId, 'sales', filter) : Promise.resolve(null),
156
+ ]);
157
+
158
+ const result = { store: store.code, store_name: store.name };
159
+ if (purchases) {
160
+ result.purchases = { summary: summarize(purchases, 'purchases') };
161
+ if (!args.summary_only) result.purchases.invoices = purchases;
162
+ }
163
+ if (sales) {
164
+ result.sales = { summary: summarize(sales, 'sales') };
165
+ if (!args.summary_only) result.sales.invoices = sales;
166
+ }
167
+ // Hint directo para el Cierre Parcial (skill-fq-resultados-mensual):
168
+ result.pl_adjustment_hint = {
169
+ purchases_by_currency: purchases ? result.purchases.summary.by_currency : null,
170
+ sales_non_ic_by_currency: sales ? result.sales.summary.non_ic_by_currency : null,
171
+ note: 'Estimación sin IVA de documentos no posteados en el rango. Montos VES convertir a USD con get_exchange_rate (BCV). No sustituye el posteo.',
172
+ };
173
+ return result;
174
+ }
175
+
176
+ export async function handleUnpostedInvoices(bcClient, args) {
177
+ if (!args.store) throw new Error('Parámetro requerido: store');
178
+ const filter = buildFilter(args);
179
+ const stores = resolveStores(args.store === 'all' ? null : [args.store]);
180
+
181
+ logger.info(`get_unposted_invoices: stores=${stores.map((s) => s.code).join(',')} filter="${filter}"`);
182
+
183
+ const storeResults = await Promise.all(
184
+ stores.map((s) => processStore(bcClient, s, args, filter))
185
+ );
186
+
187
+ const storesObj = {};
188
+ const consolidated = {
189
+ purchases_by_currency: {},
190
+ sales_by_currency: {},
191
+ sales_non_ic_by_currency: {},
192
+ invoice_count: 0,
193
+ };
194
+ const insights = [];
195
+
196
+ for (const r of storeResults) {
197
+ storesObj[r.store] = r;
198
+ if (r.purchases) {
199
+ mergeByCurrency(consolidated.purchases_by_currency, r.purchases.summary.by_currency);
200
+ consolidated.invoice_count += r.purchases.summary.count;
201
+ if (r.purchases.summary.count > 0) {
202
+ insights.push(`${r.store}: ${r.purchases.summary.count} factura(s) de compra sin postear por ${fmtByCurrency(r.purchases.summary.by_currency)} (sin IVA)`);
203
+ }
204
+ }
205
+ if (r.sales) {
206
+ mergeByCurrency(consolidated.sales_by_currency, r.sales.summary.by_currency);
207
+ mergeByCurrency(consolidated.sales_non_ic_by_currency, r.sales.summary.non_ic_by_currency);
208
+ consolidated.invoice_count += r.sales.summary.count;
209
+ if (r.sales.summary.count > 0) {
210
+ insights.push(`${r.store}: ${r.sales.summary.count} factura(s) de venta sin postear por ${fmtByCurrency(r.sales.summary.by_currency)} (sin IVA${r.sales.summary.intercompany.count ? `, ${r.sales.summary.intercompany.count} IC` : ''})`);
211
+ }
212
+ }
213
+ }
214
+
215
+ if (insights.length === 0) insights.push('Sin facturas pendientes de postear en el rango consultado.');
216
+
217
+ return {
218
+ filter_applied: filter,
219
+ period: { start: args.start_date || null, end: args.end_date || null },
220
+ stores: storesObj,
221
+ consolidated,
222
+ insights,
223
+ };
224
+ }
@@ -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
  }