@fullqueso/mcp-bc-gastos 1.19.0 → 1.23.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/config/bank-gl-map.json +5 -5
  3. package/config/bank-keywords.js +35 -0
  4. package/lib/bc-client.js +71 -11
  5. package/package.json +3 -2
  6. package/scripts/diagnose-closing.js +144 -0
  7. package/scripts/generate-month-closing-xlsx.py +325 -0
  8. package/scripts/test-closing-flow.js +71 -0
  9. package/scripts/test-closing-fq01-dec25.js +78 -0
  10. package/server.js +57 -0
  11. package/tools/auditoria/bank-ledger-entries.js +3 -3
  12. package/tools/auditoria/find-potential-matches.js +1 -1
  13. package/tools/auditoria/gl-account-entries.js +1 -1
  14. package/tools/auditoria/list-bank-accounts.js +23 -12
  15. package/tools/auditoria/pm-receipts.js +4 -4
  16. package/tools/auditoria/reconcile-pos-sales.js +11 -9
  17. package/tools/auditoria/unmatched-ledger-entries.js +3 -3
  18. package/tools/auditoria/unmatched-statement-lines.js +2 -2
  19. package/tools/cierre-mensual/fetch-ledger.js +69 -0
  20. package/tools/cierre-mensual/generate-closing-journal.js +111 -0
  21. package/tools/cierre-mensual/get-match-results.js +151 -0
  22. package/tools/cierre-mensual/get-questionnaire.js +283 -0
  23. package/tools/cierre-mensual/index.js +6 -0
  24. package/tools/cierre-mensual/journal-builder.js +219 -0
  25. package/tools/cierre-mensual/matchers/index.js +161 -0
  26. package/tools/cierre-mensual/matchers/match-cross-bank.js +168 -0
  27. package/tools/cierre-mensual/matchers/match-draft-payments.js +178 -0
  28. package/tools/cierre-mensual/matchers/match-feedback-loop.js +24 -0
  29. package/tools/cierre-mensual/matchers/match-keywords.js +65 -0
  30. package/tools/cierre-mensual/matchers/match-open-arap.js +66 -0
  31. package/tools/cierre-mensual/matchers/match-pos-terminal.js +53 -0
  32. package/tools/cierre-mensual/reconcile-with-bc.js +116 -0
  33. package/tools/cierre-mensual/start-month-closing.js +234 -0
  34. package/tools/cierre-mensual/state-store.js +211 -0
  35. package/tools/cierre-mensual/submit-answers.js +106 -0
  36. package/tools/cobranzas/customer-ledger.js +4 -4
  37. package/tools/cobranzas/vendor-ledger.js +4 -4
  38. package/tools/financials/aggregator.js +360 -0
  39. package/tools/financials/cash-flow-html.js +459 -0
  40. package/tools/financials/cash-flow.js +471 -0
  41. package/tools/financials/html-template.js +674 -0
  42. package/tools/financials/index.js +79 -0
  43. package/tools/financials/statements.js +296 -0
  44. package/tools/inventario/item-ledger-entries.js +4 -4
  45. package/tools/inventario/item-value-entries.js +2 -2
  46. package/tools/ventas/index.js +1 -0
  47. package/tools/ventas/item-sales-detail.js +366 -0
  48. package/utils/rate-limiter.js +84 -0
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """Generate month-end bank closing workbook from generate_closing_journal JSON.
3
+
4
+ Usage:
5
+ python3 generate-month-closing-xlsx.py <input.json> <output.xlsx>
6
+
7
+ Input JSON shape: { state: {...}, journal: {...} } (written by generate-closing-journal.js)
8
+ Sheets:
9
+ - Resumen
10
+ - Asiento_<BANK> per bank in journal.banks
11
+ - Cuestionario (unknown line classifications)
12
+ - Confirmaciones (POS, intercompany, vendors confirmed/declined)
13
+ """
14
+
15
+ import json
16
+ import sys
17
+
18
+ try:
19
+ from openpyxl import Workbook
20
+ from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
21
+ from openpyxl.utils import get_column_letter
22
+ except ImportError:
23
+ print("Error: openpyxl required. pip3 install openpyxl", file=sys.stderr)
24
+ sys.exit(1)
25
+
26
+ BOLD = Font(bold=True)
27
+ BOLD_W = Font(bold=True, color="FFFFFF")
28
+ HEADER_FILL = PatternFill(start_color="305496", end_color="305496", fill_type="solid")
29
+ RESIDUAL_FILL = PatternFill(start_color="FFE699", end_color="FFE699", fill_type="solid")
30
+ WARN_FILL = PatternFill(start_color="F4B084", end_color="F4B084", fill_type="solid")
31
+ TOTAL_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid")
32
+ THIN = Border(bottom=Side(style="thin", color="BFBFBF"))
33
+ NUM = '#,##0.00'
34
+
35
+
36
+ def autosize(ws, widths):
37
+ for i, w in enumerate(widths, start=1):
38
+ ws.column_dimensions[get_column_letter(i)].width = w
39
+
40
+
41
+ def write_header(ws, row, headers):
42
+ for i, h in enumerate(headers, start=1):
43
+ c = ws.cell(row=row, column=i, value=h)
44
+ c.font = BOLD_W
45
+ c.fill = HEADER_FILL
46
+ c.alignment = Alignment(horizontal="center")
47
+
48
+
49
+ def sheet_resumen(wb, state, journal):
50
+ ws = wb.create_sheet("Resumen")
51
+ ws["A1"] = f"Cierre Mensual — {state.get('store')} {state.get('month')}"
52
+ ws["A1"].font = Font(bold=True, size=14)
53
+ ws["A2"] = f"Statement date: {state.get('statement_date')}"
54
+ ws["A3"] = f"Status: {state.get('status')}"
55
+ ws["A4"] = f"Generated: {state.get('updated_at')}"
56
+
57
+ row = 6
58
+ ws.cell(row=row, column=1, value="Banco").font = BOLD
59
+ ws.cell(row=row, column=2, value="GL").font = BOLD
60
+ ws.cell(row=row, column=3, value="Apertura").font = BOLD
61
+ ws.cell(row=row, column=4, value="Cierre Statement").font = BOLD
62
+ ws.cell(row=row, column=5, value="Cierre Calculado").font = BOLD
63
+ ws.cell(row=row, column=6, value="Diferencia").font = BOLD
64
+ ws.cell(row=row, column=7, value="Filas").font = BOLD
65
+ ws.cell(row=row, column=8, value="Balanceado").font = BOLD
66
+ row += 1
67
+ for b in journal.get("banks", []):
68
+ ws.cell(row=row, column=1, value=b["bank_account"])
69
+ ws.cell(row=row, column=2, value=b["gl_account"])
70
+ ws.cell(row=row, column=3, value=b["opening_balance"]).number_format = NUM
71
+ ws.cell(row=row, column=4, value=b["statement_ending"]).number_format = NUM
72
+ ws.cell(row=row, column=5, value=b["computed_ending"]).number_format = NUM
73
+ gap = ws.cell(row=row, column=6, value=b["gap_to_clearing"])
74
+ gap.number_format = NUM
75
+ if abs(b["gap_to_clearing"] or 0) > 0.5:
76
+ gap.fill = RESIDUAL_FILL
77
+ ws.cell(row=row, column=7, value=b["summary"]["rows"])
78
+ ws.cell(row=row, column=8, value="Sí" if b["summary"]["balanced"] else "NO")
79
+ row += 1
80
+
81
+ row += 1
82
+ ws.cell(row=row, column=1, value="Total Débito").font = BOLD
83
+ ws.cell(row=row, column=2, value=journal["totals"]["debit"]).number_format = NUM
84
+ row += 1
85
+ ws.cell(row=row, column=1, value="Total Crédito").font = BOLD
86
+ ws.cell(row=row, column=2, value=journal["totals"]["credit"]).number_format = NUM
87
+ row += 1
88
+ ws.cell(row=row, column=1, value="Balanceado global").font = BOLD
89
+ ws.cell(row=row, column=2, value="Sí" if journal.get("balanced") else "NO")
90
+
91
+ if journal.get("warnings"):
92
+ row += 2
93
+ ws.cell(row=row, column=1, value="Advertencias").font = BOLD
94
+ row += 1
95
+ for w in journal["warnings"]:
96
+ c = ws.cell(row=row, column=1, value=w)
97
+ c.fill = WARN_FILL
98
+ row += 1
99
+
100
+ autosize(ws, [22, 8, 16, 18, 18, 14, 8, 12])
101
+
102
+
103
+ def sheet_journal_per_bank(wb, bank):
104
+ safe = bank["bank_account"].replace("-", "_")[:25]
105
+ ws = wb.create_sheet(f"Asiento_{safe}")
106
+ ws["A1"] = f"Asiento Propuesto — {bank['bank_account']} (GL {bank['gl_account']})"
107
+ ws["A1"].font = Font(bold=True, size=12)
108
+ ws["A2"] = (
109
+ f"Apertura: {bank['opening_balance']:,.2f} "
110
+ f"Cierre statement: {bank['statement_ending']:,.2f} "
111
+ f"Diferencia: {bank['gap_to_clearing']:,.2f}"
112
+ )
113
+ headers = [
114
+ "Fecha", "Doc No", "Tipo Cuenta", "Cuenta", "Contraparte",
115
+ "Descripción", "Débito VES", "Crédito VES", "Banco",
116
+ ]
117
+ write_header(ws, 4, headers)
118
+ row = 5
119
+ for r in bank.get("rows", []):
120
+ cells = [
121
+ r.get("posting_date"),
122
+ r.get("document_no"),
123
+ r.get("account_type"),
124
+ r.get("account_no"),
125
+ r.get("counterparty", ""),
126
+ r.get("description", ""),
127
+ r.get("debit_ves", 0),
128
+ r.get("credit_ves", 0),
129
+ r.get("bank_account", ""),
130
+ ]
131
+ for i, v in enumerate(cells, start=1):
132
+ c = ws.cell(row=row, column=i, value=v)
133
+ c.border = THIN
134
+ if i in (7, 8):
135
+ c.number_format = NUM
136
+ if r.get("is_residual"):
137
+ c.fill = RESIDUAL_FILL
138
+ row += 1
139
+ # totals
140
+ ws.cell(row=row + 1, column=6, value="Total").font = BOLD
141
+ td = ws.cell(row=row + 1, column=7, value=bank["summary"]["total_debit"])
142
+ tc = ws.cell(row=row + 1, column=8, value=bank["summary"]["total_credit"])
143
+ td.number_format = NUM
144
+ tc.number_format = NUM
145
+ td.fill = TOTAL_FILL
146
+ tc.fill = TOTAL_FILL
147
+ autosize(ws, [12, 18, 14, 12, 18, 50, 14, 14, 16])
148
+
149
+
150
+ def sheet_cuestionario(wb, state):
151
+ ws = wb.create_sheet("Cuestionario")
152
+ q = state.get("questionnaire", {})
153
+ headers = [
154
+ "Banco", "Fecha", "Descripción", "Monto VES", "Categoría",
155
+ "Sugerencia GL", "Confianza", "Clasificación usuario", "Memo", "Guardar feedback",
156
+ ]
157
+ write_header(ws, 1, headers)
158
+ row = 2
159
+ for ln in q.get("unknown_lines", []):
160
+ sug = ln.get("suggestion") or {}
161
+ cls = ln.get("user_classification") or {}
162
+ cells = [
163
+ ln.get("bank_account"),
164
+ ln.get("date"),
165
+ ln.get("description"),
166
+ ln.get("amount"),
167
+ ln.get("category"),
168
+ sug.get("gl_account") or "",
169
+ sug.get("confidence") or sug.get("source") or "",
170
+ cls.get("gl_account") or "",
171
+ cls.get("memo") or "",
172
+ "Sí" if cls.get("save_for_future") else "",
173
+ ]
174
+ for i, v in enumerate(cells, start=1):
175
+ c = ws.cell(row=row, column=i, value=v)
176
+ c.border = THIN
177
+ if i == 4:
178
+ c.number_format = NUM
179
+ row += 1
180
+ autosize(ws, [16, 12, 50, 14, 22, 12, 12, 12, 30, 14])
181
+
182
+
183
+ def sheet_confirmaciones(wb, state):
184
+ ws = wb.create_sheet("Confirmaciones")
185
+ q = state.get("questionnaire", {})
186
+ headers = [
187
+ "Grupo", "Contraparte", "Nombre", "Documento", "Fecha",
188
+ "Monto VES", "Monto USD", "Días", "Premisa", "Confirmado", "Banco asignado",
189
+ ]
190
+ write_header(ws, 1, headers)
191
+ row = 2
192
+ routing = q.get("routing", {}) or {}
193
+
194
+ def add(group, e):
195
+ nonlocal row
196
+ cells = [
197
+ group,
198
+ e.get("counterparty_no"),
199
+ e.get("counterparty_name") or "",
200
+ e.get("document_number") or "",
201
+ e.get("posting_date"),
202
+ e.get("amount_ves"),
203
+ e.get("amount_usd"),
204
+ e.get("days_old"),
205
+ e.get("premise"),
206
+ (
207
+ "Sí" if e.get("confirmed") is True
208
+ else "No" if e.get("confirmed") is False
209
+ else "(pendiente)"
210
+ ),
211
+ routing.get(e.get("counterparty_no"), ""),
212
+ ]
213
+ for i, v in enumerate(cells, start=1):
214
+ c = ws.cell(row=row, column=i, value=v)
215
+ c.border = THIN
216
+ if i in (6, 7) and isinstance(v, (int, float)):
217
+ c.number_format = NUM
218
+ row += 1
219
+
220
+ for e in q.get("pos_entries", []):
221
+ add("POS", e)
222
+ for e in q.get("intercompany_entries", []):
223
+ add("Intercompañía", e)
224
+ for e in q.get("vendor_entries", []):
225
+ add("Proveedor", e)
226
+
227
+ autosize(ws, [14, 18, 36, 18, 12, 16, 14, 8, 22, 14, 18])
228
+
229
+
230
+ def sheet_detalle_lineas(wb, state):
231
+ """One row per bank statement line with full match metadata for manual review.
232
+
233
+ Use this sheet to verify that auto-matches are correct before approving.
234
+ Filter by Banco column to focus on one account at a time.
235
+ """
236
+ ws = wb.create_sheet("Detalle Líneas Banco")
237
+ q = state.get("questionnaire", {}) or {}
238
+ headers = [
239
+ "Banco", "Fecha", "Doc No", "Tipo",
240
+ "Monto VES", "Categoría Original",
241
+ "Match Source", "Confianza",
242
+ "GL Sugerida", "Categoría Match", "Acción Sugerida",
243
+ "GL Usuario", "Memo Usuario",
244
+ "Grupo Match", "Razón", "Descripción Completa",
245
+ ]
246
+ write_header(ws, 1, headers)
247
+ row = 2
248
+ # Sort by bank then date for review-friendly order
249
+ lines = sorted(
250
+ q.get("unknown_lines", []),
251
+ key=lambda l: ((l.get("bank_account") or ""), (l.get("date") or "")),
252
+ )
253
+ for ln in lines:
254
+ sug = ln.get("match_suggestion") or {}
255
+ target = sug.get("target") or {}
256
+ cls = ln.get("user_classification") or {}
257
+ # Grupo Match: aggregate_pattern (UBII) or counterparty / target line id
258
+ grupo = (
259
+ target.get("aggregate_pattern")
260
+ or target.get("counterparty_no")
261
+ or target.get("invoice_no")
262
+ or target.get("mp_no")
263
+ or target.get("line_id")
264
+ or ""
265
+ )
266
+ cells = [
267
+ ln.get("bank_account"),
268
+ ln.get("date"),
269
+ ln.get("document_no") or "",
270
+ ln.get("type") or "",
271
+ ln.get("amount"),
272
+ ln.get("category") or "",
273
+ sug.get("source") or "",
274
+ sug.get("confidence") or "",
275
+ target.get("gl_account") or "",
276
+ target.get("category") or target.get("label") or "",
277
+ sug.get("recommended_action") or "",
278
+ cls.get("gl_account") or "",
279
+ cls.get("memo") or "",
280
+ grupo,
281
+ (sug.get("reason") or "")[:200],
282
+ ln.get("description") or "",
283
+ ]
284
+ for i, v in enumerate(cells, start=1):
285
+ c = ws.cell(row=row, column=i, value=v)
286
+ c.border = THIN
287
+ if i == 5 and isinstance(v, (int, float)):
288
+ c.number_format = NUM
289
+ # Highlight: residual to clearing or apply_in_bc
290
+ if (sug.get("recommended_action") in ("apply_in_bc", "apply_mp_draft")):
291
+ c.fill = TOTAL_FILL
292
+ elif not sug:
293
+ c.fill = RESIDUAL_FILL # unclassified
294
+ row += 1
295
+ # Freeze the header
296
+ ws.freeze_panes = "A2"
297
+ # Auto-filter so the user can sort/filter in Excel
298
+ if row > 2:
299
+ ws.auto_filter.ref = f"A1:{chr(ord('A') + len(headers) - 1)}{row - 1}"
300
+ autosize(ws, [16, 12, 18, 8, 14, 22, 14, 11, 12, 30, 18, 12, 28, 22, 50, 60])
301
+
302
+
303
+ def main(in_path, out_path):
304
+ with open(in_path, "r", encoding="utf-8") as f:
305
+ data = json.load(f)
306
+ state = data.get("state", {})
307
+ journal = data.get("journal", {})
308
+
309
+ wb = Workbook()
310
+ wb.remove(wb.active)
311
+ sheet_resumen(wb, state, journal)
312
+ for b in journal.get("banks", []):
313
+ sheet_journal_per_bank(wb, b)
314
+ sheet_detalle_lineas(wb, state)
315
+ sheet_cuestionario(wb, state)
316
+ sheet_confirmaciones(wb, state)
317
+ wb.save(out_path)
318
+ print(f"Wrote {out_path}")
319
+
320
+
321
+ if __name__ == "__main__":
322
+ if len(sys.argv) != 3:
323
+ print("Usage: generate-month-closing-xlsx.py <input.json> <output.xlsx>", file=sys.stderr)
324
+ sys.exit(2)
325
+ main(sys.argv[1], sys.argv[2])
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // End-to-end integration test for the auto-matching closing workflow.
3
+ // start_month_closing → get_closing_match_results → submit_closing_answers
4
+ // (bulk approvals + mark_apply_in_bc) → reconcile_closing_with_bc →
5
+ // generate_closing_journal
6
+ //
7
+ // Usage: node scripts/test-closing-flow.js [--store FQ01] [--month 2025-12]
8
+
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+ import { existsSync } from 'fs';
12
+ import dotenv from 'dotenv';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const envPath = join(__dirname, '..', '.env');
16
+ if (existsSync(envPath)) dotenv.config({ path: envPath });
17
+
18
+ const { BCClient } = await import('../lib/bc-client.js');
19
+ const mod = await import('../tools/cierre-mensual/index.js');
20
+
21
+ const STORE = (process.argv.indexOf('--store') >= 0 && process.argv[process.argv.indexOf('--store') + 1]) || 'FQ01';
22
+ const MONTH = (process.argv.indexOf('--month') >= 0 && process.argv[process.argv.indexOf('--month') + 1]) || '2025-12';
23
+
24
+ const bc = new BCClient();
25
+
26
+ console.error(`\n=== ${STORE} ${MONTH} — full closing flow test ===\n`);
27
+
28
+ console.error('[1/5] start_month_closing (force_refresh=true)');
29
+ const start = await mod.handleStartMonthClosing(bc, { store: STORE, month: MONTH, force_refresh: true });
30
+ console.error(' match_results_summary:', JSON.stringify(start.match_results_summary, null, 2));
31
+
32
+ console.error('\n[2/5] get_closing_match_results by_category (top 6)');
33
+ const cats = await mod.handleGetClosingMatchResults(null, { store: STORE, month: MONTH, list: 'by_category' });
34
+ for (const c of cats.by_category.slice(0, 6)) {
35
+ console.error(` ${c.category.padEnd(50)} count=${String(c.count).padStart(5)} VES=${c.total_abs_ves.toLocaleString().padStart(15)} sources=${c.sources.join(',')}`);
36
+ }
37
+
38
+ console.error('\n[3/5] submit: approve top categories + mark apply_in_bc + classify a few unclassified');
39
+ const topCategories = cats.by_category.slice(0, 6).map((c) => c.category);
40
+ const apply = await mod.handleGetClosingMatchResults(null, { store: STORE, month: MONTH, list: 'apply_in_bc', limit: 200 });
41
+ const unclassified = await mod.handleGetClosingMatchResults(null, { store: STORE, month: MONTH, list: 'unclassified', limit: 5 });
42
+ const lineClassifications = unclassified.items.map((x) => ({
43
+ line_id: x.line_id, gl_account: '67100', memo: 'Manual: ' + (x.description || '').slice(0, 40), save_for_future: true,
44
+ }));
45
+ const subRes = await mod.handleSubmitClosingAnswers(null, {
46
+ store: STORE, month: MONTH, user: 'test@fullqueso.com',
47
+ answers: {
48
+ approve_category_bulk: topCategories,
49
+ mark_apply_in_bc: apply.items.map((x) => x.line_id),
50
+ line_classifications: lineClassifications,
51
+ },
52
+ });
53
+ console.error(' status=' + subRes.status, 'pending_count=' + subRes.pending_count);
54
+ console.error(' pending_breakdown:', subRes.pending_breakdown);
55
+
56
+ console.error('\n[4/5] reconcile_closing_with_bc');
57
+ const recon = await mod.handleReconcileWithBc(bc, { store: STORE, month: MONTH });
58
+ console.error(' drift summary:', recon.summary);
59
+
60
+ console.error('\n[5/5] generate_closing_journal (allow_partial=true)');
61
+ const j = await mod.handleGenerateClosingJournal(bc, { store: STORE, month: MONTH, output_dir: '/tmp', allow_partial: true });
62
+ console.error(' xlsx_path:', j.xlsx_path);
63
+ console.error(' balanced:', j.journal_summary?.balanced);
64
+ console.error(' totals: debit=' + j.journal_summary?.total_debit, 'credit=' + j.journal_summary?.total_credit);
65
+ console.error(' banks:');
66
+ for (const b of j.journal_summary?.banks || []) {
67
+ console.error(` ${b.bank_account} rows=${b.rows} debit=${b.total_debit} credit=${b.total_credit} gap=${b.gap_to_clearing}`);
68
+ }
69
+ console.error(' apply_in_bc roster size:', j.journal_summary?.apply_in_bc_count);
70
+
71
+ console.error('\n✓ End-to-end test complete.\n');
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // End-to-end integration test for the entry-level month-end closing workflow.
3
+
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+ import { existsSync } from 'fs';
7
+ import dotenv from 'dotenv';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const envPath = join(__dirname, '..', '.env');
11
+ if (existsSync(envPath)) dotenv.config({ path: envPath });
12
+
13
+ const { BCClient } = await import('../lib/bc-client.js');
14
+ const {
15
+ handleStartMonthClosing,
16
+ handleSubmitClosingAnswers,
17
+ handleGenerateClosingJournal,
18
+ handleGetClosingQuestionnaire,
19
+ } = await import('../tools/cierre-mensual/index.js');
20
+
21
+ const bc = new BCClient();
22
+
23
+ console.error('\n=== TEST: start_month_closing FQ01 (force_refresh) ===');
24
+ const startRes = await handleStartMonthClosing(bc, { store: 'FQ01', month: '2025-12', force_refresh: true });
25
+ console.error(JSON.stringify({
26
+ status: startRes.status, month: startRes.month,
27
+ banks_count: startRes.banks?.length,
28
+ q: startRes.questionnaire_summary,
29
+ }, null, 2));
30
+
31
+ console.error('\n=== TEST: get_closing_questionnaire summary ===');
32
+ const sumRes = await handleGetClosingQuestionnaire(bc, { store: 'FQ01', month: '2025-12', bucket: 'summary' });
33
+ console.error(JSON.stringify({ buckets: sumRes.buckets, unknown_lines: sumRes.unknown_lines }, null, 2));
34
+
35
+ console.error('\n=== TEST: counterparties view ===');
36
+ const cpRes = await handleGetClosingQuestionnaire(bc, { store: 'FQ01', month: '2025-12', bucket: 'counterparties' });
37
+ console.error('POS:', JSON.stringify(cpRes.counterparties.pos_entries.map((g) => ({ cp: g.counterparty_no, name: g.counterparty_name, entries: g.entries, ves: g.total_ves, usd: g.total_usd, oldest: g.oldest_doc_date, latest: g.latest_doc_date })), null, 2));
38
+ console.error('Intercompany:', JSON.stringify(cpRes.counterparties.intercompany_entries.map((g) => ({ cp: g.counterparty_no, name: g.counterparty_name, entries: g.entries, ves: g.total_ves, usd: g.total_usd })), null, 2));
39
+ console.error('Vendors:', cpRes.counterparties.vendor_entries.length, 'groups');
40
+
41
+ console.error('\n=== TEST: pos_entries first 5 ===');
42
+ const posRes = await handleGetClosingQuestionnaire(bc, { store: 'FQ01', month: '2025-12', bucket: 'pos_entries', limit: 5, sort: 'amount_ves_desc' });
43
+ console.error(JSON.stringify({ total: posRes.total, entries: posRes.entries }, null, 2));
44
+
45
+ // Confirm all POS via counterparty_bulk shortcut, route to MN0001
46
+ console.error('\n=== TEST: submit (bulk confirm POS to MN0001) ===');
47
+ const posCPs = cpRes.counterparties.pos_entries.map((g) => g.counterparty_no);
48
+ const bulk = {};
49
+ for (const cp of posCPs) bulk[cp] = true;
50
+ const routing = {};
51
+ for (const cp of posCPs) routing[cp] = 'FQ01-MN0001';
52
+ const subRes = await handleSubmitClosingAnswers(bc, {
53
+ store: 'FQ01', month: '2025-12',
54
+ answers: { counterparty_bulk: bulk, routing },
55
+ });
56
+ console.error(JSON.stringify({ status: subRes.status, pending_count: subRes.pending_count, pending_breakdown: subRes.pending_breakdown }, null, 2));
57
+
58
+ console.error('\n=== TEST: summary after POS confirmation ===');
59
+ const sumRes2 = await handleGetClosingQuestionnaire(bc, { store: 'FQ01', month: '2025-12', bucket: 'summary' });
60
+ console.error(JSON.stringify({ buckets: sumRes2.buckets }, null, 2));
61
+
62
+ console.error('\n=== TEST: generate_closing_journal (allow_partial=true) ===');
63
+ const genRes = await handleGenerateClosingJournal(bc, {
64
+ store: 'FQ01', month: '2025-12', output_dir: '/tmp', allow_partial: true,
65
+ });
66
+ console.error(JSON.stringify({
67
+ status: genRes.status,
68
+ xlsx_path: genRes.xlsx_path,
69
+ xlsx_error: genRes.xlsx_error?.slice(0, 300),
70
+ summary: {
71
+ balanced: genRes.journal_summary.balanced,
72
+ total_debit: genRes.journal_summary.total_debit,
73
+ total_credit: genRes.journal_summary.total_credit,
74
+ banks: genRes.journal_summary.banks,
75
+ warnings_count: genRes.journal_summary.warnings.length,
76
+ unassigned_rows: genRes.journal_summary.unassigned_rows,
77
+ },
78
+ }, null, 2));
package/server.js CHANGED
@@ -86,12 +86,29 @@ import {
86
86
  salesAnalysisTool, handleSalesAnalysis,
87
87
  productPerformanceTool, handleProductPerformance,
88
88
  salesStoreComparisonTool, handleSalesStoreComparison,
89
+ itemSalesDetailTool, handleItemSalesDetail,
89
90
  } from './tools/ventas/index.js';
