@fullqueso/mcp-bc-gastos 1.8.0 → 1.10.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,57 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.10.0] - 2026-02-26
4
+
5
+ ### Highlights
6
+ - **26 tools** across 5 domains
7
+ - **New**: Consolidated bank reconciliation report tool — single call replaces 3-5 sequential tool calls
8
+ - **Performance**: Parallel API fetching in reconciliation-status, bulk MP line fetching
9
+
10
+ ### Added
11
+ - `get_bank_reconciliation_report` — consolidated bank reconciliation: progress + unmatched debits/credits + journal entry suggestions in ONE MCP call. Statement selection by number, month (`month: "2025-12"`), or latest. Includes `matchBankDescription` keyword matching + historical GL pattern matching for debit suggestions.
12
+ - Skill spec: `docs/SKILL_bank_reconciliation.md` — trigger phrases, presentation guide, example queries
13
+
14
+ ### Performance
15
+ - **Bank reconciliation**: `get_reconciliation_status` now fetches statement lines in parallel (`Promise.all`) instead of sequential `for...of` loop
16
+ - **Multi-Payment draft tools**: Replaced per-MP-header parallel line fetch (`Promise.all` on N headers) with single bulk fetch + in-memory grouping. Fixes BC 429 rate limiting for large stores (FQ28: ~1,173 invoices)
17
+ - **BC client**: `fetchWithRetry` now handles HTTP 429 with `Retry-After` header support and exponential backoff
18
+
19
+ ---
20
+
21
+ ## [1.9.0] - 2026-02-25
22
+
23
+ ### Highlights
24
+ - **25 tools** across 5 domains: expenses, bank reconciliation, POS reconciliation, AR/AP, **Multi-Payment draft visibility**
25
+ - **4 API integrations**: Standard v2.0, OData V4, Finance Reports Beta, **Custom Extension API**
26
+ - Three-tier invoice classification: Fully Pending → Drafted → In Journal
27
+
28
+ ### Added
29
+
30
+ **Multi-Payment Draft Visibility — 3 herramientas (Custom Extension API):**
31
+ - `get_draft_receivables` — facturas de venta abiertas con clasificación de tres niveles: sin documento, con borrador Open, con borrador Transferred. Incluye monto neto realmente pendiente y desglose por método de pago
32
+ - `get_draft_payables` — mismo análisis para facturas de compra (proveedores)
33
+ - `get_draft_summary` — resumen ejecutivo del pipeline de cobros y pagos en borrador por tienda
34
+
35
+ **Aging Reports — nuevo parámetro `report` en get_draft_receivables y get_draft_payables:**
36
+ - `report: "aging_pending"` — facturas SIN multipago agrupadas por mes con top vendors/customers, age_days_avg, y aging buckets (0-30, 31-60, 61-90, 90+)
37
+ - `report: "aging_full"` — ambas categorías por mes: sin multipago + con draft sin postear, incluyendo drafted_amount y net_pending
38
+ - `report: "default"` (o sin parámetro) — comportamiento existente sin cambios
39
+
40
+ ### Fixed
41
+ - **Currency conversion bug**: `totalPaymentAmount` on MP headers is a VES FlowField sum — was being reported as USD. Now fetches MP lines + BCV exchange rates to compute accurate USD totals per line (VES lines ÷ BCV rate, USD lines as-is). Affects all 3 draft tools.
42
+
43
+ ### Technical
44
+ - Custom Extension API integration (`buildCustomApiUrl`, `buildMultiPaymentApiUrl`) in BCClient
45
+ - Supports 4 new API pages from Sales Invoice Payment Automation extension v1.4.0.0:
46
+ `multiPaymentHeaders`, `multiPaymentLines`, `purchMultiPaymentHeaders`, `purchMultiPaymentLines`
47
+ - Three-tier classification logic cross-referencing customer/vendor ledger entries with MP headers
48
+ - Multi-store parallel fetching with consolidated summaries
49
+ - Payment method breakdown via MP lines (optional `include_lines` parameter)
50
+ - Aging logic is pure post-processing on existing data — no additional API calls
51
+ - Entity-parameterized aging builders (`VENDOR_ENTITY` / `CUSTOMER_ENTITY`) for correct field names per tool
52
+
53
+ ---
54
+
3
55
  ## [1.8.0] - 2026-02-23
