@fullqueso/mcp-bc-gastos 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.33.0] — 2026-07-11
4
+
5
+ ### Added — `get_income_statement`: estado de resultados en formato Power BI (Level 1)
6
+ Nuevo tool en `tools/financials/income-statement.js`. READ-ONLY.
7
+
8
+ - **Qué expone:** el reporte "Income Statement by Month" de Power BI (G/L Account **Level 1**)
9
+ para una tienda y un mes, agregando los General Ledger entries por cuenta level-1 con la
10
+ convención de signos de Power BI (`amount = debitAmount − creditAmount` → ingresos negativos,
11
+ costos/gastos positivos, Total negativo = ganancia). Devuelve las 3 secciones (40001/50001/60001),
12
+ cuentas level-1, totales, Total general y un **`accounts_map`** listo para el generador Excel
13
+ del skill-fq-resultados-mensual (`data.json`). Con `level=2` incluye las cuentas de posteo
14
+ hijas con su nombre real de BC.
15
+ - **Por qué:** resuelve las fronteras que `get_financial_statements` (agrega por categoría) no
16
+ separa: nómina **71000** vs impuestos **74000**, y otros **74000** (80xxx) vs **90000** (9xxxx).
17
+ Trabaja desde los entries por cuenta, así que cuadra 1:1 con Power BI.
18
+ - **Estructura level-1** embebida (`COA_PBI`) — fuente: screenshots Power BI FQ28, CoA compartido
19
+ FQ01/FQ28/FQ88. Espejo de `skill-fq-resultados-mensual/references/income_statement_coa.json`.
20
+ - **Nuevo en bc-client:** `getChartOfAccountNames(companyId)` (entidad BC `accounts`, cache por
21
+ companyId) para etiquetar las cuentas hijas en level 2.
22
+ - **Test:** `tests/test-income-statement.js` (agregación pura con GL entries mock, sin BC):
23
+ valida signos, mapeo por rangos, fronteras 71000/74000 y 74000/90000, totales y level 2.
24
+ - La columna **Ajustado** (drafts como posteados) y la reclasificación de dividendos las agrega
25
+ el skill sobre el `posted` — el MCP entrega solo el posted objetivo del GL.
26
+
27
+ ## [1.32.1] — 2026-07-04
28
+
29
+ ### Docs — README "Tools" regenerado desde el código (22 stale → 57 reales)
30
+ - **`scripts/generate-tools-doc.mjs`**: importa dinámicamente todos los `*Tool` de
31
+ `tools/**/*.js` (incluye definiciones en `index.js`, dedup por `name`), agrupa por
32
+ categoría y reescribe la sección `## Tools (N)` del README. Verificado 57/57 contra
33
+ el switch de `server.js`. Uso: `node scripts/generate-tools-doc.mjs --write`
34
+ (sin flag = dry-run a stdout). La sección queda marcada como autogenerada.
35
+
36
+ ## [1.32.0] — 2026-07-04
37
+
38
+ ### Added — `get_unposted_invoices`: facturas sin postear para Cierre Parcial
39
+ Nuevo tool en el cluster de draft visibility (`tools/multi-payment/unposted-invoices.js`).
40
+ Verificado contra BC real (mayo 2026, 4 empresas). READ-ONLY.
41
+
42
+ - **Qué expone:** facturas de compra y venta con `status` `Draft`/`In Review` en
43
+ `api/v2.0` `purchaseInvoices`/`salesInvoices` — documentos que NO están en el GL.
44
+ Complementa a `get_draft_payables`/`receivables`/`summary`, que son Multi-Payments
45
+ (settlement) sobre facturas ya posteadas y por tanto no sirven como ajuste P&L.
46
+ - **Para qué:** ajuste de CIERRE PARCIAL del P&L en `skill-fq-resultados-mensual`
47
+ (compras draft → +costo/gasto; ventas draft no-IC → +ingreso). Cada tienda incluye
48
+ `pl_adjustment_hint` con los montos listos.
49
+ - **Params:** `store` (incl. `all`), `type` (purchases|sales|both), `start_date`/`end_date`
50
+ (filtro por `postingDate`), `include_in_review` (default true), `summary_only`.
51
+ - **Multi-moneda correcto:** totales SIEMPRE por moneda (`by_currency`,
52
+ `non_ic_by_currency`, consolidado por moneda) — nunca se suman VES con USD.
53
+ `LCY` = USD; VES se convierte aguas arriba con `get_exchange_rate`.
54
+ - **Ventas IC:** separa clientes `IC-*` (`intercompany`) del total ajustable.
55
+ - **Smoke test:** `tests/test-unposted-invoices.js` (store y mes por CLI).
56
+ - Hallazgo del estreno (mayo 2026): FQ01 11 compras sin postear (Bs 1,838,737.54),
57
+ FQ28 4 (Bs 1,210,031.04), FQFR 2 ($7,116.50), FQ01 1 venta IC (Bs 165,456.04).
58
+
3
59
  ## [1.31.0] — 2026-07-01
4
60
 
5
61
  ### Added — KPIs de nómina operativa: tienda vs eventos, activos vs pagados
package/README.md CHANGED
@@ -52,111 +52,264 @@ cp .env.example .env
52
52
  npm start
