@fullqueso/mcp-bc-gastos 1.13.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
@@ -1,5 +1,29 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.14.0] - 2026-03-15
4
+
5
+ ### Highlights
6
+ - **35 tools** across 7 domains — new **Inventario** domain for inventory cost analysis
7
+ - 3 new tools to diagnose unit cost, transaction history, and cost trends
8
+ - Uses BC Standard v2.0 API: `items`, `itemLedgerEntries`
9
+
10
+ ### Added
11
+
12
+ **Inventario (Inventory Cost Analysis) — 3 herramientas (Standard v2.0):**
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
+
17
+ ### Technical
18
+ - New domain folder: `tools/inventario/` with `index.js` re-exports
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
+ - Zero `console.log` — all logging via `logger.info()` (console.error wrapper)
24
+
25
+ ---
26
+
3
27
  ## [1.10.0] - 2026-02-26
4
28
 
5
29
  ### Highlights
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.13.0",
4
- "description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, accounts receivable/payable, multi-payment draft visibility, payroll, and manager reports - Full Queso franchise stores",
3
+ "version": "1.14.1",
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": {
7
7
  "mcp-bc-gastos": "server.js"
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate CxP (Cuentas por Pagar) Excel report with 3 tier sheets + summary.
4
+
5
+ Usage:
6
+ python3 generate-cxp-report-xlsx.py <input.json> <output.xlsx>
7
+
8
+ Input: JSON file with { store, store_name, report_date, invoices[] }
9
+ Output: Formatted Excel with 4 sheets (Resumen, Sin Draft, Draft No Posteado, Pago Parcial)
10
+
11
+ Requires: openpyxl (pip3 install openpyxl)
12
+ """
13
+
14
+ import json
15
+ import sys
16
+ import argparse
17
+ from datetime import datetime, date
18
+ from pathlib import Path
19
+
20
+ try:
21
+ from openpyxl import Workbook
22
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
23
+ except ImportError:
24
+ print("Error: openpyxl is required. Install with: pip3 install openpyxl", file=sys.stderr)
25
+ sys.exit(1)
26
+
27
+
28
+ # ─── Styles ───
29
+
30
+ BOLD = Font(bold=True)
31
+ BOLD_14 = Font(bold=True, size=14)
32
+ BOLD_12 = Font(bold=True, size=12)
33
+ BOLD_11 = Font(bold=True, size=11)
34
+ HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
35
+ HEADER_FONT = Font(bold=True, color="FFFFFF", size=10)
36
+ SUBTOTAL_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid")
37
+ SUBTOTAL_FONT = Font(bold=True, size=10)
38
+ NUM_FMT = '#,##0.00'
39
+
40
+ TIER_FILLS = {
41
+ "fully_pending": PatternFill(start_color="FCE4EC", end_color="FCE4EC", fill_type="solid"),
42
+ "draft_in_progress": PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid"),
43
+ "in_journal": PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid"),
44
+ }
45
+
46
+ TIER_LABELS = {
47
+ "fully_pending": "Sin Draft",
48
+ "draft_in_progress": "Draft No Posteado",
49
+ "in_journal": "Pago Parcial",
50
+ }
51
+
52
+
53
+ def set_col_widths(ws, widths):
54
+ for col_letter, width in widths.items():
55
+ ws.column_dimensions[col_letter].width = width
56
+
57
+
58
+ def write_header_row(ws, row, headers, start_col=1):
59
+ for i, header in enumerate(headers):
60
+ cell = ws.cell(row=row, column=start_col + i, value=header)
61
+ cell.font = HEADER_FONT
62
+ cell.fill = HEADER_FILL
63
+ cell.alignment = Alignment(horizontal="center")
64
+
65
+
66
+ def days_since(date_str, today):
67
+ try:
68
+ d = datetime.strptime(date_str, "%Y-%m-%d").date()
69
+ return max(0, (today - d).days)
70
+ except (ValueError, TypeError):
71
+ return 0
72
+
73
+
74
+ # ─── Sheet 1: Resumen ───
75
+
76
+ def build_resumen(wb, data, invoices, today):
77
+ ws = wb.active
78
+ ws.title = "Resumen"
79
+ set_col_widths(ws, {"A": 35, "B": 14, "C": 18, "D": 18, "E": 18, "F": 18, "G": 18, "H": 18, "I": 18})
80
+
81
+ store_label = f"{data.get('store', '')} — {data.get('store_name', '')}"
82
+ report_date = data.get("report_date", "")
83
+
84
+ ws.cell(row=1, column=1, value=f"REPORTE CxP — {store_label}").font = BOLD_14
85
+ ws.cell(row=2, column=1, value=f"Fecha: {report_date}").font = BOLD_12
86
+
87
+ # Group by tier
88
+ tiers = {"fully_pending": [], "draft_in_progress": [], "in_journal": []}
89
+ for inv in invoices:
90
+ tier = inv.get("tier", "fully_pending")
91
+ if tier in tiers:
92
+ tiers[tier].append(inv)
93
+
94
+ # Summary metrics
95
+ row = 4
96
+ ws.cell(row=row, column=1, value="Concepto").font = BOLD
97
+ ws.cell(row=row, column=2, value="Facturas").font = BOLD
98
+ ws.cell(row=row, column=3, value="Monto (USD)").font = BOLD
99
+
100
+ total_count = len(invoices)
101
+ total_amount = sum(inv.get("invoice_amount", 0) for inv in invoices)
102
+
103
+ row_data = [
104
+ ("TOTAL FACTURAS ABIERTAS", total_count, total_amount),
105
+ ("", "", ""),
106
+ (TIER_LABELS["fully_pending"], len(tiers["fully_pending"]),
107
+ sum(i.get("invoice_amount", 0) for i in tiers["fully_pending"])),
108
+ (TIER_LABELS["draft_in_progress"], len(tiers["draft_in_progress"]),
109
+ sum(i.get("invoice_amount", 0) for i in tiers["draft_in_progress"])),
110
+ (TIER_LABELS["in_journal"], len(tiers["in_journal"]),
111
+ sum(i.get("invoice_amount", 0) for i in tiers["in_journal"])),
112
+ ]
113
+
114
+ for i, (label, count, amount) in enumerate(row_data):
115
+ r = row + 1 + i
116
+ ws.cell(row=r, column=1, value=label)
117
+ if label:
118
+ ws.cell(row=r, column=2, value=count)
119
+ ws.cell(row=r, column=3, value=round(amount, 2)).number_format = NUM_FMT
120
+ if i == 0:
121
+ for c in range(1, 4):
122
+ ws.cell(row=r, column=c).font = BOLD
123
+
124
+ # Vendor breakdown
125
+ row += len(row_data) + 3
126
+ ws.cell(row=row, column=1, value="DESGLOSE POR PROVEEDOR").font = BOLD_12
127
+ row += 1
128
+
129
+ vendor_map = {}
130
+ for inv in invoices:
131
+ vno = inv.get("vendor_no", "")
132
+ if vno not in vendor_map:
133
+ vendor_map[vno] = {
134
+ "name": inv.get("vendor_name", vno),
135
+ "count": 0, "total": 0,
136
+ "sin_draft": 0, "sin_draft_n": 0,
137
+ "draft": 0, "draft_n": 0,
138
+ "parcial": 0, "parcial_n": 0,
139
+ }
140
+ v = vendor_map[vno]
141
+ v["count"] += 1
142
+ v["total"] += inv.get("invoice_amount", 0)
143
+ tier = inv.get("tier", "fully_pending")
144
+ if tier == "fully_pending":
145
+ v["sin_draft"] += inv.get("invoice_amount", 0)
146
+ v["sin_draft_n"] += 1
147
+ elif tier == "draft_in_progress":
148
+ v["draft"] += inv.get("invoice_amount", 0)
149
+ v["draft_n"] += 1
150
+ elif tier == "in_journal":
151
+ v["parcial"] += inv.get("invoice_amount", 0)
152
+ v["parcial_n"] += 1
153
+
154
+ vendors = sorted(vendor_map.items(), key=lambda x: x[1]["total"], reverse=True)
155
+
156
+ headers = ["No.", "Proveedor", "Facturas", "Total (USD)", "Sin Draft", "Sin Draft $", "Draft", "Draft $", "Parcial", "Parcial $"]
157
+ write_header_row(ws, row, headers)
158
+ row += 1
159
+
160
+ for vno, v in vendors:
161
+ ws.cell(row=row, column=1, value=vno)
162
+ ws.cell(row=row, column=2, value=v["name"])
163
+ ws.cell(row=row, column=3, value=v["count"])
164
+ ws.cell(row=row, column=4, value=round(v["total"], 2)).number_format = NUM_FMT
165
+ ws.cell(row=row, column=5, value=v["sin_draft_n"])
166
+ ws.cell(row=row, column=6, value=round(v["sin_draft"], 2)).number_format = NUM_FMT
167
+ ws.cell(row=row, column=7, value=v["draft_n"])
168
+ ws.cell(row=row, column=8, value=round(v["draft"], 2)).number_format = NUM_FMT
169
+ ws.cell(row=row, column=9, value=v["parcial_n"])
170
+ ws.cell(row=row, column=10, value=round(v["parcial"], 2)).number_format = NUM_FMT
171
+ row += 1
172
+
173
+ # Totals row
174
+ if vendors:
175
+ ws.cell(row=row, column=1, value="TOTAL").font = BOLD
176
+ ws.cell(row=row, column=3, value=total_count).font = BOLD
177
+ ws.cell(row=row, column=4, value=round(total_amount, 2)).number_format = NUM_FMT
178
+ ws.cell(row=row, column=4).font = BOLD
179
+ for c in range(1, 11):
180
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
181
+
182
+
183
+ # ─── Sheet 2: Sin Draft ───
184
+
185
+ def build_sin_draft(wb, invoices, today):
186
+ ws = wb.create_sheet("Sin Draft")
187
+ tier_invs = [i for i in invoices if i.get("tier") == "fully_pending"]
188
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
189
+ fill = TIER_FILLS["fully_pending"]
190
+
191
+ set_col_widths(ws, {"A": 18, "B": 14, "C": 32, "D": 14, "E": 16, "F": 14})
192
+
193
+ ws.cell(row=1, column=1, value=f"SIN DRAFT — {len(tier_invs)} facturas").font = BOLD_14
194
+
195
+ headers = ["Factura", "Fecha", "Proveedor", "No. Proveedor", "Monto (USD)", "Dias Pendiente"]
196
+ write_header_row(ws, 3, headers)
197
+ ws.freeze_panes = "A4"
198
+
199
+ row = 4
200
+ for inv in tier_invs:
201
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
202
+ ws.cell(row=row, column=2, value=inv.get("posting_date", ""))
203
+ ws.cell(row=row, column=3, value=inv.get("vendor_name", ""))
204
+ ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
205
+ ws.cell(row=row, column=5, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
206
+ ws.cell(row=row, column=6, value=days_since(inv.get("posting_date", ""), today))
207
+ for c in range(1, 7):
208
+ ws.cell(row=row, column=c).fill = fill
209
+ row += 1
210
+
211
+ # Subtotal
212
+ if tier_invs:
213
+ ws.cell(row=row, column=1, value="TOTAL").font = SUBTOTAL_FONT
214
+ ws.cell(row=row, column=5, value=f"=SUM(E4:E{row - 1})").number_format = NUM_FMT
215
+ ws.cell(row=row, column=5).font = SUBTOTAL_FONT
216
+ for c in range(1, 7):
217
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
218
+
219
+ return len(tier_invs)
220
+
221
+
222
+ # ─── Sheet 3: Draft No Posteado ───
223
+
224
+ def build_draft_no_posteado(wb, invoices, today):
225
+ ws = wb.create_sheet("Draft No Posteado")
226
+ tier_invs = [i for i in invoices if i.get("tier") == "draft_in_progress"]
227
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
228
+ fill = TIER_FILLS["draft_in_progress"]
229
+
230
+ set_col_widths(ws, {"A": 18, "B": 14, "C": 32, "D": 14, "E": 16, "F": 16, "G": 14, "H": 16, "I": 16})
231
+
232
+ ws.cell(row=1, column=1, value=f"DRAFT NO POSTEADO — {len(tier_invs)} facturas").font = BOLD_14
233
+
234
+ headers = ["Factura", "Fecha", "Proveedor", "No. Proveedor", "Monto (USD)", "No. MP", "Status MP", "Drafted (USD)", "Pendiente Neto"]
235
+ write_header_row(ws, 3, headers)
236
+ ws.freeze_panes = "A4"
237
+
238
+ row = 4
239
+ for inv in tier_invs:
240
+ mp = inv.get("multi_payment") or {}
241
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
242
+ ws.cell(row=row, column=2, value=inv.get("posting_date", ""))
243
+ ws.cell(row=row, column=3, value=inv.get("vendor_name", ""))
244
+ ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
245
+ ws.cell(row=row, column=5, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
246
+ ws.cell(row=row, column=6, value=mp.get("mp_no", ""))
247
+ ws.cell(row=row, column=7, value=mp.get("status", ""))
248
+ ws.cell(row=row, column=8, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
249
+ ws.cell(row=row, column=9, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
250
+ for c in range(1, 10):
251
+ ws.cell(row=row, column=c).fill = fill
252
+ row += 1
253
+
254
+ if tier_invs:
255
+ ws.cell(row=row, column=1, value="TOTAL").font = SUBTOTAL_FONT
256
+ ws.cell(row=row, column=5, value=f"=SUM(E4:E{row - 1})").number_format = NUM_FMT
257
+ ws.cell(row=row, column=5).font = SUBTOTAL_FONT
258
+ ws.cell(row=row, column=8, value=f"=SUM(H4:H{row - 1})").number_format = NUM_FMT
259
+ ws.cell(row=row, column=8).font = SUBTOTAL_FONT
260
+ ws.cell(row=row, column=9, value=f"=SUM(I4:I{row - 1})").number_format = NUM_FMT
261
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
262
+ for c in range(1, 10):
263
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
264
+
265
+ return len(tier_invs)
266
+
267
+
268
+ # ─── Sheet 4: Pago Parcial ───
269
+
270
+ def build_pago_parcial(wb, invoices, today):
271
+ ws = wb.create_sheet("Pago Parcial")
272
+ tier_invs = [i for i in invoices if i.get("tier") == "in_journal"]
273
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
274
+ fill = TIER_FILLS["in_journal"]
275
+
276
+ set_col_widths(ws, {"A": 18, "B": 14, "C": 32, "D": 14, "E": 16, "F": 16, "G": 14, "H": 16, "I": 16})
277
+
278
+ ws.cell(row=1, column=1, value=f"PAGO PARCIAL — {len(tier_invs)} facturas").font = BOLD_14
279
+
280
+ headers = ["Factura", "Fecha", "Proveedor", "No. Proveedor", "Monto (USD)", "No. MP", "Status MP", "Drafted (USD)", "Pendiente Neto"]
281
+ write_header_row(ws, 3, headers)
282
+ ws.freeze_panes = "A4"
283
+
284
+ row = 4
285
+ for inv in tier_invs:
286
+ mp = inv.get("multi_payment") or {}
287
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
288
+ ws.cell(row=row, column=2, value=inv.get("posting_date", ""))
289
+ ws.cell(row=row, column=3, value=inv.get("vendor_name", ""))
290
+ ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
291
+ ws.cell(row=row, column=5, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
292
+ ws.cell(row=row, column=6, value=mp.get("mp_no", ""))
293
+ ws.cell(row=row, column=7, value=mp.get("status", ""))
294
+ ws.cell(row=row, column=8, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
295
+ ws.cell(row=row, column=9, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
296
+ for c in range(1, 10):
297
+ ws.cell(row=row, column=c).fill = fill
298
+ row += 1
299
+
300
+ if tier_invs:
301
+ ws.cell(row=row, column=1, value="TOTAL").font = SUBTOTAL_FONT
302
+ ws.cell(row=row, column=5, value=f"=SUM(E4:E{row - 1})").number_format = NUM_FMT
303
+ ws.cell(row=row, column=5).font = SUBTOTAL_FONT
304
+ ws.cell(row=row, column=8, value=f"=SUM(H4:H{row - 1})").number_format = NUM_FMT
305
+ ws.cell(row=row, column=8).font = SUBTOTAL_FONT
306
+ ws.cell(row=row, column=9, value=f"=SUM(I4:I{row - 1})").number_format = NUM_FMT
307
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
308
+ for c in range(1, 10):
309
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
310
+
311
+ return len(tier_invs)
312
+
313
+
314
+ # ─── Main ───
315
+
316
+ def main():
317
+ parser = argparse.ArgumentParser(description="Generate CxP Excel report")
318
+ parser.add_argument("input_json", help="Path to input JSON file")
319
+ parser.add_argument("output_xlsx", help="Path to output Excel file")
320
+ args = parser.parse_args()
321
+
322
+ input_path = Path(args.input_json)
323
+ output_path = Path(args.output_xlsx)
324
+
325
+ if not input_path.exists():
326
+ print(f"Error: Input file not found: {input_path}", file=sys.stderr)
327
+ sys.exit(1)
328
+
329
+ with open(input_path, "r", encoding="utf-8") as f:
330
+ data = json.load(f)
331
+
332
+ invoices = data.get("invoices", [])
333
+ today = date.today()
334
+ if data.get("report_date"):
335
+ try:
336
+ today = datetime.strptime(data["report_date"], "%Y-%m-%d").date()
337
+ except ValueError:
338
+ pass
339
+
340
+ wb = Workbook()
341
+ build_resumen(wb, data, invoices, today)
342
+ n_sin = build_sin_draft(wb, invoices, today)
343
+ n_draft = build_draft_no_posteado(wb, invoices, today)
344
+ n_parcial = build_pago_parcial(wb, invoices, today)
345
+
346
+ wb.save(str(output_path))
347
+ print(json.dumps({
348
+ "file": str(output_path),
349
+ "sheets": {"sin_draft": n_sin, "draft_no_posteado": n_draft, "pago_parcial": n_parcial},
350
+ "total": len(invoices),
351
+ }))
352
+
353
+
354
+ if __name__ == "__main__":
355
+ main()
package/server.js CHANGED
@@ -66,8 +66,16 @@ import {
66
66
  employeesTool, handleEmployees,
67
67
  } from './tools/payroll/index.js';
68
68
 
69
+ // Inventario tools (inventory cost analysis)
70
+ import {
71
+ itemCardTool, handleItemCard,
72
+ itemLedgerEntriesTool, handleItemLedgerEntries,
73
+ itemValueEntriesTool, handleItemValueEntries,
74
+ } from './tools/inventario/index.js';
75
+
69
76
  // Reports tools
70
77
  import { managerReportTool, handleGenerateManagerReport } from './tools/reports/manager-report.js';
78
+ import { cxpReportTool, handleGenerateCxpReport } from './tools/reports/cxp-report.js';
71
79
 
72
80
  const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
73
81
  const bcClient = new BCClient();
@@ -122,8 +130,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
122
130
  payrollDocumentsTool,
123
131
  payrollLinesTool,
124
132
  employeesTool,
133
+ // Inventario (inventory cost analysis)
134
+ itemCardTool,
135
+ itemLedgerEntriesTool,
136
+ itemValueEntriesTool, // get_item_cost_analysis
125
137
  // Reports
126
138
  managerReportTool,
139
+ cxpReportTool,
127
140
  ],
128
141
  };
129
142
  });
@@ -230,10 +243,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
230
243
  case 'get_employees':
231
244
  result = await handleEmployees(bcClient, args);
232
245
  break;
246
+ // Inventario (inventory cost analysis)
247
+ case 'get_item_card':
248
+ result = await handleItemCard(bcClient, args);
249
+ break;
250
+ case 'get_item_ledger_entries':
251
+ result = await handleItemLedgerEntries(bcClient, args);
252
+ break;
253
+ case 'get_item_cost_analysis':
254
+ result = await handleItemValueEntries(bcClient, args);
255
+ break;
233
256
  // Reports
234
257
  case 'generate_manager_report':
235
258
  result = await handleGenerateManagerReport(bcClient, args);
236
259
  break;
260
+ case 'generate_cxp_report':
261
+ result = await handleGenerateCxpReport(bcClient, args);
262
+ break;
237
263
  default:
238
264
  return {
239
265
  content: [{ type: 'text', text: `Herramienta desconocida: ${name}` }],
@@ -0,0 +1,3 @@
1
+ export { itemCardTool, handleItemCard } from './item-card.js';
2
+ export { itemLedgerEntriesTool, handleItemLedgerEntries } from './item-ledger-entries.js';
3
+ export { itemValueEntriesTool, handleItemValueEntries } from './item-value-entries.js';
@@ -0,0 +1,88 @@
1
+ import { resolveStores } from '../../config/company-config.js';
2
+ import { logger } from '../../utils/logger.js';
3
+
4
+ export const itemCardTool = {
5
+ name: 'get_item_card',
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.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ store: {
12
+ type: 'string',
13
+ enum: ['FQ01', 'FQ28', 'FQ88'],
14
+ description: 'Tienda a consultar.',
15
+ },
16
+ item_number: {
17
+ type: 'string',
18
+ description: 'Número de ítem exacto (e.g. FQ0850).',
19
+ },
20
+ item_search: {
21
+ type: 'string',
22
+ description: 'Búsqueda parcial por nombre (case-insensitive, client-side).',
23
+ },
24
+ item_category: {
25
+ type: 'string',
26
+ description: 'Filtrar por itemCategoryCode.',
27
+ },
28
+ },
29
+ required: ['store'],
30
+ },
31
+ };
32
+
33
+ export async function handleItemCard(bcClient, args) {
34
+ const storeParam = args.store;
35
+ if (!storeParam) throw new Error('Parámetro requerido: store');
36
+
37
+ const stores = resolveStores([storeParam]);
38
+ const store = stores[0];
39
+ const companyId = store.companyId;
40
+
41
+ const filters = [];
42
+ if (args.item_number) {
43
+ filters.push(`number eq '${args.item_number}'`);
44
+ }
45
+ if (args.item_category) {
46
+ filters.push(`itemCategoryCode eq '${args.item_category}'`);
47
+ }
48
+
49
+ const params = {
50
+ $select: 'number,displayName,type,itemCategoryCode,unitCost,unitPrice,inventory,blocked',
51
+ $orderby: 'number',
52
+ };
53
+ if (filters.length > 0) params.$filter = filters.join(' and ');
54
+ if (!args.item_number && !args.item_search) params.$top = '50';
55
+
56
+ const url = bcClient.buildApiUrl(companyId, 'items', params);
57
+ let data = await bcClient.apiCallAllPages(url);
58
+
59
+ logger.info(`${store.code}: ${data.length} items returned`);
60
+
61
+ // Client-side name search
62
+ if (args.item_search) {
63
+ const search = args.item_search.toLowerCase();
64
+ data = data.filter((item) => item.displayName && item.displayName.toLowerCase().includes(search));
65
+ }
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
+ }));
77
+
78
+ return {
79
+ store: store.code,
80
+ store_name: store.name,
81
+ total_items: items.length,
82
+ items,
83
+ };
84
+ }
85
+
86
+ function round2(n) {
87
+ return Math.round(n * 100) / 100;
88
+ }
@@ -0,0 +1,130 @@
1
+ import { resolveStores } from '../../config/company-config.js';
2
+ import { logger } from '../../utils/logger.js';
3
+
4
+ export const itemLedgerEntriesTool = {
5
+ name: 'get_item_ledger_entries',
6
+ description:
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
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ store: {
12
+ type: 'string',
13
+ enum: ['FQ01', 'FQ28', 'FQ88'],
14
+ description: 'Tienda a consultar.',
15
+ },
16
+ item_number: {
17
+ type: 'string',
18
+ description: 'Número de ítem exacto (e.g. FQ0850).',
19
+ },
20
+ entry_type: {
21
+ type: 'string',
22
+ description: 'Filtrar por tipo de entrada: Purchase, Sale, Positive_x0020_Adjmt., Negative_x0020_Adjmt., Transfer, Consumption, Assembly_x0020_Consumption, Assembly_x0020_Output.',
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: 200.',
35
+ },
36
+ },
37
+ required: ['store', 'item_number'],
38
+ },
39
+ };
40
+
41
+ export async function handleItemLedgerEntries(bcClient, args) {
42
+ const storeParam = args.store;
43
+ if (!storeParam) throw new Error('Parámetro requerido: store');
44
+ if (!args.item_number) throw new Error('Parámetro requerido: item_number');
45
+
46
+ const stores = resolveStores([storeParam]);
47
+ const store = stores[0];
48
+ const companyId = store.companyId;
49
+
50
+ const filters = [`itemNumber eq '${args.item_number}'`];
51
+ if (args.entry_type) filters.push(`entryType eq '${args.entry_type}'`);
52
+ if (args.start_date) filters.push(`postingDate ge ${args.start_date}`);
53
+ if (args.end_date) filters.push(`postingDate le ${args.end_date}`);
54
+
55
+ const top = args.top || 200;
56
+
57
+ const url = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
58
+ $filter: filters.join(' and '),
59
+ $select: 'entryNumber,itemNumber,postingDate,entryType,sourceNumber,sourceType,documentNumber,documentType,description,quantity,salesAmountActual,costAmountActual',
60
+ $orderby: 'postingDate desc',
61
+ $top: String(top),
62
+ });
63
+
64
+ const data = await bcClient.apiCall(url);
65
+ logger.info(`${store.code}: ${data.length} item ledger entries for ${args.item_number}`);
66
+
67
+ const entries = data.map((e) => ({
68
+ entry_number: e.entryNumber,
69
+ item_number: e.itemNumber,
70
+ posting_date: e.postingDate,
71
+ entry_type: e.entryType,
72
+ document_number: e.documentNumber || null,
73
+ document_type: e.documentType || null,
74
+ description: e.description || null,
75
+ quantity: round5(e.quantity || 0),
76
+ cost_amount_actual: round2(e.costAmountActual || 0),
77
+ sales_amount_actual: round2(e.salesAmountActual || 0),
78
+ unit_cost_actual: e.quantity ? round5(Math.abs((e.costAmountActual || 0) / e.quantity)) : 0,
79
+ }));
80
+
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
+ }
106
+
107
+ return {
108
+ store: store.code,
109
+ store_name: store.name,
110
+ item_number: args.item_number,
111
+ filter: { entry_type: args.entry_type || 'all', start_date: args.start_date || 'all', end_date: args.end_date || 'all' },
112
+ total_entries: entries.length,
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.',
118
+ },
119
+ by_entry_type: byType,
120
+ entries,
121
+ };
122
+ }
123
+
124
+ function round2(n) {
125
+ return Math.round(n * 100) / 100;
126
+ }
127
+
128
+ function round5(n) {
129
+ return Math.round(n * 100000) / 100000;
130
+ }