90
91
 
91
92
  // Reports tools
92
93
  import { managerReportTool, handleGenerateManagerReport } from './tools/reports/manager-report.js';
93
94
  import { cxpReportTool, handleGenerateCxpReport } from './tools/reports/cxp-report.js';
94
95
 
96
+ // Cierre mensual (month-end bank reconciliation closing)
97
+ import {
98
+ startMonthClosingTool, handleStartMonthClosing,
99
+ submitClosingAnswersTool, handleSubmitClosingAnswers,
100
+ generateClosingJournalTool, handleGenerateClosingJournal,
101
+ getClosingQuestionnaireTool, handleGetClosingQuestionnaire,
102
+ getClosingMatchResultsTool, handleGetClosingMatchResults,
103
+ reconcileWithBcTool, handleReconcileWithBc,
104
+ } from './tools/cierre-mensual/index.js';
105
+
106
+ // Financials (P&L / Financial Statements) + Cash Flow (FCF)
107
+ import {
108
+ financialStatementsTool, handleFinancialStatements,
109
+ cashFlowTool, handleCashFlow,
110
+ } from './tools/financials/index.js';
111
+
95
112
  const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
96
113
  const bcClient = new BCClient();
97
114
 
@@ -161,9 +178,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
161
178
  salesAnalysisTool,
