sap-wm-mcp 0.3.0 → 0.3.2

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 22 tools covering core operations, analytics, shift management, anomaly detection, audit history, proactive replenishment, and interim zone reconciliation. 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 23 tools covering core operations, analytics, shift management, anomaly detection, audit history, proactive replenishment, interim zone reconciliation, and cycle count management. 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
 
@@ -374,7 +374,7 @@ For write operations:
374
374
 
375
375
  ## Tools Reference
376
376
 
377
- The npm package ships **22 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.
377
+ The npm package ships **23 tools** across five capability areas. The 9 tools below are open-sourced in this repository. Analytics, shift management, anomaly detection, cycle count management, and operations tools are available in the published package.
378
378
 
379
379
  ---
380
380
 
@@ -541,6 +541,8 @@ The following tools are available in the published npm package and fully functio
541
541
  | `get_transfer_order_history` | Full TO history — creator, executor (resolved from LTAP.QNAME), and item detail; filterable by date range, status, movement type, material, `createdBy`, or `executedBy` |
542
542
  | `get_replenishment_needs` | Find forward-pick bins at or below a stock threshold — `defaultReplenishQty` param (default 50) used as fallback when no bin max qty is configured; flags bins with an open replenishment TO to avoid duplicate moves |
543
543
  | `get_interim_zone_anomalies` | Detect positive stock stranded in interim/staging zones (types 999, 998, 902) — surfaces same-day, overnight, and multi-day strandings with likely cause per zone; use `minDaysStranded` to filter noise during active shifts |
544
+ | `get_goods_issue_monitor` | Open outbound deliveries — delivery qty vs picked qty, GI status, overdue flag, and stock still in GI zone (type 999). Tracks picking progress and stalled shipments. |
545
+ | `create_cycle_count_document` | Create a WM inventory document (LI01N equivalent) for a specific bin. Activates the bin immediately by default (`LAGP.KZINV = 'IL'`). Use after `get_cycle_count_candidates` to trigger a count. |
544
546
 
545
547
  ---
546
548
 
@@ -566,11 +568,17 @@ The ABAP source is not included in this public repository. To obtain the ABAP pa
566
568
  | `ZR_WMCYCLECOUNTBIN` | CDS View | View over LAGP (cycle count indicators) |
567
569
  | `ZR_WMTRANSFERORDER` | BDEF | Behavior definition — defines actions |
568
570
  | `ZBP_R_WMTRANSFERORDER` | Class | RAP behavior implementation |
569
- | `ZWM_MFG` | Function Group | Contains RFC wrapper FM |
571
+ | `ZR_WMGOODSISSUEDELIVERY` | CDS View | View over LIKP + LIPS (outbound deliveries for GI monitor) |
572
+ | `ZR_WMTRANSFERORDER` | BDEF | Behavior definition — defines actions |
573
+ | `ZBP_R_WMTRANSFERORDER` | Class | RAP behavior implementation |
574
+ | `ZWM_MFG` | Function Group | Contains all RFC wrapper FMs |
570
575
  | `ZWM_TO_CREATE` | Function Module | RFC-enabled wrapper for `L_TO_CREATE_SINGLE` |
571
- | `ZA_WMCREATETOPARAM` | Structure | Parameter type for CreateTransferOrder action |
572
- | `ZA_WMCONFIRMTOSU` | Structure | Parameter type for ConfirmTransferOrderSU action |
573
- | `ZSD_WMMCPSERVICE` | Service Def | OData V4 service definition (7 entity sets) |
576
+ | `ZWM_TO_CANCEL` | Function Module | RFC-enabled wrapper for `L_TO_CANCEL` |
577
+ | `ZWM_INV_CREATE` | Function Module | RFC-enabled wrapper writes directly to LINK/LINP/LINV and locks bin in LAGP |
578
+ | `ZA_WMCREATETOPARAM` | Abstract Entity | Parameter type for CreateTransferOrder action |
579
+ | `ZA_WMCONFIRMTOSUPARAM` | Abstract Entity | Parameter type for ConfirmTransferOrderSU action |
580
+ | `ZA_WMCREATEINVDOCPARAM` | Abstract Entity | Parameter type for CreateInventoryDocument action |
581
+ | `ZSD_WMMCPSERVICE` | Service Def | OData V4 service definition (8 entity sets) |
574
582
  | `ZSB_WMMCPSERVICE_ODATA4_UI` | Service Binding | OData V4 UI binding |
