@fullqueso/mcp-bc-gastos 1.14.2 → 1.19.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/CHANGELOG.md +137 -0
- package/config/bank-gl-map.json +93 -0
- package/lib/bc-client.js +143 -0
- package/package.json +1 -1
- package/scripts/generate-pending-invoices-xlsx.py +378 -0
- package/scripts/generate-pending-sales-invoices-xlsx.py +372 -0
- package/scripts/report-pending-invoices.js +250 -0
- package/scripts/report-pending-sales-invoices.js +253 -0
- package/server.js +61 -0
- package/tools/auditoria/bank-ledger-entries.js +104 -0
- package/tools/auditoria/gl-account-entries.js +75 -0
- package/tools/auditoria/pm-receipts.js +182 -0
- package/tools/auditoria/reconcile-pos-sales.js +39 -16
- package/tools/cobranzas/customer-list.js +63 -0
- package/tools/inventario/index.js +4 -0
- package/tools/inventario/inventory-by-location.js +212 -0
- package/tools/inventario/inventory-change.js +386 -0
- package/tools/inventario/inventory-levels.js +214 -0
- package/tools/inventario/item-card.js +1 -50
- package/tools/inventario/item-cost-trend.js +185 -0
- package/tools/inventario/shared/cost-calculator.js +64 -0
- package/tools/ventas/index.js +3 -0
- package/tools/ventas/product-performance.js +182 -0
- package/tools/ventas/sales-analysis.js +211 -0
- package/tools/ventas/sales-store-comparison.js +192 -0
- package/utils/sales-aggregation.js +82 -0
- package/utils/sales-insights.js +167 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { resolveStores } from '../../config/company-config.js';
|
|
2
|
+
|
|
3
|
+
export const pmReceiptsTool = {
|
|
4
|
+
name: 'get_pm_receipts',
|
|
5
|
+
description:
|
|
6
|
+
'Recibos de Pago Móvil (PM) registrados en BC para una cuenta bancaria. Muestra cada recibo MPCR con su cliente, factura, y monto VES. Filtra customer ledger entries por la cuenta bancaria de balanceo (ej: FQ01-MN0008 = GL 18280). Útil para auditar qué días tienen registro de PM y cuáles faltan.',
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
store: {
|
|
11
|
+
type: 'string',
|
|
12
|
+
enum: ['FQ01', 'FQ28', 'FQ88'],
|
|
13
|
+
description: 'Tienda a consultar.',
|
|
14
|
+
},
|
|
15
|
+
bank_account: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Cuenta bancaria BC (ej: "FQ01-MN0008").',
|
|
18
|
+
},
|
|
19
|
+
date_from: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Fecha inicio (YYYY-MM-DD).',
|
|
22
|
+
},
|
|
23
|
+
date_to: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Fecha fin (YYYY-MM-DD).',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ['store', 'bank_account', 'date_from', 'date_to'],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function handlePmReceipts(bcClient, args) {
|
|
33
|
+
const { store, bank_account, date_from, date_to } = args;
|
|
34
|
+
if (!store) throw new Error('Parámetro requerido: store');
|
|
35
|
+
if (!bank_account) throw new Error('Parámetro requerido: bank_account');
|
|
36
|
+
if (!date_from || !date_to) throw new Error('Parámetros requeridos: date_from, date_to');
|
|
37
|
+
|
|
38
|
+
const stores = resolveStores([store]);
|
|
39
|
+
const storeInfo = stores[0];
|
|
40
|
+
|
|
41
|
+
// Query customer ledger entries for the 3 PM customers
|
|
42
|
+
const pmCustomers = ['FQ01-CN0006-1', 'FQ01-CN0006-2', 'FQ01-CN0007'];
|
|
43
|
+
|
|
44
|
+
const allEntries = [];
|
|
45
|
+
|
|
46
|
+
for (const customer of pmCustomers) {
|
|
47
|
+
// Use Beta API — query Payment entries for this customer
|
|
48
|
+
const url = bcClient.buildBetaApiUrl(storeInfo.companyId, 'customerLedgerEntries', {
|
|
49
|
+
$filter: [
|
|
50
|
+
`postingDate ge ${date_from}`,
|
|
51
|
+
`postingDate le ${date_to}`,
|
|
52
|
+
`customerNumber eq '${customer}'`,
|
|
53
|
+
`documentType eq 'Payment'`,
|
|
54
|
+
].join(' and '),
|
|
55
|
+
$select: 'entryNumber,documentType,description,postingDate,documentNumber,externalDocumentNumber,customerNumber,open,amount,debitAmount,creditAmount,amountLocalCurrency,currencyCode,balancingAccountNumber,balancingAccountType',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const rawEntries = await bcClient.apiCallAllPages(url);
|
|
59
|
+
|
|
60
|
+
// Filter to only entries that balance against our bank account
|
|
61
|
+
const pmEntries = rawEntries.filter(
|
|
62
|
+
(e) => e.balancingAccountNumber === bank_account
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
allEntries.push(...pmEntries);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Also query Invoice entries for cross-reference (to link receipt → invoice)
|
|
69
|
+
const invoiceEntries = [];
|
|
70
|
+
for (const customer of pmCustomers) {
|
|
71
|
+
const url = bcClient.buildBetaApiUrl(storeInfo.companyId, 'customerLedgerEntries', {
|
|
72
|
+
$filter: [
|
|
73
|
+
`postingDate ge ${date_from}`,
|
|
74
|
+
`postingDate le ${date_to}`,
|
|
75
|
+
`customerNumber eq '${customer}'`,
|
|
76
|
+
`documentType eq 'Invoice'`,
|
|
77
|
+
].join(' and '),
|
|
78
|
+
$select: 'entryNumber,documentType,description,postingDate,documentNumber,externalDocumentNumber,customerNumber,open,amount,amountLocalCurrency,currencyCode',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const rawInvoices = await bcClient.apiCallAllPages(url);
|
|
82
|
+
invoiceEntries.push(...rawInvoices);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Sort payment entries by date
|
|
86
|
+
allEntries.sort((a, b) => (a.postingDate || '').localeCompare(b.postingDate || ''));
|
|
87
|
+
|
|
88
|
+
// Build invoice lookup by (customer, date) for cross-reference
|
|
89
|
+
const invoiceByCustomerDate = {};
|
|
90
|
+
for (const inv of invoiceEntries) {
|
|
91
|
+
const key = `${inv.customerNumber}|${inv.postingDate}`;
|
|
92
|
+
if (!invoiceByCustomerDate[key]) {
|
|
93
|
+
invoiceByCustomerDate[key] = [];
|
|
94
|
+
}
|
|
95
|
+
invoiceByCustomerDate[key].push({
|
|
96
|
+
document_number: inv.documentNumber,
|
|
97
|
+
description: inv.description,
|
|
98
|
+
amount_ves: Math.round((inv.amount || 0) * 100) / 100,
|
|
99
|
+
amount_usd: Math.round((inv.amountLocalCurrency || 0) * 100) / 100,
|
|
100
|
+
external_document_number: inv.externalDocumentNumber || '',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Format entries with invoice cross-reference
|
|
105
|
+
const receipts = allEntries.map((e) => {
|
|
106
|
+
const key = `${e.customerNumber}|${e.postingDate}`;
|
|
107
|
+
const linkedInvoices = invoiceByCustomerDate[key] || [];
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
posting_date: e.postingDate,
|
|
111
|
+
receipt_no: e.documentNumber,
|
|
112
|
+
customer: e.customerNumber,
|
|
113
|
+
customer_name: e.description || '',
|
|
114
|
+
amount_ves: Math.round(Math.abs(e.amount || 0) * 100) / 100,
|
|
115
|
+
amount_usd: Math.round(Math.abs(e.amountLocalCurrency || 0) * 100) / 100,
|
|
116
|
+
external_document_no: e.externalDocumentNumber || '',
|
|
117
|
+
bank_account: e.balancingAccountNumber || '',
|
|
118
|
+
linked_invoices: linkedInvoices,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Daily summary
|
|
123
|
+
const dailySummary = {};
|
|
124
|
+
for (const r of receipts) {
|
|
125
|
+
const date = r.posting_date;
|
|
126
|
+
if (!dailySummary[date]) {
|
|
127
|
+
dailySummary[date] = { total_ves: 0, count: 0, customers: new Set() };
|
|
128
|
+
}
|
|
129
|
+
dailySummary[date].total_ves += r.amount_ves;
|
|
130
|
+
dailySummary[date].count += 1;
|
|
131
|
+
dailySummary[date].customers.add(r.customer);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Find missing days
|
|
135
|
+
const startDate = new Date(date_from);
|
|
136
|
+
const endDate = new Date(date_to);
|
|
137
|
+
const allDays = [];
|
|
138
|
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
139
|
+
allDays.push(d.toISOString().split('T')[0]);
|
|
140
|
+
}
|
|
141
|
+
const receiptDates = new Set(Object.keys(dailySummary));
|
|
142
|
+
const missingDays = allDays.filter((d) => !receiptDates.has(d));
|
|
143
|
+
|
|
144
|
+
const dailyArray = allDays.map((date) => {
|
|
145
|
+
const day = dailySummary[date];
|
|
146
|
+
return {
|
|
147
|
+
date,
|
|
148
|
+
has_receipts: !!day,
|
|
149
|
+
total_ves: day ? Math.round(day.total_ves * 100) / 100 : 0,
|
|
150
|
+
receipt_count: day ? day.count : 0,
|
|
151
|
+
customers: day ? [...day.customers].sort() : [],
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const totalVes = Math.round(receipts.reduce((s, r) => s + r.amount_ves, 0) * 100) / 100;
|
|
156
|
+
const totalUsd = Math.round(receipts.reduce((s, r) => s + r.amount_usd, 0) * 100) / 100;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
store,
|
|
160
|
+
store_name: storeInfo.name,
|
|
161
|
+
bank_account,
|
|
162
|
+
period: { from: date_from, to: date_to },
|
|
163
|
+
receipts,
|
|
164
|
+
daily_summary: dailyArray,
|
|
165
|
+
invoices_reference: invoiceEntries.map((inv) => ({
|
|
166
|
+
posting_date: inv.postingDate,
|
|
167
|
+
document_number: inv.documentNumber,
|
|
168
|
+
customer: inv.customerNumber,
|
|
169
|
+
description: inv.description,
|
|
170
|
+
amount_ves: Math.round((inv.amount || 0) * 100) / 100,
|
|
171
|
+
})),
|
|
172
|
+
summary: {
|
|
173
|
+
total_receipts: receipts.length,
|
|
174
|
+
total_ves: totalVes,
|
|
175
|
+
total_usd: totalUsd,
|
|
176
|
+
days_with_receipts: Object.keys(dailySummary).length,
|
|
177
|
+
days_without_receipts: missingDays.length,
|
|
178
|
+
missing_days: missingDays,
|
|
179
|
+
customers_found: [...new Set(receipts.map((r) => r.customer))].sort(),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -4,17 +4,37 @@ import { logger } from '../../utils/logger.js';
|
|
|
4
4
|
|
|
5
5
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
-
// Bank account suffix → GL account for journal entry suggestions
|
|
7
|
+
// Bank account suffix → GL account for journal entry suggestions (per store)
|
|
8
8
|
const BANK_GL_MAP = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
FQ01: {
|
|
10
|
+
'MN0001': '18210', // Banesco 4421 Bs.
|
|
11
|
+
'MN0002': '18220', // BDV 1925 Bs.
|
|
12
|
+
'MN0003': '18230', // Bancamiga 7015 Bs.
|
|
13
|
+
'MN0004': '18240', // BDV 5550 Bs.
|
|
14
|
+
'MN0005': '18250', // Bancamiga 4523 Bs.
|
|
15
|
+
'MN0006': '18260', // Bancrecer 2450 Bs.
|
|
16
|
+
'MN0007': '18270', // Bancrecer 2467 Bs.
|
|
17
|
+
'MN0008': '18280', // BDV 5187 Bs. (Pago Móvil)
|
|
18
|
+
'MN0012': '18290', // UBII
|
|
19
|
+
'MN0030': '18130', // Caja tienda ventas Bs.
|
|
20
|
+
'ME0030': '18140', // Caja tienda ventas $
|
|
21
|
+
},
|
|
22
|
+
FQ28: {
|
|
23
|
+
'MN0001': '18210', // BDV 8139 Bs.
|
|
24
|
+
'MN0002': '18220', // Bancrecer 5474 Bs.
|
|
25
|
+
'MN0028': '18290', // UBII 6249 Bs
|
|
26
|
+
'MN0029': '18291', // UBII 6442 * Bs
|
|
27
|
+
'MN0030': '18130', // Caja tienda ventas Bs.
|
|
28
|
+
'MN0031': '18160', // Caja transitoria tienda ventas Bs.
|
|
29
|
+
'MN0032': '18292', // UBII 4 FQ-28
|
|
30
|
+
'MN0033': '18295', // UBII 7 FQ-28
|
|
31
|
+
'MN0098': '18298', // Operaciones en Tránsito
|
|
32
|
+
'ME0002': '18310', // Bancrecer $
|
|
33
|
+
'ME0005': '18410', // Zelle Bofa $ *
|
|
34
|
+
'ME0030': '18140', // Caja tienda ventas $
|
|
35
|
+
'ME0031': '18170', // Caja transitoria tienda ventas $
|
|
36
|
+
'ME0035': '18150', // Banco Bóveda $
|
|
37
|
+
},
|
|
18
38
|
};
|
|
19
39
|
|
|
20
40
|
const COMMISSION_ACCOUNT = '67100'; // Comisiones Bancarias
|
|
@@ -140,12 +160,15 @@ function parseBankStatementLot(description) {
|
|
|
140
160
|
* Get GL account for a bank account number.
|
|
141
161
|
* "FQ01-MN0001" → "18210"
|
|
142
162
|
*/
|
|
143
|
-
function getBankGLAccount(bankAccountNo) {
|
|
163
|
+
function getBankGLAccount(bankAccountNo, store) {
|
|
144
164
|
if (!bankAccountNo) return null;
|
|
145
|
-
// Extract suffix: "FQ01-MN0001" → "MN0001"
|
|
165
|
+
// Extract store and suffix: "FQ01-MN0001" → store "FQ01", suffix "MN0001"
|
|
146
166
|
const parts = bankAccountNo.split('-');
|
|
147
167
|
const suffix = parts[parts.length - 1];
|
|
148
|
-
|
|
168
|
+
const storeKey = store || parts[0];
|
|
169
|
+
const storeMap = BANK_GL_MAP[storeKey];
|
|
170
|
+
if (!storeMap) return null;
|
|
171
|
+
return storeMap[suffix] || null;
|
|
149
172
|
}
|
|
150
173
|
|
|
151
174
|
// ── Union-Find for Compound Lots ─────────────────────────────────────────
|
|
@@ -818,7 +841,7 @@ export async function handleReconcilePOSSales(bcClient, args) {
|
|
|
818
841
|
}
|
|
819
842
|
|
|
820
843
|
// ── Journal entry suggestions for commissions ──
|
|
821
|
-
const glAccount = getBankGLAccount(bankAccount);
|
|
844
|
+
const glAccount = getBankGLAccount(bankAccount, store);
|
|
822
845
|
const journalEntries = [];
|
|
823
846
|
|
|
824
847
|
if (isBDVAggregate && bdvAggregate.commission_total > 0) {
|
|
@@ -1211,7 +1234,7 @@ export async function handleReconcilePOSSales(bcClient, args) {
|
|
|
1211
1234
|
const ubiiAvgFee = ubiiTotalBC > 0 ? round2((ubiiTotalFee / ubiiTotalBC) * 100) : 0;
|
|
1212
1235
|
|
|
1213
1236
|
// Journal entries: fee + transfer per matched day
|
|
1214
|
-
const ubiiGL = getBankGLAccount(ubiiFullAccount); // 18290
|
|
1237
|
+
const ubiiGL = getBankGLAccount(ubiiFullAccount, store); // 18290
|
|
1215
1238
|
const ubiiJournalEntries = [];
|
|
1216
1239
|
for (const m of ubiiMatchedDays) {
|
|
1217
1240
|
if (m.fee_ves > 0) {
|
|
@@ -1225,7 +1248,7 @@ export async function handleReconcilePOSSales(bcClient, args) {
|
|
|
1225
1248
|
});
|
|
1226
1249
|
}
|
|
1227
1250
|
// Transfer entry: debit receiving bank, credit UBII
|
|
1228
|
-
const receivingGL = getBankGLAccount(m.bank_deposit_bank);
|
|
1251
|
+
const receivingGL = getBankGLAccount(m.bank_deposit_bank, store);
|
|
1229
1252
|
if (receivingGL) {
|
|
1230
1253
|
ubiiJournalEntries.push({
|
|
1231
1254
|
posting_date: m.bank_deposit_date,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolveStores } from '../../config/company-config.js';
|
|
2
|
+
|
|
3
|
+
export const customerListTool = {
|
|
4
|
+
name: 'get_customer_list',
|
|
5
|
+
description:
|
|
6
|
+
'Lista de clientes con número, nombre y RIF (taxRegistrationNumber). Útil para identificar clientes por RIF o exportar directorio de clientes.',
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
store: {
|
|
11
|
+
type: 'string',
|
|
12
|
+
enum: ['FQ01', 'FQ28', 'FQ88'],
|
|
13
|
+
description: 'Tienda a consultar.',
|
|
14
|
+
},
|
|
15
|
+
customer_number: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Filtrar por número de cliente específico.',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
required: ['store'],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function handleCustomerList(bcClient, args) {
|
|
25
|
+
const store = args.store;
|
|
26
|
+
if (!store) throw new Error('Parámetro requerido: store');
|
|
27
|
+
|
|
28
|
+
const stores = resolveStores([store]);
|
|
29
|
+
const storeInfo = stores[0];
|
|
30
|
+
|
|
31
|
+
const selectFields = 'number,displayName,taxRegistrationNumber';
|
|
32
|
+
const params = { $select: selectFields, $orderby: 'number' };
|
|
33
|
+
|
|
34
|
+
if (args.customer_number) {
|
|
35
|
+
params.$filter = `number eq '${args.customer_number}'`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const url = bcClient.buildApiUrl(storeInfo.companyId, 'customers', params);
|
|
39
|
+
const allCustomers = await bcClient.apiCallAllPages(url);
|
|
40
|
+
|
|
41
|
+
const customers = allCustomers.map((c) => ({
|
|
42
|
+
number: c.number,
|
|
43
|
+
name: c.displayName,
|
|
44
|
+
rif: c.taxRegistrationNumber || '',
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: JSON.stringify(
|
|
52
|
+
{
|
|
53
|
+
store,
|
|
54
|
+
total_customers: customers.length,
|
|
55
|
+
customers,
|
|
56
|
+
},
|
|
57
|
+
null,
|
|
58
|
+
2
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { itemCardTool, handleItemCard } from './item-card.js';
|
|
2
2
|
export { itemLedgerEntriesTool, handleItemLedgerEntries } from './item-ledger-entries.js';
|
|
3
3
|
export { itemValueEntriesTool, handleItemValueEntries } from './item-value-entries.js';
|
|
4
|
+
export { itemCostTrendTool, handleItemCostTrend } from './item-cost-trend.js';
|
|
5
|
+
export { inventoryLevelsTool, handleInventoryLevels } from './inventory-levels.js';
|
|
6
|
+
export { inventoryChangeTool, handleInventoryChange } from './inventory-change.js';
|
|
7
|
+
export { inventoryByLocationTool, handleInventoryByLocation } from './inventory-by-location.js';
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { resolveStores } from '../../config/company-config.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import { isInventoryCategory } from './shared/cost-calculator.js';
|
|
4
|
+
|
|
5
|
+
export const inventoryByLocationTool = {
|
|
6
|
+
name: 'get_inventory_by_location',
|
|
7
|
+
description:
|
|
8
|
+
'Inventario desglosado por ubicación (Location Code) dentro de una tienda. Muestra qty y valor por location, con detalle de ítems por cada una. Útil para ver distribución entre depósitos/almacenes internos. Usa OData V4 para acceder a Location_Code.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
store: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
enum: ['FQ01', 'FQ28', 'FQ88'],
|
|
15
|
+
description: 'Tienda a consultar.',
|
|
16
|
+
},
|
|
17
|
+
location_code: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Filtrar por un Location Code específico (e.g. PRINCIPAL, DEPOSITO). Si se omite, muestra todas las ubicaciones.',
|
|
20
|
+
},
|
|
21
|
+
item_category: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Filtrar por itemCategoryCode (e.g. TERMINADO, INSUMO).',
|
|
24
|
+
},
|
|
25
|
+
classification: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Filtrar por inventoryPostingGroupCode (e.g. CONGELADOS, IMPORTADO, LOCAL).',
|
|
28
|
+
},
|
|
29
|
+
as_of_date: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description:
|
|
32
|
+
'Fecha de corte YYYY-MM-DD. Default: hoy (todas las entries). Si es fecha pasada, solo suma ledger entries con Posting_Date <= as_of_date.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ['store'],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function handleInventoryByLocation(bcClient, args) {
|
|
40
|
+
const storeParam = args.store;
|
|
41
|
+
if (!storeParam) throw new Error('Parámetro requerido: store');
|
|
42
|
+
|
|
43
|
+
const stores = resolveStores([storeParam]);
|
|
44
|
+
const store = stores[0];
|
|
45
|
+
const companyId = store.companyId;
|
|
46
|
+
|
|
47
|
+
// 1. Fetch items via standard API (has itemCategoryCode, inventoryPostingGroupCode, unitCost)
|
|
48
|
+
const itemFilters = [];
|
|
49
|
+
if (args.item_category) {
|
|
50
|
+
itemFilters.push(`itemCategoryCode eq '${args.item_category}'`);
|
|
51
|
+
}
|
|
52
|
+
if (args.classification) {
|
|
53
|
+
itemFilters.push(`inventoryPostingGroupCode eq '${args.classification}'`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const itemParams = {
|
|
57
|
+
$select: 'number,displayName,itemCategoryCode,inventoryPostingGroupCode,unitCost,inventory',
|
|
58
|
+
};
|
|
59
|
+
if (itemFilters.length > 0) {
|
|
60
|
+
itemParams.$filter = itemFilters.join(' and ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const itemUrl = bcClient.buildApiUrl(companyId, 'items', itemParams);
|
|
64
|
+
const items = await bcClient.apiCallAllPages(itemUrl);
|
|
65
|
+
logger.info(`${store.code}: ${items.length} items fetched`);
|
|
66
|
+
|
|
67
|
+
// Build item metadata map — exclude items without inventoryPostingGroupCode and FQ-* categories
|
|
68
|
+
const itemMeta = {};
|
|
69
|
+
let excludedCount = 0;
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
if (!item.inventoryPostingGroupCode || !isInventoryCategory(item.itemCategoryCode)) {
|
|
72
|
+
excludedCount++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
itemMeta[item.number] = {
|
|
76
|
+
name: item.displayName,
|
|
77
|
+
category: item.itemCategoryCode || 'SIN_CATEGORIA',
|
|
78
|
+
classification: item.inventoryPostingGroupCode,
|
|
79
|
+
unit_cost: item.unitCost || 0,
|
|
80
|
+
total_inventory: item.inventory || 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (excludedCount > 0) {
|
|
84
|
+
logger.info(`${store.code}: excluded ${excludedCount} items without inventoryPostingGroupCode`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Fetch item ledger entries via OData V4 (has Location_Code field)
|
|
88
|
+
const today = new Date();
|
|
89
|
+
const todayStr = today.toISOString().split('T')[0];
|
|
90
|
+
const asOfDate = args.as_of_date || todayStr;
|
|
91
|
+
const isHistorical = asOfDate < todayStr;
|
|
92
|
+
|
|
93
|
+
const odataFilters = [];
|
|
94
|
+
if (args.location_code) {
|
|
95
|
+
odataFilters.push(`Location_Code eq '${args.location_code}'`);
|
|
96
|
+
}
|
|
97
|
+
if (isHistorical) {
|
|
98
|
+
odataFilters.push(`Posting_Date le ${asOfDate}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const odataParams = {
|
|
102
|
+
$select: 'Item_No,Location_Code,Quantity',
|
|
103
|
+
};
|
|
104
|
+
if (odataFilters.length > 0) {
|
|
105
|
+
odataParams.$filter = odataFilters.join(' and ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ledgerUrl = bcClient.buildODataUrl(storeParam, 'ItemLedgerEntries', odataParams);
|
|
109
|
+
const entries = await bcClient.odataCallAllPages(ledgerUrl);
|
|
110
|
+
logger.info(`${store.code}: ${entries.length} item ledger entries fetched via OData V4 for location breakdown`);
|
|
111
|
+
|
|
112
|
+
// 3. Aggregate: { Location_Code → { Item_No → net_qty } }
|
|
113
|
+
const locationItems = {};
|
|
114
|
+
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
const loc = e.Location_Code || 'SIN_UBICACION';
|
|
117
|
+
const itemNo = e.Item_No;
|
|
118
|
+
const qty = e.Quantity || 0;
|
|
119
|
+
|
|
120
|
+
// Skip items not in our metadata (filtered out or no posting group)
|
|
121
|
+
if (!itemMeta[itemNo]) continue;
|
|
122
|
+
|
|
123
|
+
if (!locationItems[loc]) locationItems[loc] = {};
|
|
124
|
+
if (!locationItems[loc][itemNo]) locationItems[loc][itemNo] = 0;
|
|
125
|
+
locationItems[loc][itemNo] += qty;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 4. Build response per location
|
|
129
|
+
const byLocation = {};
|
|
130
|
+
const locationSummaries = [];
|
|
131
|
+
|
|
132
|
+
for (const [loc, itemQtys] of Object.entries(locationItems)) {
|
|
133
|
+
let itemCount = 0;
|
|
134
|
+
let totalQty = 0;
|
|
135
|
+
let totalCostValue = 0;
|
|
136
|
+
const itemDetails = [];
|
|
137
|
+
|
|
138
|
+
for (const [itemNo, netQty] of Object.entries(itemQtys)) {
|
|
139
|
+
const roundedQty = round5(netQty);
|
|
140
|
+
if (roundedQty === 0) continue;
|
|
141
|
+
|
|
142
|
+
const meta = itemMeta[itemNo];
|
|
143
|
+
const costValue = round2(roundedQty * meta.unit_cost);
|
|
144
|
+
|
|
145
|
+
itemCount++;
|
|
146
|
+
totalQty = round5(totalQty + roundedQty);
|
|
147
|
+
totalCostValue = round2(totalCostValue + costValue);
|
|
148
|
+
|
|
149
|
+
itemDetails.push({
|
|
150
|
+
number: itemNo,
|
|
151
|
+
name: meta.name,
|
|
152
|
+
category: meta.category,
|
|
153
|
+
classification: meta.classification,
|
|
154
|
+
qty: roundedQty,
|
|
155
|
+
unit_cost: meta.unit_cost,
|
|
156
|
+
cost_value: costValue,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (itemCount === 0) continue;
|
|
161
|
+
|
|
162
|
+
// Sort by absolute qty descending, keep top 10
|
|
163
|
+
itemDetails.sort((a, b) => Math.abs(b.qty) - Math.abs(a.qty));
|
|
164
|
+
const topItems = itemDetails.slice(0, 10);
|
|
165
|
+
|
|
166
|
+
byLocation[loc] = {
|
|
167
|
+
item_count: itemCount,
|
|
168
|
+
total_qty: round5(totalQty),
|
|
169
|
+
total_cost_value: round2(totalCostValue),
|
|
170
|
+
top_items: topItems,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
locationSummaries.push({
|
|
174
|
+
location: loc,
|
|
175
|
+
item_count: itemCount,
|
|
176
|
+
total_qty: round5(totalQty),
|
|
177
|
+
total_cost_value: round2(totalCostValue),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Sort summaries by cost value descending
|
|
182
|
+
locationSummaries.sort((a, b) => b.total_cost_value - a.total_cost_value);
|
|
183
|
+
|
|
184
|
+
// Grand totals
|
|
185
|
+
const grandTotal = {
|
|
186
|
+
locations: locationSummaries.length,
|
|
187
|
+
item_count: locationSummaries.reduce((s, l) => s + l.item_count, 0),
|
|
188
|
+
total_qty: round5(locationSummaries.reduce((s, l) => s + l.total_qty, 0)),
|
|
189
|
+
total_cost_value: round2(locationSummaries.reduce((s, l) => s + l.total_cost_value, 0)),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
store: store.code,
|
|
194
|
+
store_name: store.name,
|
|
195
|
+
as_of_date: asOfDate,
|
|
196
|
+
is_historical: isHistorical,
|
|
197
|
+
note: isHistorical
|
|
198
|
+
? `Inventario histórico al ${asOfDate} reconstruido desde item ledger entries (OData V4) con Posting_Date <= ${asOfDate}, agrupado por Location_Code. unitCost es el actual de BC (no histórico).`
|
|
199
|
+
: 'Inventario reconstruido desde item ledger entries (OData V4) agrupado por Location_Code. Cada location muestra top 10 ítems por qty. unitCost es el actual de BC.',
|
|
200
|
+
summary: locationSummaries,
|
|
201
|
+
by_location: byLocation,
|
|
202
|
+
grand_total: grandTotal,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function round2(n) {
|
|
207
|
+
return Math.round(n * 100) / 100;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function round5(n) {
|
|
211
|
+
return Math.round(n * 100000) / 100000;
|
|
212
|
+
}
|