162
179
  productPerformanceTool,
163
180
  salesStoreComparisonTool,
181
+ itemSalesDetailTool,
164
182
  // Reports
165
183
  managerReportTool,
166
184
  cxpReportTool,
185
+ // Cierre mensual (month-end bank reconciliation closing)
186
+ startMonthClosingTool,
187
+ submitClosingAnswersTool,
188
+ generateClosingJournalTool,
189
+ getClosingQuestionnaireTool,
190
+ getClosingMatchResultsTool,
191
+ reconcileWithBcTool,
192
+ // Financials (P&L / Financial Statements) + Cash Flow (FCF)
193
+ financialStatementsTool,
194
+ cashFlowTool,
167
195
  ],
168
196
  };
169
197
  });
@@ -314,6 +342,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
314
342
  case 'compare_sales_by_store':
315
343
  result = await handleSalesStoreComparison(bcClient, args);
316
344
  break;
345
+ case 'get_item_sales_detail':
346
+ result = await handleItemSalesDetail(bcClient, args);
347
+ break;
317
348
  // Reports
318
349
  case 'generate_manager_report':
319
350
  result = await handleGenerateManagerReport(bcClient, args);
@@ -321,6 +352,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
321
352
  case 'generate_cxp_report':
322
353
  result = await handleGenerateCxpReport(bcClient, args);
