@fullqueso/mcp-bc-gastos 1.17.0 → 1.19.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,10 +1,54 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.19.0] - 2026-04-13
4
+
5
+ ### Added
6
+ - **`get_customer_list` tool (cobranzas)** — Lists customers with number, name, and RIF (taxRegistrationNumber) per store. Supports optional filter by customer number.
7
+ - **`config/bank-gl-map.json`** — Reference config documenting the full bank account suffix → GL account mapping per store (FQ01, FQ28, FQ88), including GL structure for Caja y Bancos (181xx–189xx).
8
+
9
+ ### Changed
10
+ - **POS reconciliation: per-store bank GL mapping** — `BANK_GL_MAP` in `reconcile-pos-sales.js` now keyed by store (FQ01, FQ28) instead of flat suffix lookup. Adds cash register accounts (MN0030, ME0030) and full FQ28 bank set. `getBankGLAccount()` now accepts store parameter for correct GL resolution.
11
+ - Tool count: 44 → 45
12
+
13
+ ---
14
+
15
+ ## [1.18.2] - 2026-04-13
16
+
17
+ ### Fixed
18
+ - **Inventory tools: exclude FQ-\* sale/combo categories** — `get_inventory_levels`, `get_inventory_change`, and `get_inventory_by_location` now filter out items with `itemCategoryCode` starting with `FQ-`, `FQ_`, or equal to `FQVENTA`. These are sales/combo products, not real inventory, and their BC `unitCost` can be contaminated (zero-inventory edge cases), producing inflated inventory valuations. Real inventory items (TERMINADO, INSUMO, etc.) keep BC's FIFO-based `unitCost` which is accurate.
19
+
20
+ ### Changed
21
+ - **Extracted `getCalculatedCosts` to shared module** — Moved from `item-card.js` to `tools/inventario/shared/cost-calculator.js` along with `isInventoryCategory()`. Avoids duplication across inventory tools.
22
+ - **Updated `calculated_unit_cost_method.md`** — Documented category filtering logic and clarified that calculated cost is for FQ-* items only; real inventory uses BC FIFO cost.
23
+
24
+ ---
25
+
26
+ ## [1.18.1] - 2026-04-13
27
+
28
+ ### Changed
29
+ - `get_inventory_by_location` — Added `as_of_date` parameter for historical inventory reconstruction by location. Filters OData V4 item ledger entries with `Posting_Date <= as_of_date`. Also added `isInventoryCategory` filter to exclude FQ-* sale/combo items (aligns with `get_inventory_levels` behavior).
30
+
31
+ ---
32
+
33
+ ## [1.18.0] - 2026-03-19
34
+
35
+ ### Added
36
+
37
+ **Inventario domain — 2 new tools (inventory levels & change tracking):**
38
+ - `get_inventory_levels` — Inventory snapshot grouped by itemCategoryCode and inventoryPostingGroupCode (congelados/imported/local). Supports historical reconstruction via `as_of_date` (e.g., 1st of month post-EOM adjustment). Returns classification matrix with qty, cost value, and top items per group.
39
+ - `get_inventory_change` — WoW and MoM inventory change analysis. Reconstructs opening/closing inventory per period walking backwards from current balance. Movement breakdown by type (purchases, sales, adjustments, assembly). Trend summary per category and classification.
40
+
41
+ ### Changed
42
+ - Tool count: 42 → 44
43
+ - Inventario domain: 4 → 6 tools
44
+
45
+ ---
46
+
3
47
  ## [1.17.0] - 2026-03-18
4
48
 
5
49
  ### Highlights
6
50
  - **Ventas domain consolidated** from `mcp-bc-analisis-ventas` — 3 new sales analysis tools
7
- - Full Queso now has one unified BC MCP with 41 tools across 8 domains
51
+ - Full Queso now has one unified BC MCP with **42 tools** across 8 domains
8
52
 
9
53
  ### Added
10
54
 
