@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.
@@ -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
- 'MN0001': '18210', // Banesco
10
- 'MN0002': '18220', // BDV 1925
11
- 'MN0003': '18230', // Bancamiga 7015
12
- 'MN0004': '18240', // BDV 5550
13
- 'MN0005': '18250', // Bancamiga 4523
14
- 'MN0006': '18260', // Bancrecer 2450
15
- 'MN0007': '18270', // Bancrecer 2467
16
- 'MN0008': '18280', // BDV 5187 (Pago Móvil)
17
- 'MN0012': '18290', // UBII
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
- return BANK_GL_MAP[suffix] || null;
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
+ }