575
583
 
576
584
  ### abapGit compatibility
@@ -616,7 +624,8 @@ Classic WM has no standard OData APIs. This project builds one using **ABAP REST
616
624
  | `CreateTransferOrder` | `L_TO_CREATE_SINGLE` | Called via RFC wrapper `ZWM_TO_CREATE` with `DESTINATION 'NONE'` |
617
625
  | `ConfirmTransferOrder` | `L_TO_CONFIRM` | Called directly — no COMMIT needed |
618
626
  | `ConfirmTransferOrderSU` | `L_TO_CONFIRM_SU` | Called directly — no COMMIT needed |
619
- | `CancelTransferOrder` | `L_TO_CANCEL` | Called directly no COMMIT needed |
627
+ | `CancelTransferOrder` | `L_TO_CANCEL` | Called via RFC wrapper `ZWM_TO_CANCEL` with `DESTINATION 'NONE'` |
628
+ | `CreateInventoryDocument` | Direct writes to LINK/LINP/LINV | Called via RFC wrapper `ZWM_INV_CREATE` with `DESTINATION 'NONE'` |
620
629
 
621
630
  ### Why the RFC wrapper?
622
631
 
@@ -683,7 +692,9 @@ sap-wm-mcp/
683
692
  │ ├── cancelTransferOrder.js ← cancel_transfer_order
684
693
  │ ├── transferOrderHistory.js ← get_transfer_order_history
685
694
  │ ├── replenishmentNeeds.js ← get_replenishment_needs
686
- └── interimZoneAnomalies.js ← get_interim_zone_anomalies
695
+ ├── interimZoneAnomalies.js ← get_interim_zone_anomalies
696
+ │ ├── goodsIssueMonitor.js ← get_goods_issue_monitor
697
+ │ └── createCycleCountDoc.js ← create_cycle_count_document
687
698
  ├── .env.example
688
699
  └── package.json