53
53
  ```
54
54
 
55
- ## Tools (22)
55
+ ## Tools (58)
56
56
 
57
- ### Expense Analysis (5 tools)
57
+ _Sección generada por `scripts/generate-tools-doc.mjs` — no editar a mano._
58
+ _Regenerar con: `node scripts/generate-tools-doc.mjs --write`_
58
59
 
59
- #### get_expense_analysis
60
- Detailed expense analysis by category with account-level detail and benchmark comparisons.
61
- - Params: `stores`, `period`, `month`, `start_date`, `end_date`
62
-
63
- #### get_efficiency_ratios
64
- Financial ratios: expense-to-income, payroll, rent, utilities, marketing, operating margin.
65
- - Params: `stores`, `period`, `month`, `start_date`, `end_date`
60
+ ### Expense Analysis & Core (11 tools)
66
61
 
67
62
  #### compare_stores
68
- Compare all stores with efficiency rankings, variances, and savings opportunities.
63
+ Compara la eficiencia de gastos entre las tiendas de Full Queso (FQ01 Chacao, FQ28 Marqués, FQ88 Candelaria).
69
64
  - Params: `period`, `month`, `start_date`, `end_date`
70
65
 
71
66
  #### detect_anomalies
72
- Detect expense anomalies with severity levels, root causes, and recommended actions.
73
- - Params: `stores`, `period`, `month`, `start_date`, `end_date`
67
+ Detecta anomalías en los gastos operacionales de Full Queso: gastos por encima de benchmarks, incrementos inusuales vs periodo anterior, concentración excesiva en una cuenta, y alertas de margen.
68
+ - Params: `period`, `month`, `start_date`, `end_date`, `stores`, `sensitivity`
74
69
 
75
- #### get_trends
76
- Historical trend analysis (up to 6 months) with growth rates, seasonality, and ASCII charts.
77
- - Params: `store`, `months`
70
+ #### get_account_transactions
71
+ Listado completo de transacciones para una cuenta contable específica con balance running y nombre de proveedor.
72
+ - Params: `account_number`*, `store`*, `start_date`*, `end_date`*
78
73
 
79
- ### Drill-Down & Vendors (4 tools)
74
+ #### get_crm_rate
75
+ Obtiene la tasa de cambio Bs/USD desde el CRM de Full Queso (hora Caracas).
76
+ - Params: `coin`*, `date`
77
+
78
+ #### get_efficiency_ratios
79
+ Calcula ratios de eficiencia financiera de Full Queso: gastos/ingresos, nómina/ingresos, alquiler/ingresos, servicios/ingresos, marketing/ingresos y margen operativo.
80
+ - Params: `period`, `month`, `start_date`, `end_date`, `stores`
81
+
82
+ #### get_exchange_rate
83
+ Obtiene la tasa de cambio USD → VES desde Business Central.
84
+ - Params: `store`*, `date`, `start_date`, `end_date`
85
+
86
+ #### get_expense_analysis
87
+ Análisis detallado de gastos operacionales de Full Queso por categoría (nómina, alquiler, servicios, marketing, etc.) con números de cuenta específicos.
88
+ - Params: `period`, `month`, `start_date`, `end_date`, `stores`
80
89
 
81
90
  #### get_expense_details
82
- Transaction-level drill-down with vendor lookup, category/account filters, and pagination.
83
- - Params: `store` (req), `period`, `month`, `start_date`, `end_date`, `category`, `account_number`, `min_amount`, `vendor_search`, `limit`, `offset`
91
+ Drill-down de transacciones individuales de gastos operacionales.
92
+ - Params: `store`*, `period`, `month`, `start_date`, `end_date`, `category`, `account_number`, `min_amount`, `vendor_search`, `limit`, `offset`
84
93
 
85
- #### get_account_transactions
86
- Per-account ledger with running balance and vendor information.
87
- - Params: `account_number` (req), `store` (req), `start_date` (req), `end_date` (req)
94
+ #### get_trends
95
+ Análisis de tendencias históricas de gastos e ingresos de Full Queso.
96
+ - Params: `months`, `store`
88
97
 
89
98
  #### get_vendor_transactions
90
- All transactions for a vendor (partial name search) with account breakdown.
91
- - Params: `store` (req), `vendor_search` (req), `start_date` (req), `end_date` (req)
99
+ Todas las transacciones de gastos operacionales de un proveedor específico.
100
+ - Params: `store`*, `vendor_search`*, `start_date`*, `end_date`*
92
101
 
93
102
  #### list_vendors
94
- Directory of active vendors ordered by total amount paid.
95
- - Params: `store` (req), `start_date`, `end_date`
103
+ Lista todos los proveedores activos de una tienda en un periodo, con monto total pagado y número de transacciones.
104
+ - Params: `store`*, `start_date`, `end_date`
105
+
106
+ ### Auditoría / POS Reconciliation (11 tools)
96
107
 
97
- ### Bank Reconciliation (7 tools)
108
+ #### find_potential_matches
109
+ Para una línea no conciliada del banco, busca posibles correspondencias en las entradas contables de BC por monto, fecha y descripción.
110
+ - Params: `store`*, `bank_account`*, `statement_amount`*, `transaction_date`*, `description`, `date_tolerance_days`, `amount_tolerance_pct`
98
111
 
99
- > These tools use OData V4 Web Services. Amounts in VES. Read-only.
112
+ #### get_bank_ledger_entries
113
+ Todos los movimientos del libro de banco (abiertos y cerrados) para una cuenta bancaria.
114
+ - Params: `store`*, `bank_account`*, `date_from`*, `date_to`*, `open_only`
100
115
 
101
- #### list_bank_accounts
102
- List active bank accounts for a store.
103
- - Params: `store` (req)
116
+ #### get_bank_reconciliation_report
117
+ Reporte consolidado de reconciliación bancaria: progreso de todos los statements abiertos, líneas no conciliadas (débitos y créditos por separado), y sugerencias de asientos contables para débitos.
118
+ - Params: `store`*, `bank_account`*, `statement_no`, `month`, `min_amount`, `include_suggestions`, `save_to_file`, `excel_output`
119
+
120
+ #### get_gl_account_entries
121
+ Movimientos del libro mayor (G/L) para una cuenta específica.
122
+ - Params: `store`*, `gl_account`*, `date_from`*, `date_to`*
123
+
124
+ #### get_pm_receipts
125
+ Recibos de Pago Móvil (PM) registrados en BC para una cuenta bancaria.
126
+ - Params: `store`*, `bank_account`*, `date_from`*, `date_to`*
104
127
 
105
128
  #### get_reconciliation_status
106
- Open reconciliation summary: matched vs unmatched lines per bank account.
107
- - Params: `store` (req), `bank_account`
129
+ Resumen del estado de reconciliaciones bancarias abiertas: total líneas, conciliadas, pendientes y diferencia.
130
+ - Params: `store`*, `bank_account`
131
+
132
+ #### get_unmatched_ledger_entries
133
+ Entradas contables del banco en BC sin correspondencia en el estado de cuenta.
134
+ - Params: `store`*, `bank_account`*, `date_from`, `date_to`
108
135
 
109
136
  #### get_unmatched_statement_lines
110
- Bank statement lines not reconciled with BC, categorized by type (payments/deposits).
111
- - Params: `store` (req), `bank_account` (req), `statement_no`, `min_amount`, `type_filter`
137
+ Líneas del estado de cuenta bancario NO conciliadas con entradas en BC.
138
+ - Params: `store`*, `bank_account`*, `statement_no`, `min_amount`, `type_filter`
112
139
 
113
- #### get_unmatched_ledger_entries
114
- BC ledger entries not cleared at the bank, with stale entry detection (>30 days).
115
- - Params: `store` (req), `bank_account` (req), `date_from`, `date_to`
140
+ #### list_bank_accounts
141
+ Lista las cuentas bancarias de una tienda Full Queso.
142
+ - Params: `store`*
116
143
 
117
- #### find_potential_matches
118
- Scoring-based match finder (0-100) for unmatched bank lines against BC entries.
119
- - Params: `store` (req), `bank_account` (req), `statement_amount` (req), `transaction_date` (req), `description`, `date_tolerance_days`, `amount_tolerance_pct`
144
+ #### reconcile_pos_sales
145
+ Conciliación automática de ventas POS: cruza montos bancarios de BC (BankAccountLedgerEntries) con liquidaciones bancarias por número de lote, agrupa por liquidación bancaria (settlement batches), calcula comisiones reales.
146
+ - Params: `store`*, `start_date`*, `end_date`*, `bank_account`
120
147
 
121
148
  #### suggest_journal_entries
122
- Suggest GL journal entries for unreconciled bank payments using keyword matching and historical patterns.
123
- - Params: `store` (req), `bank_account` (req), `statement_no`, `auto_categorize`
149
+ Para pagos del banco sin correspondencia en BC, sugiere asientos contables basándose en la descripción y patrones históricos.
150
+ - Params: `store`*, `bank_account`*, `statement_no`, `auto_categorize`
124
151
 
125
- #### reconcile_pos_sales
126
- Multi-bank POS sales reconciliation. Matches bank deposits against BC lot records with commission tracking.
127
- - **Banesco**: lot-based matching, commission netted in deposit
128
- - **Bancrecer**: lot-based with separate commission/ISLR lines
129
- - **BDV**: aggregate matching by period totals (no lot numbers)
130
- - **UBII**: cross-account matching (BC virtual account → Bancrecer/BDV deposits)
131
- - Params: `store` (req), `start_date` (req), `end_date` (req), `bank_account`
152
+ ### Cierre Mensual Bancario (6 tools)
153
+
154
+ #### generate_closing_journal
155
+ Genera el EXCEL del CIERRE MENSUAL bancario (mes completo, multi-banco).
156
+ - Params: `store`*, `month`*, `output_dir`, `allow_partial`, `strict`
132
157
 
133
- ### Accounts Receivable & Payable (6 tools)
158
+ #### get_closing_match_results
159
+ CIERRE MENSUAL bancario: vista paginada de las 4 listas con auto-clasificación.
160
+ - Params: `store`*, `month`*, `list`, `bank_account`, `category`, `source`, `confidence`, `min_abs_amount_ves`, `sort`, `limit`, `offset`
134
161
 
135
- > These tools use the Finance Reports Beta API. Amounts in VES and USD. Read-only.
162
+ #### get_closing_questionnaire
163
+ CIERRE MENSUAL bancario: vista paginada/filtrada de las entradas del cuestionario del MES.
164
+ - Params: `store`*, `month`*, `bucket`, `counterparty_no`, `bank_account`, `status_filter`, `min_abs_amount_ves`, `min_abs_amount_usd`, `limit`, `offset`, `sort`, `pair_by_amount`, `pair_tolerance_pct`, `pair_max_days`, `description_regex`
165
+
166
+ #### reconcile_closing_with_bc
167
+ CIERRE MENSUAL bancario: refresca el estado contra BC para detectar drift desde la última corrida.
168
+ - Params: `store`*, `month`*
169
+
170
+ #### start_month_closing
171
+ CIERRE MENSUAL bancario (mes completo, multi-banco) en Business Central.
172
+ - Params: `store`*, `month`, `force_refresh`
173
+
174
+ #### submit_closing_answers
175
+ CIERRE MENSUAL bancario: registra respuestas/aprobaciones al cuestionario del MES.
176
+ - Params: `store`*, `month`*, `user`, `answers`*
177
+
178
+ ### Cobranzas (AR / AP) (7 tools)
179
+
180
+ #### get_collection_status
181
+ Verificación rápida del estado de cobranza de un período específico.
182
+ - Params: `store`*, `start_date`*, `end_date`*
136
183
 
137
184
  #### get_customer_balances
138
- Customers with outstanding balances.
139
- - Params: `store` (req), `only_with_balance`, `customer_number`
185
+ Lista clientes con saldos pendientes, montos vencidos y estado de cobranza.
186
+ - Params: `store`*, `only_with_balance`, `customer_number`
140
187
 
141
188
  #### get_customer_ledger
142
- Customer transaction history: invoices, payments, credit memos with collection rate.
143
- - Params: `store` (req), `start_date` (req), `end_date` (req), `customer_number`, `document_type`, `open_only`
189
+ Movimientos del libro mayor de clientes: facturas emitidas, pagos recibidos, notas de crédito.
190
+ - Params: `store`*, `start_date`*, `end_date`*, `customer_number`, `document_type`, `open_only`
144
191
 
145
- #### get_open_receivables
146
- Open accounts receivable by customer with aging buckets (0-30, 31-60, 61-90, 90+).
147
- - Params: `store` (req), `as_of_date`, `customer_number`, `min_amount`
192
+ #### get_customer_list
193
+ Lista de clientes con número, nombre y RIF (taxRegistrationNumber).
194
+ - Params: `store`*, `customer_number`
148
195
 
149
- #### get_collection_status
150
- Period collection completeness: "Were all December invoices collected?"
151
- - Params: `store` (req), `start_date` (req), `end_date` (req)
196
+ #### get_open_payables
197
+ Resumen de todas las cuentas por pagar abiertas.
198
+ - Params: `store`*, `as_of_date`, `vendor_number`, `min_amount`
199
+
200
+ #### get_open_receivables
201
+ Resumen rápido de todas las cuentas por cobrar abiertas.
202
+ - Params: `store`*, `as_of_date`, `customer_number`, `min_amount`
152
203
 
153
204
  #### get_vendor_ledger
154
- Vendor transaction history: bills received, payments made.
155
- - Params: `store` (req), `start_date` (req), `end_date` (req), `vendor_number`, `document_type`, `open_only`
205
+ Movimientos del libro mayor de proveedores: facturas recibidas, pagos realizados, notas de crédito.
206
+ - Params: `store`*, `start_date`*, `end_date`*, `vendor_number`, `document_type`, `open_only`
156
207
 
157
- #### get_open_payables
158
- Open accounts payable by vendor with aging buckets (0-30, 31-60, 61-90, 90+).
159
- - Params: `store` (req), `as_of_date`, `vendor_number`, `min_amount`
208
+ ### Financial Statements & Cash Flow (3 tools)
209
+
210
+ #### get_cash_flow
211
+ Free Cash Flow (FCF) por método indirecto para una o más tiendas Full Queso (FQ01, FQ28, FQ88).
212
+ - Params: `stores`, `period`, `month`, `start_date`, `end_date`, `compare_previous`, `include_cash_position`, `render_html`, `output_path`, `open_browser`, `inline_html`
213
+
214
+ #### get_financial_statements
215
+ Estado financiero completo (P&L / Profit & Loss) para una o más tiendas Full Queso (FQ01, FQ28, FQ88, FQFR) en un período.
216
+ - Params: `stores`, `period`, `month`, `start_date`, `end_date`, `render_html`, `compare_previous`, `output_path`, `open_browser`, `inline_html`
217
+
218
+ #### get_income_statement
219
+ Estado de resultados (Income Statement) en el formato EXACTO del reporte Power BI "Income Statement by Month" (G/L Account Level 1) para una tienda Full Queso (FQ01, FQ28, FQ88) y un mes.
220
+ - Params: `store`, `stores`, `period`, `month`, `start_date`, `end_date`, `level`
221
+
222
+ ### Inventario (7 tools)
223
+
224
+ #### get_inventory_by_location
225
+ Inventario desglosado por ubicación (Location Code) dentro de una tienda.
226
+ - Params: `store`*, `location_code`, `item_category`, `classification`, `as_of_date`
227
+
228
+ #### get_inventory_change
229
+ Cambio de inventario WoW (semana) o MoM (mes).
230
+ - Params: `store`*, `period`*, `periods_back`, `item_category`, `classification`
231
+
232
+ #### get_inventory_levels
233
+ Niveles de inventario agrupados por itemCategoryCode y/o inventoryPostingGroupCode (congelados, importado, local).
234
+ - Params: `store`*, `item_category`, `classification`, `as_of_date`
235
+
236
+ #### get_item_card
237
+ Datos maestros de ítems de inventario: unitCost (BC), calculated_unit_cost (real desde últimas entradas), inventory qty, unitPrice, categoría.
238
+ - Params: `store`*, `item_number`, `item_search`, `item_category`
239
+
240
+ #### get_item_cost_analysis
241
+ Análisis de costo de un ítem — calcula costo promedio ponderado de entradas recientes (compras, ensamblaje, ajustes positivos), compara con el unitCost actual de BC, y muestra el historial de costos por mes.
242
+ - Params: `store`*, `item_number`*, `months`
243
+
244
+ #### get_item_cost_trend
245
+ Tendencia de costo de ítems: compara weighted avg inbound cost de últimas 2 semanas vs últimas 4 semanas.
246
+ - Params: `store`*, `item_number`, `item_category`, `period_days`
247
+
248
+ #### get_item_ledger_entries
249
+ Entradas del libro de artículos (Item Ledger Entries) — historial de movimientos de inventario con costo real por entrada.
250
+ - Params: `store`*, `item_number`*, `entry_type`, `start_date`, `end_date`, `top`
251
+
252
+ ### Draft Visibility (Multi-Payments + facturas sin postear) (4 tools)
253
+
254
+ #### get_draft_payables
255
+ Muestra facturas de compra abiertas clasificadas en tres niveles: totalmente pendientes (sin documento de pago), con Purch.
256
+ - Params: `store`*, `status_filter`, `vendor_number`, `include_lines`, `report`, `summary_only`, `save_to_file`, `excel_output`
257
+
258
+ #### get_draft_receivables
259
+ Muestra facturas de venta abiertas clasificadas en tres niveles: totalmente pendientes (sin documento de cobro), con Multi-Payment en borrador (Open o Transferred), y el monto neto realmente sin cubrir.
260
+ - Params: `store`*, `status_filter`, `customer_number`, `include_lines`, `report`, `summary_only`, `save_to_file`, `excel_output`
261
+
262
+ #### get_draft_summary
263
+ Resumen ejecutivo consolidado de todos los Multi-Payments en borrador (Open y Transferred) para una o todas las tiendas.
264
+ - Params: `store`*
265
+
266
+ #### get_unposted_invoices
267
+ Facturas de compra y/o venta SIN POSTEAR en Business Central (status Draft o In Review).
268
+ - Params: `store`*, `type`, `start_date`, `end_date`, `include_in_review`, `summary_only`
269
+
270
+ ### Nómina (3 tools)
271
+
272
+ #### get_employees
273
+ Lista empleados de una tienda con datos de nomina: tipo, status, salarios base, bonos predeterminados, fechas.
274
+ - Params: `store`*, `status`, `payroll_type`, `exclude_managerial`, `employee_search`, `employee_code`
275
+
276
+ #### get_payroll_documents
277
+ Lista documentos de nomina (headers) con filtros por periodo, tipo, status.
278
+ - Params: `store`*, `period_code`, `payroll_type`, `exclude_managerial`, `status`, `start_date`, `end_date`, `include_employee_count`, `summary_only`
279
+
280
+ #### get_payroll_lines
281
+ Detalle de nomina por empleado para un documento.
282
+ - Params: `store`*, `document_no`*, `employee_code`, `employee_search`
283
+
284
+ ### Reports (2 tools)
285
+
286
+ #### generate_cxp_report
287
+ Genera reporte Excel de Cuentas por Pagar con 3 hojas: Sin Draft, Draft No Posteado, Pago Parcial + hoja Resumen con desglose por proveedor.
288
+ - Params: `store`*, `output_path`
289
+
290
+ #### generate_manager_report
291
+ Genera el Reporte Gerente HTML completo para una tienda FQ.
292
+ - Params: `store`*, `date`, `output_path`, `open_browser`, `payroll_days`
293
+
294
+ ### Ventas (4 tools)
295
+
296
+ #### compare_sales_by_store
297
+ Comparación de rendimiento de VENTAS entre las tiendas de Full Queso (FQ01 Chacao, FQ28 Marqués, FQ88 Candelaria).
298
+ - Params: `period`, `month`, `start_date`, `end_date`, `metrics`
299
+
300
+ #### get_item_sales_detail
301
+ Detalle de ventas por ítem específico (1–50 SKUs) con granularidad día/semana/mes/total y desglose por tienda.
302
+ - Params: `items`*, `start_date`*, `end_date`*, `stores`, `granularity`, `include_zero_days`
303
+
304
+ #### get_product_performance
305
+ Análisis detallado de rendimiento de productos de Full Queso.
306
+ - Params: `period`, `month`, `start_date`, `end_date`, `stores`, `sort_by`, `top_n`
307
+
308
+ #### get_sales_analysis
309
+ Análisis multidimensional de ventas de Full Queso.
310
+ - Params: `period`, `month`, `start_date`, `end_date`, `stores`, `dimensions`, `metrics`
311
+
312
+ _Los params marcados con `*` son requeridos. Detalle completo en `docs/tool_*.md`._
160
313
 
161
314
  ## API Integrations
162
315
 
@@ -49,6 +49,7 @@
49
49
  "MN0004": { "gl": "18240", "name": "BDV 7191 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
50
50
  "MN0005": { "gl": "18250", "name": "Bancrecer 2558 Bs.", "currency": "VES", "category": "bancos_nacionales" },
51
51
  "MN0007": { "gl": "18270", "name": "BDV Pago Movil 5145", "currency": "VES", "category": "bancos_nacionales" },
52
+ "MN0008": { "gl": "18275", "name": "Banesco 9547 Bs.", "currency": "VES", "category": "bancos_nacionales", "_nota": "Banco nuevo agregado 2026-07-10 (Puntos 20 y 21, BANESCO_9547_BS., banco 0134). gl 18275 removido de adjustment_accounts_ves el 2026-07-10 (FP): ahora es banco real, entra normal en revaluación/reconciliación." },
52
53
  "MN0028": { "gl": "18290", "name": "Ubii Bank", "currency": "VES", "category": "bancos_nacionales" },
53
54
  "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
54
55
  "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
@@ -74,6 +75,7 @@
74
75
  "18250": "Banco quinto Bs.",
75
76
  "18260": "Banco sexto Bs.",
76
77
  "18270": "Banco séptimo Bs.",
78
+ "18275": "Banesco 9547 Bs. (FQ88)",
77
79
  "18280": "Banco octavo Bs.",
78
80
  "18290": "UBII principal",
79
81
  "18291": "UBII secundario",
@@ -90,6 +92,6 @@
90
92
  "18998": "Total Bancos",
91
93
  "18999": "Total Caja y Bancos"
92
94
  },
93
- "_adjustment_accounts_nota": "Cuentas VES de ajuste/tránsito (NO son bancos reales, no tienen statement). Se tratan como informativas en la revaluación cambiaria (fuera del total de pérdida y del asiento sugerido). FP 2026-06-05.",
94
- "adjustment_accounts_ves": ["18275", "18297"]
95
+ "_adjustment_accounts_nota": "Cuentas VES de ajuste/tránsito (NO son bancos reales, no tienen statement). Se tratan como informativas en la revaluación cambiaria (fuera del total de pérdida y del asiento sugerido). FP 2026-06-05. 18275 removido 2026-07-10: BC lo reasignó al banco real Banesco 9547 (FQ88-MN0008).",
96
+ "adjustment_accounts_ves": ["18297"]
95
97
  }
package/lib/bc-client.js CHANGED
@@ -336,6 +336,20 @@ export class BCClient {
336
336
  return this.apiCallAllPages(url);
337
337
  }
338
338
 
339
+ // Nombres del Chart of Accounts (entidad `accounts` de BC api/v2.0) → { number: displayName }.
340
+ // Cache por companyId (el CoA cambia rara vez). Usado por get_income_statement (level 2)
341
+ // para etiquetar las cuentas de posteo hijas con su nombre real de BC.
342
+ async getChartOfAccountNames(companyId) {
343
+ if (!this._coaNameCache) this._coaNameCache = {};
344
+ if (this._coaNameCache[companyId]) return this._coaNameCache[companyId];
345
+ const url = this.buildApiUrl(companyId, 'accounts', { $select: 'number,displayName' });
346
+ const rows = await this.apiCallAllPages(url);
347
+ const map = {};
348
+ for (const a of rows) if (a && a.number) map[String(a.number)] = a.displayName || '';
349
+ this._coaNameCache[companyId] = map;
350
+ return map;
351
+ }
352
+
339
353
  // Saldos acumulados (a fecha) de cuentas con AMBAS monedas: debit/creditAmount = USD (LCY)
340
354
  // y additionalCurrency*Amount = VES (moneda adicional). Para revaluación de caja VES.
341
355
  // postingDate le asOfDate (acumulado desde el inicio = saldo a la fecha).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.31.0",
3
+ "version": "1.33.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,129 @@
1
+ // scripts/generate-tools-doc.mjs
2
+ // Regenera la sección "## Tools (N)" del README.md leyendo las definiciones
3
+ // REALES exportadas por tools/**/*.js (objetos *Tool con name/description/inputSchema).
4
+ // Uso: node scripts/generate-tools-doc.mjs → imprime el markdown
5
+ // node scripts/generate-tools-doc.mjs --write → reescribe README.md
6
+ // Así el README nunca vuelve a quedar stale: se regenera desde el código.
7
+
8
+ import { readdirSync, readFileSync, writeFileSync } from 'fs';
9
+ import { fileURLToPath, pathToFileURL } from 'url';
10
+ import { dirname, join, relative } from 'path';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, '..');
14
+ const TOOLS_DIR = join(ROOT, 'tools');
15
+ const README = join(ROOT, 'README.md');
16
+
17
+ const CATEGORY_LABELS = {
18
+ '': 'Expense Analysis & Core',
19
+ 'auditoria': 'Auditoría / POS Reconciliation',
20
+ 'cierre-mensual': 'Cierre Mensual Bancario',
21
+ 'cobranzas': 'Cobranzas (AR / AP)',
22
+ 'financials': 'Financial Statements & Cash Flow',
23
+ 'inventario': 'Inventario',
24
+ 'multi-payment': 'Draft Visibility (Multi-Payments + facturas sin postear)',
25
+ 'payroll': 'Nómina',
26
+ 'reports': 'Reports',
27
+ 'ventas': 'Ventas',
28
+ };
29
+
30
+ function jsFiles(dir) {
31
+ const out = [];
32
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
+ if (entry.isDirectory()) out.push(...jsFiles(join(dir, entry.name)));
34
+ else if (entry.name.endsWith('.js') && !entry.name.startsWith('.')) {
35
+ // index.js incluido: algunos tools se DEFINEN ahí (ej. financials/index.js);
36
+ // los re-exports duplicados se filtran después con dedup por name.
37
+ out.push(join(dir, entry.name));
38
+ }
39
+ }
40
+ return out.sort();
41
+ }
42
+
43
+ function firstSentence(text, max = 240) {
44
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
45
+ const dot = clean.indexOf('. ');
46
+ const s = dot > 30 ? clean.slice(0, dot + 1) : clean;
47
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s;
48
+ }
49
+
50
+ function paramsLine(schema) {
51
+ const props = schema?.properties || {};
52
+ const required = new Set(schema?.required || []);
53
+ const parts = Object.keys(props).map((k) => (required.has(k) ? `\`${k}\`*` : `\`${k}\``));
54
+ return parts.length ? `- Params: ${parts.join(', ')}` : '- Params: (ninguno)';
55
+ }
56
+
57
+ async function collectTools() {
58
+ const byCategory = new Map();
59
+ for (const file of jsFiles(TOOLS_DIR)) {
60
+ const rel = relative(TOOLS_DIR, file);
61
+ const category = rel.includes('/') ? rel.split('/')[0] : '';
62
+ let mod;
63
+ try {
64
+ mod = await import(pathToFileURL(file).href);
65
+ } catch (e) {
66
+ console.error(`WARN: no se pudo importar ${rel}: ${e.message}`);
67
+ continue;
68
+ }
69
+ for (const [key, val] of Object.entries(mod)) {
70
+ if (!key.endsWith('Tool')) continue;
71
+ if (!val || typeof val !== 'object' || !val.name || !val.description) continue;
72
+ if (!byCategory.has(category)) byCategory.set(category, []);
73
+ byCategory.get(category).push(val);
74
+ }
75
+ }
76
+ // dedup por name (algunos módulos re-exportan)
77
+ for (const [cat, tools] of byCategory) {
78
+ const seen = new Set();
79
+ byCategory.set(cat, tools.filter((t) => (seen.has(t.name) ? false : seen.add(t.name))));
80
+ }
81
+ return byCategory;
82
+ }
83
+
84
+ function buildMarkdown(byCategory) {
85
+ const order = Object.keys(CATEGORY_LABELS).filter((k) => byCategory.has(k));
86
+ for (const k of byCategory.keys()) if (!order.includes(k)) order.push(k); // dirs nuevos
87
+ const total = [...byCategory.values()].reduce((n, t) => n + t.length, 0);
88
+
89
+ const lines = [`## Tools (${total})`, '',
90
+ '_Sección generada por `scripts/generate-tools-doc.mjs` — no editar a mano._',
91
+ '_Regenerar con: `node scripts/generate-tools-doc.mjs --write`_', ''];
92
+ for (const cat of order) {
93
+ const tools = byCategory.get(cat);
94
+ const label = CATEGORY_LABELS[cat] || cat;
95
+ lines.push(`### ${label} (${tools.length} tool${tools.length !== 1 ? 's' : ''})`, '');
96
+ for (const t of tools.sort((a, b) => a.name.localeCompare(b.name))) {
97
+ lines.push(`#### ${t.name}`);
98
+ lines.push(firstSentence(t.description));
99
+ lines.push(paramsLine(t.inputSchema), '');
100
+ }
101
+ }
102
+ lines.push('_Los params marcados con `*` son requeridos. Detalle completo en `docs/tool_*.md`._', '');
103
+ return lines.join('\n');
104
+ }
105
+
106
+ function spliceReadme(md) {
107
+ const readme = readFileSync(README, 'utf8');
108
+ const lines = readme.split('\n');
109
+ const start = lines.findIndex((l) => /^## Tools/.test(l));
110
+ if (start === -1) throw new Error('No se encontró la sección "## Tools" en README.md');
111
+ let end = lines.length;
112
+ for (let i = start + 1; i < lines.length; i++) {
113
+ if (/^## /.test(lines[i])) { end = i; break; }
114
+ }
115
+ const next = [...lines.slice(0, start), ...md.split('\n'), ...lines.slice(end)];
116
+ writeFileSync(README, next.join('\n'));
117
+ return { start: start + 1, end, total_lines: next.length };
118
+ }
119
+
120
+ const byCategory = await collectTools();
121
+ const md = buildMarkdown(byCategory);
122
+
123
+ if (process.argv.includes('--write')) {
124
+ const info = spliceReadme(md);
125
+ const total = [...byCategory.values()].reduce((n, t) => n + t.length, 0);
126
+ console.log(`README.md actualizado: ${total} tools, sección en línea ${info.start} (antes terminaba en ${info.end}). Total ${info.total_lines} líneas.`);
127
+ } else {
128
+ console.log(md);
129
+ }
package/server.js CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  draftReceivablesTool, handleDraftReceivables,
63
63
  draftPayablesTool, handleDraftPayables,
64
64
  draftSummaryTool, handleDraftSummary,
65
+ unpostedInvoicesTool, handleUnpostedInvoices,
65
66
  } from './tools/multi-payment/index.js';
66
67
 
67
68
  // Payroll tools
@@ -104,10 +105,11 @@ import {
104
105
  reconcileWithBcTool, handleReconcileWithBc,
105
106
  } from './tools/cierre-mensual/index.js';
106
107
 
107
- // Financials (P&L / Financial Statements) + Cash Flow (FCF)
108
+ // Financials (P&L / Financial Statements) + Cash Flow (FCF) + Income Statement (Power BI)
108
109
  import {
109
110
  financialStatementsTool, handleFinancialStatements,
110
111
  cashFlowTool, handleCashFlow,
112
+ incomeStatementTool, handleIncomeStatement,
111
113
  } from './tools/financials/index.js';
112
114
 
113
115
  const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
@@ -164,6 +166,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
164
166
  draftReceivablesTool,
165
167
  draftPayablesTool,
166
168
  draftSummaryTool,
169
+ unpostedInvoicesTool,
167
170
  // Payroll
168
171
  payrollDocumentsTool,
169
172
  payrollLinesTool,
@@ -191,9 +194,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
191
194
  getClosingQuestionnaireTool,
192
195
  getClosingMatchResultsTool,
193
196
  reconcileWithBcTool,
194
- // Financials (P&L / Financial Statements) + Cash Flow (FCF)
197
+ // Financials (P&L / Financial Statements) + Cash Flow (FCF) + Income Statement (Power BI)
195
198
  financialStatementsTool,
196
199
  cashFlowTool,
200
+ incomeStatementTool,
197
201
  ],