4
56
 
5
57
  ### Highlights
package/lib/bc-client.js CHANGED
@@ -111,7 +111,15 @@ export class BCClient {
111
111
  async fetchWithRetry(url, options, retries = 3) {
112
112
  for (let attempt = 1; attempt <= retries; attempt++) {
113
113
  try {
114
- return await fetch(url, options);
114
+ const response = await fetch(url, options);
115
+ if (response.status === 429 && attempt < retries) {
116
+ const retryAfter = response.headers.get('Retry-After');
117
+ const delay = retryAfter ? parseInt(retryAfter) * 1000 : attempt * 3000;
118
+ logger.warn(`Rate limited (429), retry ${attempt}/${retries} in ${delay}ms`);
119
+ await new Promise((r) => setTimeout(r, delay));
120
+ continue;
121
+ }
122
+ return response;
115
123
  } catch (err) {
116
124
  if (attempt === retries) throw err;
117
125
  const delay = attempt * 2000;
@@ -163,6 +171,30 @@ export class BCClient {
163
171
  return qs ? `${base}?${qs}` : base;
164
172
  }
165
173
 
174
+ // ── Custom Extension API ─────────────────────────────────────────
175
+ // Custom API pages published by extensions (e.g., Sales Invoice Payment Automation).
176
+ // Path: api/{publisher}/{group}/{version}/companies({companyGuid})/{endpoint}
177
+ // Same OAuth token — different URL path.
178
+
179
+ buildCustomApiUrl(companyId, publisher, group, version, endpoint, params = {}) {
180
+ const base = `${this.baseUrl}/${this.tenantId}/${this.environment}/api/${publisher}/${group}/${version}/companies(${companyId})/${endpoint}`;
181
+ const searchParams = new URLSearchParams();
182
+
183
+ if (params.$filter) searchParams.set('$filter', params.$filter);
184
+ if (params.$top) searchParams.set('$top', String(params.$top));
185
+ if (params.$orderby) searchParams.set('$orderby', params.$orderby);
186
+ if (params.$select) searchParams.set('$select', params.$select);
187
+ if (params.$expand) searchParams.set('$expand', params.$expand);
188
+
189
+ const qs = searchParams.toString();
190
+ return qs ? `${base}?${qs}` : base;
191
+ }
192
+
193
+ // Shorthand for Multi-Payment API calls (publisher: fullQueso, group: multiPayment, v1.0)
194
+ buildMultiPaymentApiUrl(companyId, endpoint, params = {}) {
195
+ return this.buildCustomApiUrl(companyId, 'fullQueso', 'multiPayment', 'v1.0', endpoint, params);
196
+ }
197
+
166
198
  // ── OData V4 Web Service Methods ──────────────────────────────────
167
199
  // OData endpoints use company NAME (URL-encoded) instead of GUID.
168
200
  // Same OAuth token works; different URL path (ODataV4 vs api/v2.0).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.8.0",
4
- "description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, and accounts receivable/payable - Full Queso franchise stores",
3
+ "version": "1.10.0",
4
+ "description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, accounts receivable/payable, and multi-payment draft visibility - Full Queso franchise stores",
5
5
  "main": "server.js",
6
6
  "bin": {
7
7
  "mcp-bc-gastos": "server.js"
@@ -13,6 +13,7 @@
13
13
  "config/",
14
14
  "tools/",
15
15
  "utils/",
16
+ "scripts/",
16
17
  "README.md",
17
18
  "CHANGELOG.md",
18
19
  "LICENSE",
@@ -36,6 +37,7 @@
36
37
  "pos-reconciliation",
37
38
  "accounts-receivable",
38
39
  "accounts-payable",
40
+ "multi-payment",
39
41
  "odata",
40
42
  "fullqueso"
41
43
  ],
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate a bank reconciliation Excel report from MCP tool JSON output.
4
+
5
+ Usage:
6
+ python3 generate-bank-recon-xlsx.py <input.json> <output.xlsx>
7
+
8
+ Input: JSON file from get_bank_reconciliation_report MCP tool
9
+ Output: Formatted Excel with 3 sheets (Resumen, Debitos Pendientes, Creditos Pendientes)
10
+
11
+ Requires: openpyxl (pip3 install openpyxl)
12
+ """
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ try:
19
+ from openpyxl import Workbook
20
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, numbers
21
+ except ImportError:
22
+ print("Error: openpyxl is required. Install with: pip3 install openpyxl", file=sys.stderr)
23
+ sys.exit(1)
24
+
25
+
26
+ # ─── Styles ───
27
+
28
+ BOLD = Font(bold=True)
29
+ BOLD_14 = Font(bold=True, size=14)
30
+ BOLD_12 = Font(bold=True, size=12)
31
+ BOLD_11 = Font(bold=True, size=11)
32
+ HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
33
+ HEADER_FONT = Font(bold=True, color="FFFFFF", size=10)
34
+ SUBTOTAL_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid")
35
+ THIN_BORDER = Border(
36
+ bottom=Side(style="thin", color="CCCCCC"),
37
+ )
38
+ NUM_FMT = '#,##0.00'
39
+ PCT_FMT = '0.0%'
40
+
41
+
42
+ def fmt_bs(amount):
43
+ """Format amount as Bs. string for display."""
44
+ if amount is None:
45
+ return ""
46
+ return f"Bs. {abs(amount):,.2f}" if amount >= 0 else f"-Bs. {abs(amount):,.2f}"
47
+
48
+
49
+ def set_col_widths(ws, widths):
50
+ """Set column widths by letter."""
51
+ for col_letter, width in widths.items():
52
+ ws.column_dimensions[col_letter].width = width
53
+
54
+
55
+ def write_header_row(ws, row, headers, start_col=1):
56
+ """Write a styled header row."""
57
+ for i, header in enumerate(headers):
58
+ cell = ws.cell(row=row, column=start_col + i, value=header)
59
+ cell.font = HEADER_FONT
60
+ cell.fill = HEADER_FILL
61
+ cell.alignment = Alignment(horizontal="center")
62
+
63
+
64
+ # ─── Sheet 1: Resumen ───
65
+
66
+ def build_resumen(wb, data):
67
+ ws = wb.active
68
+ ws.title = "Resumen"
69
+ set_col_widths(ws, {"A": 45, "B": 22, "C": 22, "D": 18, "E": 18})
70
+
71
+ bank = data.get("bank_account", "")
72
+ store_name = data.get("store_name", "")
73
+ statement_no = data.get("statement_no", "")
74
+ statement_date = data.get("statement_date", "")
75
+ summary = data.get("unmatched_summary", {})
76
+ debits = data.get("unmatched_debits", {})
77
+ credits = data.get("unmatched_credits", {})
78
+
79
+ # Title block
80
+ ws.cell(row=1, column=1, value=f"CONCILIACION BANCARIA — {bank}").font = BOLD_14
81
+ ws.cell(row=2, column=1, value=store_name).font = BOLD_12
82
+ month_label = ""
83
+ if statement_date:
84
+ parts = statement_date.split("-")
85
+ months_es = ["", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
86
+ "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]
87
+ if len(parts) >= 2:
88
+ m = int(parts[1])
89
+ month_label = f"{months_es[m]} {parts[0]}" if 1 <= m <= 12 else ""
90
+ ws.cell(row=3, column=1, value=f"Statement #{statement_no} — {month_label}").font = BOLD_11
91
+
92
+ # Key metrics
93
+ row = 5
94
+ ws.cell(row=row, column=1, value="Concepto").font = BOLD
95
+ ws.cell(row=row, column=2, value="Valor").font = BOLD
96
+
97
+ metrics = [
98
+ ("Total lineas en statement", summary.get("total_statement_lines", 0)),
99
+ ("Lineas conciliadas", summary.get("matched_lines", 0)),
100
+ ("Lineas pendientes", summary.get("total_unmatched", 0)),
101
+ ("% Conciliacion", f"{summary.get('match_rate_pct', 0)}%"),
102
+ ]
103
+ for i, (label, value) in enumerate(metrics):
104
+ ws.cell(row=row + 1 + i, column=1, value=label)
105
+ cell = ws.cell(row=row + 1 + i, column=2, value=value)
106
+ if isinstance(value, (int, float)):
107
+ cell.number_format = '#,##0' if isinstance(value, int) else NUM_FMT
108
+
109
+ row += len(metrics) + 2
110
+ # Amounts
111
+ recon_progress = data.get("reconciliation_progress", {}).get("reconciliations", [])
112
+ target_recon = next((r for r in recon_progress if str(r.get("statement_no")) == str(statement_no)), {})
113
+
114
+ ws.cell(row=row, column=1, value="Monto conciliado (Bs.)")
115
+ ws.cell(row=row, column=2, value=target_recon.get("matched_amount", 0)).number_format = NUM_FMT
116
+ row += 1
117
+ ws.cell(row=row, column=1, value="Monto pendiente (Bs.)")
118
+ ws.cell(row=row, column=2, value=target_recon.get("unmatched_amount", 0)).number_format = NUM_FMT
119
+ row += 2
120
+
121
+ ws.cell(row=row, column=1, value="Balance inicio statement")
122
+ ws.cell(row=row, column=2, value=data.get("balance_last_statement", 0)).number_format = NUM_FMT
123
+ row += 1
124
+ ws.cell(row=row, column=1, value="Balance fin statement")
125
+ ws.cell(row=row, column=2, value=data.get("statement_ending_balance", 0)).number_format = NUM_FMT
126
+ row += 2
127
+
128
+ # Debit breakdown
129
+ ws.cell(row=row, column=1, value="DESGLOSE DE PENDIENTES").font = BOLD_12
130
+ row += 1
131
+ ws.cell(row=row, column=1, value="Categoria").font = BOLD
132
+ ws.cell(row=row, column=2, value="Lineas").font = BOLD
133
+ ws.cell(row=row, column=3, value="Monto (Bs.)").font = BOLD
134
+ row += 1
135
+
136
+ ws.cell(row=row, column=1, value="DEBITOS PENDIENTES").font = BOLD
137
+ ws.cell(row=row, column=2, value=debits.get("count", 0)).font = BOLD
138
+ cell = ws.cell(row=row, column=3, value=debits.get("total_amount", 0))
139
+ cell.font = BOLD
140
+ cell.number_format = NUM_FMT
141
+ row += 1
142
+
143
+ for item in debits.get("breakdown", []):
144
+ ws.cell(row=row, column=1, value=f" {item['category']}")
145
+ ws.cell(row=row, column=2, value=item["count"])
146
+ ws.cell(row=row, column=3, value=item["total_amount"]).number_format = NUM_FMT
147
+ row += 1
148
+
149
+ row += 1
150
+ ws.cell(row=row, column=1, value="CREDITOS PENDIENTES").font = BOLD
151
+ ws.cell(row=row, column=2, value=credits.get("count", 0)).font = BOLD
152
+ cell = ws.cell(row=row, column=3, value=credits.get("total_amount", 0))
153
+ cell.font = BOLD
154
+ cell.number_format = NUM_FMT
155
+ row += 1
156
+
157
+ for item in credits.get("breakdown", []):
158
+ ws.cell(row=row, column=1, value=f" {item['category']}")
159
+ ws.cell(row=row, column=2, value=item["count"])
160
+ ws.cell(row=row, column=3, value=item["total_amount"]).number_format = NUM_FMT
161
+ row += 1
162
+
163
+ # Insights
164
+ insights = data.get("insights", [])
165
+ if insights:
166
+ row += 2
167
+ ws.cell(row=row, column=1, value="OBSERVACIONES Y ACCIONES RECOMENDADAS").font = BOLD_12
168
+ row += 1
169
+ for i, insight in enumerate(insights):
170
+ ws.cell(row=row, column=1, value=f"{i + 1}. {insight}")
171
+ row += 1
172
+
173
+ return ws
174
+
175
+
176
+ # ─── Sheet 2: Debitos Pendientes ───
177
+
178
+ def build_debitos(wb, data):
179
+ ws = wb.create_sheet("Debitos Pendientes")
180
+ set_col_widths(ws, {"A": 14, "B": 45, "C": 18, "D": 22, "E": 25})
181
+
182
+ debits = data.get("unmatched_debits", {})
183
+ bank = data.get("bank_account", "")
184
+ lines = debits.get("lines", [])
185
+ total = debits.get("total_amount", 0)
186
+ count = debits.get("count", 0)
187
+
188
+ # Title
189
+ ws.cell(row=1, column=1,
190
+ value=f"DEBITOS PENDIENTES — {bank} — {_month_label(data)}").font = BOLD_14
191
+ ws.cell(row=2, column=1,
192
+ value=f"{count} lineas — Total: Bs. {total:,.2f}")
193
+
194
+ # Header row
195
+ write_header_row(ws, 4, ["Fecha", "Descripcion", "Monto (Bs.)", "Documento", "Categoria"])
196
+
197
+ # Sort by date for display
198
+ sorted_lines = sorted(lines, key=lambda l: l.get("transaction_date") or "9999")
199
+
200
+ row = 5
201
+ for line in sorted_lines:
202
+ ws.cell(row=row, column=1, value=line.get("transaction_date", ""))
203
+ ws.cell(row=row, column=2, value=line.get("description", ""))
204
+ ws.cell(row=row, column=3, value=line.get("statement_amount", 0)).number_format = NUM_FMT
205
+ ws.cell(row=row, column=4, value=line.get("document_no", ""))
206
+ ws.cell(row=row, column=5, value=line.get("line_category", ""))
207
+ row += 1
208
+
209
+ # Total row
210
+ ws.cell(row=row, column=1, value="TOTAL").font = BOLD
211
+ total_cell = ws.cell(row=row, column=3, value=f"=SUM(C5:C{row - 1})")
212
+ total_cell.font = BOLD
213
+ total_cell.number_format = NUM_FMT
214
+
215
+ # Journal suggestions section
216
+ suggestions = data.get("journal_suggestions", {})
217
+ if suggestions and suggestions.get("suggestions"):
218
+ row += 3
219
+ ws.cell(row=row, column=1,
220
+ value="SUGERENCIAS DE ASIENTOS CONTABLES").font = BOLD_12
221
+ row += 1
222
+ write_header_row(ws, row, ["Fecha", "Descripcion", "Monto (Bs.)", "Cuenta Debito", "Confianza"])
223
+ row += 1
224
+
225
+ for s in suggestions["suggestions"]:
226
+ ws.cell(row=row, column=1, value=s.get("transaction_date", ""))
227
+ ws.cell(row=row, column=2, value=s.get("bank_description", ""))
228
+ ws.cell(row=row, column=3, value=s.get("amount", 0)).number_format = NUM_FMT
229
+ entry = s.get("suggested_entry", {})
230
+ acct = entry.get("debit_account", "") if entry else ""
231
+ acct_name = entry.get("debit_account_name", "") if entry else ""
232
+ ws.cell(row=row, column=4, value=f"{acct} {acct_name}".strip() if acct else "")
233
+ ws.cell(row=row, column=5, value=s.get("confidence", ""))
234
+ row += 1
235
+
236
+ return ws
237
+
238
+
239
+ # ─── Sheet 3: Creditos Pendientes ───
240
+
241
+ def build_creditos(wb, data):
242
+ ws = wb.create_sheet("Creditos Pendientes")
243
+ set_col_widths(ws, {"A": 14, "B": 45, "C": 18, "D": 22, "E": 25})
244
+
245
+ credits = data.get("unmatched_credits", {})
246
+ bank = data.get("bank_account", "")
247
+ lines = credits.get("lines", [])
248
+ total = credits.get("total_amount", 0)
249
+ count = credits.get("count", 0)
250
+
251
+ # Title
252
+ ws.cell(row=1, column=1,
253
+ value=f"CREDITOS PENDIENTES — {bank} — {_month_label(data)}").font = BOLD_14
254
+ ws.cell(row=2, column=1,
255
+ value=f"{count} lineas — Total: Bs. {total:,.2f}")
256
+
257
+ # Group lines by category
258
+ groups = {}
259
+ for line in lines:
260
+ cat = line.get("line_category", "Otro credito")
261
+ if cat not in groups:
262
+ groups[cat] = []
263
+ groups[cat].append(line)
264
+
265
+ # Determine if category is POS (show top 25 only)
266
+ POS_PREFIXES = ("POS ", "Deposito UBII")
267
+
268
+ # Sort categories: non-POS first, then POS
269
+ non_pos_cats = [c for c in groups if not c.startswith(POS_PREFIXES)]
270
+ pos_cats = [c for c in groups if c.startswith(POS_PREFIXES)]
271
+
272
+ row = 4
273
+ first_data_row = None
274
+
275
+ for cat in non_pos_cats + pos_cats:
276
+ cat_lines = groups[cat]
277
+ is_pos = cat.startswith(POS_PREFIXES)
278
+
279
+ # Category header
280
+ ws.cell(row=row, column=1, value=cat.upper()).font = BOLD_12
281
+ row += 1
282
+ write_header_row(ws, row, ["Fecha", "Descripcion", "Monto (Bs.)", "Documento", "Tipo"])
283
+ row += 1
284
+
285
+ # Sort by date
286
+ sorted_cat = sorted(cat_lines, key=lambda l: l.get("transaction_date") or "9999")
287
+
288
+ # For POS: show top 25 by amount
289
+ if is_pos and len(sorted_cat) > 25:
290
+ display_lines = sorted(sorted_cat, key=lambda l: abs(l.get("statement_amount", 0)), reverse=True)[:25]
291
+ display_lines = sorted(display_lines, key=lambda l: l.get("transaction_date") or "9999")
292
+ remaining = len(sorted_cat) - 25
293
+ else:
294
+ display_lines = sorted_cat
295
+ remaining = 0
296
+
297
+ start_row = row
298
+ if first_data_row is None:
299
+ first_data_row = row
300
+
301
+ for line in display_lines:
302
+ ws.cell(row=row, column=1, value=line.get("transaction_date", ""))
303
+ ws.cell(row=row, column=2, value=line.get("description", ""))
304
+ ws.cell(row=row, column=3, value=line.get("statement_amount", 0)).number_format = NUM_FMT
305
+ ws.cell(row=row, column=4, value=line.get("document_no", ""))
306
+ ws.cell(row=row, column=5, value=line.get("line_category", ""))
307
+ row += 1
308
+
309
+ if remaining > 0:
310
+ ws.cell(row=row, column=1,
311
+ value=f"... +{remaining} lineas adicionales de {cat} no mostradas").font = Font(italic=True)
312
+ row += 1
313
+
314
+ # Subtotal
315
+ cat_total = sum(l.get("statement_amount", 0) for l in cat_lines)
316
+ ws.cell(row=row, column=1, value="Subtotal").font = BOLD
317
+ ws.cell(row=row, column=2, value=f"{len(cat_lines)} lineas").font = BOLD
318
+ cell = ws.cell(row=row, column=3, value=round(cat_total, 2))
319
+ cell.font = BOLD
320
+ cell.number_format = NUM_FMT
321
+ cell.fill = SUBTOTAL_FILL
322
+ row += 2
323
+
324
+ # Grand total
325
+ ws.cell(row=row, column=1, value=f"Total creditos pendientes ({count} lineas)").font = BOLD_12
326
+ cell = ws.cell(row=row, column=3, value=total)
327
+ cell.font = BOLD_12
328
+ cell.number_format = NUM_FMT
329
+
330
+ return ws
331
+
332
+
333
+ # ─── Helpers ───
334
+
335
+ def _month_label(data):
336
+ """Extract month label from statement_date."""
337
+ sd = data.get("statement_date", "")
338
+ if not sd:
339
+ return ""
340
+ months_es = ["", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
341
+ "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]
342
+ parts = sd.split("-")
343
+ if len(parts) >= 2:
344
+ m = int(parts[1])
345
+ return f"{months_es[m]} {parts[0]}" if 1 <= m <= 12 else ""
346
+ return ""
347
+
348
+
349
+ # ─── Main ───
350
+
351
+ def main():
352
+ if len(sys.argv) < 3:
353
+ print(f"Usage: {sys.argv[0]} <input.json> <output.xlsx>", file=sys.stderr)
354
+ sys.exit(1)
355
+
356
+ input_path = Path(sys.argv[1])
357
+ output_path = Path(sys.argv[2])
358
+
359
+ if not input_path.exists():
360
+ print(f"Error: Input file not found: {input_path}", file=sys.stderr)
361
+ sys.exit(1)
362
+
363
+ with open(input_path, "r", encoding="utf-8") as f:
364
+ data = json.load(f)
365
+
366
+ wb = Workbook()
367
+ build_resumen(wb, data)
368
+ build_debitos(wb, data)
369
+ build_creditos(wb, data)
370
+ wb.save(str(output_path))
371
+
372
+ print(f"Excel report saved to: {output_path}")
373
+
374
+
375
+ if __name__ == "__main__":
376
+ main()