323
354
  break;
355
+ // Cierre mensual (month-end bank reconciliation closing)
356
+ case 'start_month_closing':
357
+ result = await handleStartMonthClosing(bcClient, args);
358
+ break;
359
+ case 'submit_closing_answers':
360
+ result = await handleSubmitClosingAnswers(bcClient, args);
361
+ break;
362
+ case 'generate_closing_journal':
363
+ result = await handleGenerateClosingJournal(bcClient, args);
364
+ break;
365
+ case 'get_closing_questionnaire':
366
+ result = await handleGetClosingQuestionnaire(bcClient, args);
367
+ break;
368
+ case 'get_closing_match_results':
369
+ result = await handleGetClosingMatchResults(bcClient, args);
370
+ break;
371
+ case 'reconcile_closing_with_bc':
372
+ result = await handleReconcileWithBc(bcClient, args);
373
+ break;
374
+ // Financials (P&L / Financial Statements) + Cash Flow (FCF)
375
+ case 'get_financial_statements':
376
+ result = await handleFinancialStatements(bcClient, args);
377
+ break;
378
+ case 'get_cash_flow':
379
+ result = await handleCashFlow(bcClient, args);
380
+ break;
324
381
  default:
325
382
  return {
326
383
  content: [{ type: 'text', text: `Herramienta desconocida: ${name}` }],
@@ -45,9 +45,9 @@ export async function handleBankLedgerEntries(bcClient, args) {
45
45
  const storeInfo = stores[0];
46
46
 
47
47
  const filterParts = [
48
- `Bank_Account_No eq '${bank_account}'`,
49
- `Posting_Date ge ${date_from}`,
50
- `Posting_Date le ${date_to}`,
48
+ `Bank_Account_No eq '${bcClient._sanitizeOData(bank_account)}'`,
49
+ `Posting_Date ge ${bcClient._sanitizeOData(date_from)}`,
50
+ `Posting_Date le ${bcClient._sanitizeOData(date_to)}`,
51
51
  ];
52
52
  if (openOnly) {
53
53
  filterParts.push('Open eq true');
@@ -83,7 +83,7 @@ export async function handleFindPotentialMatches(bcClient, args) {
83
83
 
84
84
  // Fetch open bank ledger entries within date range
85
85
  const url = bcClient.buildODataUrl(store, 'BankAccountLedgerEntries', {
86
- $filter: `Bank_Account_No eq '${bankAccount}' and Open eq true and Posting_Date ge ${dateFrom} and Posting_Date le ${dateTo}`,
86
+ $filter: `Bank_Account_No eq '${bcClient._sanitizeOData(bankAccount)}' and Open eq true and Posting_Date ge ${bcClient._sanitizeOData(dateFrom)} and Posting_Date le ${bcClient._sanitizeOData(dateTo)}`,
87
87
  $orderby: 'Posting_Date desc',
88
88
  });
89
89
 
@@ -39,7 +39,7 @@ export async function handleGLAccountEntries(bcClient, args) {
39
39
  const storeInfo = stores[0];
40
40
 
41
41
  const url = bcClient.buildApiUrl(storeInfo.companyId, 'generalLedgerEntries', {
42
- $filter: `postingDate ge ${date_from} and postingDate le ${date_to} and accountNumber eq '${gl_account}'`,
42
+ $filter: `postingDate ge ${bcClient._sanitizeOData(date_from)} and postingDate le ${bcClient._sanitizeOData(date_to)} and accountNumber eq '${bcClient._sanitizeOData(gl_account)}'`,
43
43
  $select: 'entryNumber,postingDate,documentNumber,accountNumber,description,debitAmount,creditAmount',
44
44
  $orderby: 'postingDate asc',
45
45
  });
@@ -24,21 +24,32 @@ export async function handleListBankAccounts(bcClient, args) {
24
24
  const stores = resolveStores([store]);
25
25
  const storeInfo = stores[0];
26
26
 
27
- // Standard API bankAccounts no 'balance' field available
28
- const url = bcClient.buildApiUrl(storeInfo.companyId, 'bankAccounts', {
29
- $select: 'number,displayName,bankAccountNumber,currencyCode,blocked',
30
- $orderby: 'number',
31
- });
27
+ // Fetch bank accounts + posting groups in parallel via OData
28
+ // Bank_Account_Card_Excel has: No, Name, Bank_Account_No, Currency_Code,
29
+ // Bank_Acc_Posting_Group, Blocked, Balance, Last_Statement_No
30
+ // Bank_Account_Posting_Groups_Excel has: Code, G_L_Account_No
31
+ const [cardRows, pgRows] = await Promise.all([
32
+ bcClient.odataCall(bcClient.buildODataUrl(store, 'Bank_Account_Card_Excel')),
33
+ bcClient.odataCall(bcClient.buildODataUrl(store, 'Bank_Account_Posting_Groups_Excel')),
34
+ ]);
32
35
 
33
- const accounts = await bcClient.apiCallAllPages(url);
36
+ // Build posting group → GL account lookup
37
+ const glByGroup = {};
38
+ for (const pg of pgRows) {
39
+ if (pg.Code) glByGroup[pg.Code] = pg.G_L_Account_No || null;
40
+ }
34
41
 
35
- const bankAccounts = accounts
36
- .filter((acct) => !acct.blocked)
42
+ const bankAccounts = cardRows
43
+ .filter((acct) => !acct.Blocked)
37
44
  .map((acct) => ({
38
- number: acct.number,
39
- name: acct.displayName,
40
- bank_account_number: acct.bankAccountNumber || '',
41
- currency: acct.currencyCode || 'VES',
45
+ number: acct.No,
46
+ name: acct.Name,
47
+ bank_account_number: acct.Bank_Account_No || '',
48
+ currency: acct.Currency_Code || 'VES',
49
+ bankAccPostingGroup: acct.Bank_Acc_Posting_Group || null,
50
+ glAccount: acct.Bank_Acc_Posting_Group
51
+ ? (glByGroup[acct.Bank_Acc_Posting_Group] ?? null)
52
+ : null,
42
53
  }));
43
54
 
44
55
  return {
@@ -38,8 +38,8 @@ export async function handlePmReceipts(bcClient, args) {
38
38
  const stores = resolveStores([store]);
39
39
  const storeInfo = stores[0];
40
40
 
41
- // Query customer ledger entries for the 3 PM customers
42
- const pmCustomers = ['FQ01-CN0006-1', 'FQ01-CN0006-2', 'FQ01-CN0007'];
41
+ // Query customer ledger entries for the 4 same-day-settled POS customers
42
+ const pmCustomers = ['FQ01-CN0005', 'FQ01-CN0006-1', 'FQ01-CN0006-2', 'FQ01-CN0007'];
43
43
 
44
44
  const allEntries = [];
45
45
 
@@ -47,8 +47,8 @@ export async function handlePmReceipts(bcClient, args) {
47
47
  // Use Beta API — query Payment entries for this customer
48
48
  const url = bcClient.buildBetaApiUrl(storeInfo.companyId, 'customerLedgerEntries', {
49
49
  $filter: [
50
- `postingDate ge ${date_from}`,
51
- `postingDate le ${date_to}`,
50
+ `postingDate ge ${bcClient._sanitizeOData(date_from)}`,
51
+ `postingDate le ${bcClient._sanitizeOData(date_to)}`,
52
52
  `customerNumber eq '${customer}'`,
53
53
  `documentType eq 'Payment'`,
54
54
  ].join(' and '),