@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.
- package/.env.example +7 -0
- package/CHANGELOG.md +127 -0
- package/README.md +219 -66
- package/config/bank-gl-map.json +4 -2
- package/config/income-accounts.js +29 -0
- package/lib/bc-client.js +37 -3
- package/package.json +1 -1
- package/scripts/generate-tools-doc.mjs +129 -0
- package/server.js +17 -2
- package/tools/financials/income-statement.js +265 -0
- package/tools/financials/index.js +1 -0
- package/tools/get-crm-rate.js +109 -0
- package/tools/multi-payment/index.js +1 -0
- package/tools/multi-payment/unposted-invoices.js +224 -0
- package/tools/payroll/cost-model.js +51 -0
- package/tools/payroll/employees.js +29 -5
- package/tools/payroll/payroll-documents.js +196 -12
- package/tools/payroll/payroll-lines.js +57 -33
- package/tools/ventas/sales-analysis.js +1 -1
- package/tools/ventas/sales-store-comparison.js +5 -1
- package/utils/sales-aggregation.js +35 -1
|
@@ -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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
23
|
-
description:
|
|
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
|
-
|
|
73
|
+
const r = await processStore(bcClient, stores[0], args, summaryOnly);
|
|
74
|
+
delete r.summary._paid_contribs;
|
|
75
|
+
return r;
|
|
56
76
|
}
|
|
57
77
|
|
|
58
|
-
|
|
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,
|
|
79
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
}
|