@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 +52 -0
- package/lib/bc-client.js +33 -1
- package/package.json +4 -2
- package/scripts/generate-bank-recon-xlsx.py +376 -0
- package/scripts/generate-draft-payables-xlsx.py +282 -0
- package/server.js +26 -0
- package/tools/auditoria/bank-reconciliation-report.js +598 -0
- package/tools/auditoria/reconciliation-status.js +45 -45
- package/tools/multi-payment/draft-payables.js +705 -0
- package/tools/multi-payment/draft-receivables.js +704 -0
- package/tools/multi-payment/draft-summary.js +241 -0
- package/tools/multi-payment/index.js +3 -0
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
|
-
|
|
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.
|
|
4
|
-
"description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation,
|
|
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()
|