@@ -0,0 +1,93 @@
1
+ {
2
+ "version": "1.0",
3
+ "last_updated": "2026-03-23",
4
+ "description": "Mapeo banco_code (BC Bank Account suffix) → cuenta GL (Chart of Accounts). Usado por POS reconciliation y journal entry suggestions. Cada tienda tiene su propio CoA con bancos distintos.",
5
+ "stores": {
6
+ "FQ01": {
7
+ "store_name": "FQ01 - Chacao Sambil",
8
+ "accounts": {
9
+ "MN0001": { "gl": "18210", "name": "Banesco 4421 Bs.", "currency": "VES", "category": "bancos_nacionales" },
10
+ "MN0002": { "gl": "18220", "name": "BDV 1925 Bs.", "currency": "VES", "category": "bancos_nacionales" },
11
+ "MN0003": { "gl": "18230", "name": "Bancamiga 7015 Bs.", "currency": "VES", "category": "bancos_nacionales" },
12
+ "MN0004": { "gl": "18240", "name": "BDV 5550 Bs.", "currency": "VES", "category": "bancos_nacionales" },
13
+ "MN0005": { "gl": "18250", "name": "Bancamiga 4523 Bs.", "currency": "VES", "category": "bancos_nacionales" },
14
+ "MN0006": { "gl": "18260", "name": "Bancrecer 2450 Bs.", "currency": "VES", "category": "bancos_nacionales" },
15
+ "MN0007": { "gl": "18270", "name": "Bancrecer 2467 Bs.", "currency": "VES", "category": "bancos_nacionales" },
16
+ "MN0008": { "gl": "18280", "name": "BDV 5187 Bs. (Pago Móvil)", "currency": "VES", "category": "bancos_nacionales" },
17
+ "MN0012": { "gl": "18290", "name": "UBII", "currency": "VES", "category": "bancos_nacionales" },
18
+ "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
19
+ "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
20
+ "ME0006": { "gl": null, "name": "Zelle USD (Wells Fargo)", "currency": "USD", "category": "bancos_extranjeros", "_nota": "GL pendiente de confirmar" }
21
+ }
22
+ },
23
+ "FQ28": {
24
+ "store_name": "FQ28 - Marqués El Unicentro",
25
+ "accounts": {
26
+ "MN0001": { "gl": "18210", "name": "BDV 8139 Bs.", "currency": "VES", "category": "bancos_nacionales" },
27
+ "MN0002": { "gl": "18220", "name": "Bancrecer 5474 Bs.", "currency": "VES", "category": "bancos_nacionales" },
28
+ "MN0028": { "gl": "18290", "name": "UBII 6249 Bs", "currency": "VES", "category": "bancos_nacionales" },
29
+ "MN0029": { "gl": "18291", "name": "UBII 6442 * Bs", "currency": "VES", "category": "bancos_nacionales" },
30
+ "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
31
+ "MN0031": { "gl": "18160", "name": "Caja transitoria tienda ventas Bs.", "currency": "VES", "category": "caja" },
32
+ "MN0032": { "gl": "18292", "name": "UBII 4 FQ-28", "currency": "VES", "category": "bancos_nacionales" },
33
+ "MN0033": { "gl": "18295", "name": "UBII 7 FQ-28", "currency": "VES", "category": "bancos_nacionales" },
34
+ "MN0098": { "gl": "18298", "name": "Operaciones en Tránsito", "currency": "VES", "category": "bancos_nacionales" },
35
+ "ME0002": { "gl": "18310", "name": "Bancrecer $", "currency": "USD", "category": "bancos_nacionales_divisas" },
36
+ "ME0005": { "gl": "18410", "name": "Zelle Bofa $ *", "currency": "USD", "category": "bancos_extranjeros" },
37
+ "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
38
+ "ME0031": { "gl": "18170", "name": "Caja transitoria tienda ventas $", "currency": "USD", "category": "caja" },
39
+ "ME0035": { "gl": "18150", "name": "Banco Bóveda $", "currency": "USD", "category": "caja" }
40
+ }
41
+ },
42
+ "FQ88": {
43
+ "store_name": "FQ88 - La Candelaria",
44
+ "_nota": "GL accounts pendientes de confirmar con CoA de FQ88",
45
+ "accounts": {
46
+ "MN0001": { "gl": null, "name": "Bancamiga 0240 Bs.", "currency": "VES", "category": "bancos_nacionales" },
47
+ "MN0002": { "gl": null, "name": "Bancamiga 9379 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
48
+ "MN0003": { "gl": null, "name": "BDV 9127 Bs.", "currency": "VES", "category": "bancos_nacionales" },
49
+ "MN0004": { "gl": null, "name": "BDV 7191 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
50
+ "MN0005": { "gl": null, "name": "Bancrecer 2558 Bs.", "currency": "VES", "category": "bancos_nacionales" },
51
+ "MN0007": { "gl": null, "name": "BDV Pago Movil 5145", "currency": "VES", "category": "bancos_nacionales" },
52
+ "MN0028": { "gl": null, "name": "Ubii Bank", "currency": "VES", "category": "bancos_nacionales" },
53
+ "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
54
+ "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
55
+ "ME0005": { "gl": null, "name": "Zelle USD", "currency": "USD", "category": "bancos_extranjeros" }
56
+ }
57
+ }
58
+ },
59
+ "gl_structure": {
60
+ "18100": "Caja (header)",
61
+ "18110": "Caja chica VEB",
62
+ "18120": "Caja chica $",
63
+ "18130": "Caja tienda ventas VEB",
64
+ "18140": "Caja tienda ventas $",
65
+ "18150": "Bóveda $",
66
+ "18160": "Caja tránsito tienda ventas VEB",
67
+ "18170": "Caja tránsito tienda ventas $",
68
+ "18199": "Total Caja",
69
+ "18200": "Bancos Nacionales Bolívares (header)",
70
+ "18210": "Banco principal Bs.",
71
+ "18220": "Banco secundario Bs.",
72
+ "18230": "Banco terciario Bs.",
73
+ "18240": "Banco cuarto Bs.",
74
+ "18250": "Banco quinto Bs.",
75
+ "18260": "Banco sexto Bs.",
76
+ "18270": "Banco séptimo Bs.",
77
+ "18280": "Banco octavo Bs.",
78
+ "18290": "UBII principal",
79
+ "18291": "UBII secundario",
80
+ "18292": "UBII terciario",
81
+ "18295": "UBII cuarto",
82
+ "18298": "Operaciones en Tránsito",
83
+ "18299": "Total Bancos Nacionales",
84
+ "18300": "Bancos Nacionales Divisas (header)",
85
+ "18310": "Banco divisas principal",
86
+ "18399": "Total Bancos Nacionales Divisas",
87
+ "18400": "Bancos Extranjeros Divisas (header)",
88
+ "18410": "Banco extranjero principal",
89
+ "18499": "Total Bancos Extranjeros Divisas",
90
+ "18998": "Total Bancos",
91
+ "18999": "Total Caja y Bancos"
92
+ }
93
+ }
package/lib/bc-client.js CHANGED
@@ -567,7 +567,7 @@ export class BCClient {
567
567
 
568
568
  async getItemsCatalog(companyId) {
569
569
  const url = this.buildApiUrl(companyId, 'items', {
570
- $select: 'id,number,displayName,type,unitPrice,unitCost,itemCategoryCode,lastDirectCost',
570
+ $select: 'id,number,displayName,type,unitPrice,unitCost,itemCategoryCode',
571
571
  });
572
572
  return this.apiCallAllPages(url);
573
573
  }
@@ -619,7 +619,7 @@ export class BCClient {
619
619
  const item = itemsMap[itemNo];
620
620
  if (item) {
621
621
  line.itemCategoryCode = item.itemCategoryCode;
622
- line.unitCostUSD = item.lastDirectCost || item.unitCost || 0;
622
+ line.unitCostUSD = item.unitCost || 0;
623
623
  line.displayName = item.displayName || line.description;
624
624
  }
625
625
  if (!line.displayName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.17.0",
3
+ "version": "1.19.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": {
package/server.js CHANGED
@@ -54,6 +54,7 @@ import { openReceivablesTool, handleOpenReceivables } from './tools/cobranzas/op
54
54
  import { collectionStatusTool, handleCollectionStatus } from './tools/cobranzas/collection-status.js';
55
55
  import { vendorLedgerTool, handleVendorLedger } from './tools/cobranzas/vendor-ledger.js';
56
56
  import { openPayablesTool, handleOpenPayables } from './tools/cobranzas/open-payables.js';
57
+ import { customerListTool, handleCustomerList } from './tools/cobranzas/customer-list.js';
57
58
 
58
59
  // Multi-Payment draft visibility tools
59
60
  import {
@@ -75,6 +76,9 @@ import {
75
76
  itemLedgerEntriesTool, handleItemLedgerEntries,
76
77
  itemValueEntriesTool, handleItemValueEntries,
77
78
  itemCostTrendTool, handleItemCostTrend,
79
+ inventoryLevelsTool, handleInventoryLevels,
80
+ inventoryChangeTool, handleInventoryChange,
81
+ inventoryByLocationTool, handleInventoryByLocation,
78
82
  } from './tools/inventario/index.js';
79
83
 
80
84
  // Ventas tools (sales analysis)
@@ -136,6 +140,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
136
140
  collectionStatusTool,
137
141
  vendorLedgerTool,
138
142
  openPayablesTool,
143
+ customerListTool,
139
144
  // Multi-Payment draft visibility
140
145
  draftReceivablesTool,
141
146
  draftPayablesTool,
@@ -149,6 +154,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
149
154
  itemLedgerEntriesTool,
150
155
  itemValueEntriesTool, // get_item_cost_analysis
151
156
  itemCostTrendTool,
157
+ inventoryLevelsTool,
158
+ inventoryChangeTool,
159
+ inventoryByLocationTool,
152
160
  // Ventas (sales analysis)
153
161
  salesAnalysisTool,
154
162
  productPerformanceTool,
@@ -251,6 +259,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
251
259
  case 'get_open_payables':
252
260
  result = await handleOpenPayables(bcClient, args);
253
261
  break;
262
+ case 'get_customer_list':
263
+ result = await handleCustomerList(bcClient, args);
264
+ break;
254
265
  // Multi-Payment draft visibility
255
266
  case 'get_draft_receivables':
256
267
  result = await handleDraftReceivables(bcClient, args);
@@ -284,6 +295,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
284
295
  case 'get_item_cost_trend':
285
296
  result = await handleItemCostTrend(bcClient, args);
286
297
  break;
298
+ case 'get_inventory_levels':
299
+ result = await handleInventoryLevels(bcClient, args);
300
+ break;
301
+ case 'get_inventory_change':
302
+ result = await handleInventoryChange(bcClient, args);
303
+ break;
304
+ case 'get_inventory_by_location':
305
+ result = await handleInventoryByLocation(bcClient, args);
306
+ break;
287
307
  // Ventas (sales analysis)
288
308
  case 'get_sales_analysis':
289
309
  result = await handleSalesAnalysis(bcClient, args);
@@ -1,4 +1,3 @@
1
- import { DateTime } from 'luxon';
2
1
  import { resolveStores } from '../../config/company-config.js';
3
2
 
4
3
  export const bankLedgerEntriesTool = {
@@ -60,7 +59,6 @@ export async function handleBankLedgerEntries(bcClient, args) {
60
59
  });
61
60
  const entries = await bcClient.odataCallAllPages(url);
62
61
 
63
- const today = DateTime.now();
64
62
  const formatted = entries.map((e) => {
65
63
  const amount = e.Amount || 0;
66
64
  const postingDate = e.Posting_Date || null;
@@ -4,17 +4,37 @@ import { logger } from '../../utils/logger.js';
4
4
 
5
5
  // ── Constants ────────────────────────────────────────────────────────────
6
6
 
7
- // Bank account suffix → GL account for journal entry suggestions
7
+ // Bank account suffix → GL account for journal entry suggestions (per store)
8
8
  const BANK_GL_MAP = {
9
- 'MN0001': '18210', // Banesco
10
- 'MN0002': '18220', // BDV 1925
11
- 'MN0003': '18230', // Bancamiga 7015
12
- 'MN0004': '18240', // BDV 5550
13
- 'MN0005': '18250', // Bancamiga 4523
14
- 'MN0006': '18260', // Bancrecer 2450
15
- 'MN0007': '18270', // Bancrecer 2467
16
- 'MN0008': '18280', // BDV 5187 (Pago Móvil)
17
- 'MN0012': '18290', // UBII
9
+ FQ01: {
10
+ 'MN0001': '18210', // Banesco 4421 Bs.
11
+ 'MN0002': '18220', // BDV 1925 Bs.
12
+ 'MN0003': '18230', // Bancamiga 7015 Bs.
13
+ 'MN0004': '18240', // BDV 5550 Bs.
14
+ 'MN0005': '18250', // Bancamiga 4523 Bs.
15
+ 'MN0006': '18260', // Bancrecer 2450 Bs.
16
+ 'MN0007': '18270', // Bancrecer 2467 Bs.
17
+ 'MN0008': '18280', // BDV 5187 Bs. (Pago Móvil)
18
+ 'MN0012': '18290', // UBII
19
+ 'MN0030': '18130', // Caja tienda ventas Bs.
20
+ 'ME0030': '18140', // Caja tienda ventas $
21
+ },
22
+ FQ28: {
23
+ 'MN0001': '18210', // BDV 8139 Bs.
24
+ 'MN0002': '18220', // Bancrecer 5474 Bs.
25
+ 'MN0028': '18290', // UBII 6249 Bs
26
+ 'MN0029': '18291', // UBII 6442 * Bs
27
+ 'MN0030': '18130', // Caja tienda ventas Bs.
28
+ 'MN0031': '18160', // Caja transitoria tienda ventas Bs.
29
+ 'MN0032': '18292', // UBII 4 FQ-28
30
+ 'MN0033': '18295', // UBII 7 FQ-28
31
+ 'MN0098': '18298', // Operaciones en Tránsito
32
+ 'ME0002': '18310', // Bancrecer $
33
+ 'ME0005': '18410', // Zelle Bofa $ *
34
+ 'ME0030': '18140', // Caja tienda ventas $
35
+ 'ME0031': '18170', // Caja transitoria tienda ventas $
36
+ 'ME0035': '18150', // Banco Bóveda $
37
+ },
18
38
  };
19
39
 
20
40
  const COMMISSION_ACCOUNT = '67100'; // Comisiones Bancarias
@@ -140,12 +160,15 @@ function parseBankStatementLot(description) {
140
160
  * Get GL account for a bank account number.
141
161
  * "FQ01-MN0001" → "18210"
142
162
  */
143
- function getBankGLAccount(bankAccountNo) {
163
+ function getBankGLAccount(bankAccountNo, store) {
144
164
  if (!bankAccountNo) return null;
145
- // Extract suffix: "FQ01-MN0001" → "MN0001"
165
+ // Extract store and suffix: "FQ01-MN0001" → store "FQ01", suffix "MN0001"
146
166
  const parts = bankAccountNo.split('-');
147
167
  const suffix = parts[parts.length - 1];
148
- return BANK_GL_MAP[suffix] || null;
168
+ const storeKey = store || parts[0];
169
+ const storeMap = BANK_GL_MAP[storeKey];
170
+ if (!storeMap) return null;
171
+ return storeMap[suffix] || null;
149
172
  }
150
173
 
151
174
  // ── Union-Find for Compound Lots ─────────────────────────────────────────
@@ -818,7 +841,7 @@ export async function handleReconcilePOSSales(bcClient, args) {
818
841
  }
819
842
 
820
843
  // ── Journal entry suggestions for commissions ──
821
- const glAccount = getBankGLAccount(bankAccount);
844
+ const glAccount = getBankGLAccount(bankAccount, store);
822
845
  const journalEntries = [];
823
846
 
824
847
  if (isBDVAggregate && bdvAggregate.commission_total > 0) {
@@ -1211,7 +1234,7 @@ export async function handleReconcilePOSSales(bcClient, args) {
1211
1234
  const ubiiAvgFee = ubiiTotalBC > 0 ? round2((ubiiTotalFee / ubiiTotalBC) * 100) : 0;
1212
1235
 
1213
1236
  // Journal entries: fee + transfer per matched day
1214
- const ubiiGL = getBankGLAccount(ubiiFullAccount); // 18290
1237
+ const ubiiGL = getBankGLAccount(ubiiFullAccount, store); // 18290
1215
1238
  const ubiiJournalEntries = [];
1216
1239
  for (const m of ubiiMatchedDays) {
1217
1240
  if (m.fee_ves > 0) {
@@ -1225,7 +1248,7 @@ export async function handleReconcilePOSSales(bcClient, args) {
1225
1248
  });
1226
1249
  }
1227
1250
  // Transfer entry: debit receiving bank, credit UBII
1228
- const receivingGL = getBankGLAccount(m.bank_deposit_bank);
1251
+ const receivingGL = getBankGLAccount(m.bank_deposit_bank, store);
1229
1252
  if (receivingGL) {
1230
1253
  ubiiJournalEntries.push({
1231
1254
  posting_date: m.bank_deposit_date,
@@ -0,0 +1,63 @@
1
+ import { resolveStores } from '../../config/company-config.js';
2
+
3
+ export const customerListTool = {
4
+ name: 'get_customer_list',
5
+ description:
6
+ 'Lista de clientes con número, nombre y RIF (taxRegistrationNumber). Útil para identificar clientes por RIF o exportar directorio de clientes.',
7
+ inputSchema: {
8
+ type: 'object',
9
+ properties: {
10
+ store: {
11
+ type: 'string',
12
+ enum: ['FQ01', 'FQ28', 'FQ88'],
13
+ description: 'Tienda a consultar.',
14
+ },
15
+ customer_number: {
16
+ type: 'string',
17
+ description: 'Filtrar por número de cliente específico.',
18
+ },
19
+ },
20
+ required: ['store'],
21
+ },
22
+ };
23
+
24
+ export async function handleCustomerList(bcClient, args) {
25
+ const store = args.store;
26
+ if (!store) throw new Error('Parámetro requerido: store');
27
+
28
+ const stores = resolveStores([store]);
29
+ const storeInfo = stores[0];
30
+
31
+ const selectFields = 'number,displayName,taxRegistrationNumber';
32
+ const params = { $select: selectFields, $orderby: 'number' };
33
+
34
+ if (args.customer_number) {
35
+ params.$filter = `number eq '${args.customer_number}'`;
36
+ }
37
+
38
+ const url = bcClient.buildApiUrl(storeInfo.companyId, 'customers', params);
39
+ const allCustomers = await bcClient.apiCallAllPages(url);
40
+
41
+ const customers = allCustomers.map((c) => ({
42
+ number: c.number,
43
+ name: c.displayName,
44
+ rif: c.taxRegistrationNumber || '',
45
+ }));
46
+
47
+ return {
48
+ content: [
49
+ {
50
+ type: 'text',
51
+ text: JSON.stringify(
52
+ {
53
+ store,
54
+ total_customers: customers.length,
55
+ customers,
56
+ },
57
+ null,
58
+ 2
59
+ ),
60
+ },
61
+ ],
62
+ };
63
+ }
@@ -2,3 +2,6 @@ export { itemCardTool, handleItemCard } from './item-card.js';
2
2
  export { itemLedgerEntriesTool, handleItemLedgerEntries } from './item-ledger-entries.js';
3
3
  export { itemValueEntriesTool, handleItemValueEntries } from './item-value-entries.js';
4
4
  export { itemCostTrendTool, handleItemCostTrend } from './item-cost-trend.js';
5
+ export { inventoryLevelsTool, handleInventoryLevels } from './inventory-levels.js';
6
+ export { inventoryChangeTool, handleInventoryChange } from './inventory-change.js';
7
+ export { inventoryByLocationTool, handleInventoryByLocation } from './inventory-by-location.js';
@@ -0,0 +1,212 @@
1
+ import { resolveStores } from '../../config/company-config.js';
2
+ import { logger } from '../../utils/logger.js';
3
+ import { isInventoryCategory } from './shared/cost-calculator.js';
4
+
5
+ export const inventoryByLocationTool = {
6
+ name: 'get_inventory_by_location',
7
+ description:
8
+ 'Inventario desglosado por ubicación (Location Code) dentro de una tienda. Muestra qty y valor por location, con detalle de ítems por cada una. Útil para ver distribución entre depósitos/almacenes internos. Usa OData V4 para acceder a Location_Code.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ store: {
13
+ type: 'string',
14
+ enum: ['FQ01', 'FQ28', 'FQ88'],
15
+ description: 'Tienda a consultar.',
16
+ },
17
+ location_code: {
18
+ type: 'string',
19
+ description: 'Filtrar por un Location Code específico (e.g. PRINCIPAL, DEPOSITO). Si se omite, muestra todas las ubicaciones.',
20
+ },
21
+ item_category: {
22
+ type: 'string',
23
+ description: 'Filtrar por itemCategoryCode (e.g. TERMINADO, INSUMO).',
24
+ },
25
+ classification: {
26
+ type: 'string',
27
+ description: 'Filtrar por inventoryPostingGroupCode (e.g. CONGELADOS, IMPORTADO, LOCAL).',
28
+ },
29
+ as_of_date: {
30
+ type: 'string',
31
+ description:
32
+ 'Fecha de corte YYYY-MM-DD. Default: hoy (todas las entries). Si es fecha pasada, solo suma ledger entries con Posting_Date <= as_of_date.',
33
+ },
34
+ },
35
+ required: ['store'],
36
+ },
37
+ };
38
+
39
+ export async function handleInventoryByLocation(bcClient, args) {
40
+ const storeParam = args.store;
41
+ if (!storeParam) throw new Error('Parámetro requerido: store');
42
+
43
+ const stores = resolveStores([storeParam]);
44
+ const store = stores[0];
45
+ const companyId = store.companyId;
46
+
47
+ // 1. Fetch items via standard API (has itemCategoryCode, inventoryPostingGroupCode, unitCost)
48
+ const itemFilters = [];
49
+ if (args.item_category) {
50
+ itemFilters.push(`itemCategoryCode eq '${args.item_category}'`);
51
+ }
52
+ if (args.classification) {
53
+ itemFilters.push(`inventoryPostingGroupCode eq '${args.classification}'`);
54
+ }
55
+
56
+ const itemParams = {
57
+ $select: 'number,displayName,itemCategoryCode,inventoryPostingGroupCode,unitCost,inventory',
58
+ };
59
+ if (itemFilters.length > 0) {
60
+ itemParams.$filter = itemFilters.join(' and ');
61
+ }
62
+
63
+ const itemUrl = bcClient.buildApiUrl(companyId, 'items', itemParams);
64
+ const items = await bcClient.apiCallAllPages(itemUrl);
65
+ logger.info(`${store.code}: ${items.length} items fetched`);
66
+
67
+ // Build item metadata map — exclude items without inventoryPostingGroupCode and FQ-* categories
68
+ const itemMeta = {};
69
+ let excludedCount = 0;
70
+ for (const item of items) {
71
+ if (!item.inventoryPostingGroupCode || !isInventoryCategory(item.itemCategoryCode)) {
72
+ excludedCount++;
73
+ continue;
74
+ }
75
+ itemMeta[item.number] = {
76
+ name: item.displayName,
77
+ category: item.itemCategoryCode || 'SIN_CATEGORIA',
78
+ classification: item.inventoryPostingGroupCode,
79
+ unit_cost: item.unitCost || 0,
80
+ total_inventory: item.inventory || 0,
81
+ };
82
+ }
83
+ if (excludedCount > 0) {
84
+ logger.info(`${store.code}: excluded ${excludedCount} items without inventoryPostingGroupCode`);
85
+ }
86
+
87
+ // 2. Fetch item ledger entries via OData V4 (has Location_Code field)
88
+ const today = new Date();
89
+ const todayStr = today.toISOString().split('T')[0];
90
+ const asOfDate = args.as_of_date || todayStr;
91
+ const isHistorical = asOfDate < todayStr;
92
+
93
+ const odataFilters = [];
94
+ if (args.location_code) {
95
+ odataFilters.push(`Location_Code eq '${args.location_code}'`);
96
+ }
97
+ if (isHistorical) {
98
+ odataFilters.push(`Posting_Date le ${asOfDate}`);
99
+ }
100
+
101
+ const odataParams = {
102
+ $select: 'Item_No,Location_Code,Quantity',
103
+ };
104
+ if (odataFilters.length > 0) {
105
+ odataParams.$filter = odataFilters.join(' and ');
106
+ }
107
+
108
+ const ledgerUrl = bcClient.buildODataUrl(storeParam, 'ItemLedgerEntries', odataParams);
109
+ const entries = await bcClient.odataCallAllPages(ledgerUrl);
110
+ logger.info(`${store.code}: ${entries.length} item ledger entries fetched via OData V4 for location breakdown`);
111
+
112
+ // 3. Aggregate: { Location_Code → { Item_No → net_qty } }
113
+ const locationItems = {};
114
+
115
+ for (const e of entries) {
116
+ const loc = e.Location_Code || 'SIN_UBICACION';
117
+ const itemNo = e.Item_No;
118
+ const qty = e.Quantity || 0;
119
+
120
+ // Skip items not in our metadata (filtered out or no posting group)
121
+ if (!itemMeta[itemNo]) continue;
122
+
123
+ if (!locationItems[loc]) locationItems[loc] = {};
124
+ if (!locationItems[loc][itemNo]) locationItems[loc][itemNo] = 0;
125
+ locationItems[loc][itemNo] += qty;
126
+ }
127
+
128
+ // 4. Build response per location
129
+ const byLocation = {};
130
+ const locationSummaries = [];
131
+
132
+ for (const [loc, itemQtys] of Object.entries(locationItems)) {
133
+ let itemCount = 0;
134
+ let totalQty = 0;
135
+ let totalCostValue = 0;
136
+ const itemDetails = [];
137
+
138
+ for (const [itemNo, netQty] of Object.entries(itemQtys)) {
139
+ const roundedQty = round5(netQty);
140
+ if (roundedQty === 0) continue;
141
+
142
+ const meta = itemMeta[itemNo];
143
+ const costValue = round2(roundedQty * meta.unit_cost);
144
+
145
+ itemCount++;
146
+ totalQty = round5(totalQty + roundedQty);
147
+ totalCostValue = round2(totalCostValue + costValue);
148
+
149
+ itemDetails.push({
150
+ number: itemNo,
151
+ name: meta.name,
152
+ category: meta.category,
153
+ classification: meta.classification,
154
+ qty: roundedQty,
155
+ unit_cost: meta.unit_cost,
156
+ cost_value: costValue,
157
+ });
158
+ }
159
+
160
+ if (itemCount === 0) continue;
161
+
162
+ // Sort by absolute qty descending, keep top 10
163
+ itemDetails.sort((a, b) => Math.abs(b.qty) - Math.abs(a.qty));
164
+ const topItems = itemDetails.slice(0, 10);
165
+
166
+ byLocation[loc] = {
167
+ item_count: itemCount,
168
+ total_qty: round5(totalQty),
169
+ total_cost_value: round2(totalCostValue),
170
+ top_items: topItems,
171
+ };
172
+
173
+ locationSummaries.push({
174
+ location: loc,
175
+ item_count: itemCount,
176
+ total_qty: round5(totalQty),
177
+ total_cost_value: round2(totalCostValue),
178
+ });
179
+ }
180
+
181
+ // Sort summaries by cost value descending
182
+ locationSummaries.sort((a, b) => b.total_cost_value - a.total_cost_value);
183
+
184
+ // Grand totals
185
+ const grandTotal = {
186
+ locations: locationSummaries.length,
187
+ item_count: locationSummaries.reduce((s, l) => s + l.item_count, 0),
188
+ total_qty: round5(locationSummaries.reduce((s, l) => s + l.total_qty, 0)),
189
+ total_cost_value: round2(locationSummaries.reduce((s, l) => s + l.total_cost_value, 0)),
190
+ };
191
+
192
+ return {
193
+ store: store.code,
194
+ store_name: store.name,
195
+ as_of_date: asOfDate,
196
+ is_historical: isHistorical,
197
+ note: isHistorical
198
+ ? `Inventario histórico al ${asOfDate} reconstruido desde item ledger entries (OData V4) con Posting_Date <= ${asOfDate}, agrupado por Location_Code. unitCost es el actual de BC (no histórico).`
199
+ : 'Inventario reconstruido desde item ledger entries (OData V4) agrupado por Location_Code. Cada location muestra top 10 ítems por qty. unitCost es el actual de BC.',
200
+ summary: locationSummaries,
201
+ by_location: byLocation,
202
+ grand_total: grandTotal,
203
+ };
204
+ }
205
+
206
+ function round2(n) {
207
+ return Math.round(n * 100) / 100;
208
+ }
209
+
210
+ function round5(n) {
211
+ return Math.round(n * 100000) / 100000;
212
+ }