@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.
- package/CHANGELOG.md +51 -0
- package/config/bank-gl-map.json +5 -5
- package/config/bank-keywords.js +35 -0
- package/lib/bc-client.js +71 -11
- package/package.json +3 -2
- package/scripts/diagnose-closing.js +144 -0
- package/scripts/generate-month-closing-xlsx.py +325 -0
- package/scripts/test-closing-flow.js +71 -0
- package/scripts/test-closing-fq01-dec25.js +78 -0
- package/server.js +57 -0
- package/tools/auditoria/bank-ledger-entries.js +3 -3
- package/tools/auditoria/find-potential-matches.js +1 -1
- package/tools/auditoria/gl-account-entries.js +1 -1
- package/tools/auditoria/list-bank-accounts.js +23 -12
- package/tools/auditoria/pm-receipts.js +4 -4
- package/tools/auditoria/reconcile-pos-sales.js +11 -9
- package/tools/auditoria/unmatched-ledger-entries.js +3 -3
- package/tools/auditoria/unmatched-statement-lines.js +2 -2
- package/tools/cierre-mensual/fetch-ledger.js +69 -0
- package/tools/cierre-mensual/generate-closing-journal.js +111 -0
- package/tools/cierre-mensual/get-match-results.js +151 -0
- package/tools/cierre-mensual/get-questionnaire.js +283 -0
- package/tools/cierre-mensual/index.js +6 -0
- package/tools/cierre-mensual/journal-builder.js +219 -0
- package/tools/cierre-mensual/matchers/index.js +161 -0
- package/tools/cierre-mensual/matchers/match-cross-bank.js +168 -0
- package/tools/cierre-mensual/matchers/match-draft-payments.js +178 -0
- package/tools/cierre-mensual/matchers/match-feedback-loop.js +24 -0
- package/tools/cierre-mensual/matchers/match-keywords.js +65 -0
- package/tools/cierre-mensual/matchers/match-open-arap.js +66 -0
- package/tools/cierre-mensual/matchers/match-pos-terminal.js +53 -0
- package/tools/cierre-mensual/reconcile-with-bc.js +116 -0
- package/tools/cierre-mensual/start-month-closing.js +234 -0
- package/tools/cierre-mensual/state-store.js +211 -0
- package/tools/cierre-mensual/submit-answers.js +106 -0
- package/tools/cobranzas/customer-ledger.js +4 -4
- package/tools/cobranzas/vendor-ledger.js +4 -4
- package/tools/financials/aggregator.js +360 -0
- package/tools/financials/cash-flow-html.js +459 -0
- package/tools/financials/cash-flow.js +471 -0
- package/tools/financials/html-template.js +674 -0
- package/tools/financials/index.js +79 -0
- package/tools/financials/statements.js +296 -0
- package/tools/inventario/item-ledger-entries.js +4 -4
- package/tools/inventario/item-value-entries.js +2 -2
- package/tools/ventas/index.js +1 -0
- package/tools/ventas/item-sales-detail.js +366 -0
- 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
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 =
|
|
36
|
-
.filter((acct) => !acct.
|
|
42
|
+
const bankAccounts = cardRows
|
|
43
|
+
.filter((acct) => !acct.Blocked)
|
|
37
44
|
.map((acct) => ({
|
|
38
|
-
number: acct.
|
|
39
|
-
name: acct.
|
|
40
|
-
bank_account_number: acct.
|
|
41
|
-
currency: acct.
|
|
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
|
|
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 '),
|