689
700
  ```
@@ -708,7 +719,7 @@ Running both side-by-side shows the contrast directly: same tools, same question
708
719
  | Phase | Status | Description |
709
720
  |---|---|---|
710
721
  | Phase 0 — RAP Service | ✅ Complete | Custom OData V4 service with 7 entity sets over classic WM tables |
711
- | Phase 1 — Local MCP | ✅ Complete | 22 tools working, security hardened, published to npm |
722
+ | Phase 1 — Local MCP | ✅ Complete | 23 tools working, security hardened, published to npm |
712
723
  | Phase 2 — BTP CF | 🔜 Planned | Deploy to SAP BTP Cloud Foundry with SSE transport + XSUAA + Cloud Connector |
713
724
  | Phase 3 — Joule Agent | 💡 Future | Native Joule Studio agent using the same RAP service |
714
725
 
package/index.js CHANGED
@@ -29,8 +29,10 @@ import { getTransferOrderHistory } from './tools/transferOrderHistory.js';
29
29
  import { cancelTransferOrder } from './tools/cancelTransferOrder.js';
30
30
  import { getReplenishmentNeeds } from './tools/replenishmentNeeds.js';
31
31
  import { getInterimZoneAnomalies } from './tools/interimZoneAnomalies.js';
32
+ import { getGoodsIssueMonitor } from './tools/goodsIssueMonitor.js';
33
+ import { createCycleCountDoc } from './tools/createCycleCountDoc.js';
32
34
 
33
- const server = new McpServer({ name: 'sap-wm-mcp', version: '0.3.0' });
35
+ const server = new McpServer({ name: 'sap-wm-mcp', version: '0.3.2' });
34
36
 
35
37
  // Tool 1 — get_bin_status
36
38
  server.tool(
@@ -40,7 +42,7 @@ server.tool(
40
42
  warehouse: z.string().describe('Warehouse number e.g. 102'),
41
43
  storageType: z.string().optional().describe('Storage type e.g. 001'),
42
44
  bin: z.string().optional().describe('Specific bin number e.g. 01-01-01'),
43
- top: z.number().optional().default(20).describe('Max records to return')
45
+ top: z.coerce.number().optional().default(20).describe('Max records to return')
44
46
  },
45
47
  async (params) => {
46
48
  try {
@@ -60,7 +62,7 @@ server.tool(
60
62
  warehouse: z.string().describe('Warehouse number e.g. 102'),
61
63
  material: z.string().optional().describe('Material number e.g. TG0001'),
62
64
  storageType: z.string().optional().describe('Filter by storage type'),
63
- top: z.number().optional().default(20).describe('Max records to return')
65
+ top: z.coerce.number().optional().default(20).describe('Max records to return')
64
66
  },
65
67
  async (params) => {
66
68
  try {
@@ -80,7 +82,7 @@ server.tool(
80
82
  warehouse: z.string().describe('Warehouse number e.g. 102'),
81
83
  storageType: z.string().optional().describe('Storage type to filter e.g. 001'),
82
84
  binType: z.string().optional().describe('Bin type to filter e.g. E1, E2 — use when destination bin must match a specific storage unit type'),
83
- top: z.number().optional().default(50).describe('Max records to return')
85
+ top: z.coerce.number().optional().default(50).describe('Max records to return')
84
86
  },
85
87
  async (params) => {
86
88
  try {
@@ -99,7 +101,7 @@ server.tool(
99
101
  {
100
102
  warehouse: z.string().describe('Warehouse number e.g. 102'),
101
103
  storageType: z.string().optional().describe('Filter by storage type'),
102
- top: z.number().optional().default(100).describe('Max bins to analyze')
104
+ top: z.coerce.number().optional().default(100).describe('Max bins to analyze')
103
105
  },
104
106
  async (params) => {
105
107
  try {
@@ -120,7 +122,7 @@ server.tool(
120
122
  movementType: z.string().describe('WM movement type e.g. 999'),
121
123
  material: z.string().describe('Material number e.g. TG0001'),
122
124
  plant: z.string().describe('Plant e.g. 1710'),
123
- quantity: z.number().describe('Quantity to move'),
125
+ quantity: z.coerce.number().describe('Quantity to move'),
124
126
  unitOfMeasure: z.string().optional().default('').describe('Unit of measure — leave empty to use material base UOM (recommended)'),
125
127
  sourceType: z.string().optional().default('').describe('Source storage type e.g. 001'),
126
128
  sourceBin: z.string().optional().default('').describe('Source bin e.g. 01-02-01'),
@@ -185,7 +187,7 @@ server.tool(
185
187
  storageType: z.string().optional().describe('Filter by source or destination storage type e.g. 999'),
186
188
  bin: z.string().optional().describe('Filter by source or destination bin e.g. AUFNAHME'),
187
189
  material: z.string().optional().describe('Filter by material number e.g. TG0001'),
188
- top: z.number().optional().default(50).describe('Max TO headers to return')
190
+ top: z.coerce.number().optional().default(50).describe('Max TO headers to return')
189
191
  },
190
192
  async (params) => {
191
193
  try {
@@ -205,7 +207,7 @@ server.tool(
205
207
  warehouse: z.string().describe('Warehouse number e.g. 102'),
206
208
  storageType: z.string().optional().describe('Storage type e.g. 001. Omit to see all types.'),
207
209
  bin: z.string().optional().describe('Narrow to a specific bin within the type'),
208
- top: z.number().optional().default(100).describe('Max records to return')
210
+ top: z.coerce.number().optional().default(100).describe('Max records to return')
209
211
  },
210
212
  async (params) => {
211
213
  try {
@@ -226,7 +228,7 @@ server.tool(
226
228
  status: z.enum(['open', 'partial', 'to-created', 'completed']).optional().describe('TR status filter. Defaults to open + partial + to-created. Use to-created to see TRs where a TO exists but is not yet confirmed.'),
227
229
  material: z.string().optional().describe('Filter by material number e.g. TG0001'),
228
230
  storageType: z.string().optional().describe('Filter by source or destination storage type'),
229
- top: z.number().optional().default(50).describe('Max records to return')
231
+ top: z.coerce.number().optional().default(50).describe('Max records to return')
230
232
  },
231
233
  async (params) => {
232
234
  try {
@@ -247,7 +249,7 @@ server.tool(
247
249
  plant: z.string().describe('Plant e.g. 1010'),
248
250
  storageLocation: z.string().optional().describe('Storage location (LGORT) linked to this warehouse e.g. 0002 — required for accurate results, otherwise MARD returns all plant stock'),
249
251
  material: z.string().optional().describe('Narrow to a specific material'),
250
- threshold: z.number().optional().default(0).describe('Ignore variances smaller than this quantity')
252
+ threshold: z.coerce.number().optional().default(0).describe('Ignore variances smaller than this quantity')
251
253
  },
252
254
  async (params) => {
253
255
  try {
@@ -266,8 +268,8 @@ server.tool(
266
268
  {
267
269
  warehouse: z.string().describe('Warehouse number e.g. 102'),
268
270
  storageType: z.string().optional().describe('Limit to a specific storage type e.g. 001'),
269
- daysSinceLastCount: z.number().optional().default(180).describe('Flag bins not counted within this many days (default 180)'),
270
- top: z.number().optional().default(100).describe('Max bins to return')
271
+ daysSinceLastCount: z.coerce.number().optional().default(180).describe('Flag bins not counted within this many days (default 180)'),
272
+ top: z.coerce.number().optional().default(100).describe('Max bins to return')
271
273
  },
272
274
  async (params) => {
273
275
  try {
@@ -287,8 +289,8 @@ server.tool(
287
289
  warehouse: z.string().describe('Warehouse number e.g. 102'),
288
290
  storageType: z.string().optional().describe('Narrow to a specific storage type'),
289
291
  material: z.string().optional().describe('Narrow to a specific material'),
290
- daysSinceLastMove: z.number().optional().default(90).describe('Flag stock not moved in this many days (default 90)'),
291
- top: z.number().optional().default(100).describe('Max quants to scan')
292
+ daysSinceLastMove: z.coerce.number().optional().default(90).describe('Flag stock not moved in this many days (default 90)'),
293
+ top: z.coerce.number().optional().default(100).describe('Max quants to scan')
292
294
  },
293
295
  async (params) => {
294
296
  try {
@@ -343,8 +345,8 @@ server.tool(
343
345
  {
344
346
  warehouse: z.string().describe('Warehouse number e.g. 102'),
345
347
  storageType: z.string().optional().describe('Limit to a specific storage type e.g. 003'),
346
- threshold: z.number().optional().default(3).describe('Flag bin+material combos with this many quants or more (default 3)'),
347
- top: z.number().optional().default(200).describe('Max stock records to scan')
348
+ threshold: z.coerce.number().optional().default(3).describe('Flag bin+material combos with this many quants or more (default 3)'),
349
+ top: z.coerce.number().optional().default(200).describe('Max stock records to scan')
348
350
  },
349
351
  async (params) => {
350
352
  try {
@@ -363,8 +365,8 @@ server.tool(
363
365
  {
364
366
  warehouse: z.string().describe('Warehouse number e.g. 102'),
365
367
  storageType: z.string().optional().describe('Specific zone type to check e.g. 999. Defaults to 999 + 998.'),
366
- minAgeDays: z.number().optional().default(7).describe('Only show negatives older than this many days (default 7). Set to 0 to see all.'),
367
- top: z.number().optional().default(500).describe('Max stock records to scan')
368
+ minAgeDays: z.coerce.number().optional().default(7).describe('Only show negatives older than this many days (default 7). Set to 0 to see all.'),
369
+ top: z.coerce.number().optional().default(500).describe('Max stock records to scan')
368
370
  },
369
371
  async (params) => {
370
372
  try {
@@ -407,7 +409,7 @@ server.tool(
407
409
  createdBy: z.string().optional().describe('Filter by the SAP user who created the TO e.g. NOMANH'),
408
410
  executedBy: z.string().optional().describe('Filter by the SAP user who executed/confirmed the TO'),
409
411
  material: z.string().optional().describe('Filter by material number e.g. TG0001'),
410
- top: z.number().optional().default(50).describe('Max number of TOs to return (default 50)')
412
+ top: z.coerce.number().optional().default(50).describe('Max number of TOs to return (default 50)')
411
413
  },
412
414
  async (params) => {
413
415
  try {
@@ -445,10 +447,10 @@ server.tool(
445
447
  warehouse: z.string().describe('Warehouse number e.g. 102'),
446
448
  storageType: z.string().optional().default('P01').describe('Forward pick storage type to check (default P01)'),
447
449
  material: z.string().optional().describe('Narrow to a specific material e.g. TG0001'),
448
- minimumQuantity: z.number().optional().default(0).describe('Flag bins at or below this level. Default 0 = empty/negative only. Overridden per bin when LAGP min qty (MNMNG) is configured.'),
449
- targetQuantity: z.number().optional().describe('Fill-to target — overrides bin master MaximumQuantity when set.'),
450
- defaultReplenishQty: z.number().optional().default(50).describe('Fallback replenishment qty when bin master max qty (MXMNG) is 0 and no targetQuantity given. Default 50.'),
451
- top: z.number().optional().default(50).describe('Max records to return')
450
+ minimumQuantity: z.coerce.number().optional().default(0).describe('Flag bins at or below this level. Default 0 = empty/negative only. Overridden per bin when LAGP min qty (MNMNG) is configured.'),
451
+ targetQuantity: z.coerce.number().optional().describe('Fill-to target — overrides bin master MaximumQuantity when set.'),
452
+ defaultReplenishQty: z.coerce.number().optional().default(50).describe('Fallback replenishment qty when bin master max qty (MXMNG) is 0 and no targetQuantity given. Default 50.'),
453
+ top: z.coerce.number().optional().default(50).describe('Max records to return')
452
454
  },
453
455
  async (params) => {
454
456
  try {
@@ -467,9 +469,9 @@ server.tool(
467
469
  {
468
470
  warehouse: z.string().describe('Warehouse number e.g. 102'),
469
471
  interimTypes: z.array(z.string()).optional().default(['999', '998', '902']).describe('Interim storage types to check — default [999, 998, 902]'),
470
- minDaysStranded: z.number().optional().default(0).describe('Only return stock stranded for at least this many days. Default 0 = include same-day strandings.'),
472
+ minDaysStranded: z.coerce.number().optional().default(0).describe('Only return stock stranded for at least this many days. Default 0 = include same-day strandings.'),
471
473
  material: z.string().optional().describe('Narrow to a specific material e.g. TG0001'),
472
- top: z.number().optional().default(100).describe('Max quants to scan (scans top * 3 internally to account for client-side filtering)')
474
+ top: z.coerce.number().optional().default(100).describe('Max quants to scan (scans top * 3 internally to account for client-side filtering)')
473
475
  },
474
476
  async (params) => {
475
477
  try {
@@ -481,6 +483,47 @@ server.tool(
481
483
  }
482
484
  );
483
485
 
486
+ // Tool 23 — get_goods_issue_monitor
487
+ server.tool(
488
+ 'get_goods_issue_monitor',
489
+ 'Show open outbound deliveries in a classic WM warehouse — delivery qty vs picked qty, GI status, overdue flag, and which items still have stock in the GI zone (type 999). Use to track picking progress, identify stalled shipments, and understand the outbound delivery context behind TOs sourced from the GI zone.',
490
+ {
491
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
492
+ shippingPoint: z.string().optional().describe('Filter by shipping point e.g. 1000'),
493
+ includeCompleted: z.boolean().optional().default(false).describe('Include deliveries where GI has already been posted (default false — open only)'),
494
+ material: z.string().optional().describe('Narrow to a specific material e.g. TG0001'),
495
+ top: z.coerce.number().optional().default(50).describe('Max delivery items to fetch (default 50)')
496
+ },
497
+ async (params) => {
498
+ try {
499
+ const result = await getGoodsIssueMonitor(params);
500
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
501
+ } catch (err) {
502
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
503
+ }
504
+ }
505
+ );
506
+
507
+ // Tool 24 — create_cycle_count_document
508
+ server.tool(
509
+ 'create_cycle_count_document',
510
+ 'Create a classic WM inventory document (cycle count) for a specific bin — equivalent to LI01N. Activates the bin for counting immediately by default. Use after get_cycle_count_candidates to trigger a count on flagged bins.',
511
+ {
512
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
513
+ storageType: z.string().describe('Storage type of the bin to count e.g. 001'),
514
+ bin: z.string().describe('Storage bin to count e.g. 01-02-03'),
515
+ activateNow: z.boolean().optional().default(true).describe('Activate the inventory document immediately so the bin is ready for counting (default true)')
516
+ },
517
+ async (params) => {
518
+ try {
519
+ const result = await createCycleCountDoc(params);
520
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
521
+ } catch (err) {
522
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
523
+ }
524
+ }
525
+ );
526
+
484
527
  const transport = new StdioServerTransport();
485
528
  await server.connect(transport);
486
529
  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.3.0",
3
+ "version": "0.3.2",
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,54 @@
1
+ import { s4hPost, s4hGet } 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 createCycleCountDoc({
8
+ warehouse,
9
+ storageType,
10
+ bin,
11
+ activateNow = true
12
+ }) {
13
+ const path = `${BASE}/WMTransferOrder/${NS}.CreateInventoryDocument`;
14
+
15
+ const body = {
16
+ WarehouseNumber: esc(warehouse),
17
+ StorageType: esc(storageType),
18
+ StorageBin: esc(bin),
19
+ ActivateNow: activateNow ? 'X' : ' '
20
+ };
21
+
22
+ await s4hPost(path, body);
23
+
24
+ // RAP static action returns $self (WMTransferOrder) — field assignment does not
25
+ // propagate back through the OData layer. Instead, read the inventory document
26
+ // number from the bin master record (LAGP.IVNUM) via a follow-up GET on
27
+ // WMStorageBin. When activateNow=true the bin is locked and IVNUM is set.
28
+ const binFilter = `WarehouseNumber eq '${esc(warehouse)}' and StorageType eq '${esc(storageType)}' and StorageBin eq '${esc(bin)}'`;
29
+ const binData = await s4hGet(`${BASE}/WMStorageBin?$filter=${encodeURIComponent(binFilter)}&$select=InventoryDocNumber,InventoryStatus`);
30
+ const binRow = binData?.value?.[0];
31
+ const invDocNumber = binRow?.InventoryDocNumber?.trim() || null;
32
+
33
+ if (!invDocNumber) {
34
+ return {
35
+ success: true,
36
+ warning: 'Inventory document created but number unavailable via OData — verify in SAP via LI04.',
37
+ warehouse,
38
+ storageType,
39
+ bin,
40
+ activated: activateNow
41
+ };
42
+ }
43
+
44
+ return {
45
+ success: true,
46
+ warehouse,
47
+ storageType,
48
+ bin,
49
+ inventoryDocNumber: invDocNumber,
50
+ inventoryStatus: binRow?.InventoryStatus ?? '',
51
+ activated: activateNow,
52
+ message: `Inventory document ${invDocNumber} created${activateNow ? ' and activated' : ''} for bin ${storageType}/${bin}`
53
+ };
54
+ }
@@ -0,0 +1,92 @@
1
+ import { s4hGet } 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/WMGoodsIssueDelivery`;
5
+
6
+ const GI_STATUS = {
7
+ 'A': 'Not started',
8
+ 'B': 'Partially picked',
9
+ 'C': 'Goods issue posted',
10
+ ' ': 'Not started'
11
+ };
12
+
13
+ export async function getGoodsIssueMonitor({
14
+ warehouse,
15
+ shippingPoint,
16
+ includeCompleted = false,
17
+ material,
18
+ top = 50
19
+ }) {
20
+ const filters = [
21
+ `WarehouseNumber eq '${esc(warehouse)}'`
22
+ ];
23
+ if (!includeCompleted) filters.push(`GIStatus ne 'C'`);
24
+ if (shippingPoint) filters.push(`ShippingPoint eq '${esc(shippingPoint)}'`);
25
+ if (material) filters.push(`Material eq '${esc(material)}'`);
26
+
27
+ const path = `${BASE}?$filter=${encodeURIComponent(filters.join(' and '))}&$top=${top}&$orderby=PlannedGIDate asc`;
28
+ const data = await s4hGet(path);
29
+ const rows = data.value ?? [];
30
+
31
+ const today = new Date();
32
+ const todayStr = today.toISOString().slice(0, 10);
33
+
34
+ // Group items by delivery number
35
+ const deliveryMap = {};
36
+ for (const r of rows) {
37
+ const del = r.DeliveryNumber;
38
+ if (!deliveryMap[del]) {
39
+ deliveryMap[del] = {
40
+ deliveryNumber: del,
41
+ shippingPoint: r.ShippingPoint,
42
+ customer: r.Customer?.trimStart?.() ?? r.Customer,
43
+ plannedGIDate: r.PlannedGIDate,
44
+ actualGIDate: r.ActualGIDate || null,
45
+ deliveryDate: r.DeliveryDate,
46
+ giStatus: r.GIStatus,
47
+ giStatusText: GI_STATUS[r.GIStatus] ?? r.GIStatus,
48
+ isOverdue: r.PlannedGIDate && r.PlannedGIDate < todayStr && r.GIStatus !== 'C',
49
+ items: []
50
+ };
51
+ }
52
+ const deliveryQty = parseFloat(r.DeliveryQuantity ?? 0);
53
+ const pickedQty = parseFloat(r.PickedQuantity ?? 0);
54
+ deliveryMap[del].items.push({
55
+ item: r.DeliveryItem,
56
+ material: r.Material?.trimStart?.() ?? r.Material,
57
+ plant: r.Plant,
58
+ storageType: r.StorageType,
59
+ bin: r.StorageBin,
60
+ wmMovementType: r.WMMovementType,
61
+ wmTORequired: r.WMTORequired,
62
+ deliveryQty,
63
+ pickedQty,
64
+ remainingQty: Math.max(0, deliveryQty - pickedQty),
65
+ uom: r.DeliveryUOM,
66
+ pickComplete: deliveryQty > 0 && pickedQty >= deliveryQty
67
+ });
68
+ }
69
+
70
+ const deliveries = Object.values(deliveryMap);
71
+ const overdue = deliveries.filter(d => d.isOverdue);
72
+
73
+ return {
74
+ warehouse,
75
+ filters: {
76
+ shippingPoint: shippingPoint ?? 'all',
77
+ includeCompleted,
78
+ material: material ?? 'all'
79
+ },
80
+ summary: {
81
+ totalDeliveries: deliveries.length,
82
+ overdueDeliveries: overdue.length,
83
+ byStatus: {
84
+ notStarted: deliveries.filter(d => d.giStatus === 'A' || d.giStatus === ' ').length,
85
+ partial: deliveries.filter(d => d.giStatus === 'B').length,
86
+ complete: deliveries.filter(d => d.giStatus === 'C').length
87
+ }
88
+ },
89
+ truncated: rows.length === top,
90
+ deliveries
91
+ };
92
+ }