@fullqueso/mcp-bc-gastos 1.14.0 → 1.14.2

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 CHANGED
@@ -4,22 +4,22 @@
4
4
 
5
5
  ### Highlights
6
6
  - **35 tools** across 7 domains — new **Inventario** domain for inventory cost analysis
7
- - 3 new tools to diagnose FIFO unit cost, open layers, and value entry adjustments
8
- - Uses BC Standard v2.0 API: `items`, `itemLedgerEntries`, `valueEntries`
7
+ - 3 new tools to diagnose unit cost, transaction history, and cost trends
8
+ - Uses BC Standard v2.0 API: `items`, `itemLedgerEntries`
9
9
 
10
10
  ### Added
11
11
 
12
12
  **Inventario (Inventory Cost Analysis) — 3 herramientas (Standard v2.0):**
13
- - `get_item_card` — datos maestros de ítems: unitCost, costingMethod, inventory qty, unitPrice, categoría. Filtra por número, búsqueda por nombre, o categoría
14
- - `get_item_ledger_entries` — capas FIFO abiertas/cerradas con remainingQuantity y costAmountActual. Calcula `weighted_avg_unit_cost` sobre capas abiertas. Filtros por open_only (default: true), entry_type, fecha
15
- - `get_item_value_entries` — detalle de valorización por entrada del ledger: costPerUnit, ajustes de costo, revaluaciones. Permite drill-down por item_ledger_entry_number específico
13
+ - `get_item_card` — datos maestros de ítems: unitCost, inventory qty, unitPrice, categoría. Filtra por número, búsqueda por nombre, o categoría
14
+ - `get_item_ledger_entries` — historial de movimientos de inventario con costo real por entrada. Resumen por entry_type (Assembly Output, Sale, Purchase, Adjustments). Calcula `unit_cost_actual` y `inbound_avg_unit_cost`
15
+ - `get_item_cost_analysis` — análisis completo: weighted avg inbound cost vs BC unitCost, tendencia de costo por mes, últimas 5 entradas de recepción, detección de discrepancia
16
16
 
17
17
  ### Technical
18
18
  - New domain folder: `tools/inventario/` with `index.js` re-exports
19
- - Spec document: `docs/tool_inventario.md`
20
- - All tools use `buildApiUrl()` + `apiCall()` from BCClient (Standard v2.0, GUID-based)
21
- - `unit_cost_actual` computed per ledger entry: `|costAmountActual / quantity|`
22
- - `weighted_avg_unit_cost` computed proportionally from remaining qty of open layers
19
+ - Spec document: `docs/tool_inventario.md` with BC v2.0 API field limitations documented
20
+ - BC v2.0 API does NOT expose `costingMethod`, `remainingQuantity`, `open`, or `valueEntries` — cost analysis computed from full transaction history instead
21
+ - Entry types use BC OData encoding: `Assembly_x0020_Output`, `Negative_x0020_Adjmt_x002E_`, etc.
22
+ - Parallel fetch of items + itemLedgerEntries in cost analysis tool
23
23
  - Zero `console.log` — all logging via `logger.info()` (console.error wrapper)
24
24
 
25
25
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.14.0",
3
+ "version": "1.14.2",
4
4
  "description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, accounts receivable/payable, multi-payment draft visibility, payroll, inventory cost analysis, and manager reports - Full Queso franchise stores",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -133,7 +133,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
133
133
  // Inventario (inventory cost analysis)
134
134
  itemCardTool,
135
135
  itemLedgerEntriesTool,
136
- itemValueEntriesTool,
136
+ itemValueEntriesTool, // get_item_cost_analysis
137
137
  // Reports
138
138
  managerReportTool,
139
139
  cxpReportTool,
@@ -250,7 +250,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
250
250
  case 'get_item_ledger_entries':
251
251
  result = await handleItemLedgerEntries(bcClient, args);
252
252
  break;
253
- case 'get_item_value_entries':
253
+ case 'get_item_cost_analysis':
254
254
  result = await handleItemValueEntries(bcClient, args);
255
255
  break;
256
256
  // Reports
