sap-wm-mcp 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -13,7 +13,7 @@ Connect AI agents — Claude, Copilot, or any MCP-compatible client — directly
13
13
  >
14
14
  > This project ships a custom RAP OData V4 service that exposes classic WM operations as a proper API — and wraps it in an MCP server so AI agents can drive it. Large portions of the SAP install base are still on classic WM. This fills the gap.
15
15
 
16
- The **npm package** ships 18 tools covering core operations, analytics, shift management, and anomaly detection. This repository contains the 9 open-sourced tool implementations — the ABAP RAP service source and additional tool source are available separately (see [ABAP Service Installation](#abap-service-installation)).
16
+ The **npm package** ships 21 tools covering core operations, analytics, shift management, anomaly detection, audit history, and proactive replenishment. This repository contains the 9 open-sourced tool implementations — the ABAP RAP service source and additional tool source are available separately (see [ABAP Service Installation](#abap-service-installation)).
17
17
 
18
18
  ---
19
19
 
@@ -56,7 +56,7 @@ AI Agent (Claude / Copilot / any MCP client)
56
56
  ├── LTAP (Transfer Order Items)
57
57
  ├── LTBK (Transfer Requirement Header)
58
58
  ├── MARD (IM Stock per Storage Location)
59
- └── FMs L_TO_CREATE_SINGLE · L_TO_CONFIRM · L_TO_CONFIRM_SU
59
+ └── FMs L_TO_CREATE_SINGLE · L_TO_CONFIRM · L_TO_CONFIRM_SU · L_TO_CANCEL
60
60
  ```
61
61
 
62
62
  Unlike EWM, classic WM has no standard OData APIs. This package requires a **custom RAP OData V4 service** (`ZSD_WMMCPSERVICE`) installed in your SAP system. The MCP server calls that service. See [ABAP Service Installation](#abap-service-installation).
@@ -362,11 +362,19 @@ For write operations:
362
362
  "Are there any fragmented quants that need consolidation TOs?"
363
363
  ```
364
364
 
365
+ **Audit and history**
366
+ ```
367
+ "Show me all transfer orders created in warehouse 102 this month"
368
+ "Which TOs did NOMANH create last week?"
369
+ "Show me all open TOs created more than 3 days ago — something is stuck"
370
+ "Give me a history of movement type 999 TOs for material TG0001"
371
+ ```
372
+
365
373
  ---
366
374
 
367
375
  ## Tools Reference
368
376
 
369
- The npm package ships **18 tools** across four capability areas. The 9 tools below are open-sourced in this repository. Analytics, shift management, and anomaly detection tools are available in the published package.
377
+ The npm package ships **21 tools** across five capability areas. The 9 tools below are open-sourced in this repository. Analytics, shift management, anomaly detection, and operations tools are available in the published package.
370
378
 
371
379
  ---
372
380
 
@@ -496,6 +504,21 @@ Confirm all open Transfer Orders on a storage unit in one call — useful for SU
496
504
 
497
505
  ---
498
506
 
507
+ #### `cancel_transfer_order`
508
+
509
+ Cancel an open Transfer Order — removes it from the active TO queue. Equivalent to transaction **LT15**.
510
+
511
+ Internally calls `L_TO_CANCEL`. Returns a clear error message for each SAP exception (already confirmed, locked, partially confirmed, etc.).
512
+
513
+ | Parameter | Type | Required | Description |
514
+ |---|---|---|---|
515
+ | `warehouse` | string | ✅ | Warehouse number |
516
+ | `transferOrderNumber` | string | ✅ | Transfer order number — e.g. `0000000652` |
517
+
518
+ > **Note:** Only open (unconfirmed) TOs can be cancelled. Confirmed TOs, partially confirmed TOs, and TOs locked by another user will return a descriptive error.
519
+
520
+ ---
521
+
499
522
  ### Additional Tools (npm package)
500
523
 
501
524
  The following tools are available in the published npm package and fully functional via `npx sap-wm-mcp`:
@@ -511,6 +534,8 @@ The following tools are available in the published npm package and fully functio
511
534
  | `get_quant_fragmentation` | Bin+material combinations with excessive quant count — consolidation candidates |
512
535
  | `get_unresolved_su_negatives` | Persistent negative quants in SU zones older than a configurable age |
513
536
  | `get_inventory_anomalies` | Bins stuck in mid-inventory state — empty bins with locks, open count docs, orphaned lock codes |
537
+ | `get_transfer_order_history` | Full TO history with creation date, creator, confirmation date, executor, duration, and item detail — filterable by date range, status, movement type, material, or user |
538
+ | `get_replenishment_needs` | Find forward-pick bins at or below a stock threshold — flags bins with an open replenishment TO already in progress to avoid duplicate moves |
514
539
 
515
540
  ---
516
541
 
@@ -586,6 +611,7 @@ Classic WM has no standard OData APIs. This project builds one using **ABAP REST
586
611
  | `CreateTransferOrder` | `L_TO_CREATE_SINGLE` | Called via RFC wrapper `ZWM_TO_CREATE` with `DESTINATION 'NONE'` |
587
612
  | `ConfirmTransferOrder` | `L_TO_CONFIRM` | Called directly — no COMMIT needed |
588
613
  | `ConfirmTransferOrderSU` | `L_TO_CONFIRM_SU` | Called directly — no COMMIT needed |
614
+ | `CancelTransferOrder` | `L_TO_CANCEL` | Called directly — no COMMIT needed |
589
615
 
590
616
  ### Why the RFC wrapper?
591
617
 
@@ -648,7 +674,9 @@ sap-wm-mcp/
648
674
  │ ├── openTransferOrders.js ← get_open_transfer_orders
649
675
  │ ├── createTransferOrder.js ← create_transfer_order
650
676
  │ ├── confirmTransferOrder.js ← confirm_transfer_order
651
- └── confirmTransferOrderSU.js ← confirm_transfer_order_su
677
+ ├── confirmTransferOrderSU.js ← confirm_transfer_order_su
678
+ │ ├── cancelTransferOrder.js ← cancel_transfer_order
679
+ │ └── transferOrderHistory.js ← get_transfer_order_history
652
680
  ├── .env.example
653
681
  └── package.json
654
682
  ```
@@ -673,7 +701,7 @@ Running both side-by-side shows the contrast directly: same tools, same question
673
701
  | Phase | Status | Description |
674
702
  |---|---|---|
675
703
  | Phase 0 — RAP Service | ✅ Complete | Custom OData V4 service with 7 entity sets over classic WM tables |
676
- | Phase 1 — Local MCP | ✅ Complete | 18 tools working, security hardened, published to npm |
704
+ | Phase 1 — Local MCP | ✅ Complete | 21 tools working, security hardened, published to npm |
677
705
  | Phase 2 — BTP CF | 🔜 Planned | Deploy to SAP BTP Cloud Foundry with SSE transport + XSUAA + Cloud Connector |
678
706
  | Phase 3 — Joule Agent | 💡 Future | Native Joule Studio agent using the same RAP service |
679
707
 
package/index.js CHANGED
@@ -25,8 +25,11 @@ import { getGoodsReceiptMonitor } from './tools/goodsReceiptMonitor.js';
25
25
  import { getQuantFragmentation } from './tools/quantFragmentation.js';
26
26
  import { getUnresolvedSuNegatives } from './tools/unresolvedSuNegatives.js';
27
27
  import { getInventoryAnomalies } from './tools/inventoryAnomalies.js';
28
+ import { getTransferOrderHistory } from './tools/transferOrderHistory.js';
29
+ import { cancelTransferOrder } from './tools/cancelTransferOrder.js';
30
+ import { getReplenishmentNeeds } from './tools/replenishmentNeeds.js';
28
31
 
29
- const server = new McpServer({ name: 'sap-wm-mcp', version: '0.2.0' });
32
+ const server = new McpServer({ name: 'sap-wm-mcp', version: '0.2.6' });
30
33
 
31
34
  // Tool 1 — get_bin_status
32
35
  server.tool(
@@ -388,6 +391,71 @@ server.tool(
388
391
  }
389
392
  );
390
393
 
394
+ // Tool 19 — get_transfer_order_history
395
+ server.tool(
396
+ 'get_transfer_order_history',
397
+ 'Get the full history of classic WM Transfer Orders in S/4HANA — when they were created, by whom, when confirmed, who executed them, and how long they took. Filter by date range, status (open/confirmed/all), movement type, material, creator, or executor.',
398
+ {
399
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
400
+ dateFrom: z.string().optional().describe('Filter TOs created from this date (YYYY-MM-DD) e.g. 2026-01-01'),
401
+ dateTo: z.string().optional().describe('Filter TOs created up to this date (YYYY-MM-DD) e.g. 2026-03-31'),
402
+ status: z.enum(['open', 'confirmed', 'all']).optional().default('all').describe('Filter by confirmation status — open, confirmed, or all (default: all)'),
403
+ movementType: z.string().optional().describe('Filter by WM movement type e.g. 999'),
404
+ createdBy: z.string().optional().describe('Filter by the SAP user who created the TO e.g. NOMANH'),
405
+ executedBy: z.string().optional().describe('Filter by the SAP user who executed/confirmed the TO'),
406
+ material: z.string().optional().describe('Filter by material number e.g. TG0001'),
407
+ top: z.number().optional().default(50).describe('Max number of TOs to return (default 50)')
408
+ },
409
+ async (params) => {
410
+ try {
411
+ const result = await getTransferOrderHistory(params);
412
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
413
+ } catch (err) {
414
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
415
+ }
416
+ }
417
+ );
418
+
419
+ // Tool 20 — cancel_transfer_order
420
+ server.tool(
421
+ 'cancel_transfer_order',
422
+ 'Cancel an open classic WM Transfer Order — releases the source quant lock and removes the destination bin reservation. Only works on TOs not yet confirmed. Use to recover from a wrong bin or quantity before retrying with corrected parameters.',
423
+ {
424
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
425
+ transferOrderNumber: z.string().describe('Transfer order number to cancel e.g. 0000000730')
426
+ },
427
+ async (params) => {
428
+ try {
429
+ const result = await cancelTransferOrder(params);
430
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
431
+ } catch (err) {
432
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
433
+ }
434
+ }
435
+ );
436
+
437
+ // Tool 21 — get_replenishment_needs
438
+ server.tool(
439
+ 'get_replenishment_needs',
440
+ 'Find forward pick and fixed storage bins that are at or below their minimum stock threshold and need replenishment. Returns urgency level (critical = empty or negative, low = below minimum) and flags bins where an open replenishment TO already exists. Run at shift start and before peak picking periods.',
441
+ {
442
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
443
+ storageType: z.string().optional().default('P01').describe('Forward pick storage type to check (default P01)'),
444
+ material: z.string().optional().describe('Narrow to a specific material e.g. TG0001'),
445
+ minimumQuantity: z.number().optional().default(0).describe('Flag bins with stock at or below this level. Default 0 = empty or negative only.'),
446
+ targetQuantity: z.number().optional().describe('Fill-to target quantity — used to calculate replenishmentQty in the response. Leave unset if unknown.'),
447
+ top: z.number().optional().default(50).describe('Max records to return')
448
+ },
449
+ async (params) => {
450
+ try {
451
+ const result = await getReplenishmentNeeds(params);
452
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
453
+ } catch (err) {
454
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
455
+ }
456
+ }
457
+ );
458
+
391
459
  const transport = new StdioServerTransport();
392
460
  await server.connect(transport);
393
461
  console.error('SAP WM MCP Server running (stdio)...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-wm-mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "MCP server for SAP Classic Warehouse Management — connects AI agents to S/4HANA WM via a custom RAP OData V4 service. For systems where EWM is not active.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,20 @@
1
+ import { s4hPost } from '../lib/s4hClient.js';
2
+ import { esc } from '../lib/sanitize.js';
3
+
4
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001`;
5
+ const NS = `com.sap.gateway.srvd.zsd_wmmcpservice.v0001`;
6
+
7
+ export async function cancelTransferOrder({ warehouse, transferOrderNumber }) {
8
+ // Instance action — key goes in the URL path, not the body
9
+ const path = `${BASE}/WMTransferOrder(WarehouseNumber='${encodeURIComponent(esc(warehouse))}',TransferOrderNumber='${encodeURIComponent(esc(transferOrderNumber))}')/${NS}.CancelTransferOrder`;
10
+
11
+ await s4hPost(path, {});
12
+
13
+ return {
14
+ success: true,
15
+ toNumber: transferOrderNumber,
16
+ message: 'Transfer order cancelled successfully',
17
+ previousStatus: 'open',
18
+ newStatus: 'cancelled'
19
+ };
20
+ }
@@ -0,0 +1,140 @@
1
+ import { s4hGet } from '../lib/s4hClient.js';
2
+ import { esc } from '../lib/sanitize.js';
3
+
4
+ const BASE_STOCK = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMWarehouseStock`;
5
+ const BASE_HEADER = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMTransferOrder`;
6
+ const BASE_ITEM = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMTransferOrderItem`;
7
+
8
+ export async function getReplenishmentNeeds({
9
+ warehouse,
10
+ storageType = 'P01',
11
+ material,
12
+ minimumQuantity = 0,
13
+ targetQuantity,
14
+ top = 50
15
+ }) {
16
+ // Step 1 + 2 in parallel: stock per bin + open TO headers
17
+ const stockFilters = [
18
+ `WarehouseNumber eq '${esc(warehouse)}'`,
19
+ `StorageType eq '${esc(storageType)}'`
20
+ ];
21
+ if (material) stockFilters.push(`Material eq '${esc(material)}'`);
22
+
23
+ const stockPath = `${BASE_STOCK}?$filter=${encodeURIComponent(stockFilters.join(' and '))}`
24
+ + `&$top=${top * 10}`
25
+ + `&$select=StorageType,StorageBin,Material,Plant,TotalStock,AvailableStock,PickQuantity,UnitOfMeasure`;
26
+
27
+ const headerPath = `${BASE_HEADER}?$filter=${encodeURIComponent(
28
+ `WarehouseNumber eq '${esc(warehouse)}' and IsConfirmed ne true`
29
+ )}&$top=500&$select=TransferOrderNumber`;
30
+
31
+ const [stockData, headerData] = await Promise.all([
32
+ s4hGet(stockPath),
33
+ s4hGet(headerPath)
34
+ ]);
35
+
36
+ const allStock = stockData.value ?? [];
37
+ const openHeaders = headerData.value ?? [];
38
+
39
+ // Aggregate stock by bin+material (multiple quants per bin collapse to one row)
40
+ const stockMap = new Map();
41
+ for (const row of allStock) {
42
+ const mat = row.Material?.trimStart?.() ?? row.Material;
43
+ const key = `${row.StorageBin}::${mat}`;
44
+ if (!stockMap.has(key)) {
45
+ stockMap.set(key, {
46
+ storageType: row.StorageType,
47
+ bin: row.StorageBin,
48
+ material: mat,
49
+ plant: row.Plant,
50
+ totalStock: 0,
51
+ availableStock: 0,
52
+ pickQty: 0,
53
+ uom: row.UnitOfMeasure
54
+ });
55
+ }
56
+ const e = stockMap.get(key);
57
+ e.totalStock += parseFloat(row.TotalStock ?? 0);
58
+ e.availableStock += parseFloat(row.AvailableStock ?? 0);
59
+ e.pickQty += parseFloat(row.PickQuantity ?? 0);
60
+ }
61
+
62
+ // Filter: bins at or below the minimum threshold
63
+ const lowBins = [...stockMap.values()].filter(e => e.totalStock <= minimumQuantity);
64
+
65
+ if (lowBins.length === 0) {
66
+ return {
67
+ count: 0,
68
+ truncated: false,
69
+ warehouse,
70
+ storageType,
71
+ filters: { material: material ?? 'all', minimumQuantity, targetQuantity: targetQuantity ?? null },
72
+ summary: { critical: 0, low: 0 },
73
+ bins: [],
74
+ note: `All bins in storage type ${storageType} are above minimum quantity (${minimumQuantity})`
75
+ };
76
+ }
77
+
78
+ // Fetch open TO items targeting this type as destination (replenishment TOs in progress)
79
+ const replenTOMap = new Map(); // 'bin::material' → TO number
80
+ if (openHeaders.length > 0) {
81
+ const toFilter = openHeaders
82
+ .map(h => `TransferOrderNumber eq '${esc(h.TransferOrderNumber)}'`)
83
+ .join(' or ');
84
+ const itemFilters = [
85
+ `WarehouseNumber eq '${esc(warehouse)}'`,
86
+ `DestStorageType eq '${esc(storageType)}'`,
87
+ `(${toFilter})`
88
+ ];
89
+ if (material) itemFilters.push(`Material eq '${esc(material)}'`);
90
+
91
+ const itemPath = `${BASE_ITEM}?$filter=${encodeURIComponent(itemFilters.join(' and '))}`
92
+ + `&$top=500&$select=TransferOrderNumber,DestBin,Material`;
93
+ const itemData = await s4hGet(itemPath);
94
+
95
+ for (const item of (itemData.value ?? [])) {
96
+ const mat = item.Material?.trimStart?.() ?? item.Material;
97
+ const key = `${item.DestBin}::${mat}`;
98
+ if (!replenTOMap.has(key)) replenTOMap.set(key, item.TransferOrderNumber);
99
+ }
100
+ }
101
+
102
+ // Build response — sorted critical first (most negative / lowest stock first)
103
+ const bins = lowBins
104
+ .sort((a, b) => a.totalStock - b.totalStock)
105
+ .map(e => {
106
+ const key = `${e.bin}::${e.material}`;
107
+ const urgency = e.totalStock <= 0 ? 'critical' : 'low';
108
+ const openTONumber = replenTOMap.get(key) ?? null;
109
+ const replenishmentQty = targetQuantity != null
110
+ ? Math.max(0, targetQuantity - e.totalStock)
111
+ : null;
112
+ return {
113
+ storageType: e.storageType,
114
+ bin: e.bin,
115
+ material: e.material,
116
+ plant: e.plant,
117
+ currentStock: e.totalStock,
118
+ availableStock: e.availableStock,
119
+ pickQty: e.pickQty,
120
+ replenishmentQty,
121
+ uom: e.uom,
122
+ urgency,
123
+ openReplenTO: openTONumber !== null,
124
+ openTONumber
125
+ };
126
+ });
127
+
128
+ return {
129
+ count: bins.length,
130
+ truncated: allStock.length >= top * 10,
131
+ warehouse,
132
+ storageType,
133
+ filters: { material: material ?? 'all', minimumQuantity, targetQuantity: targetQuantity ?? null },
134
+ summary: {
135
+ critical: bins.filter(b => b.urgency === 'critical').length,
136
+ low: bins.filter(b => b.urgency === 'low').length
137
+ },
138
+ bins
139
+ };
140
+ }
@@ -0,0 +1,146 @@
1
+ import { s4hGet } from '../lib/s4hClient.js';
2
+ import { esc } from '../lib/sanitize.js';
3
+
4
+ const BASE_HEADER = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMTransferOrder`;
5
+ const BASE_ITEM = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMTransferOrderItem`;
6
+
7
+ export async function getTransferOrderHistory({
8
+ warehouse,
9
+ dateFrom,
10
+ dateTo,
11
+ status = 'all',
12
+ movementType,
13
+ createdBy,
14
+ executedBy,
15
+ material,
16
+ top = 50
17
+ }) {
18
+
19
+ // Step 1 — Build header filters
20
+ const headerFilters = [`WarehouseNumber eq '${esc(warehouse)}'`];
21
+
22
+ if (status === 'confirmed') headerFilters.push(`IsConfirmed eq true`);
23
+ if (status === 'open') headerFilters.push(`IsConfirmed ne true`);
24
+
25
+ if (dateFrom) headerFilters.push(`CreatedDate ge ${esc(dateFrom)}`);
26
+ if (dateTo) headerFilters.push(`CreatedDate le ${esc(dateTo)}`);
27
+ if (movementType) headerFilters.push(`MovementType eq '${esc(movementType)}'`);
28
+ if (createdBy) headerFilters.push(`CreatedBy eq '${esc(createdBy)}'`);
29
+ if (executedBy) headerFilters.push(`ExecutedBy eq '${esc(executedBy)}'`);
30
+
31
+ const headerSelect = [
32
+ 'WarehouseNumber','TransferOrderNumber','MovementType','IsConfirmed',
33
+ 'CreatedDate','CreatedTime','CreatedBy',
34
+ 'ConfirmedDate','ExecutedBy','StartDate','StartTime','EndDate','EndTime',
35
+ 'TransferReqNumber','NumberOfItems'
36
+ ].join(',');
37
+
38
+ const headerPath = `${BASE_HEADER}?$filter=${encodeURIComponent(headerFilters.join(' and '))}&$orderby=CreatedDate desc,CreatedTime desc&$top=${top}&$select=${headerSelect}`;
39
+ const headerData = await s4hGet(headerPath);
40
+ const headers = headerData.value ?? [];
41
+
42
+ if (headers.length === 0) {
43
+ return {
44
+ count: 0,
45
+ truncated: false,
46
+ warehouse,
47
+ filters: { dateFrom: dateFrom ?? 'all', dateTo: dateTo ?? 'all', status, movementType: movementType ?? 'all' },
48
+ orders: []
49
+ };
50
+ }
51
+
52
+ // Step 2 — Fetch items for the returned TOs
53
+ const toNumbers = [...new Set(headers.map(h => h.TransferOrderNumber))];
54
+ const toFilter = toNumbers.map(n => `TransferOrderNumber eq '${esc(n)}'`).join(' or ');
55
+
56
+ const itemFilters = [`WarehouseNumber eq '${esc(warehouse)}'`, `(${toFilter})`];
57
+ if (material) itemFilters.push(`Material eq '${esc(material)}'`);
58
+
59
+ const itemPath = `${BASE_ITEM}?$filter=${encodeURIComponent(itemFilters.join(' and '))}&$top=${top * 10}`;
60
+ const itemData = await s4hGet(itemPath);
61
+ const allItems = itemData.value ?? [];
62
+
63
+ // Step 3 — Group items by TO number
64
+ const itemsByTo = {};
65
+ for (const item of allItems) {
66
+ const key = item.TransferOrderNumber;
67
+ if (!itemsByTo[key]) itemsByTo[key] = [];
68
+ itemsByTo[key].push({
69
+ item: item.TransferOrderItem,
70
+ material: item.Material?.trimStart?.() ?? item.Material,
71
+ plant: item.Plant,
72
+ sourceType: item.SourceStorageType,
73
+ sourceBin: item.SourceBin,
74
+ destType: item.DestStorageType,
75
+ destBin: item.DestBin,
76
+ requiredQty: parseFloat(item.RequiredQuantity) || 0,
77
+ confirmedQty: parseFloat(item.ConfirmedQuantity) || 0,
78
+ uom: item.UnitOfMeasure
79
+ });
80
+ }
81
+
82
+ // Step 4 — If material filter active, drop TOs that have no matching items
83
+ const filteredHeaders = material
84
+ ? headers.filter(h => itemsByTo[h.TransferOrderNumber]?.length > 0)
85
+ : headers;
86
+
87
+ // Step 5 — Build response
88
+ const today = new Date();
89
+
90
+ const orders = filteredHeaders.map(h => {
91
+ const items = itemsByTo[h.TransferOrderNumber] ?? [];
92
+ const createdAt = h.CreatedDate ? new Date(h.CreatedDate) : null;
93
+ const daysSince = createdAt ? Math.floor((today - createdAt) / 86400000) : null;
94
+ const isConfirmed = h.IsConfirmed === true || h.IsConfirmed === 'X';
95
+
96
+ // Duration: minutes between StartDate/Time and EndDate/Time
97
+ let durationMinutes = null;
98
+ if (h.StartDate && h.EndDate && h.StartTime && h.EndTime) {
99
+ const start = new Date(`${h.StartDate}T${h.StartTime}`);
100
+ const end = new Date(`${h.EndDate}T${h.EndTime}`);
101
+ if (!isNaN(start) && !isNaN(end)) {
102
+ durationMinutes = Math.round((end - start) / 60000);
103
+ }
104
+ }
105
+
106
+ return {
107
+ toNumber: h.TransferOrderNumber,
108
+ status: isConfirmed ? 'confirmed' : 'open',
109
+ movementType: h.MovementType,
110
+ transferReqNumber: h.TransferReqNumber || null,
111
+ numberOfItems: parseInt(h.NumberOfItems) || items.length,
112
+ created: {
113
+ date: h.CreatedDate,
114
+ time: h.CreatedTime,
115
+ by: h.CreatedBy,
116
+ daysAgo: daysSince
117
+ },
118
+ execution: isConfirmed ? {
119
+ confirmedDate: h.ConfirmedDate || null,
120
+ executedBy: h.ExecutedBy || null,
121
+ startDate: h.StartDate || null,
122
+ startTime: h.StartTime || null,
123
+ endDate: h.EndDate || null,
124
+ endTime: h.EndTime || null,
125
+ durationMinutes
126
+ } : null,
127
+ items
128
+ };
129
+ });
130
+
131
+ return {
132
+ count: orders.length,
133
+ truncated: headers.length === top,
134
+ warehouse,
135
+ filters: {
136
+ dateFrom: dateFrom ?? 'all',
137
+ dateTo: dateTo ?? 'all',
138
+ status,
139
+ movementType: movementType ?? 'all',
140
+ createdBy: createdBy ?? 'all',
141
+ executedBy: executedBy ?? 'all',
142
+ material: material ?? 'all'
143
+ },
144
+ orders
145
+ };
146
+ }