@fullqueso/mcp-bc-gastos 1.14.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.14.1",
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": {
@@ -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: {
@@ -64,25 +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
- blocked: item.blocked || false,
76
- }));
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
+ });
77
92
 
78
93
  return {
79
94
  store: store.code,
80
95
  store_name: store.name,
81
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.',
82
98
  items,
83
99
  };
84
100
  }
85
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
+
86
152
  function round2(n) {
87
153
  return Math.round(n * 100) / 100;
88
154
  }
155
+
156
+ function round5(n) {
157
+ return Math.round(n * 100000) / 100000;
158
+ }