@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 +56 -0
- package/README.md +219 -66
- package/config/bank-gl-map.json +4 -2
- package/lib/bc-client.js +14 -0
- package/package.json +1 -1
- package/scripts/generate-tools-doc.mjs +129 -0
- package/server.js +12 -2
- package/tools/financials/income-statement.js +265 -0
- package/tools/financials/index.js +1 -0
- package/tools/multi-payment/index.js +1 -0
- package/tools/multi-payment/unposted-invoices.js +224 -0
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 (
|
|
55
|
+
## Tools (58)
|
|
56
56
|
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
- Params: `
|
|
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
|
-
####
|
|
76
|
-
|
|
77
|
-
- Params: `store
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
- Params: `store
|
|
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
|
-
####
|
|
86
|
-
|
|
87
|
-
- Params: `
|
|
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
|
-
|
|
91
|
-
- Params: `store
|
|
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
|
-
|
|
95
|
-
- Params: `store
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
####
|
|
102
|
-
|
|
103
|
-
- Params: `store`
|
|
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
|
-
|
|
107
|
-
- Params: `store
|
|
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
|
-
|
|
111
|
-
- Params: `store
|
|
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
|
-
####
|
|
114
|
-
|
|
115
|
-
- Params: `store
|
|
140
|
+
#### list_bank_accounts
|
|
141
|
+
Lista las cuentas bancarias de una tienda Full Queso.
|
|
142
|
+
- Params: `store`*
|
|
116
143
|
|
|
117
|
-
####
|
|
118
|
-
|
|
119
|
-
- Params: `store
|
|
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
|
-
|
|
123
|
-
- Params: `store
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
- Params: `store
|
|
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
|
-
|
|
143
|
-
- Params: `store
|
|
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
|
-
####
|
|
146
|
-
|
|
147
|
-
- Params: `store
|
|
192
|
+
#### get_customer_list
|
|
193
|
+
Lista de clientes con número, nombre y RIF (taxRegistrationNumber).
|
|
194
|
+
- Params: `store`*, `customer_number`
|
|
148
195
|
|
|
149
|
-
####
|
|
150
|
-
|
|
151
|
-
- Params: `store`
|
|
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
|
-
|
|
155
|
-
- Params: `store
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
package/config/bank-gl-map.json
CHANGED
|
@@ -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": ["
|
|
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.
|
|
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
|
+
}
|