@fullqueso/mcp-bc-gastos 1.13.0 → 1.14.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.
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 FIFO unit cost, open layers, and value entry adjustments
8
+ - Uses BC Standard v2.0 API: `items`, `itemLedgerEntries`, `valueEntries`
9
+
10
+ ### Added
11
+
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
16
+
17
+ ### Technical
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
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.0",
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,
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_value_entries':
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,89 @@
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,costingMethod,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
+ costing_method: item.costingMethod || null,
76
+ blocked: item.blocked || false,
77
+ }));
78
+
79
+ return {
80
+ store: store.code,
81
+ store_name: store.name,
82
+ total_items: items.length,
83
+ items,
84
+ };
85
+ }
86
+
87
+ function round2(n) {
88
+ return Math.round(n * 100) / 100;
89
+ }
@@ -0,0 +1,125 @@
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) — 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.',
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
+ open_only: {
21
+ type: 'boolean',
22
+ description: 'true = solo entradas abiertas (capas FIFO activas). Default: true.',
23
+ },
24
+ entry_type: {
25
+ type: 'string',
26
+ enum: ['Purchase', 'Sale', 'Positive Adjmt.', 'Negative Adjmt.', 'Transfer', 'Consumption', 'Output'],
27
+ description: 'Filtrar por tipo de entrada.',
28
+ },
29
+ start_date: {
30
+ type: 'string',
31
+ description: 'Fecha inicio (YYYY-MM-DD).',
32
+ },
33
+ end_date: {
34
+ type: 'string',
35
+ description: 'Fecha fin (YYYY-MM-DD).',
36
+ },
37
+ top: {
38
+ type: 'number',
39
+ description: 'Limitar resultados. Default: 100.',
40
+ },
41
+ },
42
+ required: ['store', 'item_number'],
43
+ },
44
+ };
45
+
46
+ export async function handleItemLedgerEntries(bcClient, args) {
47
+ const storeParam = args.store;
48
+ if (!storeParam) throw new Error('Parámetro requerido: store');
49
+ if (!args.item_number) throw new Error('Parámetro requerido: item_number');
50
+
51
+ const stores = resolveStores([storeParam]);
52
+ const store = stores[0];
53
+ const companyId = store.companyId;
54
+
55
+ const openOnly = args.open_only !== false; // default true
56
+ const filters = [`itemNumber eq '${args.item_number}'`];
57
+
58
+ if (openOnly) filters.push('open eq true');
59
+ if (args.entry_type) filters.push(`entryType eq '${args.entry_type}'`);
60
+ if (args.start_date) filters.push(`postingDate ge ${args.start_date}`);
61
+ if (args.end_date) filters.push(`postingDate le ${args.end_date}`);
62
+
63
+ const top = args.top || 100;
64
+
65
+ const url = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
66
+ $filter: filters.join(' and '),
67
+ $select: 'entryNumber,itemNumber,postingDate,entryType,documentNumber,description,quantity,remainingQuantity,costAmountActual,costAmountExpected,open,locationCode',
68
+ $orderby: 'postingDate desc',
69
+ $top: String(top),
70
+ });
71
+
72
+ const data = await bcClient.apiCall(url);
73
+ logger.info(`${store.code}: ${data.length} item ledger entries for ${args.item_number} (open_only=${openOnly})`);
74
+
75
+ const entries = data.map((e) => ({
76
+ entry_number: e.entryNumber,
77
+ item_number: e.itemNumber,
78
+ posting_date: e.postingDate,
79
+ entry_type: e.entryType,
80
+ document_number: e.documentNumber || null,
81
+ description: e.description || null,
82
+ quantity: round5(e.quantity || 0),
83
+ remaining_quantity: round5(e.remainingQuantity || 0),
84
+ cost_amount_actual: round2(e.costAmountActual || 0),
85
+ cost_amount_expected: round2(e.costAmountExpected || 0),
86
+ 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
+ }));
90
+
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;
102
+
103
+ return {
104
+ store: store.code,
105
+ store_name: store.name,
106
+ item_number: args.item_number,
107
+ filter: { open_only: openOnly, entry_type: args.entry_type || 'all' },
108
+ 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),
114
+ },
115
+ entries,
116
+ };
117
+ }
118
+
119
+ function round2(n) {
120
+ return Math.round(n * 100) / 100;
121
+ }
122
+
123
+ function round5(n) {
124
+ return Math.round(n * 100000) / 100000;
125
+ }