@fullqueso/mcp-bc-gastos 1.17.0 → 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,386 @@
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 inventoryChangeTool = {
6
+ name: 'get_inventory_change',
7
+ description:
8
+ 'Cambio de inventario WoW (semana) o MoM (mes). Reconstruye inventario de apertura/cierre por período, desglosado por itemCategoryCode y inventoryPostingGroupCode. Muestra movimientos por tipo (compras, ventas, ajustes, ensamblaje). Máximo 12 períodos.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ store: {
13
+ type: 'string',
14
+ enum: ['FQ01', 'FQ28', 'FQ88'],
15
+ description: 'Tienda a consultar.',
16
+ },
17
+ period: {
18
+ type: 'string',
19
+ enum: ['week', 'month'],
20
+ description: 'Tipo de período: week (lunes-domingo) o month (mes calendario).',
21
+ },
22
+ periods_back: {
23
+ type: 'number',
24
+ description: 'Cantidad de períodos a analizar (default: 4, máximo: 12).',
25
+ },
26
+ item_category: {
27
+ type: 'string',
28
+ description: 'Filtrar por itemCategoryCode (e.g. TERMINADO, INSUMO).',
29
+ },
30
+ classification: {
31
+ type: 'string',
32
+ description: 'Filtrar por inventoryPostingGroupCode (e.g. CONGELADOS, IMPORTADO, LOCAL).',
33
+ },
34
+ },
35
+ required: ['store', 'period'],
36
+ },
37
+ };
38
+
39
+ export async function handleInventoryChange(bcClient, args) {
40
+ const storeParam = args.store;
41
+ if (!storeParam) throw new Error('Parámetro requerido: store');
42
+ if (!args.period) throw new Error('Parámetro requerido: period');
43
+
44
+ const stores = resolveStores([storeParam]);
45
+ const store = stores[0];
46
+ const companyId = store.companyId;
47
+
48
+ const periodsBack = Math.min(args.periods_back || 4, 12);
49
+ const today = new Date();
50
+ const todayStr = today.toISOString().split('T')[0];
51
+
52
+ // Compute period boundaries
53
+ const periods = buildPeriods(args.period, periodsBack, today);
54
+ const earliestStart = periods[0].start;
55
+
56
+ // Fetch items
57
+ const itemFilters = [];
58
+ if (args.item_category) {
59
+ itemFilters.push(`itemCategoryCode eq '${args.item_category}'`);
60
+ }
61
+ if (args.classification) {
62
+ itemFilters.push(`inventoryPostingGroupCode eq '${args.classification}'`);
63
+ }
64
+
65
+ const itemParams = {
66
+ $select: 'number,displayName,itemCategoryCode,inventoryPostingGroupCode,unitCost,inventory',
67
+ };
68
+ if (itemFilters.length > 0) {
69
+ itemParams.$filter = itemFilters.join(' and ');
70
+ }
71
+
72
+ const itemUrl = bcClient.buildApiUrl(companyId, 'items', itemParams);
73
+ const items = await bcClient.apiCallAllPages(itemUrl);
74
+ logger.info(`${store.code}: ${items.length} items fetched`);
75
+
76
+ // Exclude items without inventoryPostingGroupCode and FQ-* categories — not real inventory
77
+ const inventoryItems = items.filter((item) =>
78
+ item.inventoryPostingGroupCode && isInventoryCategory(item.itemCategoryCode),
79
+ );
80
+ const excludedCount = items.length - inventoryItems.length;
81
+ if (excludedCount > 0) {
82
+ logger.info(`${store.code}: excluded ${excludedCount} items without inventoryPostingGroupCode`);
83
+ }
84
+
85
+ // Build item lookup
86
+ const itemLookup = {};
87
+ for (const item of inventoryItems) {
88
+ itemLookup[item.number] = {
89
+ name: item.displayName,
90
+ category: item.itemCategoryCode || 'SIN_CATEGORIA',
91
+ classification: item.inventoryPostingGroupCode,
92
+ unit_cost: item.unitCost || 0,
93
+ current_qty: item.inventory || 0,
94
+ };
95
+ }
96
+
97
+ const itemNumbers = new Set(Object.keys(itemLookup));
98
+
99
+ // Fetch all ledger entries for the full range
100
+ const ledgerFilters = [
101
+ `postingDate ge ${earliestStart}`,
102
+ `postingDate le ${todayStr}`,
103
+ ];
104
+
105
+ const ledgerUrl = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
106
+ $filter: ledgerFilters.join(' and '),
107
+ $select: 'itemNumber,quantity,postingDate,entryType',
108
+ });
109
+
110
+ let entries = await bcClient.apiCallAllPages(ledgerUrl);
111
+ logger.info(`${store.code}: ${entries.length} ledger entries in range ${earliestStart} to ${todayStr}`);
112
+
113
+ // Filter to relevant items (if category/classification was applied)
114
+ if (itemFilters.length > 0) {
115
+ entries = entries.filter((e) => itemNumbers.has(e.itemNumber));
116
+ logger.info(`Filtered to ${entries.length} entries for selected items`);
117
+ }
118
+
119
+ // Bucket entries into periods
120
+ const periodEntries = periods.map(() => []);
121
+ for (const e of entries) {
122
+ const date = e.postingDate;
123
+ for (let i = 0; i < periods.length; i++) {
124
+ if (date >= periods[i].start && date <= periods[i].end) {
125
+ periodEntries[i].push(e);
126
+ break;
127
+ }
128
+ }
129
+ }
130
+
131
+ // Compute net change per item per period (for reconstruction)
132
+ // netChangePerItem[periodIdx][itemNumber] = sum of quantity
133
+ const netChangePerItem = periods.map(() => ({}));
134
+ for (let i = 0; i < periods.length; i++) {
135
+ for (const e of periodEntries[i]) {
136
+ netChangePerItem[i][e.itemNumber] = (netChangePerItem[i][e.itemNumber] || 0) + (e.quantity || 0);
137
+ }
138
+ }
139
+
140
+ // Reconstruct closing/opening per item per period, walking backwards
141
+ // closingQty[periodIdx][itemNumber] = inventory at end of period
142
+ const closingQty = periods.map(() => ({}));
143
+ const openingQty = periods.map(() => ({}));
144
+
145
+ const lastIdx = periods.length - 1;
146
+
147
+ // Last period closing = current inventory
148
+ for (const [itemNo, info] of Object.entries(itemLookup)) {
149
+ closingQty[lastIdx][itemNo] = info.current_qty;
150
+ }
151
+
152
+ // Walk backwards
153
+ for (let i = lastIdx; i >= 0; i--) {
154
+ for (const itemNo of itemNumbers) {
155
+ const closing = closingQty[i][itemNo] || 0;
156
+ const netChange = netChangePerItem[i][itemNo] || 0;
157
+ const opening = closing - netChange;
158
+ openingQty[i][itemNo] = opening;
159
+
160
+ // Closing of previous period = opening of this period
161
+ if (i > 0) {
162
+ closingQty[i - 1][itemNo] = opening;
163
+ }
164
+ }
165
+ }
166
+
167
+ // Build period results
168
+ const periodResults = [];
169
+ for (let i = 0; i < periods.length; i++) {
170
+ const p = periods[i];
171
+ const pEntries = periodEntries[i];
172
+
173
+ // Classify movements by item
174
+ const movementsByItem = {};
175
+ for (const e of pEntries) {
176
+ if (!movementsByItem[e.itemNumber]) {
177
+ movementsByItem[e.itemNumber] = { purchases_in: 0, sales_out: 0, adjustments: 0, assembly: 0, other: 0 };
178
+ }
179
+ const m = movementsByItem[e.itemNumber];
180
+ const qty = e.quantity || 0;
181
+
182
+ switch (e.entryType) {
183
+ case 'Purchase':
184
+ m.purchases_in += qty;
185
+ break;
186
+ case 'Sale':
187
+ m.sales_out += qty;
188
+ break;
189
+ case 'Positive_x0020_Adjmt_x002E_':
190
+ case 'Negative_x0020_Adjmt_x002E_':
191
+ m.adjustments += qty;
192
+ break;
193
+ case 'Assembly_x0020_Output':
194
+ case 'Assembly_x0020_Consumption':
195
+ m.assembly += qty;
196
+ break;
197
+ default:
198
+ m.other += qty;
199
+ break;
200
+ }
201
+ }
202
+
203
+ // Aggregate by category and classification
204
+ const byCategory = {};
205
+ const byClassification = {};
206
+ let totalOpening = 0;
207
+ let totalClosing = 0;
208
+ const totalMovements = { purchases_in: 0, sales_out: 0, adjustments: 0, assembly: 0, other: 0 };
209
+
210
+ for (const itemNo of itemNumbers) {
211
+ const info = itemLookup[itemNo];
212
+ const cat = info.category;
213
+ const cls = info.classification;
214
+ const opQty = openingQty[i][itemNo] || 0;
215
+ const clQty = closingQty[i][itemNo] || 0;
216
+ const mv = movementsByItem[itemNo] || { purchases_in: 0, sales_out: 0, adjustments: 0, assembly: 0, other: 0 };
217
+
218
+ totalOpening += opQty;
219
+ totalClosing += clQty;
220
+ for (const k of Object.keys(totalMovements)) totalMovements[k] += mv[k];
221
+
222
+ // by_category
223
+ if (!byCategory[cat]) {
224
+ byCategory[cat] = { opening_qty: 0, closing_qty: 0, movements: { purchases_in: 0, sales_out: 0, adjustments: 0, assembly: 0, other: 0 } };
225
+ }
226
+ byCategory[cat].opening_qty += opQty;
227
+ byCategory[cat].closing_qty += clQty;
228
+ for (const k of Object.keys(totalMovements)) byCategory[cat].movements[k] += mv[k];
229
+
230
+ // by_classification
231
+ if (!byClassification[cls]) {
232
+ byClassification[cls] = { opening_qty: 0, closing_qty: 0, movements: { purchases_in: 0, sales_out: 0, adjustments: 0, assembly: 0, other: 0 } };
233
+ }
234
+ byClassification[cls].opening_qty += opQty;
235
+ byClassification[cls].closing_qty += clQty;
236
+ for (const k of Object.keys(totalMovements)) byClassification[cls].movements[k] += mv[k];
237
+ }
238
+
239
+ // Finalize groups with net_change and change_pct
240
+ for (const group of Object.values(byCategory)) {
241
+ finalizeGroup(group);
242
+ }
243
+ for (const group of Object.values(byClassification)) {
244
+ finalizeGroup(group);
245
+ }
246
+
247
+ const totalNetChange = round5(totalClosing - totalOpening);
248
+ periodResults.push({
249
+ label: p.label,
250
+ start: p.start,
251
+ end: p.end,
252
+ is_partial: p.isPartial || false,
253
+ by_category: byCategory,
254
+ by_classification: byClassification,
255
+ totals: {
256
+ opening_qty: round5(totalOpening),
257
+ closing_qty: round5(totalClosing),
258
+ net_change: totalNetChange,
259
+ change_pct: totalOpening !== 0 ? round2((totalNetChange / Math.abs(totalOpening)) * 100) : null,
260
+ movements: roundMovements(totalMovements),
261
+ },
262
+ });
263
+ }
264
+
265
+ // Trend summary
266
+ const trendByCategory = buildTrendSummary(periodResults, 'by_category');
267
+ const trendByClassification = buildTrendSummary(periodResults, 'by_classification');
268
+
269
+ return {
270
+ store: store.code,
271
+ store_name: store.name,
272
+ period_type: args.period,
273
+ periods_analyzed: periods.length,
274
+ note: 'Inventario de apertura reconstruido restando movimientos desde inventario actual hacia atrás. sales_out es negativo (salida). adjustments incluye positivos y negativos.',
275
+ periods: periodResults,
276
+ trend_summary: {
277
+ by_category: trendByCategory,
278
+ by_classification: trendByClassification,
279
+ },
280
+ };
281
+ }
282
+
283
+ // --- Helpers ---
284
+
285
+ function buildPeriods(type, count, today) {
286
+ const periods = [];
287
+ const todayStr = today.toISOString().split('T')[0];
288
+
289
+ if (type === 'month') {
290
+ for (let i = 0; i < count; i++) {
291
+ const d = new Date(today);
292
+ d.setDate(1);
293
+ d.setMonth(d.getMonth() - i);
294
+ const start = d.toISOString().split('T')[0];
295
+
296
+ const endD = new Date(d);
297
+ endD.setMonth(endD.getMonth() + 1);
298
+ endD.setDate(endD.getDate() - 1);
299
+ let end = endD.toISOString().split('T')[0];
300
+ const isPartial = end > todayStr;
301
+ if (isPartial) end = todayStr;
302
+
303
+ const label = start.substring(0, 7) + (isPartial ? ' (parcial)' : '');
304
+ periods.unshift({ label, start, end, isPartial });
305
+ }
306
+ } else {
307
+ // week — Monday to Sunday
308
+ const dayOfWeek = today.getDay();
309
+ const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
310
+ const thisMonday = new Date(today);
311
+ thisMonday.setDate(thisMonday.getDate() - mondayOffset);
312
+
313
+ for (let i = 0; i < count; i++) {
314
+ const monday = new Date(thisMonday);
315
+ monday.setDate(monday.getDate() - i * 7);
316
+ const sunday = new Date(monday);
317
+ sunday.setDate(sunday.getDate() + 6);
318
+
319
+ const start = monday.toISOString().split('T')[0];
320
+ let end = sunday.toISOString().split('T')[0];
321
+ const isPartial = end > todayStr;
322
+ if (isPartial) end = todayStr;
323
+
324
+ const label = `Sem ${start}` + (isPartial ? ' (parcial)' : '');
325
+ periods.unshift({ label, start, end, isPartial });
326
+ }
327
+ }
328
+
329
+ return periods;
330
+ }
331
+
332
+ function finalizeGroup(group) {
333
+ group.opening_qty = round5(group.opening_qty);
334
+ group.closing_qty = round5(group.closing_qty);
335
+ group.net_change = round5(group.closing_qty - group.opening_qty);
336
+ group.change_pct = group.opening_qty !== 0
337
+ ? round2((group.net_change / Math.abs(group.opening_qty)) * 100)
338
+ : null;
339
+ group.movements = roundMovements(group.movements);
340
+ }
341
+
342
+ function buildTrendSummary(periodResults, groupKey) {
343
+ const allGroups = new Set();
344
+ for (const p of periodResults) {
345
+ for (const g of Object.keys(p[groupKey])) allGroups.add(g);
346
+ }
347
+
348
+ const summary = {};
349
+ for (const group of allGroups) {
350
+ const changes = [];
351
+ for (const p of periodResults) {
352
+ if (p[groupKey][group] && p[groupKey][group].change_pct !== null) {
353
+ changes.push(p[groupKey][group].change_pct);
354
+ }
355
+ }
356
+ if (changes.length === 0) {
357
+ summary[group] = { direction: 'sin_datos', avg_change_pct: null };
358
+ continue;
359
+ }
360
+ const avg = round2(changes.reduce((s, c) => s + c, 0) / changes.length);
361
+ let direction = 'stable';
362
+ if (avg > 3) direction = 'growing';
363
+ else if (avg < -3) direction = 'shrinking';
364
+
365
+ summary[group] = { direction, avg_change_pct: avg };
366
+ }
367
+ return summary;
368
+ }
369
+
370
+ function roundMovements(m) {
371
+ return {
372
+ purchases_in: round5(m.purchases_in),
373
+ sales_out: round5(m.sales_out),
374
+ adjustments: round5(m.adjustments),
375
+ assembly: round5(m.assembly),
376
+ other: round5(m.other),
377
+ };
378
+ }
379
+
380
+ function round2(n) {
381
+ return Math.round(n * 100) / 100;
382
+ }
383
+
384
+ function round5(n) {
385
+ return Math.round(n * 100000) / 100000;
386
+ }
@@ -0,0 +1,214 @@
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 inventoryLevelsTool = {
6
+ name: 'get_inventory_levels',
7
+ description:
8
+ 'Niveles de inventario agrupados por itemCategoryCode y/o inventoryPostingGroupCode (congelados, importado, local). Soporta fecha de corte histórica (e.g. 1ero del mes post-ajuste). Devuelve matriz de clasificación con qty, costo, y top ítems por grupo.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ store: {
13
+ type: 'string',
14
+ enum: ['FQ01', 'FQ28', 'FQ88'],
15
+ description: 'Tienda a consultar.',
16
+ },
17
+ item_category: {
18
+ type: 'string',
19
+ description: 'Filtrar por itemCategoryCode (e.g. TERMINADO, INSUMO).',
20
+ },
21
+ classification: {
22
+ type: 'string',
23
+ description: 'Filtrar por inventoryPostingGroupCode (e.g. CONGELADOS, IMPORTADO, LOCAL).',
24
+ },
25
+ as_of_date: {
26
+ type: 'string',
27
+ description:
28
+ 'Fecha de corte YYYY-MM-DD. Default: hoy. Si es fecha pasada, reconstruye inventario restando movimientos posteriores.',
29
+ },
30
+ },
31
+ required: ['store'],
32
+ },
33
+ };
34
+
35
+ export async function handleInventoryLevels(bcClient, args) {
36
+ const storeParam = args.store;
37
+ if (!storeParam) throw new Error('Parámetro requerido: store');
38
+
39
+ const stores = resolveStores([storeParam]);
40
+ const store = stores[0];
41
+ const companyId = store.companyId;
42
+
43
+ const today = new Date();
44
+ const todayStr = today.toISOString().split('T')[0];
45
+ const asOfDate = args.as_of_date || todayStr;
46
+ const isHistorical = asOfDate < todayStr;
47
+
48
+ // Fetch all items
49
+ const itemFilters = [];
50
+ if (args.item_category) {
51
+ itemFilters.push(`itemCategoryCode eq '${args.item_category}'`);
52
+ }
53
+ if (args.classification) {
54
+ itemFilters.push(`inventoryPostingGroupCode eq '${args.classification}'`);
55
+ }
56
+
57
+ const itemParams = {
58
+ $select: 'number,displayName,itemCategoryCode,inventoryPostingGroupCode,unitCost,unitPrice,inventory',
59
+ };
60
+ if (itemFilters.length > 0) {
61
+ itemParams.$filter = itemFilters.join(' and ');
62
+ }
63
+
64
+ const itemUrl = bcClient.buildApiUrl(companyId, 'items', itemParams);
65
+ let items = await bcClient.apiCallAllPages(itemUrl);
66
+ logger.info(`${store.code}: ${items.length} items fetched`);
67
+
68
+ // Build item map with current inventory
69
+ // Exclude items without inventoryPostingGroupCode — they are not real inventory
70
+ // (e.g. expense items like straws/cups that go direct to cost)
71
+ // Also exclude FQ-*/FQ_*/FQVENTA categories — sales/combo items, not inventory
72
+ const inventoryItems = items.filter((item) =>
73
+ item.inventoryPostingGroupCode && isInventoryCategory(item.itemCategoryCode),
74
+ );
75
+ const excludedCount = items.length - inventoryItems.length;
76
+ if (excludedCount > 0) {
77
+ logger.info(`${store.code}: excluded ${excludedCount} non-inventory items (no posting group or FQ-* category)`);
78
+ }
79
+
80
+ const itemMap = {};
81
+ for (const item of inventoryItems) {
82
+ itemMap[item.number] = {
83
+ number: item.number,
84
+ name: item.displayName,
85
+ category: item.itemCategoryCode || 'SIN_CATEGORIA',
86
+ classification: item.inventoryPostingGroupCode,
87
+ unit_cost: item.unitCost || 0,
88
+ unit_price: item.unitPrice || 0,
89
+ qty: item.inventory || 0,
90
+ };
91
+ }
92
+
93
+ // If historical, reconstruct inventory at as_of_date
94
+ if (isHistorical) {
95
+ const dayAfter = nextDay(asOfDate);
96
+ const ledgerFilters = [
97
+ `postingDate ge ${dayAfter}`,
98
+ `postingDate le ${todayStr}`,
99
+ ];
100
+
101
+ const ledgerUrl = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
102
+ $filter: ledgerFilters.join(' and '),
103
+ $select: 'itemNumber,quantity',
104
+ });
105
+
106
+ const entries = await bcClient.apiCallAllPages(ledgerUrl);
107
+ logger.info(`${store.code}: ${entries.length} ledger entries after ${asOfDate} for reconstruction`);
108
+
109
+ // Build adjustment map: total movement after as_of_date per item
110
+ const adjustments = {};
111
+ for (const e of entries) {
112
+ adjustments[e.itemNumber] = (adjustments[e.itemNumber] || 0) + (e.quantity || 0);
113
+ }
114
+
115
+ // Reconstruct: historical = current - movements_after
116
+ for (const [itemNo, adj] of Object.entries(adjustments)) {
117
+ if (itemMap[itemNo]) {
118
+ itemMap[itemNo].qty = round5(itemMap[itemNo].qty - adj);
119
+ }
120
+ }
121
+ }
122
+
123
+ // Build classification matrix
124
+ const byCategory = {};
125
+ const byClassification = {};
126
+ const matrix = {};
127
+
128
+ for (const item of Object.values(itemMap)) {
129
+ const cat = item.category;
130
+ const cls = item.classification;
131
+ const costValue = round2(item.qty * item.unit_cost);
132
+
133
+ // by_category
134
+ if (!byCategory[cat]) {
135
+ byCategory[cat] = { item_count: 0, total_qty: 0, total_cost_value: 0, _items: [] };
136
+ }
137
+ byCategory[cat].item_count++;
138
+ byCategory[cat].total_qty = round5(byCategory[cat].total_qty + item.qty);
139
+ byCategory[cat].total_cost_value = round2(byCategory[cat].total_cost_value + costValue);
140
+ byCategory[cat]._items.push({ number: item.number, name: item.name, qty: item.qty, cost_value: costValue });
141
+
142
+ // by_classification
143
+ if (!byClassification[cls]) {
144
+ byClassification[cls] = { item_count: 0, total_qty: 0, total_cost_value: 0, _items: [] };
145
+ }
146
+ byClassification[cls].item_count++;
147
+ byClassification[cls].total_qty = round5(byClassification[cls].total_qty + item.qty);
148
+ byClassification[cls].total_cost_value = round2(byClassification[cls].total_cost_value + costValue);
149
+ byClassification[cls]._items.push({ number: item.number, name: item.name, qty: item.qty, cost_value: costValue });
150
+
151
+ // matrix: category → classification
152
+ if (!matrix[cat]) matrix[cat] = {};
153
+ if (!matrix[cat][cls]) {
154
+ matrix[cat][cls] = { item_count: 0, total_qty: 0, total_cost_value: 0 };
155
+ }
156
+ matrix[cat][cls].item_count++;
157
+ matrix[cat][cls].total_qty = round5(matrix[cat][cls].total_qty + item.qty);
158
+ matrix[cat][cls].total_cost_value = round2(matrix[cat][cls].total_cost_value + costValue);
159
+ }
160
+
161
+ // Add top 5 items per group, remove _items helper
162
+ for (const group of Object.values(byCategory)) {
163
+ group.top_items = group._items
164
+ .sort((a, b) => Math.abs(b.qty) - Math.abs(a.qty))
165
+ .slice(0, 5)
166
+ .map(({ number, name, qty, cost_value }) => ({ number, name, qty: round5(qty), cost_value: round2(cost_value) }));
167
+ delete group._items;
168
+ }
169
+ for (const group of Object.values(byClassification)) {
170
+ group.top_items = group._items
171
+ .sort((a, b) => Math.abs(b.qty) - Math.abs(a.qty))
172
+ .slice(0, 5)
173
+ .map(({ number, name, qty, cost_value }) => ({ number, name, qty: round5(qty), cost_value: round2(cost_value) }));
174
+ delete group._items;
175
+ }
176
+
177
+ // Grand totals
178
+ const allItems = Object.values(itemMap);
179
+ const grandTotal = {
180
+ categories: Object.keys(byCategory).length,
181
+ classifications: Object.keys(byClassification).length,
182
+ item_count: allItems.length,
183
+ total_qty: round5(allItems.reduce((s, i) => s + i.qty, 0)),
184
+ total_cost_value: round2(allItems.reduce((s, i) => s + i.qty * i.unit_cost, 0)),
185
+ };
186
+
187
+ return {
188
+ store: store.code,
189
+ store_name: store.name,
190
+ as_of_date: asOfDate,
191
+ is_historical: isHistorical,
192
+ note: isHistorical
193
+ ? 'Inventario histórico reconstruido restando movimientos posteriores a as_of_date. unitCost FIFO actual de BC. Excluye categorías FQ-* (venta/combos).'
194
+ : 'Inventario actual con unitCost FIFO de BC. Excluye categorías FQ-* (venta/combos).',
195
+ by_category: byCategory,
196
+ by_classification: byClassification,
197
+ matrix,
198
+ grand_total: grandTotal,
199
+ };
200
+ }
201
+
202
+ function nextDay(dateStr) {
203
+ const d = new Date(dateStr + 'T00:00:00');
204
+ d.setDate(d.getDate() + 1);
205
+ return d.toISOString().split('T')[0];
206
+ }
207
+
208
+ function round2(n) {
209
+ return Math.round(n * 100) / 100;
210
+ }
211
+
212
+ function round5(n) {
213
+ return Math.round(n * 100000) / 100000;
214
+ }
@@ -1,5 +1,6 @@
1
1
  import { resolveStores } from '../../config/company-config.js';
