@fullqueso/mcp-bc-gastos 1.13.0 → 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/package.json +2 -2
- package/scripts/generate-cxp-report-xlsx.py +355 -0
- package/server.js +26 -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/reports/cxp-report.js +150 -0
- package/tools/reports/manager-report.js +156 -9
- 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/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, multi-payment draft visibility, payroll, and manager reports - Full Queso franchise stores",
|
|
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"
|
|
@@ -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
|
@@ -66,8 +66,16 @@ import {
|
|
|
66
66
|
employeesTool, handleEmployees,
|
|
67
67
|
} from './tools/payroll/index.js';
|
|
68
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
|
+
|
|
69
76
|
// Reports tools
|
|
70
77
|
import { managerReportTool, handleGenerateManagerReport } from './tools/reports/manager-report.js';
|
|
78
|
+
import { cxpReportTool, handleGenerateCxpReport } from './tools/reports/cxp-report.js';
|
|
71
79
|
|
|
72
80
|
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
|
|
73
81
|
const bcClient = new BCClient();
|
|
@@ -122,8 +130,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
122
130
|
payrollDocumentsTool,
|
|
123
131
|
payrollLinesTool,
|
|
124
132
|
employeesTool,
|
|
133
|
+
// Inventario (inventory cost analysis)
|
|
134
|
+
itemCardTool,
|
|
135
|
+
itemLedgerEntriesTool,
|
|
136
|
+
itemValueEntriesTool,
|
|
125
137
|
// Reports
|
|
126
138
|
managerReportTool,
|
|
139
|
+
cxpReportTool,
|
|
127
140
|
],
|
|
128
141
|
};
|
|
129
142
|
});
|
|
@@ -230,10 +243,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
230
243
|
case 'get_employees':
|
|
231
244
|
result = await handleEmployees(bcClient, args);
|
|
232
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;
|
|
233
256
|
// Reports
|
|
234
257
|
case 'generate_manager_report':
|
|
235
258
|
result = await handleGenerateManagerReport(bcClient, args);
|
|
236
259
|
break;
|
|
260
|
+
case 'generate_cxp_report':
|
|
261
|
+
result = await handleGenerateCxpReport(bcClient, args);
|
|
262
|
+
break;
|
|
237
263
|
default:
|
|
238
264
|
return {
|
|
239
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { resolveStores } from '../../config/company-config.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export const itemLedgerEntriesTool = {
|
|
5
|
+
name: 'get_item_ledger_entries',
|
|
6
|
+
description:
|
|
7
|
+
'Entradas del libro de artículos (Item Ledger Entries) — capas FIFO abiertas/cerradas con cantidades restantes y costo real. Esencial para diagnosticar por qué el unitCost de un ítem tiene cierto valor. Por defecto muestra solo capas abiertas.',
|
|
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
|
+
open_only: {
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
description: 'true = solo entradas abiertas (capas FIFO activas). Default: true.',
|
|
23
|
+
},
|
|
24
|
+
entry_type: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
enum: ['Purchase', 'Sale', 'Positive Adjmt.', 'Negative Adjmt.', 'Transfer', 'Consumption', 'Output'],
|
|
27
|
+
description: 'Filtrar por tipo de entrada.',
|
|
28
|
+
},
|
|
29
|
+
start_date: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Fecha inicio (YYYY-MM-DD).',
|
|
32
|
+
},
|
|
33
|
+
end_date: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Fecha fin (YYYY-MM-DD).',
|
|
36
|
+
},
|
|
37
|
+
top: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
description: 'Limitar resultados. Default: 100.',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ['store', 'item_number'],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export async function handleItemLedgerEntries(bcClient, args) {
|
|
47
|
+
const storeParam = args.store;
|
|
48
|
+
if (!storeParam) throw new Error('Parámetro requerido: store');
|
|
49
|
+
if (!args.item_number) throw new Error('Parámetro requerido: item_number');
|
|
50
|
+
|
|
51
|
+
const stores = resolveStores([storeParam]);
|
|
52
|
+
const store = stores[0];
|
|
53
|
+
const companyId = store.companyId;
|
|
54
|
+
|
|
55
|
+
const openOnly = args.open_only !== false; // default true
|
|
56
|
+
const filters = [`itemNumber eq '${args.item_number}'`];
|
|
57
|
+
|
|
58
|
+
if (openOnly) filters.push('open eq true');
|
|
59
|
+
if (args.entry_type) filters.push(`entryType eq '${args.entry_type}'`);
|
|
60
|
+
if (args.start_date) filters.push(`postingDate ge ${args.start_date}`);
|
|
61
|
+
if (args.end_date) filters.push(`postingDate le ${args.end_date}`);
|
|
62
|
+
|
|
63
|
+
const top = args.top || 100;
|
|
64
|
+
|
|
65
|
+
const url = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
|
|
66
|
+
$filter: filters.join(' and '),
|
|
67
|
+
$select: 'entryNumber,itemNumber,postingDate,entryType,documentNumber,description,quantity,remainingQuantity,costAmountActual,costAmountExpected,open,locationCode',
|
|
68
|
+
$orderby: 'postingDate desc',
|
|
69
|
+
$top: String(top),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const data = await bcClient.apiCall(url);
|
|
73
|
+
logger.info(`${store.code}: ${data.length} item ledger entries for ${args.item_number} (open_only=${openOnly})`);
|
|
74
|
+
|
|
75
|
+
const entries = data.map((e) => ({
|
|
76
|
+
entry_number: e.entryNumber,
|
|
77
|
+
item_number: e.itemNumber,
|
|
78
|
+
posting_date: e.postingDate,
|
|
79
|
+
entry_type: e.entryType,
|
|
80
|
+
document_number: e.documentNumber || null,
|
|
81
|
+
description: e.description || null,
|
|
82
|
+
quantity: round5(e.quantity || 0),
|
|
83
|
+
remaining_quantity: round5(e.remainingQuantity || 0),
|
|
84
|
+
cost_amount_actual: round2(e.costAmountActual || 0),
|
|
85
|
+
cost_amount_expected: round2(e.costAmountExpected || 0),
|
|
86
|
+
unit_cost_actual: e.quantity ? round5(Math.abs((e.costAmountActual || 0) / e.quantity)) : 0,
|
|
87
|
+
open: e.open,
|
|
88
|
+
location_code: e.locationCode || null,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// Summary for open layers
|
|
92
|
+
const openEntries = entries.filter((e) => e.open);
|
|
93
|
+
const totalRemainingQty = openEntries.reduce((sum, e) => sum + e.remaining_quantity, 0);
|
|
94
|
+
const totalCostOpen = openEntries.reduce((sum, e) => {
|
|
95
|
+
// Proportional cost for remaining qty
|
|
96
|
+
if (e.quantity !== 0) {
|
|
97
|
+
return sum + (e.cost_amount_actual * (e.remaining_quantity / e.quantity));
|
|
98
|
+
}
|
|
99
|
+
return sum;
|
|
100
|
+
}, 0);
|
|
101
|
+
const weightedAvgCost = totalRemainingQty !== 0 ? Math.abs(totalCostOpen / totalRemainingQty) : 0;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
store: store.code,
|
|
105
|
+
store_name: store.name,
|
|
106
|
+
item_number: args.item_number,
|
|
107
|
+
filter: { open_only: openOnly, entry_type: args.entry_type || 'all' },
|
|
108
|
+
total_entries: entries.length,
|
|
109
|
+
open_layers_summary: {
|
|
110
|
+
open_layers_count: openEntries.length,
|
|
111
|
+
total_remaining_qty: round5(totalRemainingQty),
|
|
112
|
+
total_cost_remaining: round2(totalCostOpen),
|
|
113
|
+
weighted_avg_unit_cost: round5(weightedAvgCost),
|
|
114
|
+
},
|
|
115
|
+
entries,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function round2(n) {
|
|
120
|
+
return Math.round(n * 100) / 100;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function round5(n) {
|
|
124
|
+
return Math.round(n * 100000) / 100000;
|
|
125
|
+
}
|