@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 +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 +41 -0
- package/tools/auditoria/bank-ledger-entries.js +106 -0
- package/tools/auditoria/gl-account-entries.js +75 -0
- package/tools/auditoria/pm-receipts.js +182 -0
- package/tools/inventario/index.js +1 -0
- package/tools/inventario/item-cost-trend.js +185 -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
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.
|
|
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()
|