@fullqueso/mcp-bc-gastos 1.11.1 → 1.14.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 +24 -0
- package/lib/bc-client.js +5 -0
- package/package.json +4 -2
- package/scripts/generate-cxp-report-xlsx.py +355 -0
- package/server.js +56 -0
- package/tools/inventario/index.js +3 -0
- package/tools/inventario/item-card.js +89 -0
- package/tools/inventario/item-ledger-entries.js +125 -0
- package/tools/inventario/item-value-entries.js +109 -0
- package/tools/payroll/employees.js +147 -0
- package/tools/payroll/index.js +3 -0
- package/tools/payroll/payroll-documents.js +172 -0
- package/tools/payroll/payroll-lines.js +128 -0
- package/tools/reports/cxp-report.js +150 -0
- package/tools/reports/manager-report.js +1434 -0
- package/utils/snapshot-manager.js +198 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [1.14.0] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
- **35 tools** across 7 domains — new **Inventario** domain for inventory cost analysis
|
|
7
|
+
- 3 new tools to diagnose FIFO unit cost, open layers, and value entry adjustments
|
|
8
|
+
- Uses BC Standard v2.0 API: `items`, `itemLedgerEntries`, `valueEntries`
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
**Inventario (Inventory Cost Analysis) — 3 herramientas (Standard v2.0):**
|
|
13
|
+
- `get_item_card` — datos maestros de ítems: unitCost, costingMethod, inventory qty, unitPrice, categoría. Filtra por número, búsqueda por nombre, o categoría
|
|
14
|
+
- `get_item_ledger_entries` — capas FIFO abiertas/cerradas con remainingQuantity y costAmountActual. Calcula `weighted_avg_unit_cost` sobre capas abiertas. Filtros por open_only (default: true), entry_type, fecha
|
|
15
|
+
- `get_item_value_entries` — detalle de valorización por entrada del ledger: costPerUnit, ajustes de costo, revaluaciones. Permite drill-down por item_ledger_entry_number específico
|
|
16
|
+
|
|
17
|
+
### Technical
|
|
18
|
+
- New domain folder: `tools/inventario/` with `index.js` re-exports
|
|
19
|
+
- Spec document: `docs/tool_inventario.md`
|
|
20
|
+
- All tools use `buildApiUrl()` + `apiCall()` from BCClient (Standard v2.0, GUID-based)
|
|
21
|
+
- `unit_cost_actual` computed per ledger entry: `|costAmountActual / quantity|`
|
|
22
|
+
- `weighted_avg_unit_cost` computed proportionally from remaining qty of open layers
|
|
23
|
+
- Zero `console.log` — all logging via `logger.info()` (console.error wrapper)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
3
27
|
## [1.10.0] - 2026-02-26
|
|
4
28
|
|
|
5
29
|
### Highlights
|
package/lib/bc-client.js
CHANGED
|
@@ -196,6 +196,11 @@ export class BCClient {
|
|
|
196
196
|
return this.buildCustomApiUrl(companyId, 'fullQueso', 'multiPayment', 'v1.0', endpoint, params);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
// Shorthand for Payroll API calls (publisher: fullQueso, group: fqPayroll, v1.0)
|
|
200
|
+
buildPayrollApiUrl(companyId, endpoint, params = {}) {
|
|
201
|
+
return this.buildCustomApiUrl(companyId, 'fullQueso', 'fqPayroll', 'v1.0', endpoint, params);
|
|
202
|
+
}
|
|
203
|
+
|
|
199
204
|
// ── OData V4 Web Service Methods ──────────────────────────────────
|
|
200
205
|
// OData endpoints use company NAME (URL-encoded) instead of GUID.
|
|
201
206
|
// Same OAuth token works; different URL path (ODataV4 vs api/v2.0).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullqueso/mcp-bc-gastos",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, accounts receivable/payable,
|
|
3
|
+
"version": "1.14.0",
|
|
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": {
|
|
7
7
|
"mcp-bc-gastos": "server.js"
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"accounts-receivable",
|
|
39
39
|
"accounts-payable",
|
|
40
40
|
"multi-payment",
|
|
41
|
+
"payroll",
|
|
42
|
+
"manager-report",
|
|
41
43
|
"odata",
|
|
42
44
|
"fullqueso"
|
|
43
45
|
],
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate CxP (Cuentas por Pagar) Excel report with 3 tier sheets + summary.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 generate-cxp-report-xlsx.py <input.json> <output.xlsx>
|
|
7
|
+
|
|
8
|
+
Input: JSON file with { store, store_name, report_date, invoices[] }
|
|
9
|
+
Output: Formatted Excel with 4 sheets (Resumen, Sin Draft, Draft No Posteado, Pago Parcial)
|
|
10
|
+
|
|
11
|
+
Requires: openpyxl (pip3 install openpyxl)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import argparse
|
|
17
|
+
from datetime import datetime, date
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from openpyxl import Workbook
|
|
22
|
+
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
|
23
|
+
except ImportError:
|
|
24
|
+
print("Error: openpyxl is required. Install with: pip3 install openpyxl", file=sys.stderr)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── Styles ───
|
|
29
|
+
|
|
30
|
+
BOLD = Font(bold=True)
|
|
31
|
+
BOLD_14 = Font(bold=True, size=14)
|
|
32
|
+
BOLD_12 = Font(bold=True, size=12)
|
|
33
|
+
BOLD_11 = Font(bold=True, size=11)
|
|
34
|
+
HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
35
|
+
HEADER_FONT = Font(bold=True, color="FFFFFF", size=10)
|
|
36
|
+
SUBTOTAL_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid")
|
|
37
|
+
SUBTOTAL_FONT = Font(bold=True, size=10)
|
|
38
|
+
NUM_FMT = '#,##0.00'
|
|
39
|
+
|
|
40
|
+
TIER_FILLS = {
|
|
41
|
+
"fully_pending": PatternFill(start_color="FCE4EC", end_color="FCE4EC", fill_type="solid"),
|
|
42
|
+
"draft_in_progress": PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid"),
|
|
43
|
+
"in_journal": PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
TIER_LABELS = {
|
|
47
|
+
"fully_pending": "Sin Draft",
|
|
48
|
+
"draft_in_progress": "Draft No Posteado",
|
|
49
|
+
"in_journal": "Pago Parcial",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def set_col_widths(ws, widths):
|
|
54
|
+
for col_letter, width in widths.items():
|
|
55
|
+
ws.column_dimensions[col_letter].width = width
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_header_row(ws, row, headers, start_col=1):
|
|
59
|
+
for i, header in enumerate(headers):
|
|
60
|
+
cell = ws.cell(row=row, column=start_col + i, value=header)
|
|
61
|
+
cell.font = HEADER_FONT
|
|
62
|
+
cell.fill = HEADER_FILL
|
|
63
|
+
cell.alignment = Alignment(horizontal="center")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def days_since(date_str, today):
|
|
67
|
+
try:
|
|
68
|
+
d = datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
69
|
+
return max(0, (today - d).days)
|
|
70
|
+
except (ValueError, TypeError):
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─── Sheet 1: Resumen ───
|
|
75
|
+
|
|
76
|
+
def build_resumen(wb, data, invoices, today):
|
|
77
|
+
ws = wb.active
|
|
78
|
+
ws.title = "Resumen"
|
|
79
|
+
set_col_widths(ws, {"A": 35, "B": 14, "C": 18, "D": 18, "E": 18, "F": 18, "G": 18, "H": 18, "I": 18})
|
|
80
|
+
|
|
81
|
+
store_label = f"{data.get('store', '')} — {data.get('store_name', '')}"
|
|
82
|
+
report_date = data.get("report_date", "")
|
|
83
|
+
|
|
84
|
+
ws.cell(row=1, column=1, value=f"REPORTE CxP — {store_label}").font = BOLD_14
|
|
85
|
+
ws.cell(row=2, column=1, value=f"Fecha: {report_date}").font = BOLD_12
|
|
86
|
+
|
|
87
|
+
# Group by tier
|
|
88
|
+
tiers = {"fully_pending": [], "draft_in_progress": [], "in_journal": []}
|
|
89
|
+
for inv in invoices:
|
|
90
|
+
tier = inv.get("tier", "fully_pending")
|
|
91
|
+
if tier in tiers:
|
|
92
|
+
tiers[tier].append(inv)
|
|
93
|
+
|
|
94
|
+
# Summary metrics
|
|
95
|
+
row = 4
|
|
96
|
+
ws.cell(row=row, column=1, value="Concepto").font = BOLD
|
|
97
|
+
ws.cell(row=row, column=2, value="Facturas").font = BOLD
|
|
98
|
+
ws.cell(row=row, column=3, value="Monto (USD)").font = BOLD
|
|
99
|
+
|
|
100
|
+
total_count = len(invoices)
|
|
101
|
+
total_amount = sum(inv.get("invoice_amount", 0) for inv in invoices)
|
|
102
|
+
|
|
103
|
+
row_data = [
|
|
104
|
+
("TOTAL FACTURAS ABIERTAS", total_count, total_amount),
|
|
105
|
+
("", "", ""),
|
|
106
|
+
(TIER_LABELS["fully_pending"], len(tiers["fully_pending"]),
|
|
107
|
+
sum(i.get("invoice_amount", 0) for i in tiers["fully_pending"])),
|
|
108
|
+
(TIER_LABELS["draft_in_progress"], len(tiers["draft_in_progress"]),
|
|
109
|
+
sum(i.get("invoice_amount", 0) for i in tiers["draft_in_progress"])),
|
|
110
|
+
(TIER_LABELS["in_journal"], len(tiers["in_journal"]),
|
|
111
|
+
sum(i.get("invoice_amount", 0) for i in tiers["in_journal"])),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
for i, (label, count, amount) in enumerate(row_data):
|
|
115
|
+
r = row + 1 + i
|
|
116
|
+
ws.cell(row=r, column=1, value=label)
|
|
117
|
+
if label:
|
|
118
|
+
ws.cell(row=r, column=2, value=count)
|
|
119
|
+
ws.cell(row=r, column=3, value=round(amount, 2)).number_format = NUM_FMT
|
|
120
|
+
if i == 0:
|
|
121
|
+
for c in range(1, 4):
|
|
122
|
+
ws.cell(row=r, column=c).font = BOLD
|
|
123
|
+
|
|
124
|
+
# Vendor breakdown
|
|
125
|
+
row += len(row_data) + 3
|
|
126
|
+
ws.cell(row=row, column=1, value="DESGLOSE POR PROVEEDOR").font = BOLD_12
|
|
127
|
+
row += 1
|
|
128
|
+
|
|
129
|
+
vendor_map = {}
|
|
130
|
+
for inv in invoices:
|
|
131
|
+
vno = inv.get("vendor_no", "")
|
|
132
|
+
if vno not in vendor_map:
|
|
133
|
+
vendor_map[vno] = {
|
|
134
|
+
"name": inv.get("vendor_name", vno),
|
|
135
|
+
"count": 0, "total": 0,
|
|
136
|
+
"sin_draft": 0, "sin_draft_n": 0,
|
|
137
|
+
"draft": 0, "draft_n": 0,
|
|
138
|
+
"parcial": 0, "parcial_n": 0,
|
|
139
|
+
}
|
|
140
|
+
v = vendor_map[vno]
|
|
141
|
+
v["count"] += 1
|
|
142
|
+
v["total"] += inv.get("invoice_amount", 0)
|
|
143
|
+
tier = inv.get("tier", "fully_pending")
|
|
144
|
+
if tier == "fully_pending":
|
|
145
|
+
v["sin_draft"] += inv.get("invoice_amount", 0)
|
|
146
|
+
v["sin_draft_n"] += 1
|
|
147
|
+
elif tier == "draft_in_progress":
|
|
148
|
+
v["draft"] += inv.get("invoice_amount", 0)
|
|
149
|
+
v["draft_n"] += 1
|
|
150
|
+
elif tier == "in_journal":
|
|
151
|
+
v["parcial"] += inv.get("invoice_amount", 0)
|
|
152
|
+
v["parcial_n"] += 1
|
|
153
|
+
|
|
154
|
+
vendors = sorted(vendor_map.items(), key=lambda x: x[1]["total"], reverse=True)
|
|
155
|
+
|
|
156
|
+
headers = ["No.", "Proveedor", "Facturas", "Total (USD)", "Sin Draft", "Sin Draft $", "Draft", "Draft $", "Parcial", "Parcial $"]
|
|
157
|
+
write_header_row(ws, row, headers)
|
|
158
|
+
row += 1
|
|
159
|
+
|
|
160
|
+
for vno, v in vendors:
|
|
161
|
+
ws.cell(row=row, column=1, value=vno)
|
|
162
|
+
ws.cell(row=row, column=2, value=v["name"])
|
|
163
|
+
ws.cell(row=row, column=3, value=v["count"])
|
|
164
|
+
ws.cell(row=row, column=4, value=round(v["total"], 2)).number_format = NUM_FMT
|
|
165
|
+
ws.cell(row=row, column=5, value=v["sin_draft_n"])
|
|
166
|
+
ws.cell(row=row, column=6, value=round(v["sin_draft"], 2)).number_format = NUM_FMT
|
|
167
|
+
ws.cell(row=row, column=7, value=v["draft_n"])
|
|
168
|
+
ws.cell(row=row, column=8, value=round(v["draft"], 2)).number_format = NUM_FMT
|
|
169
|
+
ws.cell(row=row, column=9, value=v["parcial_n"])
|
|
170
|
+
ws.cell(row=row, column=10, value=round(v["parcial"], 2)).number_format = NUM_FMT
|
|
171
|
+
row += 1
|
|
172
|
+
|
|
173
|
+
# Totals row
|
|
174
|
+
if vendors:
|
|
175
|
+
ws.cell(row=row, column=1, value="TOTAL").font = BOLD
|
|
176
|
+
ws.cell(row=row, column=3, value=total_count).font = BOLD
|
|
177
|
+
ws.cell(row=row, column=4, value=round(total_amount, 2)).number_format = NUM_FMT
|
|
178
|
+
ws.cell(row=row, column=4).font = BOLD
|
|
179
|
+
for c in range(1, 11):
|
|
180
|
+
ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ─── Sheet 2: Sin Draft ───
|
|
184
|
+
|
|
185
|
+
def build_sin_draft(wb, invoices, today):
|
|
186
|
+
ws = wb.create_sheet("Sin Draft")
|
|
187
|
+
tier_invs = [i for i in invoices if i.get("tier") == "fully_pending"]
|
|
188
|
+
tier_invs.sort(key=lambda i: i.get("posting_date", ""))
|
|
189
|
+
fill = TIER_FILLS["fully_pending"]
|
|
190
|
+
|
|
191
|
+
set_col_widths(ws, {"A": 18, "B": 14, "C": 32, "D": 14, "E": 16, "F": 14})
|
|
192
|
+
|
|
193
|
+
ws.cell(row=1, column=1, value=f"SIN DRAFT — {len(tier_invs)} facturas").font = BOLD_14
|
|
194
|
+
|
|
195
|
+
headers = ["Factura", "Fecha", "Proveedor", "No. Proveedor", "Monto (USD)", "Dias Pendiente"]
|
|
196
|
+
write_header_row(ws, 3, headers)
|
|
197
|
+
ws.freeze_panes = "A4"
|
|
198
|
+
|
|
199
|
+
row = 4
|
|
200
|
+
for inv in tier_invs:
|
|
201
|
+
ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
|
|
202
|
+
ws.cell(row=row, column=2, value=inv.get("posting_date", ""))
|
|
203
|
+
ws.cell(row=row, column=3, value=inv.get("vendor_name", ""))
|
|
204
|
+
ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
|
|
205
|
+
ws.cell(row=row, column=5, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
|
|
206
|
+
ws.cell(row=row, column=6, value=days_since(inv.get("posting_date", ""), today))
|
|
207
|
+
for c in range(1, 7):
|
|
208
|
+
ws.cell(row=row, column=c).fill = fill
|
|
209
|
+
row += 1
|
|
210
|
+
|
|
211
|
+
# Subtotal
|
|
212
|
+
if tier_invs:
|
|
213
|
+
ws.cell(row=row, column=1, value="TOTAL").font = SUBTOTAL_FONT
|
|
214
|
+
ws.cell(row=row, column=5, value=f"=SUM(E4:E{row - 1})").number_format = NUM_FMT
|
|
215
|
+
ws.cell(row=row, column=5).font = SUBTOTAL_FONT
|
|
216
|
+
for c in range(1, 7):
|
|
217
|
+
ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
|
|
218
|
+
|
|
219
|
+
return len(tier_invs)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ─── Sheet 3: Draft No Posteado ───
|
|
223
|
+
|
|
224
|
+
def build_draft_no_posteado(wb, invoices, today):
|
|
225
|
+
ws = wb.create_sheet("Draft No Posteado")
|
|
226
|
+
tier_invs = [i for i in invoices if i.get("tier") == "draft_in_progress"]
|
|
227
|
+
tier_invs.sort(key=lambda i: i.get("posting_date", ""))
|
|
228
|
+
fill = TIER_FILLS["draft_in_progress"]
|
|
229
|
+
|
|
230
|
+
set_col_widths(ws, {"A": 18, "B": 14, "C": 32, "D": 14, "E": 16, "F": 16, "G": 14, "H": 16, "I": 16})
|
|
231
|
+
|
|
232
|
+
ws.cell(row=1, column=1, value=f"DRAFT NO POSTEADO — {len(tier_invs)} facturas").font = BOLD_14
|
|
233
|
+
|
|
234
|
+
headers = ["Factura", "Fecha", "Proveedor", "No. Proveedor", "Monto (USD)", "No. MP", "Status MP", "Drafted (USD)", "Pendiente Neto"]
|
|
235
|
+
write_header_row(ws, 3, headers)
|
|
236
|
+
ws.freeze_panes = "A4"
|
|
237
|
+
|
|
238
|
+
row = 4
|
|
239
|
+
for inv in tier_invs:
|
|
240
|
+
mp = inv.get("multi_payment") or {}
|
|
241
|
+
ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
|
|
242
|
+
ws.cell(row=row, column=2, value=inv.get("posting_date", ""))
|
|
243
|
+
ws.cell(row=row, column=3, value=inv.get("vendor_name", ""))
|
|
244
|
+
ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
|
|
245
|
+
ws.cell(row=row, column=5, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
|
|
246
|
+
ws.cell(row=row, column=6, value=mp.get("mp_no", ""))
|
|
247
|
+
ws.cell(row=row, column=7, value=mp.get("status", ""))
|
|
248
|
+
ws.cell(row=row, column=8, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
|
|
249
|
+
ws.cell(row=row, column=9, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
|
|
250
|
+
for c in range(1, 10):
|
|
251
|
+
ws.cell(row=row, column=c).fill = fill
|
|
252
|
+
row += 1
|
|
253
|
+
|
|
254
|
+
if tier_invs:
|
|
255
|
+
ws.cell(row=row, column=1, value="TOTAL").font = SUBTOTAL_FONT
|
|
256
|
+
ws.cell(row=row, column=5, value=f"=SUM(E4:E{row - 1})").number_format = NUM_FMT
|
|
257
|
+
ws.cell(row=row, column=5).font = SUBTOTAL_FONT
|
|
258
|
+
ws.cell(row=row, column=8, value=f"=SUM(H4:H{row - 1})").number_format = NUM_FMT
|
|
259
|
+
ws.cell(row=row, column=8).font = SUBTOTAL_FONT
|
|
260
|
+
ws.cell(row=row, column=9, value=f"=SUM(I4:I{row - 1})").number_format = NUM_FMT
|
|
261
|
+
ws.cell(row=row, column=9).font = SUBTOTAL_FONT
|
|
262
|
+
for c in range(1, 10):
|
|
263
|
+
ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
|
|
264
|
+
|
|
265
|
+
return len(tier_invs)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ─── Sheet 4: Pago Parcial ───
|
|
269
|
+
|
|
270
|
+
def build_pago_parcial(wb, invoices, today):
|
|
271
|
+
ws = wb.create_sheet("Pago Parcial")
|
|
272
|
+
tier_invs = [i for i in invoices if i.get("tier") == "in_journal"]
|
|
273
|
+
tier_invs.sort(key=lambda i: i.get("posting_date", ""))
|
|
274
|
+
fill = TIER_FILLS["in_journal"]
|
|
275
|
+
|
|
276
|
+
set_col_widths(ws, {"A": 18, "B": 14, "C": 32, "D": 14, "E": 16, "F": 16, "G": 14, "H": 16, "I": 16})
|
|
277
|
+
|
|
278
|
+
ws.cell(row=1, column=1, value=f"PAGO PARCIAL — {len(tier_invs)} facturas").font = BOLD_14
|
|
279
|
+
|
|
280
|
+
headers = ["Factura", "Fecha", "Proveedor", "No. Proveedor", "Monto (USD)", "No. MP", "Status MP", "Drafted (USD)", "Pendiente Neto"]
|
|
281
|
+
write_header_row(ws, 3, headers)
|
|
282
|
+
ws.freeze_panes = "A4"
|
|
283
|
+
|
|
284
|
+
row = 4
|
|
285
|
+
for inv in tier_invs:
|
|
286
|
+
mp = inv.get("multi_payment") or {}
|
|
287
|
+
ws.cell(row=row, column=1, value=inv.get("invoice_no", ""))
|
|
288
|
+
ws.cell(row=row, column=2, value=inv.get("posting_date", ""))
|
|
289
|
+
ws.cell(row=row, column=3, value=inv.get("vendor_name", ""))
|
|
290
|
+
ws.cell(row=row, column=4, value=inv.get("vendor_no", ""))
|
|
291
|
+
ws.cell(row=row, column=5, value=inv.get("invoice_amount", 0)).number_format = NUM_FMT
|
|
292
|
+
ws.cell(row=row, column=6, value=mp.get("mp_no", ""))
|
|
293
|
+
ws.cell(row=row, column=7, value=mp.get("status", ""))
|
|
294
|
+
ws.cell(row=row, column=8, value=mp.get("total_payment_amount", 0)).number_format = NUM_FMT
|
|
295
|
+
ws.cell(row=row, column=9, value=mp.get("net_pending", inv.get("invoice_amount", 0))).number_format = NUM_FMT
|
|
296
|
+
for c in range(1, 10):
|
|
297
|
+
ws.cell(row=row, column=c).fill = fill
|
|
298
|
+
row += 1
|
|
299
|
+
|
|
300
|
+
if tier_invs:
|
|
301
|
+
ws.cell(row=row, column=1, value="TOTAL").font = SUBTOTAL_FONT
|
|
302
|
+
ws.cell(row=row, column=5, value=f"=SUM(E4:E{row - 1})").number_format = NUM_FMT
|
|
303
|
+
ws.cell(row=row, column=5).font = SUBTOTAL_FONT
|
|
304
|
+
ws.cell(row=row, column=8, value=f"=SUM(H4:H{row - 1})").number_format = NUM_FMT
|
|
305
|
+
ws.cell(row=row, column=8).font = SUBTOTAL_FONT
|
|
306
|
+
ws.cell(row=row, column=9, value=f"=SUM(I4:I{row - 1})").number_format = NUM_FMT
|
|
307
|
+
ws.cell(row=row, column=9).font = SUBTOTAL_FONT
|
|
308
|
+
for c in range(1, 10):
|
|
309
|
+
ws.cell(row=row, column=c).fill = SUBTOTAL_FILL
|
|
310
|
+
|
|
311
|
+
return len(tier_invs)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ─── Main ───
|
|
315
|
+
|
|
316
|
+
def main():
|
|
317
|
+
parser = argparse.ArgumentParser(description="Generate CxP Excel report")
|
|
318
|
+
parser.add_argument("input_json", help="Path to input JSON file")
|
|
319
|
+
parser.add_argument("output_xlsx", help="Path to output Excel file")
|
|
320
|
+
args = parser.parse_args()
|
|
321
|
+
|
|
322
|
+
input_path = Path(args.input_json)
|
|
323
|
+
output_path = Path(args.output_xlsx)
|
|
324
|
+
|
|
325
|
+
if not input_path.exists():
|
|
326
|
+
print(f"Error: Input file not found: {input_path}", file=sys.stderr)
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
|
|
329
|
+
with open(input_path, "r", encoding="utf-8") as f:
|
|
330
|
+
data = json.load(f)
|
|
331
|
+
|
|
332
|
+
invoices = data.get("invoices", [])
|
|
333
|
+
today = date.today()
|
|
334
|
+
if data.get("report_date"):
|
|
335
|
+
try:
|
|
336
|
+
today = datetime.strptime(data["report_date"], "%Y-%m-%d").date()
|
|
337
|
+
except ValueError:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
wb = Workbook()
|
|
341
|
+
build_resumen(wb, data, invoices, today)
|
|
342
|
+
n_sin = build_sin_draft(wb, invoices, today)
|
|
343
|
+
n_draft = build_draft_no_posteado(wb, invoices, today)
|
|
344
|
+
n_parcial = build_pago_parcial(wb, invoices, today)
|
|
345
|
+
|
|
346
|
+
wb.save(str(output_path))
|
|
347
|
+
print(json.dumps({
|
|
348
|
+
"file": str(output_path),
|
|
349
|
+
"sheets": {"sin_draft": n_sin, "draft_no_posteado": n_draft, "pago_parcial": n_parcial},
|
|
350
|
+
"total": len(invoices),
|
|
351
|
+
}))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
main()
|
package/server.js
CHANGED
|
@@ -59,6 +59,24 @@ import {
|
|
|
59
59
|
draftSummaryTool, handleDraftSummary,
|
|
60
60
|
} from './tools/multi-payment/index.js';
|
|
61
61
|
|
|
62
|
+
// Payroll tools
|
|
63
|
+
import {
|
|
64
|
+
payrollDocumentsTool, handlePayrollDocuments,
|
|
65
|
+
payrollLinesTool, handlePayrollLines,
|
|
66
|
+
employeesTool, handleEmployees,
|
|
67
|
+
} from './tools/payroll/index.js';
|
|
68
|
+
|
|
69
|
+
// Inventario tools (inventory cost analysis)
|
|
70
|
+
import {
|
|
71
|
+
itemCardTool, handleItemCard,
|
|
72
|
+
itemLedgerEntriesTool, handleItemLedgerEntries,
|
|
73
|
+
itemValueEntriesTool, handleItemValueEntries,
|
|
74
|
+
} from './tools/inventario/index.js';
|
|
75
|
+
|
|
76
|
+
// Reports tools
|
|
77
|
+
import { managerReportTool, handleGenerateManagerReport } from './tools/reports/manager-report.js';
|
|
78
|
+
import { cxpReportTool, handleGenerateCxpReport } from './tools/reports/cxp-report.js';
|
|
79
|
+
|
|
62
80
|
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
|
|
63
81
|
const bcClient = new BCClient();
|
|
64
82
|
|
|
@@ -108,6 +126,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
108
126
|
draftReceivablesTool,
|
|
109
127
|
draftPayablesTool,
|
|
110
128
|
draftSummaryTool,
|
|
129
|
+
// Payroll
|
|
130
|
+
payrollDocumentsTool,
|
|
131
|
+
payrollLinesTool,
|
|
132
|
+
employeesTool,
|
|
133
|
+
// Inventario (inventory cost analysis)
|
|
134
|
+
itemCardTool,
|
|
135
|
+
itemLedgerEntriesTool,
|
|
136
|
+
itemValueEntriesTool,
|
|
137
|
+
// Reports
|
|
138
|
+
managerReportTool,
|
|
139
|
+
cxpReportTool,
|
|
111
140
|
],
|
|
112
141
|
};
|
|
113
142
|
});
|
|
@@ -204,6 +233,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
204
233
|
case 'get_draft_summary':
|
|
205
234
|
result = await handleDraftSummary(bcClient, args);
|
|
206
235
|
break;
|
|
236
|
+
// Payroll
|
|
237
|
+
case 'get_payroll_documents':
|
|
238
|
+
result = await handlePayrollDocuments(bcClient, args);
|
|
239
|
+
break;
|
|
240
|
+
case 'get_payroll_lines':
|
|
241
|
+
result = await handlePayrollLines(bcClient, args);
|
|
242
|
+
break;
|
|
243
|
+
case 'get_employees':
|
|
244
|
+
result = await handleEmployees(bcClient, args);
|
|
245
|
+
break;
|
|
246
|
+
// Inventario (inventory cost analysis)
|
|
247
|
+
case 'get_item_card':
|
|
248
|
+
result = await handleItemCard(bcClient, args);
|
|
249
|
+
break;
|
|
250
|
+
case 'get_item_ledger_entries':
|
|
251
|
+
result = await handleItemLedgerEntries(bcClient, args);
|
|
252
|
+
break;
|
|
253
|
+
case 'get_item_value_entries':
|
|
254
|
+
result = await handleItemValueEntries(bcClient, args);
|
|
255
|
+
break;
|
|
256
|
+
// Reports
|
|
257
|
+
case 'generate_manager_report':
|
|
258
|
+
result = await handleGenerateManagerReport(bcClient, args);
|
|
259
|
+
break;
|
|
260
|
+
case 'generate_cxp_report':
|
|
261
|
+
result = await handleGenerateCxpReport(bcClient, args);
|
|
262
|
+
break;
|
|
207
263
|
default:
|
|
208
264
|
return {
|
|
209
265
|
content: [{ type: 'text', text: `Herramienta desconocida: ${name}` }],
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { resolveStores } from '../../config/company-config.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export const itemCardTool = {
|
|
5
|
+
name: 'get_item_card',
|
|
6
|
+
description:
|
|
7
|
+
'Datos maestros de ítems de inventario: unitCost, costingMethod, inventory qty, unitPrice, categoría. Filtra por número de ítem, búsqueda por nombre, o categoría.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
store: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: ['FQ01', 'FQ28', 'FQ88'],
|
|
14
|
+
description: 'Tienda a consultar.',
|
|
15
|
+
},
|
|
16
|
+
item_number: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Número de ítem exacto (e.g. FQ0850).',
|
|
19
|
+
},
|
|
20
|
+
item_search: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Búsqueda parcial por nombre (case-insensitive, client-side).',
|
|
23
|
+
},
|
|
24
|
+
item_category: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Filtrar por itemCategoryCode.',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ['store'],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function handleItemCard(bcClient, args) {
|
|
34
|
+
const storeParam = args.store;
|
|
35
|
+
if (!storeParam) throw new Error('Parámetro requerido: store');
|
|
36
|
+
|
|
37
|
+
const stores = resolveStores([storeParam]);
|
|
38
|
+
const store = stores[0];
|
|
39
|
+
const companyId = store.companyId;
|
|
40
|
+
|
|
41
|
+
const filters = [];
|
|
42
|
+
if (args.item_number) {
|
|
43
|
+
filters.push(`number eq '${args.item_number}'`);
|
|
44
|
+
}
|
|
45
|
+
if (args.item_category) {
|
|
46
|
+
filters.push(`itemCategoryCode eq '${args.item_category}'`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const params = {
|
|
50
|
+
$select: 'number,displayName,type,itemCategoryCode,unitCost,unitPrice,inventory,costingMethod,blocked',
|
|
51
|
+
$orderby: 'number',
|
|
52
|
+
};
|
|
53
|
+
if (filters.length > 0) params.$filter = filters.join(' and ');
|
|
54
|
+
if (!args.item_number && !args.item_search) params.$top = '50';
|
|
55
|
+
|
|
56
|
+
const url = bcClient.buildApiUrl(companyId, 'items', params);
|
|
57
|
+
let data = await bcClient.apiCallAllPages(url);
|
|
58
|
+
|
|
59
|
+
logger.info(`${store.code}: ${data.length} items returned`);
|
|
60
|
+
|
|
61
|
+
// Client-side name search
|
|
62
|
+
if (args.item_search) {
|
|
63
|
+
const search = args.item_search.toLowerCase();
|
|
64
|
+
data = data.filter((item) => item.displayName && item.displayName.toLowerCase().includes(search));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const items = data.map((item) => ({
|
|
68
|
+
number: item.number,
|
|
69
|
+
name: item.displayName,
|
|
70
|
+
type: item.type,
|
|
71
|
+
category: item.itemCategoryCode || null,
|
|
72
|
+
unit_cost: round2(item.unitCost || 0),
|
|
73
|
+
unit_price: round2(item.unitPrice || 0),
|
|
74
|
+
inventory: round2(item.inventory || 0),
|
|
75
|
+
costing_method: item.costingMethod || null,
|
|
76
|
+
blocked: item.blocked || false,
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
store: store.code,
|
|
81
|
+
store_name: store.name,
|
|
82
|
+
total_items: items.length,
|
|
83
|
+
items,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function round2(n) {
|
|
88
|
+
return Math.round(n * 100) / 100;
|
|
89
|
+
}
|