@@ -4,7 +4,7 @@ import { logger } from '../../utils/logger.js';
4
4
  export const itemCardTool = {
5
5
  name: 'get_item_card',
6
6
  description:
7
- 'Datos maestros de ítems de inventario: unitCost, costingMethod, inventory qty, unitPrice, categoría. Filtra por número de ítem, búsqueda por nombre, o categoría.',
7
+ 'Datos maestros de ítems de inventario: unitCost (BC), calculated_unit_cost (real desde últimas entradas), inventory qty, unitPrice, categoría. El calculated_unit_cost es más confiable que unitCost cuando hay entradas con fechas erróneas.',
8
8
  inputSchema: {
9
9
  type: 'object',
10
10
  properties: {
@@ -47,7 +47,7 @@ export async function handleItemCard(bcClient, args) {
47
47
  }
48
48
 
49
49
  const params = {
50
- $select: 'number,displayName,type,itemCategoryCode,unitCost,unitPrice,inventory,costingMethod,blocked',
50
+ $select: 'number,displayName,type,itemCategoryCode,unitCost,unitPrice,inventory,blocked',
51
51
  $orderby: 'number',
52
52
  };
53
53
  if (filters.length > 0) params.$filter = filters.join(' and ');
@@ -64,26 +64,95 @@ export async function handleItemCard(bcClient, args) {
64
64
  data = data.filter((item) => item.displayName && item.displayName.toLowerCase().includes(search));
65
65
  }
66
66
 
67
- const items = data.map((item) => ({
68
- number: item.number,
69
- name: item.displayName,
70
- type: item.type,
71
- category: item.itemCategoryCode || null,
72
- unit_cost: round2(item.unitCost || 0),
73
- unit_price: round2(item.unitPrice || 0),
74
- inventory: round2(item.inventory || 0),
75
- costing_method: item.costingMethod || null,
76
- blocked: item.blocked || false,
77
- }));
67
+ // Calculate real unit cost from recent inbound ledger entries
68
+ const itemNumbers = data.map((item) => item.number);
69
+ const calculatedCosts = await getCalculatedCosts(bcClient, companyId, itemNumbers);
70
+
71
+ const items = data.map((item) => {
72
+ const calc = calculatedCosts[item.number];
73
+ const bcCost = round5(item.unitCost || 0);
74
+ const calcCost = calc ? calc.unit_cost : bcCost;
75
+ const hasDiscrepancy = Math.abs(bcCost - calcCost) > 0.01;
76
+
77
+ return {
78
+ number: item.number,
79
+ name: item.displayName,
80
+ type: item.type,
81
+ category: item.itemCategoryCode || null,
82
+ unit_cost_bc: bcCost,
83
+ calculated_unit_cost: round5(calcCost),
84
+ cost_discrepancy: hasDiscrepancy,
85
+ unit_price: round2(item.unitPrice || 0),
86
+ margin_pct: item.unitPrice > 0 ? round2(((item.unitPrice - calcCost) / item.unitPrice) * 100) : 0,
87
+ inventory: round2(item.inventory || 0),
88
+ blocked: item.blocked || false,
89
+ ...(calc ? { cost_source: calc.source, cost_sample_size: calc.sample_size } : {}),
90
+ };
91
+ });
78
92
 
79
93
  return {
80
94
  store: store.code,
81
95
  store_name: store.name,
82
96
  total_items: items.length,
97
+ note: 'calculated_unit_cost = weighted avg from last 3 inbound entries (Assembly Output / Purchase). More reliable than unit_cost_bc when BC has entries with incorrect posting dates.',
83
98
  items,
84
99
  };
85
100
  }
86
101
 
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
+
87
152
  function round2(n) {
88
153
  return Math.round(n * 100) / 100;
89
154
  }
155
+
156
+ function round5(n) {
157
+ return Math.round(n * 100000) / 100000;
158
+ }
@@ -4,7 +4,7 @@ import { logger } from '../../utils/logger.js';
4
4
  export const itemLedgerEntriesTool = {
5
5
  name: 'get_item_ledger_entries',
6
6
  description:
7
- 'Entradas del libro de artículos (Item Ledger Entries) — capas FIFO abiertas/cerradas con cantidades restantes y costo real. Esencial para diagnosticar por qué el unitCost de un ítem tiene cierto valor. Por defecto muestra solo capas abiertas.',
7
+ 'Entradas del libro de artículos (Item Ledger Entries) — historial de movimientos de inventario con costo real por entrada. Muestra compras, ventas, ajustes, consumos, y ensamblaje. Calcula unit_cost_actual por entrada y resumen de costos por tipo de entrada.',
8
8
  inputSchema: {
9
9
  type: 'object',
10
10
  properties: {
@@ -17,14 +17,9 @@ export const itemLedgerEntriesTool = {
17
17
  type: 'string',
18
18
  description: 'Número de ítem exacto (e.g. FQ0850).',
19
19
  },
20
- open_only: {
21
- type: 'boolean',
22
- description: 'true = solo entradas abiertas (capas FIFO activas). Default: true.',
23
- },
24
20
  entry_type: {
25
21
  type: 'string',
26
- enum: ['Purchase', 'Sale', 'Positive Adjmt.', 'Negative Adjmt.', 'Transfer', 'Consumption', 'Output'],
27
- description: 'Filtrar por tipo de entrada.',
22
+ description: 'Filtrar por tipo de entrada: Purchase, Sale, Positive_x0020_Adjmt., Negative_x0020_Adjmt., Transfer, Consumption, Assembly_x0020_Consumption, Assembly_x0020_Output.',
28
23
  },
29
24
  start_date: {
30
25
  type: 'string',
@@ -36,7 +31,7 @@ export const itemLedgerEntriesTool = {
36
31
  },
37
32
  top: {
38
33
  type: 'number',
39
- description: 'Limitar resultados. Default: 100.',
34
+ description: 'Limitar resultados. Default: 200.',
40
35
  },
41
36
  },
42
37
  required: ['store', 'item_number'],
@@ -52,25 +47,22 @@ export async function handleItemLedgerEntries(bcClient, args) {
52
47
  const store = stores[0];
53
48
  const companyId = store.companyId;
54
49
 
55
- const openOnly = args.open_only !== false; // default true
56
50
  const filters = [`itemNumber eq '${args.item_number}'`];
57
-
58
- if (openOnly) filters.push('open eq true');
59
51
  if (args.entry_type) filters.push(`entryType eq '${args.entry_type}'`);
60
52
  if (args.start_date) filters.push(`postingDate ge ${args.start_date}`);
61
53
  if (args.end_date) filters.push(`postingDate le ${args.end_date}`);
62
54
 
63
- const top = args.top || 100;
55
+ const top = args.top || 200;
64
56
 
65
57
  const url = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
66
58
  $filter: filters.join(' and '),
67
- $select: 'entryNumber,itemNumber,postingDate,entryType,documentNumber,description,quantity,remainingQuantity,costAmountActual,costAmountExpected,open,locationCode',
59
+ $select: 'entryNumber,itemNumber,postingDate,entryType,sourceNumber,sourceType,documentNumber,documentType,description,quantity,salesAmountActual,costAmountActual',
68
60
  $orderby: 'postingDate desc',
69
61
  $top: String(top),
70
62
  });
71
63
 
72
64
  const data = await bcClient.apiCall(url);
73
- logger.info(`${store.code}: ${data.length} item ledger entries for ${args.item_number} (open_only=${openOnly})`);
65
+ logger.info(`${store.code}: ${data.length} item ledger entries for ${args.item_number}`);
74
66
 
75
67
  const entries = data.map((e) => ({
76
68
  entry_number: e.entryNumber,
@@ -78,40 +70,53 @@ export async function handleItemLedgerEntries(bcClient, args) {
78
70
  posting_date: e.postingDate,
79
71
  entry_type: e.entryType,
80
72
  document_number: e.documentNumber || null,
73
+ document_type: e.documentType || null,
81
74
  description: e.description || null,
82
75
  quantity: round5(e.quantity || 0),
83
- remaining_quantity: round5(e.remainingQuantity || 0),
84
76
  cost_amount_actual: round2(e.costAmountActual || 0),
85
- cost_amount_expected: round2(e.costAmountExpected || 0),
77
+ sales_amount_actual: round2(e.salesAmountActual || 0),
86
78
  unit_cost_actual: e.quantity ? round5(Math.abs((e.costAmountActual || 0) / e.quantity)) : 0,
87
- open: e.open,
88
- location_code: e.locationCode || null,
89
79
  }));
90
80
 
91
- // Summary for open layers
92
- const openEntries = entries.filter((e) => e.open);
93
- const totalRemainingQty = openEntries.reduce((sum, e) => sum + e.remaining_quantity, 0);
94
- const totalCostOpen = openEntries.reduce((sum, e) => {
95
- // Proportional cost for remaining qty
96
- if (e.quantity !== 0) {
97
- return sum + (e.cost_amount_actual * (e.remaining_quantity / e.quantity));
98
- }
99
- return sum;
100
- }, 0);
101
- const weightedAvgCost = totalRemainingQty !== 0 ? Math.abs(totalCostOpen / totalRemainingQty) : 0;
81
+ // Summary by entry type
82
+ const byType = {};
83
+ for (const e of entries) {
84
+ const type = e.entry_type;
85
+ if (!byType[type]) byType[type] = { count: 0, total_qty: 0, total_cost: 0 };
86
+ byType[type].count += 1;
87
+ byType[type].total_qty += e.quantity;
88
+ byType[type].total_cost += e.cost_amount_actual;
89
+ }
90
+
91
+ // Compute net inventory and weighted avg cost from all entries
92
+ const totalQty = entries.reduce((s, e) => s + e.quantity, 0);
93
+ const totalCost = entries.reduce((s, e) => s + e.cost_amount_actual, 0);
94
+
95
+ // Weighted avg cost from inbound entries only (positive qty = receipts)
96
+ const inbound = entries.filter((e) => e.quantity > 0);
97
+ const inboundQty = inbound.reduce((s, e) => s + e.quantity, 0);
98
+ const inboundCost = inbound.reduce((s, e) => s + e.cost_amount_actual, 0);
99
+ const avgInboundCost = inboundQty > 0 ? round5(inboundCost / inboundQty) : 0;
100
+
101
+ for (const [type, stats] of Object.entries(byType)) {
102
+ stats.total_qty = round5(stats.total_qty);
103
+ stats.total_cost = round2(stats.total_cost);
104
+ stats.avg_unit_cost = stats.total_qty !== 0 ? round5(Math.abs(stats.total_cost / stats.total_qty)) : 0;
105
+ }
102
106
 
103
107
  return {
104
108
  store: store.code,
105
109
  store_name: store.name,
106
110
  item_number: args.item_number,
107
- filter: { open_only: openOnly, entry_type: args.entry_type || 'all' },
111
+ filter: { entry_type: args.entry_type || 'all', start_date: args.start_date || 'all', end_date: args.end_date || 'all' },
108
112
  total_entries: entries.length,
109
- open_layers_summary: {
110
- open_layers_count: openEntries.length,
111
- total_remaining_qty: round5(totalRemainingQty),
112
- total_cost_remaining: round2(totalCostOpen),
113
- weighted_avg_unit_cost: round5(weightedAvgCost),
113
+ summary: {
114
+ net_quantity: round5(totalQty),
115
+ net_cost: round2(totalCost),
116
+ inbound_avg_unit_cost: avgInboundCost,
117
+ note: 'BC v2.0 API does not expose remainingQuantity or open flag. Net quantity = sum of all entry quantities (positive=in, negative=out). unitCost on Item Card is BC-calculated from FIFO layers internally.',
114
118
  },
119
+ by_entry_type: byType,
115
120
  entries,
116
121
  };
117
122
  }
@@ -2,9 +2,9 @@ import { resolveStores } from '../../config/company-config.js';
2
2
  import { logger } from '../../utils/logger.js';
3
3
 
4
4
  export const itemValueEntriesTool = {
5
- name: 'get_item_value_entries',
5
+ name: 'get_item_cost_analysis',
6
6
  description:
7
- 'Value Entries de un ítem — detalle de valorización por entrada del ledger: costo por unidad exacto, ajustes de costo, revaluaciones. Usar después de get_item_ledger_entries para profundizar en una capa de costo específica.',
7
+ 'Análisis de costo de un ítem — calcula costo promedio ponderado de entradas recientes (compras, ensamblaje, ajustes positivos), compara con el unitCost actual de BC, y muestra el historial de costos por mes. Útil para entender por qué el unitCost difiere del costo esperado.',
8
8
  inputSchema: {
9
9
  type: 'object',
10
10
  properties: {
@@ -17,21 +17,9 @@ export const itemValueEntriesTool = {
17
17
  type: 'string',
18
18
  description: 'Número de ítem exacto (e.g. FQ0850).',
19
19
  },
20
- item_ledger_entry_number: {
20
+ months: {
21
21
  type: 'number',
22
- description: 'Filtrar por entrada específica del item ledger (entryNumber del get_item_ledger_entries).',
23
- },
24
- start_date: {
25
- type: 'string',
26
- description: 'Fecha inicio (YYYY-MM-DD).',
27
- },
28
- end_date: {
29
- type: 'string',
30
- description: 'Fecha fin (YYYY-MM-DD).',
31
- },
32
- top: {
33
- type: 'number',
34
- description: 'Limitar resultados. Default: 100.',
22
+ description: 'Meses de historial a analizar. Default: 6.',
35
23
  },
36
24
  },
37
25
  required: ['store', 'item_number'],
@@ -46,57 +34,106 @@ export async function handleItemValueEntries(bcClient, args) {
46
34
  const stores = resolveStores([storeParam]);
47
35
  const store = stores[0];
48
36
  const companyId = store.companyId;
37
+ const months = args.months || 6;
38
+
39
+ // Calculate start date
40
+ const now = new Date();
41
+ const startDate = new Date(now.getFullYear(), now.getMonth() - months, 1);
42
+ const startStr = startDate.toISOString().split('T')[0];
49
43
 
50
- const filters = [`itemNumber eq '${args.item_number}'`];
44
+ // Fetch item card + all ledger entries in parallel
45
+ const [itemData, entries] = await Promise.all([
46
+ bcClient.apiCall(bcClient.buildApiUrl(companyId, 'items', {
47
+ $filter: `number eq '${args.item_number}'`,
48
+ $select: 'number,displayName,unitCost,unitPrice,inventory',
49
+ })),
50
+ bcClient.apiCallAllPages(bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
51
+ $filter: `itemNumber eq '${args.item_number}' and postingDate ge ${startStr}`,
52
+ $select: 'entryNumber,postingDate,entryType,documentNumber,quantity,costAmountActual',
53
+ $orderby: 'postingDate asc',
54
+ })),
55
+ ]);
51
56
 
52
- if (args.item_ledger_entry_number) {
53
- filters.push(`itemLedgerEntryNumber eq ${args.item_ledger_entry_number}`);
57
+ const item = itemData[0] || null;
58
+ logger.info(`${store.code}: ${entries.length} ledger entries for ${args.item_number} since ${startStr}`);
59
+
60
+ // Separate inbound (receipts) vs outbound (issues)
61
+ const inbound = []; // Purchase, Positive Adjmt, Assembly Output
62
+ const outbound = []; // Sale, Negative Adjmt, Consumption, Assembly Consumption
63
+
64
+ for (const e of entries) {
65
+ const mapped = {
66
+ entry_number: e.entryNumber,
67
+ posting_date: e.postingDate,
68
+ entry_type: e.entryType,
69
+ document_number: e.documentNumber,
70
+ quantity: e.quantity,
71
+ cost_amount_actual: round2(e.costAmountActual || 0),
72
+ unit_cost: e.quantity ? round5(Math.abs((e.costAmountActual || 0) / e.quantity)) : 0,
73
+ };
74
+
75
+ if (e.quantity > 0) {
76
+ inbound.push(mapped);
77
+ } else if (e.quantity < 0) {
78
+ outbound.push(mapped);
79
+ }
54
80
  }
55
- if (args.start_date) filters.push(`postingDate ge ${args.start_date}`);
56
- if (args.end_date) filters.push(`postingDate le ${args.end_date}`);
57
-
58
- const top = args.top || 100;
59
-
60
- const url = bcClient.buildApiUrl(companyId, 'valueEntries', {
61
- $filter: filters.join(' and '),
62
- $select: 'entryNumber,itemLedgerEntryNumber,itemNumber,postingDate,entryType,documentNumber,costAmountActual,costAmountExpected,costPerUnit,valuedQuantity,invoicedQuantity,costPostedToGL',
63
- $orderby: 'postingDate desc',
64
- $top: String(top),
65
- });
66
-
67
- const data = await bcClient.apiCall(url);
68
- logger.info(`${store.code}: ${data.length} value entries for ${args.item_number}`);
69
-
70
- const entries = data.map((e) => ({
71
- entry_number: e.entryNumber,
72
- item_ledger_entry_number: e.itemLedgerEntryNumber,
73
- item_number: e.itemNumber,
74
- posting_date: e.postingDate,
75
- entry_type: e.entryType,
76
- document_number: e.documentNumber || null,
77
- cost_amount_actual: round2(e.costAmountActual || 0),
78
- cost_amount_expected: round2(e.costAmountExpected || 0),
79
- cost_per_unit: round5(e.costPerUnit || 0),
80
- valued_quantity: round5(e.valuedQuantity || 0),
81
- invoiced_quantity: round5(e.invoicedQuantity || 0),
82
- cost_posted_to_gl: round2(e.costPostedToGL || 0),
81
+
82
+ // Cost trend by month (inbound only)
83
+ const monthlyInbound = {};
84
+ for (const e of inbound) {
85
+ const month = e.posting_date.substring(0, 7); // YYYY-MM
86
+ if (!monthlyInbound[month]) monthlyInbound[month] = { qty: 0, cost: 0, entries: 0 };
87
+ monthlyInbound[month].qty += e.quantity;
88
+ monthlyInbound[month].cost += e.cost_amount_actual;
89
+ monthlyInbound[month].entries += 1;
90
+ }
91
+ const costTrend = Object.entries(monthlyInbound).map(([month, data]) => ({
92
+ month,
93
+ inbound_qty: round5(data.qty),
94
+ inbound_cost: round2(data.cost),
95
+ avg_unit_cost: data.qty > 0 ? round5(data.cost / data.qty) : 0,
96
+ entries: data.entries,
83
97
  }));
84
98
 
85
- // Summary
86
- const totalCostActual = entries.reduce((sum, e) => sum + e.cost_amount_actual, 0);
87
- const totalValuedQty = entries.reduce((sum, e) => sum + e.valued_quantity, 0);
99
+ // Overall inbound weighted avg
100
+ const totalInboundQty = inbound.reduce((s, e) => s + e.quantity, 0);
101
+ const totalInboundCost = inbound.reduce((s, e) => s + e.cost_amount_actual, 0);
102
+ const weightedAvgInbound = totalInboundQty > 0 ? round5(totalInboundCost / totalInboundQty) : 0;
103
+
104
+ // Last 5 inbound entries (most recent costs)
105
+ const recentInbound = inbound.slice(-5).reverse();
106
+
107
+ // Discrepancy analysis
108
+ const bcUnitCost = item ? item.unitCost : null;
109
+ const discrepancy = bcUnitCost !== null ? round5(bcUnitCost - weightedAvgInbound) : null;
88
110
 
89
111
  return {
90
112
  store: store.code,
91
113
  store_name: store.name,
92
114
  item_number: args.item_number,
93
- total_entries: entries.length,
94
- summary: {
95
- total_cost_actual: round2(totalCostActual),
96
- total_valued_qty: round5(totalValuedQty),
97
- avg_cost_per_unit: totalValuedQty !== 0 ? round5(Math.abs(totalCostActual / totalValuedQty)) : 0,
115
+ item_name: item ? item.displayName : null,
116
+ current_bc_data: item ? {
117
+ unit_cost: item.unitCost,
118
+ unit_price: item.unitPrice,
119
+ inventory_on_hand: item.inventory,
120
+ } : null,
121
+ cost_analysis: {
122
+ period: `${startStr} to today`,
123
+ total_inbound_qty: round5(totalInboundQty),
124
+ total_inbound_cost: round2(totalInboundCost),
125
+ weighted_avg_inbound_cost: weightedAvgInbound,
126
+ bc_unit_cost: bcUnitCost,
127
+ discrepancy,
128
+ discrepancy_note: discrepancy !== null && Math.abs(discrepancy) > 0.01
129
+ ? `BC unitCost (${bcUnitCost}) differs from weighted avg inbound (${weightedAvgInbound}) by ${discrepancy}. BC uses FIFO layers internally — the unitCost reflects remaining open layers, not the simple average of all receipts.`
130
+ : 'BC unitCost matches weighted avg inbound cost.',
98
131
  },
99
- entries,
132
+ cost_trend_by_month: costTrend,
133
+ recent_inbound_entries: recentInbound,
134
+ total_outbound_entries: outbound.length,
135
+ total_outbound_qty: round5(outbound.reduce((s, e) => s + e.quantity, 0)),
136
+ total_entries_in_period: entries.length,
100
137
  };
101
138
  }
102
139