@fullqueso/mcp-bc-gastos 1.14.0 → 1.14.1
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
|
|
8
|
-
- Uses BC Standard v2.0 API: `items`, `itemLedgerEntries
|
|
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,
|
|
14
|
-
- `get_item_ledger_entries` —
|
|
15
|
-
- `
|
|
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
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
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.
|
|
3
|
+
"version": "1.14.1",
|
|
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 '
|
|
253
|
+
case 'get_item_cost_analysis':
|
|
254
254
|
result = await handleItemValueEntries(bcClient, args);
|
|
255
255
|
break;
|
|
256
256
|
// Reports
|
|
@@ -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,
|
|
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 ');
|
|
@@ -72,7 +72,6 @@ export async function handleItemCard(bcClient, args) {
|
|
|
72
72
|
unit_cost: round2(item.unitCost || 0),
|
|
73
73
|
unit_price: round2(item.unitPrice || 0),
|
|
74
74
|
inventory: round2(item.inventory || 0),
|
|
75
|
-
costing_method: item.costingMethod || null,
|
|
76
75
|
blocked: item.blocked || false,
|
|
77
76
|
}));
|
|
78
77
|
|
|
@@ -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) —
|
|
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
|
-
|
|
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:
|
|
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 ||
|
|
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,
|
|
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}
|
|
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
|
-
|
|
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
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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: {
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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: '
|
|
5
|
+
name: 'get_item_cost_analysis',
|
|
6
6
|
description:
|
|
7
|
-
'
|
|
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
|
-
|
|
20
|
+
months: {
|
|
21
21
|
type: 'number',
|
|
22
|
-
description: '
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
//
|
|
86
|
-
const
|
|
87
|
-
const
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|