@fullqueso/mcp-bc-gastos 1.14.2 → 1.17.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 CHANGED
@@ -1,5 +1,98 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.17.0] - 2026-03-18
4
+
5
+ ### Highlights
6
+ - **Ventas domain consolidated** from `mcp-bc-analisis-ventas` — 3 new sales analysis tools
7
+ - Full Queso now has one unified BC MCP with 41 tools across 8 domains
8
+
9
+ ### Added
10
+
11
+ **New domain: Ventas (tools/ventas/) — 3 tools:**
12
+ - `get_sales_analysis` — Multi-dimensional sales analysis by product, category, customer, store, time period. Revenue from GL entries (USD), trend comparison vs previous period, actionable insights
13
+ - `get_product_performance` — Top/bottom product performers with growth trends, per-store breakdown, margin analysis, and promotion opportunities
14
+ - `compare_sales_by_store` — Sales comparison across FQ01/FQ28/FQ88: revenue, transactions, avg ticket, top products, daily patterns, cross-store insights
15
+
16
+ **bc-client.js — Sales API methods:**
17
+ - `getSalesInvoicesExpanded()` — Sales invoices with `$expand=salesInvoiceLines`
18
+ - `getSalesInvoicesWithLines()` — Flattened invoices + enriched lines
19
+ - `getItemsCatalog()` — Items master data for product enrichment
20
+ - `getSalesStoreData()` — Per-store sales orchestration (invoices + items + GL revenue + exchange rate enrichment)
21
+ - `getAllSalesStoreData()` — Multi-store sequential fetch with shared exchange rates
22
+
23
+ **Utilities:**
24
+ - `utils/sales-aggregation.js` — Sales-specific aggregation: by field, by date, summary calculation, growth, top/bottom N
25
+ - `utils/sales-insights.js` — Business insight generation: revenue trends, margin alerts, store gap analysis, product opportunities
26
+
27
+ ### Notes
28
+ - `compare_sales_by_store` (sales) is separate from `compare_stores` (expenses) to avoid naming conflict
29
+ - Revenue uses GL entries (accounts 40000-49999) as authoritative USD source
30
+ - Line-level amounts used only for relative product/category breakdowns
31
+ - This consolidation deprecates the standalone `@fullqueso/mcp-bc-analisis-ventas` package
32
+
33
+ ## [1.16.0] - 2026-03-16
34
+
35
+ ### Highlights
36
+ - **Dynamic CLI reports** for pending invoice auditing — both Purchase and Sales
37
+ - Parametric scripts: any store (FQ01/FQ28/FQ88/all), any month range, auto-generated Excel
38
+ - 3-tab Excel output: Sin Draft, Con Draft, Pago Parcial — with subtotals by month and invoice numbers for audit
39
+
40
+ ### Added
41
+
42
+ **Scripts — Posted Purchase Invoices Pendientes:**
43
+ - `scripts/report-pending-invoices.js` — Node.js CLI that fetches open vendor ledger entries + purchMultiPaymentHeaders/Lines, classifies invoices by payment status, and generates Excel via Python
44
+ - `scripts/generate-pending-invoices-xlsx.py` — Python (openpyxl) Excel generator with 3 tabs, month subtotals, color-coded tiers, and grand totals
45
+
46
+ **Scripts — Posted Sales Invoices Pendientes:**
47
+ - `scripts/report-pending-sales-invoices.js` — Same pattern for customer side: customerLedgerEntries + multiPaymentHeaders/Lines
48
+ - `scripts/generate-pending-sales-invoices-xlsx.py` — Sales-specific Excel generator (Cliente instead of Proveedor, Posted SI instead of Posted PI)
49
+
50
+ ### Usage
51
+ ```bash
52
+ # Purchase invoices
53
+ node scripts/report-pending-invoices.js --store FQ28 --from 2025-05 --to 2025-07
54
+
55
+ # Sales invoices
56
+ node scripts/report-pending-sales-invoices.js --store FQ01 --from 2025-12 --to 2026-01
57
+
58
+ # All stores, custom output path
59
+ node scripts/report-pending-invoices.js --store all --from 2026-03 --to 2026-03 --out ~/Desktop/report.xlsx
60
+ ```
61
+
62
+ ### Technical
63
+ - CLI args via `node:util/parseArgs`: `--store`, `--from`, `--to`, `--out`
64
+ - Parallel API calls: ledger + MP headers + exchange rates fetched concurrently
65
+ - Bulk MP line fetching with server-side $filter for ≤50 headers
66
+ - USD conversion via BCV exchange rate for VES payment lines
67
+ - JSON intermediate file (auto-cleaned) passed to Python for Excel generation
68
+ - Output defaults to `~/Downloads/{STORE}_{Type}_{period}_{date}.xlsx`
69
+
70
+ ---
71
+
72
+ ## [1.15.0] - 2026-03-15
73
+
74
+ ### Highlights
75
+ - **36 tools** across 7 domains — new `get_item_cost_trend` tool for inventory cost trending
76
+ - 2W vs 4W rolling average comparison to detect rising/falling ingredient costs
77
+ - Documented `calculated_unit_cost` methodology for cross-project use (MCP + Dashboard)
78
+
79
+ ### Added
80
+
81
+ **Inventario — 1 new tool:**
82
+ - `get_item_cost_trend` — compares weighted avg inbound cost of last 2 weeks vs last 4 weeks. Detects if cost is trending up (>+5%), down (<-5%), or stable. Supports single item or bulk analysis by category. Includes stale cost detection (no inbound > 30 days).
83
+
84
+ **Documentation:**
85
+ - `docs/calculated_unit_cost_method.md` — complete methodology for calculated unit cost (Last 3 Inbound) and cost trending (2W vs 4W). Shared reference for mcp-fullqueso-bc-gastos and mcp-fullqueso-dashboard COGS table implementation.
86
+
87
+ ### Technical
88
+ - New file: `tools/inventario/item-cost-trend.js`
89
+ - Configurable `period_days` parameter (default 28, recent = half)
90
+ - Results sorted by severity: cost spikes first, then stable items
91
+ - Batch API calls (5 concurrent) to avoid BC rate limiting
92
+ - Category filtering via cross-reference with items endpoint
93
+
94
+ ---
95
+
3
96
  ## [1.14.0] - 2026-03-15