198
202
  };
199
203
  });
@@ -305,6 +309,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
305
309
  case 'get_draft_summary':
306
310
  result = await handleDraftSummary(bcClient, args);
307
311
  break;
312
+ case 'get_unposted_invoices':
313
+ result = await handleUnpostedInvoices(bcClient, args);
314
+ break;
308
315
  // Payroll
309
316
  case 'get_payroll_documents':
310
317
  result = await handlePayrollDocuments(bcClient, args);
@@ -383,6 +390,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
383
390
  case 'get_cash_flow':
384
391
  result = await handleCashFlow(bcClient, args);
385
392
  break;
393
+ case 'get_income_statement':
394
+ result = await handleIncomeStatement(bcClient, args);
395
+ break;
386
396
  default:
387
397
  return {
388
398
  content: [{ type: 'text', text: `Herramienta desconocida: ${name}` }],
@@ -0,0 +1,265 @@
1
+ // tools/financials/income-statement.js
2
+ //
3
+ // get_income_statement — Estado de resultados en el formato exacto del reporte Power BI
4
+ // "Income Statement by Month" (G/L Account Level 1). Agrega los General Ledger entries por
5
+ // bucket level-1 con la convención de signos de Power BI:
6
+ // amount = debitAmount − creditAmount
7
+ // → ingresos (crédito) salen NEGATIVOS, costos/gastos (débito) POSITIVOS,
8
+ // Total = Σ(debit − credit); Total NEGATIVO = ganancia.
9
+ //
10
+ // Resuelve la parte frágil que el skill hacía a mano: partir los gastos por las fronteras
11
+ // level-1 (nómina 71000 vs impuestos 74000, otros 74000 vs 90000). Aquí se hace desde los
12
+ // entries por cuenta, así que cuadra 1:1 con Power BI. La columna Ajustado (drafts como
13
+ // posteados) y la reclasificación de dividendos las agrega el skill sobre este posted.
14
+ //
15
+ // Consumido por skill-fq-resultados-mensual (build_income_statement.py). READ-ONLY.
16
+
17
+ import { resolveStores } from '../../config/company-config.js';
18
+ import { getDateRange } from '../../utils/date-helper.js';
19
+ import { round2 } from '../../utils/currency-converter.js';
20
+ import { logger } from '../../utils/logger.js';
21
+ import { periodLabel } from './statements.js';
22
+
23
+ // ── Estructura level-1 de Power BI ────────────────────────────────────────────
24
+ // Fuente: screenshots del reporte Power BI FQ28 (refresh 2026-05-04). CoA compartido
25
+ // FQ01/FQ28/FQ88. MANTENER SINCRONIZADO con
26
+ // skill-fq-resultados-mensual/references/income_statement_coa.json.
27
+ export const COA_PBI = {
28
+ sections: [
29
+ {
30
+ id: 'ingresos', total_account: '40001', total_name: 'TOTAL INGRESOS', range: [40000, 49999],
31
+ level1: [
32
+ { account: '40100', name: 'Ingreso Ventas', from: 40000, to: 40299 },
33
+ { account: '40300', name: 'Ingreso Facturación Directa', from: 40300, to: 40399 },
34
+ { account: '40400', name: 'Ingreso por Servicios', from: 40400, to: 40499 },
35
+ { account: '40500', name: 'Otros Ingresos', from: 40500, to: 40599 },
36
+ { account: '40600', name: 'Ingresos por Alquiler', from: 40600, to: 40899 },
37
+ { account: '40900', name: 'Ingresos Extraordinarios', from: 40900, to: 49999 },
38
+ ],
39
+ },
40
+ {
41
+ id: 'costo_ventas', total_account: '50001', total_name: 'TOTAL COSTO DE VENTAS', range: [50000, 59999],
42
+ level1: [
43
+ { account: '50100', name: 'Costo de Productos', from: 50000, to: 50999 },
44
+ { account: '51000', name: 'Otros Costos de Producción', from: 51000, to: 51999 },
45
+ { account: '52000', name: 'Costos Desechables y Empaques', from: 52000, to: 55999 },
46
+ { account: '56000', name: 'Fletes', from: 56000, to: 56999 },
47
+ { account: '57000', name: 'Mantenimiento y reparación de equipos cocina', from: 57000, to: 59999 },
48
+ ],
49
+ },
50
+ {
51
+ id: 'gastos_operativos', total_account: '60001', total_name: 'TOTAL GASTOS OPERATIVOS', range: [60000, 99999],
52
+ level1: [
53
+ { account: '60002', name: 'Gastos Planta Física', from: 60000, to: 61999 },
54
+ { account: '62000', name: 'gastos de logistica', from: 62000, to: 62999 },
55
+ { account: '63000', name: 'Gastos Mercadeo, Publicidad y Ventas', from: 63000, to: 63999 },
56
+ { account: '64000', name: 'Gastos Administrativos Tienda', from: 64000, to: 66999 },
57
+ { account: '67000', name: 'Egresos Financieros', from: 67000, to: 67999 },
58
+ { account: '68000', name: 'Gastos Servicios Contratados', from: 68000, to: 70999 },
59
+ { account: '71000', name: 'sueldos y salarios', from: 71000, to: 73999 },
60
+ { account: '74000', name: 'Impuestos y permisologias', from: 74000, to: 89999 },
61
+ { account: '90000', name: 'Otros Egresos', from: 90000, to: 99999 },
62
+ ],
63
+ },
64
+ ],
65
+ };
66
+
67
+ function sectionIdFor(acctNum) {
68
+ if (acctNum >= 40000 && acctNum <= 49999) return 'ingresos';
69
+ if (acctNum >= 50000 && acctNum <= 59999) return 'costo_ventas';
70
+ if (acctNum >= 60000 && acctNum <= 99999) return 'gastos_operativos';
71
+ return null;
72
+ }
73
+
74
+ function bucketFor(section, acctNum) {
75
+ for (const b of section.level1) {
76
+ if (acctNum >= b.from && acctNum <= b.to) return b;
77
+ }
78
+ return section.level1[section.level1.length - 1]; // fallback defensivo
79
+ }
80
+
81
+ // ── Agregación pura (testeable sin BC) ────────────────────────────────────────
82
+ // entries: [{ accountNumber, debitAmount, creditAmount, description? }]
83
+ // opts: { level = 1, accountNames = {} }
84
+ export function buildStatement(entries, { level = 1, accountNames = {} } = {}) {
85
+ const secDefById = Object.fromEntries(COA_PBI.sections.map((s) => [s.id, s]));
86
+ const acc = {};
87
+ for (const s of COA_PBI.sections) {
88
+ acc[s.id] = { total: 0, buckets: {} };
89
+ for (const b of s.level1) acc[s.id].buckets[b.account] = { amount: 0, children: {} };
90
+ }
91
+
92
+ for (const e of entries || []) {
93
+ const acctNum = parseInt(e.accountNumber, 10);
94
+ if (!Number.isFinite(acctNum)) continue;
95
+ const secId = sectionIdFor(acctNum);
96
+ if (!secId) continue;
97
+ const bucket = bucketFor(secDefById[secId], acctNum);
98
+ const amt = (e.debitAmount || 0) - (e.creditAmount || 0); // convención Power BI
99
+ const box = acc[secId];
100
+ box.total += amt;
101
+ box.buckets[bucket.account].amount += amt;
102
+ if (level >= 2) {
103
+ const key = String(e.accountNumber);
104
+ const ch = box.buckets[bucket.account].children;
105
+ if (!ch[key]) ch[key] = { account: key, name: accountNames[key] || e.description || `Cuenta ${key}`, amount: 0 };
106
+ ch[key].amount += amt;
107
+ }
108
+ }
109
+
110
+ const sections = [];
111
+ const accounts_map = {};
112
+ let grand = 0;
113
+ for (const s of COA_PBI.sections) {
114
+ const box = acc[s.id];
115
+ const accounts = [];
116
+ for (const b of s.level1) {
117
+ const bk = box.buckets[b.account];
118
+ const amount = round2(bk.amount);
119
+ const childArr = Object.values(bk.children)
120
+ .map((c) => ({ account: c.account, name: c.name, amount: round2(c.amount) }))
121
+ .sort((x, y) => x.account.localeCompare(y.account));
122
+ const row = { account: b.account, name: b.name, amount };
123
+ if (level >= 2) row.children = childArr;
124
+ accounts.push(row);
125
+
126
+ const mapEntry = { posted: amount };
127
+ if (level >= 2 && childArr.length) {
128
+ mapEntry.children = {};
129
+ for (const c of childArr) mapEntry.children[c.account] = { name: c.name, posted: c.amount };
130
+ }
131
+ accounts_map[b.account] = mapEntry;
132
+ }
133
+ const total = round2(box.total);
134
+ grand += total;
135
+ sections.push({ id: s.id, total_account: s.total_account, total_name: s.total_name, total, accounts });
136
+ }
137
+
138
+ return {
139
+ sign_convention: 'power_bi (amount = debit − credit): ingresos negativos, costos/gastos positivos, Total negativo = ganancia',
140
+ sections,
141
+ grand_total: round2(grand),
142
+ accounts_map,
143
+ };
144
+ }
145
+
146
+ // ── Orquestador ───────────────────────────────────────────────────────────────
147
+ export async function getIncomeStatement(bcClient, params = {}) {
148
+ const t0 = Date.now();
149
+ const period = params.period || 'last_month';
150
+ const level = Number(params.level) === 2 ? 2 : 1;
151
+
152
+ let storeCodes = params.stores && params.stores.length ? params.stores : (params.store ? [params.store] : ['all']);
153
+ const stores = resolveStores(storeCodes.includes('all') ? null : storeCodes);
154
+ const { start, end } = getDateRange(period, params.start_date, params.end_date, params.month);
155
+ const label = periodLabel(start, end);
156
+
157
+ logger.info(`[income_statement] stores=${stores.map((s) => s.code).join(',')} ${start}→${end} level=${level}`);
158
+
159
+ const fxTask = bcClient
160
+ .getExchangeRates(stores[0].code)
161
+ .then((rates) => bcClient.getExchangeRateForDate(rates, end))
162
+ .catch((err) => {
163
+ logger.warn(`[income_statement] exchange rate fetch failed: ${err.message}`);
164
+ return null;
165
+ });
166
+
167
+ const perStore = await Promise.all(
168
+ stores.map(async (s) => {
169
+ const [income, cogs, expenses] = await Promise.all([
170
+ bcClient.getGLEntries(s.companyId, start, end, '40000', '49999'),
171
+ bcClient.getGLEntries(s.companyId, start, end, '50000', '59999'),
172
+ bcClient.getGLEntries(s.companyId, start, end, '60000', '99999'),
173
+ ]);
174
+ let accountNames = {};
175
+ if (level >= 2) {
176
+ accountNames = await bcClient.getChartOfAccountNames(s.companyId).catch((err) => {
177
+ logger.warn(`[income_statement] CoA names fetch failed (${s.code}): ${err.message}`);
178
+ return {};
179
+ });
180
+ }
181
+ const statement = buildStatement([...income, ...cogs, ...expenses], { level, accountNames });
182
+ return {
183
+ code: s.code, name: s.name,
184
+ companyName: decodeURIComponent(s.companyName || '') || s.name,
185
+ statement,
186
+ };
187
+ })
188
+ );
189
+
190
+ const fx = await fxTask;
191
+
192
+ const storesOut = {};
193
+ for (const r of perStore) {
194
+ storesOut[r.code] = {
195
+ store_code: r.code,
196
+ store_name: r.name,
197
+ company_name: r.companyName,
198
+ ...r.statement,
199
+ };
200
+ }
201
+
202
+ return {
203
+ format: 'power_bi_income_statement',
204
+ level,
205
+ period: { start, end, label },
206
+ exchange_rate: fx ? { rate: round2(fx.rate), label: `1 USD = ${round2(fx.rate)} VES` } : null,
207
+ generated_at: new Date().toISOString(),
208
+ note:
209
+ 'Columna POSTED = Power BI tal cual (amount = debit − credit; ingresos negativos; Total negativo = ganancia). ' +
210
+ 'El skill-fq-resultados-mensual agrega la columna Ajustado (drafts como posteados vía get_unposted_invoices + ' +
211
+ 'nómina Draft) y la reclasificación de nómina gerencial a dividendos. `accounts_map` va listo para el data.json ' +
212
+ 'del generador (build_income_statement.py): clave = cuenta level-1, valor = { posted, children? }.',
213
+ stores: storesOut,
214
+ query_duration_ms: Date.now() - t0,
215
+ };
216
+ }
217
+
218
+ // ── Definición del tool + handler ─────────────────────────────────────────────
219
+ export const incomeStatementTool = {
220
+ name: 'get_income_statement',
221
+ description:
222
+ 'Estado de resultados (Income Statement) en el formato EXACTO del reporte Power BI "Income Statement by Month" ' +
223
+ '(G/L Account Level 1) para una tienda Full Queso (FQ01, FQ28, FQ88) y un mes. Agrega los General Ledger entries ' +
224
+ 'por cuenta level-1 con la convención de signos de Power BI (ingresos negativos, costos/gastos positivos, Total ' +
225
+ 'negativo = ganancia), listo para comparar 1:1 con Power BI. Devuelve las 3 secciones (40001 TOTAL INGRESOS, ' +
226
+ '50001 TOTAL COSTO DE VENTAS, 60001 TOTAL GASTOS OPERATIVOS) con sus cuentas level-1, totales y Total general, ' +
227
+ 'más un `accounts_map` listo para el generador Excel del skill. Con level=2 incluye las cuentas de posteo hijas ' +
228
+ 'con su nombre real de BC. Resuelve las fronteras 71000/74000 y 74000/90000 que get_financial_statements no separa. ' +
229
+ 'Úsalo para: income statement, estado de resultados level 1, resultado mensual comparable con Power BI, P&L por ' +
230
+ 'cuenta. La columna Ajustado (drafts) la agrega el skill. READ-ONLY.',
231
+ inputSchema: {
232
+ type: 'object',
233
+ properties: {
234
+ store: {
235
+ type: 'string',
236
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR'],
237
+ description: 'Tienda a analizar (una sola, recomendado). Alternativa a `stores`.',
238
+ },
239
+ stores: {
240
+ type: 'array',
241
+ items: { type: 'string', enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'] },
242
+ description: 'Tiendas a analizar. Default: ["all"]. Para el income statement de una tienda usar `store`.',
243
+ },
244
+ period: {
245
+ type: 'string',
246
+ enum: ['last_month', 'this_month', 'specific_month', 'custom'],
247
+ description: 'Período. Default: last_month. Para un mes exacto usar period="specific_month" + month.',
248
+ default: 'last_month',
249
+ },
250
+ month: { type: 'string', description: 'Mes "YYYY-MM" (con period="specific_month").' },
251
+ start_date: { type: 'string', description: 'Fecha inicio "YYYY-MM-DD" (con period="custom").' },
252
+ end_date: { type: 'string', description: 'Fecha fin "YYYY-MM-DD" (con period="custom").' },
253
+ level: {
254
+ type: 'number',
255
+ enum: [1, 2],
256
+ description: 'Nivel de detalle: 1 = level-1 como Power BI (default); 2 = con cuentas de posteo hijas.',
257
+ default: 1,
258
+ },
259
+ },
260
+ },
261
+ };
262
+
263
+ export async function handleIncomeStatement(bcClient, args) {
264
+ return getIncomeStatement(bcClient, args || {});
265
+ }
@@ -8,6 +8,7 @@
8
8
  import { getFinancialStatements } from './statements.js';
9
9
 
10
10
  export { cashFlowTool, handleCashFlow } from './cash-flow.js';
11
+ export { incomeStatementTool, handleIncomeStatement } from './income-statement.js';
11
12
 
12
13
  export const financialStatementsTool = {
13
14
  name: 'get_financial_statements',
@@ -1,3 +1,4 @@
1
1
  export { draftReceivablesTool, handleDraftReceivables } from './draft-receivables.js';
2
2
  export { draftPayablesTool, handleDraftPayables } from './draft-payables.js';
3
3
  export { draftSummaryTool, handleDraftSummary } from './draft-summary.js';
4
+ export { unpostedInvoicesTool, handleUnpostedInvoices } from './unposted-invoices.js';
@@ -0,0 +1,224 @@
1
+ // tools/multi-payment/unposted-invoices.js
2
+ // Facturas de compra y venta SIN POSTEAR en Business Central (status Draft /
3
+ // In Review en api/v2.0 purchaseInvoices y salesInvoices). A diferencia de los
4
+ // tools de multi-payment (settlement de facturas ya posteadas), estos documentos
5
+ // NO están en el GL todavía → son los ajustes de "Cierre Parcial" del P&L:
6
+ // compras draft → +COGS/gasto estimado; ventas draft → +ingreso estimado.
7
+ // Consumido por skill-fq-resultados-mensual.
8
+
9
+ import { resolveStores } from '../../config/company-config.js';
10
+ import { logger } from '../../utils/logger.js';
11
+
12
+ const round2 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
13
+
14
+ export const unpostedInvoicesTool = {
15
+ name: 'get_unposted_invoices',
16
+ description:
17
+ 'Facturas de compra y/o venta SIN POSTEAR en Business Central (status Draft o In Review). ' +
18
+ 'Estos documentos NO están en el GL: son la base del ajuste de CIERRE PARCIAL del P&L ' +
19
+ '(compras draft → costo/gasto estimado; ventas draft → ingreso estimado). ' +
20
+ 'NO confundir con get_draft_payables/receivables (Multi-Payments de cobro/pago sobre ' +
21
+ 'facturas ya posteadas). Filtra por rango de postingDate, separa ventas intercompañía (IC-*) ' +
22
+ 'y totaliza por moneda. Úsalo para: cierre parcial, resultados con drafts, facturas sin ' +
23
+ 'postear, "qué falta por postear del mes". READ-ONLY.',
24
+ inputSchema: {
25
+ type: 'object',
26
+ properties: {
27
+ store: {
28
+ type: 'string',
29
+ enum: ['FQ01', 'FQ28', 'FQ88', 'FQFR', 'all'],
30
+ description: 'Tienda(s) a consultar.',
31
+ },
32
+ type: {
33
+ type: 'string',
34
+ enum: ['purchases', 'sales', 'both'],
35
+ description: 'Tipo de facturas. Default: both.',
36
+ },
37
+ start_date: {
38
+ type: 'string',
39
+ description: 'Fecha inicio YYYY-MM-DD (filtra por postingDate). Opcional.',
40
+ },
41
+ end_date: {
42
+ type: 'string',
43
+ description: 'Fecha fin YYYY-MM-DD (filtra por postingDate). Opcional.',
44
+ },
45
+ include_in_review: {
46
+ type: 'boolean',
47
+ description: "Incluir status 'In Review' además de 'Draft'. Default: true.",
48
+ },
49
+ summary_only: {
50
+ type: 'boolean',
51
+ description: 'Solo totales, sin lista de facturas. Default: false.',
52
+ },
53
+ },
54
+ required: ['store'],
55
+ },
56
+ };
57
+
58
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
59
+
60
+ function buildStatusFilter(includeInReview) {
61
+ return includeInReview
62
+ ? "(status eq 'Draft' or status eq 'In Review')"
63
+ : "status eq 'Draft'";
64
+ }
65
+
66
+ function buildFilter(args) {
67
+ const parts = [buildStatusFilter(args.include_in_review !== false)];
68
+ if (args.start_date) {
69
+ if (!DATE_RE.test(args.start_date)) throw new Error(`start_date inválida: ${args.start_date}`);
70
+ parts.push(`postingDate ge ${args.start_date}`);
71
+ }
72
+ if (args.end_date) {
73
+ if (!DATE_RE.test(args.end_date)) throw new Error(`end_date inválida: ${args.end_date}`);
74
+ parts.push(`postingDate le ${args.end_date}`);
75
+ }
76
+ return parts.join(' and ');
77
+ }
78
+
79
+ function mapInvoice(inv, kind) {
80
+ return {
81
+ number: inv.number,
82
+ status: inv.status,
83
+ counterparty_number: kind === 'purchases' ? inv.vendorNumber : inv.customerNumber,
84
+ counterparty_name: kind === 'purchases' ? inv.vendorName : inv.customerName,
85
+ invoice_date: inv.invoiceDate,
86
+ posting_date: inv.postingDate,
87
+ currency: inv.currencyCode || 'LCY',
88
+ total_excl_tax: round2(inv.totalAmountExcludingTax ?? 0),
89
+ total_incl_tax: round2(inv.totalAmountIncludingTax ?? 0),
90
+ };
91
+ }
92
+
93
+ function summarize(invoices, kind) {
94
+ const by_currency = {};
95
+ const non_ic_by_currency = {};
96
+ const ic = { count: 0, by_currency: {} };
97
+ for (const inv of invoices) {
98
+ by_currency[inv.currency] = round2((by_currency[inv.currency] || 0) + inv.total_excl_tax);
99
+ const isIC = kind === 'sales' && String(inv.counterparty_number || '').toUpperCase().startsWith('IC-');
100
+ if (isIC) {
101
+ ic.count += 1;
102
+ ic.by_currency[inv.currency] = round2((ic.by_currency[inv.currency] || 0) + inv.total_excl_tax);
103
+ } else if (kind === 'sales') {
104
+ non_ic_by_currency[inv.currency] = round2((non_ic_by_currency[inv.currency] || 0) + inv.total_excl_tax);
105
+ }
106
+ }
107
+ const summary = {
108
+ count: invoices.length,
109
+ by_currency,
110
+ mixed_currency: Object.keys(by_currency).length > 1,
111
+ by_status: invoices.reduce((acc, i) => { acc[i.status] = (acc[i.status] || 0) + 1; return acc; }, {}),
112
+ };
113
+ if (kind === 'sales') {
114
+ summary.intercompany = ic;
115
+ summary.non_ic_by_currency = non_ic_by_currency;
116
+ }
117
+ return summary;
118
+ }
119
+
120
+ function fmtByCurrency(byCurrency) {
121
+ const parts = Object.entries(byCurrency).map(([cur, amt]) => {
122
+ const n = amt.toLocaleString('en-US', { minimumFractionDigits: 2 });
123
+ return cur === 'VES' ? `Bs ${n}` : `$${n}${cur === 'LCY' ? '' : ` ${cur}`}`;
124
+ });
125
+ return parts.join(' + ') || '0';
126
+ }
127
+
128
+ function mergeByCurrency(target, source) {
129
+ for (const [cur, amt] of Object.entries(source || {})) {
130
+ target[cur] = round2((target[cur] || 0) + amt);
131
+ }
132
+ }
133
+
134
+ async function fetchKind(bcClient, companyId, kind, filter) {
135
+ const endpoint = kind === 'purchases' ? 'purchaseInvoices' : 'salesInvoices';
136
+ const select = kind === 'purchases'
137
+ ? 'number,status,vendorNumber,vendorName,invoiceDate,postingDate,currencyCode,totalAmountExcludingTax,totalAmountIncludingTax'
138
+ : 'number,status,customerNumber,customerName,invoiceDate,postingDate,currencyCode,totalAmountExcludingTax,totalAmountIncludingTax';
139
+ const url = bcClient.buildApiUrl(companyId, endpoint, {
140
+ $filter: filter,
141
+ $select: select,
142
+ $orderby: 'postingDate desc',
143
+ });
144
+ const rows = await bcClient.apiCallAllPages(url);
145
+ return rows.map((r) => mapInvoice(r, kind));
146
+ }
147
+
148
+ async function processStore(bcClient, store, args, filter) {
149
+ const type = args.type || 'both';
150
+ const wantP = type === 'purchases' || type === 'both';
151
+ const wantS = type === 'sales' || type === 'both';
152
+
153
+ const [purchases, sales] = await Promise.all([
154
+ wantP ? fetchKind(bcClient, store.companyId, 'purchases', filter) : Promise.resolve(null),
155
+ wantS ? fetchKind(bcClient, store.companyId, 'sales', filter) : Promise.resolve(null),
156
+ ]);
157
+
158
+ const result = { store: store.code, store_name: store.name };
159
+ if (purchases) {
160
+ result.purchases = { summary: summarize(purchases, 'purchases') };
161
+ if (!args.summary_only) result.purchases.invoices = purchases;
162
+ }
163
+ if (sales) {
164
+ result.sales = { summary: summarize(sales, 'sales') };
165
+ if (!args.summary_only) result.sales.invoices = sales;
166
+ }
167
+ // Hint directo para el Cierre Parcial (skill-fq-resultados-mensual):
168
+ result.pl_adjustment_hint = {
169
+ purchases_by_currency: purchases ? result.purchases.summary.by_currency : null,
170
+ sales_non_ic_by_currency: sales ? result.sales.summary.non_ic_by_currency : null,
171
+ note: 'Estimación sin IVA de documentos no posteados en el rango. Montos VES convertir a USD con get_exchange_rate (BCV). No sustituye el posteo.',
172
+ };
173
+ return result;
174
+ }
175
+
176
+ export async function handleUnpostedInvoices(bcClient, args) {
177
+ if (!args.store) throw new Error('Parámetro requerido: store');
178
+ const filter = buildFilter(args);
179
+ const stores = resolveStores(args.store === 'all' ? null : [args.store]);
180
+
181
+ logger.info(`get_unposted_invoices: stores=${stores.map((s) => s.code).join(',')} filter="${filter}"`);
182
+
183
+ const storeResults = await Promise.all(
184
+ stores.map((s) => processStore(bcClient, s, args, filter))
185
+ );
186
+
187
+ const storesObj = {};
188
+ const consolidated = {
189
+ purchases_by_currency: {},
190
+ sales_by_currency: {},
191
+ sales_non_ic_by_currency: {},
192
+ invoice_count: 0,
193
+ };
194
+ const insights = [];
195
+
196
+ for (const r of storeResults) {
197
+ storesObj[r.store] = r;
198
+ if (r.purchases) {
199
+ mergeByCurrency(consolidated.purchases_by_currency, r.purchases.summary.by_currency);
200
+ consolidated.invoice_count += r.purchases.summary.count;
201
+ if (r.purchases.summary.count > 0) {
202
+ insights.push(`${r.store}: ${r.purchases.summary.count} factura(s) de compra sin postear por ${fmtByCurrency(r.purchases.summary.by_currency)} (sin IVA)`);
203
+ }
204
+ }
205
+ if (r.sales) {
206
+ mergeByCurrency(consolidated.sales_by_currency, r.sales.summary.by_currency);
207
+ mergeByCurrency(consolidated.sales_non_ic_by_currency, r.sales.summary.non_ic_by_currency);
208
+ consolidated.invoice_count += r.sales.summary.count;
209
+ if (r.sales.summary.count > 0) {
210
+ insights.push(`${r.store}: ${r.sales.summary.count} factura(s) de venta sin postear por ${fmtByCurrency(r.sales.summary.by_currency)} (sin IVA${r.sales.summary.intercompany.count ? `, ${r.sales.summary.intercompany.count} IC` : ''})`);
211
+ }
212
+ }
213
+ }
214
+
215
+ if (insights.length === 0) insights.push('Sin facturas pendientes de postear en el rango consultado.');
216
+
217
+ return {
218
+ filter_applied: filter,
219
+ period: { start: args.start_date || null, end: args.end_date || null },
220
+ stores: storesObj,
221
+ consolidated,
222
+ insights,
223
+ };
224
+ }