2
2
  import { logger } from '../../utils/logger.js';
3
+ import { getCalculatedCosts } from './shared/cost-calculator.js';
3
4
 
4
5
  export const itemCardTool = {
5
6
  name: 'get_item_card',
@@ -99,56 +100,6 @@ export async function handleItemCard(bcClient, args) {
99
100
  };
100
101
  }
101
102
 
102
- /**
103
- * Fetch the most recent inbound entries per item and compute current unit cost.
104
- * Uses the last 10 Assembly Output / Purchase entries (most recent cost, not historical avg).
105
- * Excludes future-dated entries and Sales Returns.
106
- */
107
- async function getCalculatedCosts(bcClient, companyId, itemNumbers) {
108
- if (itemNumbers.length === 0) return {};
109
-
110
- const results = {};
111
- const todayStr = new Date().toISOString().split('T')[0];
112
- const typeFilter = `(entryType eq 'Purchase' or entryType eq 'Assembly_x0020_Output' or entryType eq 'Positive_x0020_Adjmt_x002E_')`;
113
-
114
- // Fetch per item (need $top per item, can't batch with $top)
115
- const fetches = itemNumbers.map(async (itemNo) => {
116
- const filter = `itemNumber eq '${itemNo}' and ${typeFilter} and postingDate le ${todayStr}`;
117
- const url = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
118
- $filter: filter,
119
- $select: 'itemNumber,quantity,costAmountActual,postingDate',
120
- $orderby: 'postingDate desc',
121
- $top: '10',
122
- });
123
- try {
124
- const entries = await bcClient.apiCall(url);
125
- if (entries.length === 0) return;
126
-
127
- // Use last 3 inbound entries (most current cost)
128
- const recent = entries.slice(0, 3);
129
- const totalQty = recent.reduce((s, e) => s + (e.quantity || 0), 0);
130
- const totalCost = recent.reduce((s, e) => s + (e.costAmountActual || 0), 0);
131
- if (totalQty > 0) {
132
- results[itemNo] = {
133
- unit_cost: round5(totalCost / totalQty),
134
- source: `last_3_inbound`,
135
- sample_size: recent.length,
136
- latest_date: recent[0].postingDate,
137
- };
138
- }
139
- } catch (err) {
140
- logger.info(`Cost calc failed for ${itemNo}: ${err.message}`);
141
- }
142
- });
143
-
144
- // Run in batches of 5 to avoid rate limiting
145
- for (let i = 0; i < fetches.length; i += 5) {
146
- await Promise.all(fetches.slice(i, i + 5));
147
- }
148
-
149
- return results;
150
- }
151
-
152
103
  function round2(n) {
153
104
  return Math.round(n * 100) / 100;
154
105
  }