4
97
 
5
98
  ### Highlights
package/lib/bc-client.js CHANGED
@@ -530,6 +530,149 @@ export class BCClient {
530
530
  return filters.top ? this.apiCall(url) : this.apiCallAllPages(url);
531
531
  }
532
532
 
533
+ // ── Sales Analysis Methods ──────────────────────────────────────
534
+
535
+ async getSalesInvoicesExpanded(companyId, startDate, endDate) {
536
+ const url = this.buildApiUrl(companyId, 'salesInvoices', {
537
+ $filter: `postingDate ge ${startDate} and postingDate le ${endDate}`,
538
+ $orderby: 'postingDate desc',
539
+ $expand: 'salesInvoiceLines',
540
+ });
541
+ return this.apiCallAllPages(url);
542
+ }
543
+
544
+ async getSalesInvoicesWithLines(companyId, startDate, endDate) {
545
+ const expandedInvoices = await this.getSalesInvoicesExpanded(companyId, startDate, endDate);
546
+ const invoices = [];
547
+ const lines = [];
548
+
549
+ for (const inv of expandedInvoices) {
550
+ const { salesInvoiceLines, ...invoiceData } = inv;
551
+ invoices.push(invoiceData);
552
+
553
+ for (const line of (salesInvoiceLines || [])) {
554
+ lines.push({
555
+ ...line,
556
+ postingDate: inv.postingDate,
557
+ invoiceDate: inv.invoiceDate,
558
+ documentNo: inv.number,
559
+ customerName: inv.customerName,
560
+ amount: line.amountExcludingTax || line.netAmount || 0,
561
+ });
562
+ }
563
+ }
564
+
565
+ return { invoices, lines };
566
+ }
567
+
568
+ async getItemsCatalog(companyId) {
569
+ const url = this.buildApiUrl(companyId, 'items', {
570
+ $select: 'id,number,displayName,type,unitPrice,unitCost,itemCategoryCode,lastDirectCost',
571
+ });
572
+ return this.apiCallAllPages(url);
573
+ }
574
+
575
+ async getSalesStoreData(storeCode, startDate, endDate, exchangeRates = null) {
576
+ const stores = resolveStores([storeCode]);
577
+ const store = stores[0];
578
+
579
+ logger.info(`Fetching sales data for ${store.name}: ${startDate} to ${endDate}`);
580
+
581
+ const salesData = await this.getSalesInvoicesWithLines(store.companyId, startDate, endDate);
582
+ const items = await this.getItemsCatalog(store.companyId);
583
+
584
+ // Revenue from GL (40000-49999) for authoritative USD amounts
585
+ const revenueEntries = await this.getRevenueEntries(store.companyId, startDate, endDate);
586
+ const glRevenue = { total_usd: 0, breakdown: {} };
587
+ for (const e of revenueEntries) {
588
+ const amount = (e.creditAmount || 0) - (e.debitAmount || 0);
589
+ glRevenue.total_usd += amount;
590
+ const acct = e.accountNumber;
591
+ glRevenue.breakdown[acct] = (glRevenue.breakdown[acct] || 0) + amount;
592
+ }
593
+ for (const acct of Object.keys(glRevenue.breakdown)) {
594
+ glRevenue.breakdown[acct] = Math.round(glRevenue.breakdown[acct] * 100) / 100;
595
+ }
596
+ glRevenue.total_usd = Math.round(glRevenue.total_usd * 100) / 100;
597
+
598
+ // Exchange rates
599
+ if (!exchangeRates) {
600
+ try {
601
+ exchangeRates = await this.getExchangeRates(storeCode);
602
+ } catch (err) {
603
+ logger.warn('Could not fetch exchange rates:', err.message);
604
+ exchangeRates = [];
605
+ }
606
+ }
607
+
608
+ const periodRate = this.getExchangeRateForDate(exchangeRates, endDate);
609
+
610
+ // Build items lookup
611
+ const itemsMap = {};
612
+ for (const item of items) {
613
+ itemsMap[item.number] = item;
614
+ }
615
+
616
+ // Enrich lines with item data and converted costs
617
+ for (const line of salesData.lines) {
618
+ const itemNo = line.lineObjectNumber || line.itemId;
619
+ const item = itemsMap[itemNo];
620
+ if (item) {
621
+ line.itemCategoryCode = item.itemCategoryCode;
622
+ line.unitCostUSD = item.lastDirectCost || item.unitCost || 0;
623
+ line.displayName = item.displayName || line.description;
624
+ }
625
+ if (!line.displayName) {
626
+ line.displayName = line.description || 'Sin nombre';
627
+ }
628
+ line.itemNumber = line.lineObjectNumber || '';
629
+ line.unitCostUSD = line.unitCostUSD || 0;
630
+
631
+ if (exchangeRates.length > 0 && line.unitCostUSD) {
632
+ const rateForDate = this.getExchangeRateForDate(exchangeRates, line.postingDate || endDate);
633
+ const rate = rateForDate ? rateForDate.rate : (periodRate ? periodRate.rate : 0);
634
+ line.unitCostVES = line.unitCostUSD * rate;
635
+ line.exchangeRate = rate;
636
+ } else {
637
+ line.unitCostVES = 0;
638
+ line.exchangeRate = periodRate ? periodRate.rate : null;
639
+ }
640
+ }
641
+
642
+ return {
643
+ invoices: salesData.invoices,
644
+ lines: salesData.lines,
645
+ items,
646
+ itemsMap,
647
+ exchangeRate: periodRate,
648
+ glRevenue,
649
+ storeName: store.name,
650
+ storeCode: store.code,
651
+ };
652
+ }
653
+
654
+ async getAllSalesStoreData(storeCodes, startDate, endDate) {
655
+ const stores = resolveStores(storeCodes);
656
+ const results = {};
657
+
658
+ // Fetch exchange rates once (shared across all companies in same tenant)
659
+ let exchangeRates = [];
660
+ try {
661
+ exchangeRates = await this.getExchangeRates(stores[0].code);
662
+ logger.info(`Exchange rates loaded: ${exchangeRates.length} entries`);
663
+ } catch (err) {
664
+ logger.warn('Could not fetch exchange rates:', err.message);
665
+ }
666
+
667
+ // Fetch stores sequentially to avoid BC API rate limiting
668
+ for (const store of stores) {
669
+ const data = await this.getSalesStoreData(store.code, startDate, endDate, exchangeRates);
670
+ results[store.code] = data;
671
+ }
672
+
673
+ return results;
674
+ }
675
+
533
676
  async getCompanies() {
534
677
  const url = `${this.baseUrl}/${this.tenantId}/${this.environment}/api/v2.0/companies`;
535
678
  return this.apiCall(url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.14.2",
3
+ "version": "1.17.0",
4
4
  "description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, accounts receivable/payable, multi-payment draft visibility, payroll, inventory cost analysis, and manager reports - Full Queso franchise stores",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate FQ28 Pending Invoices Excel — May/Jun/Jul 2025
4
+ 3 tabs: Sin Draft, Con Draft, Pago Parcial
5
+ Each tab lists posted purchase invoice numbers for auditing.
6
+
7
+ Usage: python3 generate-fq28-pending-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="4472C4", end_color="4472C4", fill_type="solid")
28
+ HEADER_FONT = Font(bold=True, color="FFFFFF", size=10)
29
+ SUBTOTAL_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", 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
+ def month_label(ym):
46
+ """Convert '2025-05' to 'Mayo 2025'."""
47
+ if len(ym) >= 7:
48
+ mm = ym[5:7]
49
+ yyyy = ym[:4]
50
+ return f"{MONTH_NAMES_ES.get(mm, mm)} {yyyy}"
51
+ return ym
52
+
53
+ # Keep MONTH_LABELS as a fallback dict that uses the dynamic function
54
+ class _MonthLabels(dict):
55
+ def get(self, key, default=None):
56
+ return month_label(key) if key and len(key) >= 7 else (default or key)
57
+ def __getitem__(self, key):
58
+ return month_label(key)
59
+ def __contains__(self, key):
60
+ return key is not None and len(key) >= 7
61
+
62
+ MONTH_LABELS = _MonthLabels()
63
+
64
+
65
+ def set_col_widths(ws, widths):
66
+ for col_letter, width in widths.items():
67
+ ws.column_dimensions[col_letter].width = width
68
+
69
+
70
+ def write_header_row(ws, row, headers, start_col=1):
71
+ for i, header in enumerate(headers):
72
+ cell = ws.cell(row=row, column=start_col + i, value=header)
73
+ cell.font = HEADER_FONT
74
+ cell.fill = HEADER_FILL
75
+ cell.alignment = Alignment(horizontal="center")
76
+
77
+
78
+ def days_since(date_str, today):
79
+ try:
80
+ d = datetime.strptime(date_str, "%Y-%m-%d").date()
81
+ return max(0, (today - d).days)
82
+ except (ValueError, TypeError):
83
+ return 0
84
+
85
+
86
+ def get_month(date_str):
87
+ return date_str[:7] if date_str and len(date_str) >= 7 else ""
88
+
89
+
90
+ # ─── Tab: Sin Draft ───
91
+ def build_sin_draft(wb, invoices, today, ctx=None):
92
+ ctx = ctx or {}
93
+ ws = wb.active
94
+ ws.title = "Sin Draft"
95
+ tier_invs = [i for i in invoices if i.get("tier") == "fully_pending"]
96
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
97
+ fill = TIER_FILLS["fully_pending"]
98
+
99
+ set_col_widths(ws, {"A": 20, "B": 16, "C": 16, "D": 14, "E": 35, "F": 16, "G": 14})
100
+
101
+ total_amount = sum(i.get("invoice_amount", 0) for i in tier_invs)
102
+ title = f"SIN DRAFT — {ctx.get('store','')} {ctx.get('store_name','')} — {ctx.get('period','')}"
103
+ ws.cell(row=1, column=1, value=title).font = BOLD_14
104
+ ws.cell(row=2, column=1, value=f"{len(tier_invs)} facturas pendientes sin ningún draft de pago | Total: ${total_amount:,.2f} USD").font = BOLD_11
105
+
106
+ headers = ["No. Factura (Posted PI)", "Mes", "Fecha Posting", "No. Proveedor", "Proveedor", "Monto (USD)", "Días Pendiente"]
107
+ write_header_row(ws, 4, headers)
108
+ ws.freeze_panes = "A5"
109
+
110
+ row = 5
111
+ current_month = None
112
+ month_start_row = row
113
+
114
+ for inv in tier_invs:
115
+ month = get_month(inv.get("posting_date", ""))
116
+ if current_month and month != current_month:
117
+ # Month subtotal
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("vendor_no", ""))
132
+ ws.cell(row=row, column=5, value=inv.get("vendor_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
+ # Last month subtotal
140
+ if current_month and tier_invs:
141
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
142
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
143
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
144
+ for c in range(1, 8):
145
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
146
+ row += 1
147
+
148
+ # Grand total
149
+ if tier_invs:
150
+ row += 1
151
+ ws.cell(row=row, column=1, value="TOTAL SIN DRAFT").font = BOLD_12
152
+ ws.cell(row=row, column=5, value=f"{len(tier_invs)} facturas").font = BOLD
153
+ ws.cell(row=row, column=6, value=round(total_amount, 2)).number_format = NUM_FMT
154
+ ws.cell(row=row, column=6).font = BOLD_12
155
+
156
+ return len(tier_invs)
157
+
158
+
159
+ # ─── Tab: Con Draft ───
160
+ def build_con_draft(wb, invoices, today, ctx=None):
161
+ ctx = ctx or {}
162
+ ws = wb.create_sheet("Con Draft")
163
+ tier_invs = [i for i in invoices if i.get("tier") == "draft_in_progress"]
164
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
165
+ fill = TIER_FILLS["draft_in_progress"]
166
+
167
+ set_col_widths(ws, {"A": 20, "B": 16, "C": 16, "D": 14, "E": 35, "F": 16, "G": 18, "H": 14, "I": 16, "J": 16})
168
+
169
+ total_amount = sum(i.get("invoice_amount", 0) for i in tier_invs)
170
+ total_drafted = sum((i.get("multi_payment") or {}).get("total_payment_amount", 0) for i in tier_invs)
171
+ title = f"CON DRAFT (No Posteado) — {ctx.get('store','')} {ctx.get('store_name','')} — {ctx.get('period','')}"
172
+ ws.cell(row=1, column=1, value=title).font = BOLD_14
173
+ ws.cell(row=2, column=1, value=f"{len(tier_invs)} facturas con draft de pago creado | Total: ${total_amount:,.2f} USD | Drafted: ${total_drafted:,.2f} USD").font = BOLD_11
174
+
175
+ headers = ["No. Factura (Posted PI)", "Mes", "Fecha Posting", "No. Proveedor", "Proveedor", "Monto (USD)", "No. Multi-Payment", "Status MP", "Drafted (USD)", "Pendiente Neto"]
176
+ write_header_row(ws, 4, headers)
177
+ ws.freeze_panes = "A5"
178
+
179
+ row = 5
180
+ current_month = None
181
+ month_start_row = row
182
+
183
+ for inv in tier_invs:
184
+ month = get_month(inv.get("posting_date", ""))
185
+ mp = inv.get("multi_payment") or {}
186
+
187
+ if current_month and month != current_month:
188
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
189
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
190
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
191
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
192
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
193
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
194
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
195
+ for c in range(1, 11):
196
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
197
+ row += 1
198
+ month_start_row = row
199
+
200
+ current_month = month
201
+
202
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
203
+ ws.cell(row=row, column=2, value=MONTH_LABELS.get(month, month))
204
+ ws.cell(row=row, column=3, value=inv.get("posting_date", ""))
205
+ ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
206
+ ws.cell(row=row, column=5, value=inv.get("vendor_name", ""))
207
+ ws.cell(row=row, column=6, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
208
+ ws.cell(row=row, column=7, value=mp.get("mp_no", ""))
209
+ ws.cell(row=row, column=8, value=mp.get("status", ""))
210
+ ws.cell(row=row, column=9, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
211
+ ws.cell(row=row, column=10, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
212
+ for c in range(1, 11):
213
+ ws.cell(row=row, column=c).fill = fill
214
+ row += 1
215
+
216
+ # Last month subtotal
217
+ if current_month and tier_invs:
218
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
219
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
220
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
221
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
222
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
223
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
224
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
225
+ for c in range(1, 11):
226
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
227
+ row += 1
228
+
229
+ # Grand total
230
+ if tier_invs:
231
+ row += 1
232
+ ws.cell(row=row, column=1, value="TOTAL CON DRAFT").font = BOLD_12
233
+ ws.cell(row=row, column=5, value=f"{len(tier_invs)} facturas").font = BOLD
234
+ ws.cell(row=row, column=6, value=round(total_amount, 2)).number_format = NUM_FMT
235
+ ws.cell(row=row, column=6).font = BOLD_12
236
+ ws.cell(row=row, column=9, value=round(total_drafted, 2)).number_format = NUM_FMT
237
+ ws.cell(row=row, column=9).font = BOLD_12
238
+
239
+ return len(tier_invs)
240
+
241
+
242
+ # ─── Tab: Pago Parcial ───
243
+ def build_pago_parcial(wb, invoices, today, ctx=None):
244
+ ctx = ctx or {}
245
+ ws = wb.create_sheet("Pago Parcial")
246
+ tier_invs = [i for i in invoices if i.get("tier") == "in_journal"]
247
+ tier_invs.sort(key=lambda i: i.get("posting_date", ""))
248
+ fill = TIER_FILLS["in_journal"]
249
+
250
+ set_col_widths(ws, {"A": 20, "B": 16, "C": 16, "D": 14, "E": 35, "F": 16, "G": 18, "H": 14, "I": 16, "J": 16})
251
+
252
+ total_amount = sum(i.get("invoice_amount", 0) for i in tier_invs)
253
+ total_drafted = sum((i.get("multi_payment") or {}).get("total_payment_amount", 0) for i in tier_invs)
254
+ total_net = sum((i.get("multi_payment") or {}).get("net_pending", 0) for i in tier_invs)
255
+ title = f"PAGO PARCIAL (Transferred) — {ctx.get('store','')} {ctx.get('store_name','')} — {ctx.get('period','')}"
256
+ ws.cell(row=1, column=1, value=title).font = BOLD_14
257
+ ws.cell(row=2, column=1, value=f"{len(tier_invs)} facturas con pago parcial | Total: ${total_amount:,.2f} USD | Pagado: ${total_drafted:,.2f} | Pendiente: ${total_net:,.2f}").font = BOLD_11
258
+
259
+ headers = ["No. Factura (Posted PI)", "Mes", "Fecha Posting", "No. Proveedor", "Proveedor", "Monto (USD)", "No. Multi-Payment", "Status MP", "Pagado (USD)", "Pendiente Neto"]
260
+ write_header_row(ws, 4, headers)
261
+ ws.freeze_panes = "A5"
262
+
263
+ row = 5
264
+ current_month = None
265
+ month_start_row = row
266
+
267
+ for inv in tier_invs:
268
+ month = get_month(inv.get("posting_date", ""))
269
+ mp = inv.get("multi_payment") or {}
270
+
271
+ if current_month and month != current_month:
272
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
273
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
274
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
275
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
276
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
277
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
278
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
279
+ for c in range(1, 11):
280
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
281
+ row += 1
282
+ month_start_row = row
283
+
284
+ current_month = month
285
+
286
+ ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
287
+ ws.cell(row=row, column=2, value=MONTH_LABELS.get(month, month))
288
+ ws.cell(row=row, column=3, value=inv.get("posting_date", ""))
289
+ ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
290
+ ws.cell(row=row, column=5, value=inv.get("vendor_name", ""))
291
+ ws.cell(row=row, column=6, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
292
+ ws.cell(row=row, column=7, value=mp.get("mp_no", ""))
293
+ ws.cell(row=row, column=8, value=mp.get("status", ""))
294
+ ws.cell(row=row, column=9, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
295
+ ws.cell(row=row, column=10, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
296
+ for c in range(1, 11):
297
+ ws.cell(row=row, column=c).fill = fill
298
+ row += 1
299
+
300
+ # Last month subtotal
301
+ if current_month and tier_invs:
302
+ ws.cell(row=row, column=1, value=f"Subtotal {MONTH_LABELS.get(current_month, current_month)}").font = SUBTOTAL_FONT
303
+ ws.cell(row=row, column=6, value=f"=SUM(F{month_start_row}:F{row - 1})").number_format = NUM_FMT
304
+ ws.cell(row=row, column=6).font = SUBTOTAL_FONT
305
+ ws.cell(row=row, column=9, value=f"=SUM(I{month_start_row}:I{row - 1})").number_format = NUM_FMT
306
+ ws.cell(row=row, column=9).font = SUBTOTAL_FONT
307
+ ws.cell(row=row, column=10, value=f"=SUM(J{month_start_row}:J{row - 1})").number_format = NUM_FMT
308
+ ws.cell(row=row, column=10).font = SUBTOTAL_FONT
309
+ for c in range(1, 11):
310
+ ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
311
+ row += 1
312
+
313
+ # Grand total
314
+ if tier_invs:
315
+ row += 1
316
+ ws.cell(row=row, column=1, value="TOTAL PAGO PARCIAL").font = BOLD_12
317
+ ws.cell(row=row, column=5, value=f"{len(tier_invs)} facturas").font = BOLD
318
+ ws.cell(row=row, column=6, value=round(total_amount, 2)).number_format = NUM_FMT
319
+ ws.cell(row=row, column=6).font = BOLD_12
320
+ ws.cell(row=row, column=9, value=round(total_drafted, 2)).number_format = NUM_FMT
321
+ ws.cell(row=row, column=9).font = BOLD_12
322
+ ws.cell(row=row, column=10, value=round(total_net, 2)).number_format = NUM_FMT
323
+ ws.cell(row=row, column=10).font = BOLD_12
324
+
325
+ return len(tier_invs)
326
+
327
+
328
+ # ─── Main ───
329
+ def main():
330
+ if len(sys.argv) < 3:
331
+ print("Usage: python3 generate-fq28-pending-xlsx.py <input.json> <output.xlsx>", file=sys.stderr)
332
+ sys.exit(1)
333
+
334
+ input_path = Path(sys.argv[1])
335
+ output_path = Path(sys.argv[2])
336
+
337
+ if not input_path.exists():
338
+ print(f"Error: Input not found: {input_path}", file=sys.stderr)
339
+ sys.exit(1)
340
+
341
+ with open(input_path, "r", encoding="utf-8") as f:
342
+ data = json.load(f)
343
+
344
+ invoices = data.get("invoices", [])
345
+ today = date.today()
346
+ if data.get("report_date"):
347
+ try:
348
+ today = datetime.strptime(data["report_date"], "%Y-%m-%d").date()
349
+ except ValueError:
350
+ pass
351
+
352
+ store = data.get("store", "")
353
+ store_name = data.get("store_name", store)
354
+ dr = data.get("date_range", {})
355
+ period = ""
356
+ if dr.get("start") and dr.get("end"):
357
+ s_label = month_label(dr["start"][:7])
358
+ e_label = month_label(dr["end"][:7])
359
+ period = f"{s_label}" if s_label == e_label else f"{s_label} — {e_label}"
360
+
361
+ header_ctx = {"store": store, "store_name": store_name, "period": period}
362
+
363
+ wb = Workbook()
364
+ n_sin = build_sin_draft(wb, invoices, today, header_ctx)
365
+ n_draft = build_con_draft(wb, invoices, today, header_ctx)
366
+ n_parcial = build_pago_parcial(wb, invoices, today, header_ctx)
367
+
368
+ wb.save(str(output_path))
369
+ result = {
370
+ "file": str(output_path),
371
+ "sheets": {"sin_draft": n_sin, "con_draft": n_draft, "pago_parcial": n_parcial},
372
+ "total": len(invoices),
373
+ }
374
+ print(json.dumps(result))
375
+
376
+
377
+ if __name__ == "__main__":
378
+ main()