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 +33 -5
- package/index.js +69 -1
- package/package.json +1 -1
- package/tools/cancelTransferOrder.js +20 -0
- package/tools/replenishmentNeeds.js +140 -0
- package/tools/transferOrderHistory.js +146 -0
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
|
|
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 **
|
|
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
|
-
│
|
|
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 |
|
|
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.
|
|
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.
|
|
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
|
+
}
|