@fullqueso/mcp-bc-gastos 1.14.2 → 1.19.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.
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate Pending Sales Invoices Excel — Dynamic
4
+ 3 tabs: Sin Draft, Con Draft, Pago Parcial
5
+ Each tab lists posted sales invoice numbers for auditing.
6
+
7
+ Usage: python3 generate-pending-sales-invoices-xlsx.py <input.json> <output.xlsx>
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from datetime import datetime, date
13
+ from pathlib import Path
14
+
15
+ try:
16
+ from openpyxl import Workbook
17
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
18
+ except ImportError:
19
+ print("Error: openpyxl required. Install: pip3 install openpyxl", file=sys.stderr)
20
+ sys.exit(1)
21
+
22
+ # ─── Styles ───
23
+ BOLD = Font(bold=True)
24
+ BOLD_14 = Font(bold=True, size=14)
25
+ BOLD_12 = Font(bold=True, size=12)
26
+ BOLD_11 = Font(bold=True, size=11)
27
+ HEADER_FILL = PatternFill(start_color="2E75B6", end_color="2E75B6", fill_type="solid")
28
+ HEADER_FONT = Font(bold=True, color="FFFFFF", size=10)
29
+ SUBTOTAL_FILL = PatternFill(start_color="D6E4F0", end_color="D6E4F0", fill_type="solid")
30
+ SUBTOTAL_FONT = Font(bold=True, size=10)
31
+ NUM_FMT = '#,##0.00'
32
+
33
+ TIER_FILLS = {
34
+ "fully_pending": PatternFill(start_color="FCE4EC", end_color="FCE4EC", fill_type="solid"),
35
+ "draft_in_progress": PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid"),
36
+ "in_journal": PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid"),
37
+ }
38
+
39
+ MONTH_NAMES_ES = {
40
+ "01": "Enero", "02": "Febrero", "03": "Marzo", "04": "Abril",
41
+ "05": "Mayo", "06": "Junio", "07": "Julio", "08": "Agosto",
42
+ "09": "Septiembre", "10": "Octubre", "11": "Noviembre", "12": "Diciembre",
43
+ }
44
+
45
+
46
+ def month_label(ym):
47
+ """Convert '2025-05' to 'Mayo 2025'."""
48
+ if len(ym) >= 7:
49
+ mm = ym[5:7]
50
+ yyyy = ym[:4]
51
+ return f"{MONTH_NAMES_ES.get(mm, mm)} {yyyy}"
52
+ return ym
53
+
54
+
55
+ class _MonthLabels(dict):
56
+ def get(self, key, default=None):
57
+ return month_label(key) if key and len(key) >= 7 else (default or key)
58
+ def __getitem__(self, key):
59
+ return month_label(key)
60
+ def __contains__(self, key):
61
+ return key is not None and len(key) >= 7
62
+
63
+ MONTH_LABELS = _MonthLabels()
64
+
65
+
66
+ def set_col_widths(ws, widths):
67
+ for col_letter, width in widths.items():
68
+ ws.column_dimensions[col_letter].width = width
69
+
70
+
71
+ def write_header_row(ws, row, headers, start_col=1):
72
+ for i, header in enumerate(headers):
73
+ cell = ws.cell(row=row, column=start_col + i, value=header)
74
+ cell.font = HEADER_FONT
75
+ cell.fill = HEADER_FILL
76
+ cell.alignment = Alignment(horizontal="center")
77
+
78
+
79
+ def days_since(date_str, today):
80
+ try:
81
+ d = datetime.strptime(date_str, "%Y-%m-%d").date()
82
+ return max(0, (today - d).days)
83
+ except (ValueError, TypeError):
84
+ return 0
85
+
86
+
87
+ def get_month(date_str):
88
+ return date_str[:7] if date_str and len(date_str) >= 7 else ""
89
+
90
+
91
+ # ─── Tab: Sin Draft ───
92
+ def build_sin_draft(wb, invoices, today, ctx=None):
93
+ ctx = ctx or {}
94
+ ws = wb.active
95
+ ws.title = "Sin Draft"
96
+ tier_invs = [i for i in invoices if i.get("tier") == "fully_pending"]
97
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
98
+ fill = TIER_FILLS["fully_pending"]
99
+
100
+ set_col_widths(ws, {"A": 22, "B": 16, "C": 16, "D": 14, "E": 35, "F": 16, "G": 14})
101
+
102
+ total_amount = sum(i.get("invoice_amount", 0) for i in tier_invs)
103
+ title = f"SIN DRAFT — {ctx.get('store','')} {ctx.get('store_name','')} — {ctx.get('period','')}"
104
+ ws.cell(row=1, column=1, value=title).font = BOLD_14
105
+ ws.cell(row=2, column=1, value=f"{len(tier_invs)} facturas de venta pendientes sin draft de cobro | Total: ${total_amount:,.2f} USD").font = BOLD_11
106
+
107
+ headers = ["No. Factura (Posted SI)", "Mes", "Fecha Posting", "No. Cliente", "Cliente", "Monto (USD)", "Dias Pendiente"]
108
+ write_header_row(ws, 4, headers)
109
+ ws.freeze_panes = "A5"
110
+
111
+ row = 5
112
+ current_month = None
113
+ month_start_row = row
114
+
115
+ for inv in tier_invs:
116
+ month = get_month(inv.get("posting_date", ""))
117
+ if current_month and month != current_month:
118
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
119
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
120
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
121
+ for c in range(1, 8):
122
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
123
+ row += 1
124
+ month_start_row = row
125
+
126
+ current_month = month
127
+
128
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
129
+ ws.cell(row=row, column=2, value=MONTH_LABELS.get(month, month))
130
+ ws.cell(row=row, column=3, value=inv.get("posting_date", ""))
131
+ ws.cell(row=row, column=4, value=inv.get("customer_no", ""))
132
+ ws.cell(row=row, column=5, value=inv.get("customer_name", ""))
133
+ ws.cell(row=row, column=6, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
134
+ ws.cell(row=row, column=7, value=days_since(inv.get("posting_date", ""), today))
135
+ for c in range(1, 8):
136
+ ws.cell(row=row, column=c).fill = fill
137
+ row += 1
138
+
139
+ if current_month and tier_invs:
140
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
141
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
142
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
143
+ for c in range(1, 8):
144
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
145
+ row += 1
146
+
147
+ if tier_invs:
148
+ row += 1
149
+ ws.cell(row=row, column=1, value="TOTAL SIN DRAFT").font = BOLD_12
150
+ ws.cell(row=row, column=5, value=f"{len(tier_invs)} facturas").font = BOLD
151
+ ws.cell(row=row, column=6, value=round(total_amount, 2)).number_format = NUM_FMT
152
+ ws.cell(row=row, column=6).font = BOLD_12
153
+
154
+ return len(tier_invs)
155
+
156
+
157
+ # ─── Tab: Con Draft ───
158
+ def build_con_draft(wb, invoices, today, ctx=None):
159
+ ctx = ctx or {}
160
+ ws = wb.create_sheet("Con Draft")
161
+ tier_invs = [i for i in invoices if i.get("tier") == "draft_in_progress"]
162
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
163
+ fill = TIER_FILLS["draft_in_progress"]
164
+
165
+ set_col_widths(ws, {"A": 22, "B": 16, "C": 16, "D": 14, "E": 35, "F": 16, "G": 18, "H": 14, "I": 16, "J": 16})
166
+
167
+ total_amount = sum(i.get("invoice_amount", 0) for i in tier_invs)
168
+ total_drafted = sum((i.get("multi_payment") or {}).get("total_payment_amount", 0) for i in tier_invs)
169
+ title = f"CON DRAFT (No Posteado) — {ctx.get('store','')} {ctx.get('store_name','')} — {ctx.get('period','')}"
170
+ ws.cell(row=1, column=1, value=title).font = BOLD_14
171
+ ws.cell(row=2, column=1, value=f"{len(tier_invs)} facturas con draft de cobro | Total: ${total_amount:,.2f} USD | Drafted: ${total_drafted:,.2f} USD").font = BOLD_11
172
+
173
+ headers = ["No. Factura (Posted SI)", "Mes", "Fecha Posting", "No. Cliente", "Cliente", "Monto (USD)", "No. Multi-Payment", "Status MP", "Drafted (USD)", "Pendiente Neto"]
174
+ write_header_row(ws, 4, headers)
175
+ ws.freeze_panes = "A5"
176
+
177
+ row = 5
178
+ current_month = None
179
+ month_start_row = row
180
+
181
+ for inv in tier_invs:
182
+ month = get_month(inv.get("posting_date", ""))
183
+ mp = inv.get("multi_payment") or {}
184
+
185
+ if current_month and month != current_month:
186
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
187
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
188
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
189
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
190
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
191
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
192
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
193
+ for c in range(1, 11):
194
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
195
+ row += 1
196
+ month_start_row = row
197
+
198
+ current_month = month
199
+
200
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
201
+ ws.cell(row=row, column=2, value=MONTH_LABELS.get(month, month))
202
+ ws.cell(row=row, column=3, value=inv.get("posting_date", ""))
203
+ ws.cell(row=row, column=4, value=inv.get("customer_no", ""))
204
+ ws.cell(row=row, column=5, value=inv.get("customer_name", ""))
205
+ ws.cell(row=row, column=6, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
206
+ ws.cell(row=row, column=7, value=mp.get("mp_no", ""))
207
+ ws.cell(row=row, column=8, value=mp.get("status", ""))
208
+ ws.cell(row=row, column=9, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
209
+ ws.cell(row=row, column=10, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
210
+ for c in range(1, 11):
211
+ ws.cell(row=row, column=c).fill = fill
212
+ row += 1
213
+
214
+ if current_month and tier_invs:
215
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
216
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
217
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
218
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
219
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
220
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
221
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
222
+ for c in range(1, 11):
223
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
224
+ row += 1
225
+
226
+ if tier_invs:
227
+ row += 1
228
+ ws.cell(row=row, column=1, value="TOTAL CON DRAFT").font = BOLD_12
229
+ ws.cell(row=row, column=5, value=f"{len(tier_invs)} facturas").font = BOLD
230
+ ws.cell(row=row, column=6, value=round(total_amount, 2)).number_format = NUM_FMT
231
+ ws.cell(row=row, column=6).font = BOLD_12
232
+ ws.cell(row=row, column=9, value=round(total_drafted, 2)).number_format = NUM_FMT
233
+ ws.cell(row=row, column=9).font = BOLD_12
234
+
235
+ return len(tier_invs)
236
+
237
+
238
+ # ─── Tab: Pago Parcial ───
239
+ def build_pago_parcial(wb, invoices, today, ctx=None):
240
+ ctx = ctx or {}
241
+ ws = wb.create_sheet("Pago Parcial")
242
+ tier_invs = [i for i in invoices if i.get("tier") == "in_journal"]
243
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
244
+ fill = TIER_FILLS["in_journal"]
245
+
246
+ set_col_widths(ws, {"A": 22, "B": 16, "C": 16, "D": 14, "E": 35, "F": 16, "G": 18, "H": 14, "I": 16, "J": 16})
247
+
248
+ total_amount = sum(i.get("invoice_amount", 0) for i in tier_invs)
249
+ total_drafted = sum((i.get("multi_payment") or {}).get("total_payment_amount", 0) for i in tier_invs)
250
+ total_net = sum((i.get("multi_payment") or {}).get("net_pending", 0) for i in tier_invs)
251
+ title = f"PAGO PARCIAL (Transferred) — {ctx.get('store','')} {ctx.get('store_name','')} — {ctx.get('period','')}"
252
+ ws.cell(row=1, column=1, value=title).font = BOLD_14
253
+ ws.cell(row=2, column=1, value=f"{len(tier_invs)} facturas con cobro parcial | Total: ${total_amount:,.2f} USD | Cobrado: ${total_drafted:,.2f} | Pendiente: ${total_net:,.2f}").font = BOLD_11
254
+
255
+ headers = ["No. Factura (Posted SI)", "Mes", "Fecha Posting", "No. Cliente", "Cliente", "Monto (USD)", "No. Multi-Payment", "Status MP", "Cobrado (USD)", "Pendiente Neto"]
256
+ write_header_row(ws, 4, headers)
257
+ ws.freeze_panes = "A5"
258
+
259
+ row = 5
260
+ current_month = None
261
+ month_start_row = row
262
+
263
+ for inv in tier_invs:
264
+ month = get_month(inv.get("posting_date", ""))
265
+ mp = inv.get("multi_payment") or {}
266
+
267
+ if current_month and month != current_month:
268
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
269
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
270
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
271
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
272
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
273
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
274
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
275
+ for c in range(1, 11):
276
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
277
+ row += 1
278
+ month_start_row = row
279
+
280
+ current_month = month
281
+
282
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
283
+ ws.cell(row=row, column=2, value=MONTH_LABELS.get(month, month))
284
+ ws.cell(row=row, column=3, value=inv.get("posting_date", ""))
285
+ ws.cell(row=row, column=4, value=inv.get("customer_no", ""))
286
+ ws.cell(row=row, column=5, value=inv.get("customer_name", ""))
287
+ ws.cell(row=row, column=6, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
288
+ ws.cell(row=row, column=7, value=mp.get("mp_no", ""))
289
+ ws.cell(row=row, column=8, value=mp.get("status", ""))
290
+ ws.cell(row=row, column=9, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
291
+ ws.cell(row=row, column=10, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
292
+ for c in range(1, 11):
293
+ ws.cell(row=row, column=c).fill = fill
294
+ row += 1
295
+
296
+ if current_month and tier_invs:
297
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
298
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
299
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
300
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
301
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
302
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
303
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
304
+ for c in range(1, 11):
305
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
306
+ row += 1
307
+
308
+ if tier_invs:
309
+ row += 1
310
+ ws.cell(row=row, column=1, value="TOTAL PAGO PARCIAL").font = BOLD_12
311
+ ws.cell(row=row, column=5, value=f"{len(tier_invs)} facturas").font = BOLD
312
+ ws.cell(row=row, column=6, value=round(total_amount, 2)).number_format = NUM_FMT
313
+ ws.cell(row=row, column=6).font = BOLD_12
314
+ ws.cell(row=row, column=9, value=round(total_drafted, 2)).number_format = NUM_FMT
315
+ ws.cell(row=row, column=9).font = BOLD_12
316
+ ws.cell(row=row, column=10, value=round(total_net, 2)).number_format = NUM_FMT
317
+ ws.cell(row=row, column=10).font = BOLD_12
318
+
319
+ return len(tier_invs)
320
+
321
+
322
+ # ─── Main ───
323
+ def main():
324
+ if len(sys.argv) < 3:
325
+ print("Usage: python3 generate-pending-sales-invoices-xlsx.py <input.json> <output.xlsx>", file=sys.stderr)
326
+ sys.exit(1)
327
+
328
+ input_path = Path(sys.argv[1])
329
+ output_path = Path(sys.argv[2])
330
+
331
+ if not input_path.exists():
332
+ print(f"Error: Input not found: {input_path}", file=sys.stderr)
333
+ sys.exit(1)
334
+
335
+ with open(input_path, "r", encoding="utf-8") as f:
336
+ data = json.load(f)
337
+
338
+ invoices = data.get("invoices", [])
339
+ today = date.today()
340
+ if data.get("report_date"):
341
+ try:
342
+ today = datetime.strptime(data["report_date"], "%Y-%m-%d").date()
343
+ except ValueError:
344
+ pass
345
+
346
+ store = data.get("store", "")
347
+ store_name = data.get("store_name", store)
348
+ dr = data.get("date_range", {})
349
+ period = ""
350
+ if dr.get("start") and dr.get("end"):
351
+ s_label = month_label(dr["start"][:7])
352
+ e_label = month_label(dr["end"][:7])
353
+ period = f"{s_label}" if s_label == e_label else f"{s_label} — {e_label}"
354
+
355
+ header_ctx = {"store": store, "store_name": store_name, "period": period}
356
+
357
+ wb = Workbook()
358
+ n_sin = build_sin_draft(wb, invoices, today, header_ctx)
359
+ n_draft = build_con_draft(wb, invoices, today, header_ctx)
360
+ n_parcial = build_pago_parcial(wb, invoices, today, header_ctx)
361
+
362
+ wb.save(str(output_path))
363
+ result = {
364
+ "file": str(output_path),
365
+ "sheets": {"sin_draft": n_sin, "con_draft": n_draft, "pago_parcial": n_parcial},
366
+ "total": len(invoices),
367
+ }
368
+ print(json.dumps(result))
369
+
370
+
371
+ if __name__ == "__main__":
372
+ main()
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reporte dinámico: Posted Purchase Invoices Pendientes
4
+ * 3 tabs Excel: Sin Draft, Con Draft, Pago Parcial
5
+ *
6
+ * Usage:
7
+ * node scripts/report-pending-invoices.js --store FQ01 --from 2025-12 --to 2026-01
8
+ * node scripts/report-pending-invoices.js --store FQ28 --from 2025-05 --to 2025-07
9
+ * node scripts/report-pending-invoices.js --store all --from 2026-02 --to 2026-02
10
+ */
11
+
12
+ import { join, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
15
+ import { execSync } from 'node:child_process';
16
+ import { parseArgs } from 'node:util';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+ const PROJECT_ROOT = dirname(__dirname);
21
+
22
+ // ── Parse CLI args ──
23
+ const { values: args } = parseArgs({
24
+ options: {
25
+ store: { type: 'string', short: 's' },
26
+ from: { type: 'string', short: 'f' },
27
+ to: { type: 'string', short: 't' },
28
+ out: { type: 'string', short: 'o' },
29
+ },
30
+ });
31
+
32
+ if (!args.store || !args.from || !args.to) {
33
+ console.error('Usage: node scripts/report-pending-invoices.js --store FQ01 --from 2025-12 --to 2026-01');
34
+ console.error(' --store FQ01 | FQ28 | FQ88 | all');
35
+ console.error(' --from YYYY-MM (mes inicio)');
36
+ console.error(' --to YYYY-MM (mes fin)');
37
+ console.error(' --out (opcional) ruta Excel de salida');
38
+ process.exit(1);
39
+ }
40
+
41
+ // ── Validate month format and build date range ──
42
+ function parseMonth(ym) {
43
+ const m = ym.match(/^(\d{4})-(\d{2})$/);
44
+ if (!m) { console.error(`Formato inválido: "${ym}". Usar YYYY-MM`); process.exit(1); }
45
+ return { year: parseInt(m[1]), month: parseInt(m[2]), raw: ym };
46
+ }
47
+
48
+ function lastDayOfMonth(year, month) {
49
+ return new Date(year, month, 0).getDate();
50
+ }
51
+
52
+ const fromM = parseMonth(args.from);
53
+ const toM = parseMonth(args.to);
54
+ const DATE_START = `${fromM.raw}-01`;
55
+ const DATE_END = `${toM.raw}-${lastDayOfMonth(toM.year, toM.month)}`;
56
+
57
+ // ── Load env & modules ──
58
+ const envPath = join(PROJECT_ROOT, '.env');
59
+ if (existsSync(envPath)) {
60
+ const dotenv = await import('dotenv');
61
+ dotenv.config({ path: envPath });
62
+ }
63
+
64
+ const { BCClient } = await import('../lib/bc-client.js');
65
+ const { resolveStores } = await import('../config/company-config.js');
66
+
67
+ const storeParam = args.store.toUpperCase();
68
+ const stores = resolveStores(storeParam === 'ALL' ? null : [storeParam]);
69
+ const bcClient = new BCClient();
70
+
71
+ // ── Process each store ──
72
+ async function processStore(storeInfo) {
73
+ const companyId = storeInfo.companyId;
74
+ const code = storeInfo.code;
75
+
76
+ const ledgerUrl = bcClient.buildBetaApiUrl(companyId, 'vendorLedgerEntries', {
77
+ $filter: `open eq true and documentType eq 'Invoice' and postingDate ge ${DATE_START} and postingDate le ${DATE_END}`,
78
+ $select: 'entryNumber,documentNumber,vendorNumber,postingDate,amount,amountLocalCurrency,currencyCode,description',
79
+ });
80
+
81
+ const mpUrl = bcClient.buildMultiPaymentApiUrl(companyId, 'purchMultiPaymentHeaders', {
82
+ $filter: "status ne 'Posted'",
83
+ $select: 'no,invoiceNo,vendorNo,vendorName,invoiceAmount,remainingAmount,totalPaymentAmount,status,postingDate,currencyCode',
84
+ });
85
+
86
+ const [openInvoices, mpHeaders, exchangeRates] = await Promise.all([
87
+ bcClient.apiCallAllPages(ledgerUrl),
88
+ bcClient.apiCallAllPages(mpUrl),
89
+ bcClient.getExchangeRates(code),
90
+ ]);
91
+
92
+ console.error(`[${code}] ${openInvoices.length} open invoices, ${mpHeaders.length} PMP headers`);
93
+
94
+ // Build PMP lookup
95
+ const mpByInvoice = {};
96
+ const vendorNames = {};
97
+ for (const mp of mpHeaders) {
98
+ if (!mpByInvoice[mp.invoiceNo]) mpByInvoice[mp.invoiceNo] = [];
99
+ mpByInvoice[mp.invoiceNo].push(mp);
100
+ if (mp.vendorNo && mp.vendorName) vendorNames[mp.vendorNo] = mp.vendorName;
101
+ }
102
+
103
+ // Fetch PMP lines in bulk
104
+ const linesByMp = {};
105
+ if (mpHeaders.length > 0) {
106
+ const mpNos = new Set(mpHeaders.map((mp) => mp.no));
107
+ const linesParams = {
108
+ $select: 'documentNo,lineNo,paymentMethod,amountLCY,currencyCode,bankAccountNo,referenceNo,description',
109
+ };
110
+ if (mpHeaders.length <= 50) {
111
+ linesParams.$filter = [...mpNos].map((no) => `documentNo eq '${no}'`).join(' or ');
112
+ }
113
+ const allLinesUrl = bcClient.buildMultiPaymentApiUrl(companyId, 'purchMultiPaymentLines', linesParams);
114
+ const allLines = await bcClient.apiCallAllPages(allLinesUrl);
115
+ for (const line of allLines) {
116
+ if (mpNos.has(line.documentNo)) {
117
+ if (!linesByMp[line.documentNo]) linesByMp[line.documentNo] = [];
118
+ linesByMp[line.documentNo].push(line);
119
+ }
120
+ }
121
+ }
122
+
123
+ // Compute USD for each PMP
124
+ const mpUsdInfo = {};
125
+ for (const mp of mpHeaders) {
126
+ mpUsdInfo[mp.no] = computeMpUsd(bcClient, mp, linesByMp[mp.no] || [], exchangeRates);
127
+ }
128
+
129
+ // Classify invoices
130
+ const invoices = [];
131
+ for (const inv of openInvoices) {
132
+ const docNo = inv.documentNumber;
133
+ const amountUsd = round2(Math.abs(inv.amountLocalCurrency || 0));
134
+ const mps = mpByInvoice[docNo] || [];
135
+
136
+ let tier, multiPayment = null;
137
+
138
+ if (mps.length === 0) {
139
+ tier = 'fully_pending';
140
+ } else {
141
+ let totalDraftedUsd = 0;
142
+ for (const mp of mps) totalDraftedUsd += mpUsdInfo[mp.no].usd;
143
+ totalDraftedUsd = round2(totalDraftedUsd);
144
+ const hasTransferred = mps.some((mp) => mp.status === 'Transferred');
145
+ tier = hasTransferred ? 'in_journal' : 'draft_in_progress';
146
+ multiPayment = {
147
+ mp_no: mps[0].no,
148
+ status: mps[0].status,
149
+ total_payment_amount: totalDraftedUsd,
150
+ net_pending: round2(Math.max(0, amountUsd - totalDraftedUsd)),
151
+ };
152
+ if (mps.length > 1) {
153
+ multiPayment.additional_mps = mps.slice(1).map((mp) => ({
154
+ mp_no: mp.no, status: mp.status, total_payment_amount: mpUsdInfo[mp.no].usd,
155
+ }));
156
+ }
157
+ }
158
+
159
+ invoices.push({
160
+ invoice_no: docNo,
161
+ vendor_no: inv.vendorNumber,
162
+ vendor_name: vendorNames[inv.vendorNumber] || inv.description || inv.vendorNumber,
163
+ posting_date: inv.postingDate,
164
+ invoice_amount: amountUsd,
165
+ currency_code: inv.currencyCode || 'USD',
166
+ tier,
167
+ multi_payment: multiPayment,
168
+ });
169
+ }
170
+
171
+ invoices.sort((a, b) => a.posting_date.localeCompare(b.posting_date));
172
+ return { store: code, store_name: storeInfo.name, invoices };
173
+ }
174
+
175
+ // ── Helpers ──
176
+ function round2(n) { return Math.round(n * 100) / 100; }
177
+
178
+ function computeMpUsd(bcClient, mp, lines, exchangeRates) {
179
+ const rateInfo = bcClient.getExchangeRateForDate(exchangeRates, mp.postingDate);
180
+ const rate = rateInfo ? rateInfo.rate : null;
181
+ if (lines.length === 0) {
182
+ if (rate) return { usd: round2((mp.totalPaymentAmount || 0) / rate), rate };
183
+ return { usd: round2(mp.totalPaymentAmount || 0), rate: null };
184
+ }
185
+ if (lines.some((l) => l.currencyCode === 'VES') && !rate) {
186
+ return { usd: round2(mp.totalPaymentAmount || 0), rate: null };
187
+ }
188
+ let usdTotal = 0;
189
+ for (const line of lines) {
190
+ const amount = line.amountLCY || 0;
191
+ usdTotal += line.currencyCode === 'VES' ? amount / rate : amount;
192
+ }
193
+ return { usd: round2(usdTotal), rate };
194
+ }
195
+
196
+ // ── Main ──
197
+ console.error(`[report] Stores: ${stores.map(s => s.code).join(', ')} | Period: ${DATE_START} → ${DATE_END}`);
198
+
199
+ const results = await Promise.all(stores.map(processStore));
200
+
201
+ // Merge all invoices (for multi-store) or use single result
202
+ const allInvoices = results.flatMap(r => r.invoices);
203
+ const storeLabel = results.length === 1 ? results[0].store : 'ALL';
204
+ const storeName = results.length === 1 ? results[0].store_name : 'Todas las tiendas';
205
+
206
+ const counts = { sin_draft: 0, con_draft: 0, pago_parcial: 0 };
207
+ for (const inv of allInvoices) {
208
+ if (inv.tier === 'fully_pending') counts.sin_draft++;
209
+ else if (inv.tier === 'draft_in_progress') counts.con_draft++;
210
+ else if (inv.tier === 'in_journal') counts.pago_parcial++;
211
+ }
212
+
213
+ console.error(`[report] Total: ${allInvoices.length} invoices | Sin Draft=${counts.sin_draft}, Con Draft=${counts.con_draft}, Pago Parcial=${counts.pago_parcial}`);
214
+
215
+ // Write JSON
216
+ const reportDate = new Date().toISOString().substring(0, 10);
217
+ const jsonData = {
218
+ store: storeLabel,
219
+ store_name: storeName,
220
+ report_date: reportDate,
221
+ date_range: { start: DATE_START, end: DATE_END },
222
+ invoices: allInvoices,
223
+ };
224
+
225
+ const ts = Date.now();
226
+ const jsonPath = `/tmp/pending_${storeLabel}_${ts}.json`;
227
+ writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2), 'utf-8');
228
+
229
+ // Generate Excel
230
+ const periodSlug = `${args.from.replace('-', '')}-${args.to.replace('-', '')}`;
231
+ const defaultOut = join(process.env.HOME || '/tmp', 'Downloads', `${storeLabel}_Pendientes_${periodSlug}_${reportDate.replace(/-/g, '')}.xlsx`);
232
+ const outputPath = args.out ? args.out.replace(/^~/, process.env.HOME || '/tmp') : defaultOut;
233
+
234
+ const scriptPath = join(PROJECT_ROOT, 'scripts', 'generate-pending-invoices-xlsx.py');
235
+ const venvPython = join(process.env.HOME || '/tmp', 'mcp-venv', 'bin', 'python3');
236
+ const pythonCmd = existsSync(venvPython) ? venvPython : 'python3';
237
+
238
+ try {
239
+ const stdout = execSync(`"${pythonCmd}" "${scriptPath}" "${jsonPath}" "${outputPath}"`, {
240
+ timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8',
241
+ });
242
+ console.error(`[report] ✓ Excel: ${outputPath}`);
243
+ console.error(stdout);
244
+ } catch (e) {
245
+ console.error(`[report] Error: ${e.message}`);
246
+ if (e.stderr) console.error(e.stderr);
247
+ process.exit(1);
248
+ } finally {
249
+ try { unlinkSync(jsonPath); } catch (_) {}
250
+ }