@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.
- package/CHANGELOG.md +137 -0
- package/config/bank-gl-map.json +93 -0
- package/lib/bc-client.js +143 -0
- package/package.json +1 -1
- package/scripts/generate-pending-invoices-xlsx.py +378 -0
- package/scripts/generate-pending-sales-invoices-xlsx.py +372 -0
- package/scripts/report-pending-invoices.js +250 -0
- package/scripts/report-pending-sales-invoices.js +253 -0
- package/server.js +61 -0
- package/tools/auditoria/bank-ledger-entries.js +104 -0
- package/tools/auditoria/gl-account-entries.js +75 -0
- package/tools/auditoria/pm-receipts.js +182 -0
- package/tools/auditoria/reconcile-pos-sales.js +39 -16
- package/tools/cobranzas/customer-list.js +63 -0
- package/tools/inventario/index.js +4 -0
- package/tools/inventario/inventory-by-location.js +212 -0
- package/tools/inventario/inventory-change.js +386 -0
- package/tools/inventario/inventory-levels.js +214 -0
- package/tools/inventario/item-card.js +1 -50
- package/tools/inventario/item-cost-trend.js +185 -0
- package/tools/inventario/shared/cost-calculator.js +64 -0
- package/tools/ventas/index.js +3 -0
- package/tools/ventas/product-performance.js +182 -0
- package/tools/ventas/sales-analysis.js +211 -0
- package/tools/ventas/sales-store-comparison.js +192 -0
- package/utils/sales-aggregation.js +82 -0
- package/utils/sales-insights.js +167 -0
|
@@ -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
|
+
}
|