Business Central: OptiPick guide
Below is a schematic overview of how the pick process is typically handled today in Business Central (on the left) and how it would look like after integrating with our OptiPick API (on the right).
The entry point of the flow is when Sales Orders are registered in the system, when these are ready to be fulfilled, they are placed in the “Released” status. Through different business rules, different groups of Sales Orders are converted into Warehouse Shipments. No separate distinction is made between dividing Sales Orders into waves (larger groups of Sales Orders with e.g. the same carrier or pickup time) and the grouping of Sales Orders within a single pick route. These Warehouse Shipments are then converted into Warehouse Picks and Warehouse Activity Lines afterwards, which allocates a certain location (Bin Code) to each pick. Finally, these activities can be assigned to people on the floor and executed.
The OptiPick API should be called after Business Central has resolved source bins for the candidate orders in a wave. In practice, this means first generating pick suggestions (e.g., by creating Warehouse Picks or simulating the pick creation) so that each line has a Bin Code. The full wave (potentially spanning multiple Warehouse Shipments) is then bundled into a single OptiPick request, allowing OptiPick to cluster orders into optimal routes subject to stop conditions (e.g., trolley capacity). The request ideally includes the original Warehouse Shipment ID, the as-is pick sequence (BC’s Bin Ranking order), and each line’s Bin Code and quantities. This enables OptiPick to compute both “as-is” and “optimized” distances and provides a safe fallback: if the optimization cannot be applied, the originally created BC picks (or their bin-ranking order) can still be executed unchanged. To apply the optimized plan, you can either re-sequence existing Warehouse Picks when clusters align with current routes or recreate picks per OptiPick cluster (via the Pick Worksheet/creation codeunit) so each cluster becomes a single picking route on the floor.
In the remainder of this guide we demonstrate, in a sandbox environment, how we can generate a request (JSON format) and send it to our API. We’ll add different elements to the BC UI to configure the necessary parameters and trigger these optimizations manually. Ideally, these optimizations are of course triggered automatically and results should be persisted in the necessary data tables of BC.
Required API data
Below is a minimal JSON (with truncated information) for the /optimize/cluster endpoint which can do both batching of Sales Orders into picking routes, as well as determining the optimal sequence of the Warehouse Picks within such a route (which is typically done in BC by sorting by “Bin Ranking”).
1{ 2 "site_name": "Example", 3 "start_points": ["START"], 4 "end_points": ["END"], 5 "picks": [ 6 { 7 "pick_id": "pick_1", 8 "location_id": "BH-06", 9 "order_id": "order_0", 10 "wave_id": "wave_0", 11 "list_id": "list_0", 12 "asis_sequence": 0 13 }, 14 ... 15 ], 16 "parameters": { 17 "max_orders": 2, 18 ... 19 } 20}
Only the following two attributes are required at root-level of the request:
site_name: a reference to the floorplan in our OptiPick web application. This is is necessary to know the distances between all locations in the warehouse.picks: a list of Warehouse Tasks that need to be executed.
For each pick, the following attributes are required:
pick_id: a unique ID for each element. These will be reused in our response so you can easily link back to your input data.location_id: where to pick from in the warehouse. OptiPick supports multiple locations in case SKUs are spread in the warehouse.order_id: an identifier that groups picks for the same customer together. Picks with the same order_id are always picked within the same picking route (Warehouse Order)
Additionally, the following attributes can be specified for each pick:
wave_id: our algorithm applies grouping on each wave separately. Often these are all the orders that have to be picked up by a specific carrier at a specific pickup time.list_id: an identifier grouping picks together according to the logic of BC. These correspond with a Warehouse Shipment. As explained above, calculating & providing this is recommended. It serves as a fallback mechanism and allows to calculate the distance savings.asis_sequence: the sequence order, according to the BC logic, in which the picks are executed within a pick route (typically a pick snake formed by sorting on “Bin Ranking” associated with every location in the warehouse). OptiPick supports grouping optimally according to the pick snake by setting one of the parameters. To allow for this, the raw sequence values (so not the index after sorting) have to be provided.
Finally, different parameters can be provided that control the routing & clustering policy, stop conditions (under which conditions no more pick tasks can be added to a pick route), regexes to convert location IDs from the WMS to locations known to the OptiPick webapp, and more.
BC Data tables
Warehouse & SKU Locations
Let’s create a simple DEMO warehouse with a couple locations in Business Central. We use the Tell Me / Search function (ALT + Q) to navigate to Locations and click on “+ New”. We will enable “Bin Mandatory”,”Require Shipment”, “Require Pick” and “Always Create Pick Line”. We set the “Default Bin Selection” to “Fixed Bin”.


Once created, we can go to the “Bins” tab to configure our locations. We’ll make a small warehouse with 4 aisles, each having 4 different bays and associate a “Bin Ranking” to each.

From here, we can navigate to “Contents” to assign SKUs to these locations

When this is done, we need to re-stock each of these locations. This can be done by going to Item Journals (through Alt + Q).

OptiPick Floorplan
We also need to create a floorplan in the web application of OptiPick (https://optipick.optioryx.com/). This is necessary for our algorithms to be aware of the distances between all points within your warehouse.


Sales Orders
We now create a few ales orders, using items that have been assigned to a bin location in a previous step. Make sure to put each of the created sales order into the “Released” status in order for them to be picked up in the next Warehouse Shipment step.

Warehouse Shipments
Once the Sales Orders are created and Bin Codes are resolved, we can bundle these into Warehouse Shipments which correspond to a picking route. We can navigate there through ALT + Q > Warehouse Shipments, then click on “New” and go to the “Prepare” tab. After filling in our Warehouse Name (DEMO) we can click on “Get Source Documents” to select the Sales Orders to group together.


Afterwards we can turn these into Pick activities in the “Home” tab and clicking on “Create Pick”. In what now follows, we will call our own API to group Sales Orders into picking routes.
Integrating OptiPick API
Setup
We first create a new environment using the Admin Center which can be accessed through https://businesscentral.dynamics.com/[TENANT_ID]/admin

After creating a new sandbox development environment, we create a new AL project (VSCode has excellent plugins for this) and configure our launch.json and app.json as below:
1{ 2 "id": "<CENSORED>", 3 "name": "OptiPick", 4 "publisher": "Default Publisher", 5 "version": "1.0.0.0", 6 "dependencies": [], 7 "platform": "1.0.0.0", 8 "application": "26.5.38752.38944", 9 "runtime": "15.2", 10 "idRanges": [ 11 { "from": 50100, "to": 50149 } 12 ] 13}
1{ 2 "version": "0.2.0", 3 "configurations": [ 4 { 5 "name": "BC Online", 6 "type": "al", 7 "request": "launch", 8 "server": "https://api.businesscentral.dynamics.com", 9 "environmentName": "DEV", 10 "tenant": "<CENSORED>", 11 "schemaUpdateMode": "Synchronize" 12 } 13 ] 14}
Configuration screen
We now create a page and corresponding table to set the different parameters for our API such as the endpoint URL, API key, warehouse name in BC, site name in OptiPick, start and end point of our picking routes, Max Orders (how many sales orders we can place in a shipment at most), and regular expression (used to transform the location bins from BC to location IDs that can be found in the OptiPick application).
1namespace DefaultPublisher.AL; 2 3table 50110 "Optioryx Setup" 4{ 5 DataClassification = CustomerContent; 6 7 fields 8 { 9 field(1; "Primary Key"; Code[10]) { DataClassification = SystemMetadata; } 10 field(10; "Default Location"; Code[10]) { Caption = 'Default WMS Location'; InitValue = "DEMO"; } 11 field(20; "Site Name"; Text[100]) { Caption = 'Site Name'; InitValue = "Business Central"; } 12 field(30; "Start Point"; Text[50]) { Caption = 'Start Point'; InitValue = "START"; } 13 field(40; "End Point"; Text[50]) { Caption = 'End Point'; InitValue = "END"; } 14 15 field(100; "Cluster URL"; Text[250]) { Caption = 'Cluster URL'; InitValue = "https://optipick.api.optioryx.com/optimize/cluster?synchronous=true"; } 16 field(110; "Cluster API Key"; Text[250]) { Caption = 'Cluster API Key'; ExtendedDatatype = Masked; InitValue = "<CENSORED>"; } 17 18 field(120; "Max Orders"; Integer) { Caption = 'Max Orders per Group'; InitValue = 2; } 19 field(130; "LocRegex Pattern"; Text[100]) { Caption = 'Location Regex Pattern'; InitValue = "(.*)-(.*)-A"; } 20 field(140; "LocRegex Replace"; Text[100]) { Caption = 'Location Regex Replace'; InitValue = "\1-\2"; } 21 } 22 23 keys { key(PK; "Primary Key") { Clustered = true; } } 24 25 trigger OnInsert() 26 begin 27 if "Primary Key" = '' then 28 "Primary Key" := 'SETUP'; 29 end; 30}
1namespace DefaultPublisher.AL; 2 3page 50111 "Optioryx Setup" 4{ 5 PageType = Card; 6 SourceTable = "Optioryx Setup"; 7 ApplicationArea = All; 8 UsageCategory = Administration; 9 10 layout 11 { 12 area(content) 13 { 14 group(General) 15 { 16 field("Default Location"; Rec."Default Location") { ApplicationArea = All; } 17 field("Site Name"; Rec."Site Name") { ApplicationArea = All; } 18 field("Start Point"; Rec."Start Point") { ApplicationArea = All; } 19 field("End Point"; Rec."End Point") { ApplicationArea = All; } 20 } 21 group(OptiGroup) 22 { 23 Caption = 'OptiGroup (Cluster) Settings'; 24 field("Cluster URL"; Rec."Cluster URL") { ApplicationArea = All; } 25 field("Cluster API Key"; Rec."Cluster API Key") { ApplicationArea = All; } 26 field("Max Orders"; Rec."Max Orders") { ApplicationArea = All; } 27 field("LocRegex Pattern"; Rec."LocRegex Pattern") { ApplicationArea = All; } 28 field("LocRegex Replace"; Rec."LocRegex Replace") { ApplicationArea = All; } 29 } 30 } 31 } 32 33 actions 34 { 35 area(processing) 36 { 37 action(CreateOrOpen) 38 { 39 Caption = 'Create/Init'; 40 ApplicationArea = All; 41 Image = New; 42 trigger OnAction() 43 var 44 s: Record "Optioryx Setup"; 45 begin 46 if not s.Get('SETUP') then begin 47 s.Init(); 48 s.Insert(); 49 Message('Setup record created. Fill in Default Location and API settings.'); 50 end else 51 Page.Run(Page::"Optioryx Setup", s); 52 end; 53 } 54 } 55 } 56}
We can access this newly created page through ALT + Q:


Adding a “Propose Groups” button to Warehouse Shipments
We will extend the Warehouse Shipment page:
1pageextension 50131 "WhseShptList_OptiGroup" extends "Warehouse Shipment List" 2{ 3 actions 4 { 5 addfirst(Processing) 6 { 7 action(OptiGroupPropose) 8 { 9 Caption = 'Propose Groups (OptiGroup)'; 10 ApplicationArea = All; 11 Image = Group; 12 Promoted = true; 13 PromotedCategory = Process; 14 PromotedIsBig = true; 15 16 trigger OnAction() 17 var 18 Mgt: Codeunit "OptiGroup Mgt"; 19 begin 20 Mgt.RunForReleasedNotOnShipment(); 21 end; 22 } 23 } 24 } 25}
Clustering result screen
This page will show the results of our API response (generated after clicking on the “Propose Group” button of the previous step) in a more visual way.
1namespace DefaultPublisher.AL; 2 3table 50112 "OptiGroup Result Line" 4{ 5 DataClassification = CustomerContent; 6 7 fields 8 { 9 field(20; "Cluster Id"; Text[50]) { } 10 field(30; "Order No."; Code[20]) { Caption = 'Sales Order No.'; } 11 field(40; "Pick Id"; Text[50]) { } 12 field(50; "Location Id"; Text[50]) { } 13 } 14 keys 15 { 16 key(PK; "Cluster Id", "Pick Id") { Clustered = true; } 17 } 18}
1namespace DefaultPublisher.AL; 2 3page 50113 "OptiGroup Results" 4{ 5 PageType = List; 6 ApplicationArea = All; 7 SourceTable = "OptiGroup Result Line"; 8 Caption = 'OptiGroup Results (Last Run)'; 9 Editable = false; 10 11 layout 12 { 13 area(content) 14 { 15 repeater(Grp) 16 { 17 field("Cluster Id"; Rec."Cluster Id") { ApplicationArea = All; } 18 field("Order No."; Rec."Order No.") { ApplicationArea = All; } 19 field("Pick Id"; Rec."Pick Id") { ApplicationArea = All; } 20 field("Location Id"; Rec."Location Id") { ApplicationArea = All; } 21 } 22 } 23 } 24}
CodeUnit to call our API
We’ll import a few namespaces first:
1namespace DefaultPublisher.AL; 2 3using Microsoft.Sales.Document; 4using Microsoft.Warehouse.Document; 5using Microsoft.Warehouse.Structure;
The “main” procedure is shown below. After declaring the variables, we check if the configuration screen has been filled in properly, if not we show an error message. Afterwards, we start creating a JsonObject that will serve as our request payload. The root object will contain a site_name, start_points, end_points a nested json object with parameters and a list of picks. To the parameters we add max_orders which is a simple integer and a location_regex which is a list of list of two elements corresponding to a match and substitution regex respectively, to construct this a sub-procedure is called. Then we iterate over all Sale Headers that are in “Released” status. For each of these headers we first check if it has not yet been assigned to a Warehouse Shipment and if the Sales Header corresponds to the correct warehouse (one of the parameters we configured in the configuration screen). If both checks pass, we iterate over its Sale Lines, each such line corresponds to one pick object for our API. For each Sale Line, we get the Bin Location that is associated with the ordered SKU and then create a pick object (JSON) to add to our picks list. When this loop is done, we can add the picks list to our payload, add the necessary HTTP headers for our API request and post this to our API. Finally, we create OptiGroup result records and visualise these.
1procedure RunForReleasedNotOnShipment() 2var 3 Setup: Record "Optioryx Setup"; 4 SalesHdr: Record "Sales Header"; 5 SalesLine: Record "Sales Line"; 6 WhseShptLine: Record "Warehouse Shipment Line"; 7 BinContent: Record "Bin Content"; 8 9 Http: HttpClient; 10 Resp: HttpResponseMessage; 11 Req: HttpRequestMessage; 12 Cnt: HttpContent; 13 Hdrs: HttpHeaders; 14 15 Payload: JsonObject; 16 Params: JsonObject; 17 Picks: JsonArray; 18 PickObj: JsonObject; 19 20 BodyTxt: Text; 21 RunId: Guid; 22 LinesFound: Integer; 23 24 ApiKey: Text; 25 26 // Map pick_id -> order no (so we can reconstruct if API only returns pick_ids) 27 PickToOrder: Dictionary of [Text, Code[20]]; 28 binCode: Code[20]; // Declare binCode 29 pickId: Text[50]; // Declare pickId 30begin 31 if not Setup.FindFirst() then 32 Error('Open "Optioryx Setup" and create the setup first.'); 33 34 if (Setup."Cluster URL" = '') or (Setup."Cluster API Key" = '') then 35 Error('Cluster URL/API Key missing in setup.'); 36 if Setup."Default Location" = '' then 37 Error('Default Location is empty in setup.'); 38 39 // --- Build request payload --- 40 Payload.Add('site_name', Setup."Site Name"); 41 Payload.Add('start_points', Setup."Start Point"); 42 Payload.Add('end_points', Setup."End Point"); 43 44 Params.Add('max_orders', Setup."Max Orders"); 45 46 // Optional location_regex parameter 47 if (Setup."LocRegex Pattern" <> '') and (Setup."LocRegex Replace" <> '') then begin 48 AddRegexParam(Params, Setup."LocRegex Pattern", Setup."LocRegex Replace"); 49 end; 50 51 Payload.Add('parameters', Params); 52 53 // Collect picks from released SOs at Default Location not already on a Whse Shipment 54 PickToOrder := PickToOrder; // no Create() needed 55 SalesHdr.Reset(); 56 SalesHdr.SetRange("Document Type", SalesHdr."Document Type"::Order); 57 SalesHdr.SetRange(Status, SalesHdr.Status::Released); 58 59 if SalesHdr.FindSet() then 60 repeat 61 if HasReleasedLinesAtLocation(SalesHdr, Setup."Default Location") 62 and not ExistsOnWhseShipment(SalesHdr) then begin 63 SalesLine.SetRange("Document Type", SalesHdr."Document Type"); 64 SalesLine.SetRange("Document No.", SalesHdr."No."); 65 SalesLine.SetRange(Type, SalesLine.Type::Item); 66 SalesLine.SetRange("Location Code", Setup."Default Location"); 67 68 if SalesLine.FindSet() then 69 repeat 70 if SalesLine.Quantity > 0 then begin 71 binCode := GetBestBinForLine(SalesLine); 72 if binCode <> '' then begin 73 Clear(PickObj); // New JsonObject for each pick 74 pickId := StrSubstNo('SO-%1-%2', SalesHdr."No.", SalesLine."Line No."); 75 PickObj.Add('pick_id', pickId); 76 PickObj.Add('location_id', binCode); 77 PickObj.Add('wave_id', 1); 78 PickObj.Add('order_id', SalesHdr."No."); 79 Picks.Add(PickObj); 80 81 PickToOrder.Add(pickId, SalesHdr."No."); 82 LinesFound += 1; 83 end; 84 end; 85 until SalesLine.Next() = 0; 86 end; 87 until SalesHdr.Next() = 0; 88 89 if LinesFound = 0 then 90 Error('No candidate sales lines found at location %1 (released and not on a warehouse shipment).', Setup."Default Location"); 91 92 Payload.Add('picks', Picks); 93 94 // --- HTTP call --- 95 Payload.WriteTo(BodyTxt); 96 Cnt.WriteFrom(BodyTxt); 97 98 // --- Content headers (body only) --- 99 Cnt.GetHeaders(Hdrs); 100 // Do NOT clear here; this is the content header collection 101 if Hdrs.Contains('Content-Type') then 102 Hdrs.Remove('Content-Type'); 103 if Hdrs.Contains('Content-Length') then 104 Hdrs.Remove('Content-Length'); 105 Hdrs.Add('Content-Type', 'application/json'); 106 107 // --- Request headers (NOT content headers) --- 108 Hdrs := Http.DefaultRequestHeaders(); // switch Hdrs to the request header collection 109 // If Http is reused across calls, remove old values first 110 if Hdrs.Contains('Accept') then 111 Hdrs.Remove('Accept'); 112 if Hdrs.Contains('x-api-key') then 113 Hdrs.Remove('x-api-key'); 114 if Hdrs.Contains('Authorization') then 115 Hdrs.Remove('Authorization'); 116 117 Hdrs.Add('Accept', 'application/json'); 118 Hdrs.Add('x-api-key', Setup."Cluster API Key"); 119 Hdrs.TryAddWithoutValidation('User-Agent', 'curl/8.5.0'); 120 121 // DebugHttpRequest(Http, Setup."Cluster URL", Cnt, BodyTxt); 122 123 // --- Send with Http.Post --- 124 Http.Timeout := 120000; 125 if not Http.Post(Setup."Cluster URL", Cnt, Resp) then 126 Error('HTTP POST failed.'); 127 128 Resp.Content().ReadAs(BodyTxt); 129 if not Resp.IsSuccessStatusCode() then 130 Error('OptiGroup error %1: %2', Resp.HttpStatusCode(), CopyStr(BodyTxt, 1, 2048)); 131 132 // --- Parse & store results --- 133 RunId := CreateGuid(); 134 StoreGroupingResult(RunId, BodyTxt, PickToOrder); 135 136 // Show results for this run 137 ShowResultsForRun(RunId); 138end;
The sub-procedure to construct the location_regex for the parameters is shown below:
1procedure AddRegexParam(var Params: JsonObject; Pattern: Text; Replace: Text) 2var 3 regexOuter: JsonArray; 4 regexPair: JsonArray; 5 tok: JsonToken; 6begin 7 // Only add if both are non-empty (avoid server complaining about invalid pattern) 8 if (Pattern = '') or (Replace = '') then 9 exit; 10 11 regexPair.Add(Pattern); // JSON string 12 regexPair.Add(Replace); // JSON string 13 14 if Params.Contains('location_regex') then begin 15 Params.Get('location_regex', tok); 16 regexOuter := tok.AsArray(); 17 end; 18 19 regexOuter.Add(regexPair); 20 if Params.Contains('location_regex') then 21 Params.Remove('location_regex'); 22 23 Params.Add('location_regex', regexOuter); 24end; 25
Both check performed on a Sales Header to confirm whether it can be clustered (it is associated to the correct warehouse we configured and has not yet been clustered before) are shown below.
1local procedure HasReleasedLinesAtLocation(SalesHdr: Record "Sales Header"; LocationCode: Code[10]): Boolean 2var 3 SL: Record "Sales Line"; 4begin 5 SL.SetRange("Document Type", SalesHdr."Document Type"); 6 SL.SetRange("Document No.", SalesHdr."No."); 7 SL.SetRange(Type, SL.Type::Item); 8 SL.SetRange("Location Code", LocationCode); 9 exit(SL.FindFirst()); 10end; 11 12local procedure ExistsOnWhseShipment(SalesHdr: Record "Sales Header"): Boolean 13var 14 WSL: Record "Warehouse Shipment Line"; 15begin 16 // Source Type 37 = Sales Line; Source Subtype 1 = Order 17 WSL.SetRange("Source Type", 37); 18 WSL.SetRange("Source Subtype", 1); 19 WSL.SetRange("Source No.", SalesHdr."No."); 20 exit(WSL.FindFirst()); 21end;
The sub-procedure below retrieves the corresponding location. In this case we will just take the first one in case multiple are associated, but smarter logic such as allocating the location with the lowest stock that is still sufficient for the specific Sales Order can of course be implemented as well. If no location is found, we return an empty string and handle this in an if-statement within the main loop, but ideally an error is thrown.
1local procedure GetBestBinForLine(SalesLine: Record "Sales Line"): Code[20] 2var 3 BC: Record "Bin Content"; 4begin 5 if SalesLine."Bin Code" <> '' then 6 exit(SalesLine."Bin Code"); 7 8 BC.SetRange("Location Code", SalesLine."Location Code"); 9 BC.SetRange("Item No.", SalesLine."No."); 10 BC.SetRange(Default, true); 11 if BC.FindFirst() then 12 exit(BC."Bin Code"); 13 14 exit(''); 15end;
The function below parses the response that is returned by our API to create different “OptiGroup Result” records (one of the data tables we created in a previous step).
1local procedure StoreGroupingResult(RunId: Guid; ResponseTxt: Text; PickToOrder: Dictionary of [Text, Code[20]]) 2var 3 R: Record "OptiGroup Result Line"; 4 Root: JsonObject; 5 Tok: JsonToken; 6 Clusters: JsonArray; 7 ClusterTok: JsonToken; 8 ClusterObj: JsonObject; 9 PicksTok: JsonToken; 10 PicksArr: JsonArray; 11 ElemTok: JsonToken; 12 LocArr: JsonArray; 13 14 ClusterIdTxt: Text; 15 PickIdTxt: Text; 16 OrderNo: Code[20]; 17 LocIdToken: JsonToken; 18 LocId: Text; 19 i: Integer; 20 j: Integer; 21 PObj: JsonObject; 22begin 23 // Remove previous results for this RunId 24 if R.FindSet() then 25 repeat 26 R.Delete(); 27 until R.Next() = 0; 28 29 // Message(ResponseTxt); 30 31 // Parse JSON 32 if not Root.ReadFrom(ResponseTxt) then 33 Error('Could not parse OptiGroup response.'); 34 35 if not Root.Get('optimal_clusters', Tok) then 36 Error('Unexpected OptiGroup response shape. 37%1', CopyStr(ResponseTxt, 1, 10000)); 38 39 Clusters := Tok.AsArray(); 40 41 for i := 0 to Clusters.Count() - 1 do begin 42 Clusters.Get(i, ClusterTok); 43 ClusterObj := ClusterTok.AsObject(); 44 ClusterIdTxt := GetTextProp(ClusterObj, 'list_id'); 45 46 // Case A: picks array 47 ClusterObj.Get('picks', PicksTok); 48 PicksArr := PicksTok.AsArray(); 49 for j := 0 to PicksArr.Count() - 1 do begin 50 PicksArr.Get(j, ElemTok); 51 PObj := ElemTok.AsObject(); 52 53 PickIdTxt := GetTextProp(PObj, 'pick_id'); 54 LocArr := PObj.GetArray('location_id'); 55 if LocArr.Count() > 0 then begin 56 LocArr.Get(0, LocIdToken); 57 LocId := LocIdToken.AsValue().AsText(); 58 end else 59 LocId := ''; // fallback 60 OrderNo := CopyStr(GetTextProp(PObj, 'order_id'), 1, 20); 61 62 if (OrderNo = '') and (PickIdTxt <> '') and PickToOrder.ContainsKey(PickIdTxt) then 63 OrderNo := PickToOrder.Get(PickIdTxt); 64 65 if (OrderNo <> '') and (PickIdTxt <> '') then begin 66 R.Init(); 67 R."Cluster Id" := ClusterIdTxt; 68 R."Order No." := OrderNo; 69 R."Pick Id" := PickIdTxt; 70 R."Location Id" := LocId; 71 // Message('About to insert: ClusterId=%1, PickId=%2, LocationId=%3', ClusterIdTxt, PickIdTxt, LocId); 72 R.Insert(); 73 end; 74 end; 75 end; 76end;
Which makes use of the procedure below:
1local procedure GetTextProp(var Obj: JsonObject; Name: Text): Text 2var 3 Tok: JsonToken; 4begin 5 if Obj.Get(Name, Tok) and Tok.IsValue() then 6 exit(Tok.AsValue().AsText()); 7 exit(''); 8end;
The final sub procedure makes the table view appear:
1local procedure ShowResultsForRun(RunId: Guid) 2var 3 R: Record "OptiGroup Result Line"; 4 P: Page "OptiGroup Results"; 5begin 6 P.SetTableView(R); 7 Page.Run(Page::"OptiGroup Results", R); 8end;
This is it! We now have an additional button in the Warehouse Shipments page that retrieves all Released Sales Orders that have not yet been grouped into Shipments and sends the necessary information to our API, afterwards the results of our optimization are displayed visually.


These results can also be visually displayed on the floor plan through the Operational Analysis page in our web application:

Field mapping (recap)
| OptiPick JSON | BC Source |
|---|---|
picks[].pick_id | Stable key you generate, e.g. WSH-[ShipmentNo]-[LineNo] or WPK-[PickNo]-[ActivityLineNo] |
picks[].order_id | Sales Order No. (Sales Header"."No.") or Shipment’s Source No. |
picks[].location_id | Warehouse Activity Line"."Bin Code" (post-pick or simulated) |
picks[].wave_id | Your wave bucket (e.g., “DHL-2025-10-07”) or Shipment No. group |
picks[].list_id | Original grouping label (e.g., Warehouse Shipment No.) for as-is comparison/fallback |
picks[].asis_sequence | BC’s as-is order number you compute from Bin Ranking (or original activity line order) |
site_name | OptiPick floorplan name |
parameters.location_regex | Regex pairs to map BC Bin Codes → OptiPick floorplan node IDs |
Where to call OptiPick in BC?
Pick one of these patterns (most teams start with A, then move to B):
A) Pragmatic/POC (lowest friction)
-
Create Warehouse Shipments as usual for your dispatch window.
-
Run Create Pick on all those shipments.
-
Read Warehouse Activity Lines across all created picks → build one OptiPick request (wave-level).
-
Apply results:
-
If clusters match existing routes, re-sequence the pick lines.
-
If clusters re-bucket orders, delete the temporary picks and re-create one pick per cluster (Pick Worksheet / codeunit).
B) Clean “simulation” approach (more dev work)
Call BC’s pick logic in temporary mode to resolve Bin Codes without actually creating picks.
Send the full wave to OptiPick.
Create one Warehouse Pick per returned cluster directly.
Both give OptiPick bin-accurate inputs. A) is easiest to implement; B) avoids throwaway picks.
Persisting the results (BC)
-
Grouping: create one Warehouse Pick per OptiPick cluster. Picks may span multiple Warehouse Shipments (standard BC allows this through the Pick Worksheet/creation codeunit).
-
Sequence: update each pick’s activity lines to follow OptiPick’s sequence (store an “Optim Sequence” in a custom field or reuse “Sorting” if suitable).
-
Traceability: store run_id / timestamp on:
a lightweight “OptiPick Run” header,
and/or on each Warehouse Pick (custom field),
plus save the raw request/response blob for audit/KPIs.
- Fallback: retain the original “as-is” sequence (Bin Ranking order) so the pick can still be executed unchanged if needed.
Setup checklist (BC)
-
Read Authorizations: Sales Header/Line, Warehouse Shipment, Warehouse Pick, Warehouse Activity Line, Bin, Bin Content.
-
Write Authorizations: Warehouse Pick & Activity Lines (or temp picks), custom tables (OptiPick Setup/Run/Results).
-
BC Warehouse configuration: (i) Location with Bin Mandatory, Require Shipment, Require Pick; (ii) Default Bin Selection = Fixed Bin (for predictable allocation); (iii) Bin Ranking populated (your “as-is” baseline)
-
Connectivity: Allow outbound HTTPS to your OptiPick endpoints; set API key in a secured field (Masked).
-
Feature toggle: Add a simple setup flag (e.g., “OptiPick Enabled”) so ops can disable quickly.
-
Floorplan: Confirm regex maps Bin Code → floorplan node for every pick location.
Additional Tips & Tricks
Reliability & monitoring
- Retries/backoff for HTTP 429/5xx.
- Timeouts + circuit breaker → fall back to BC’s as-is.
- Log: selected wave scope, request hash, HTTP code, run id, decision (optimized vs fallback).
Idempotency
- If you re-call OptiPick for the same wave, ensure you don’t duplicate Warehouse Picks: either delete & rebuild or upsert by Run ID.
Performance
- Filter at the DB: get only Released SOs and their Shipment/Pick lines for the wave.
Floorplan fidelity
- If an item can be in multiple bins, you can pass multiple location_id candidates (OptiPick can choose the best).
- Keep regexes updated as bin naming evolves.
Security
- Keep API keys in a masked field
- Restrict who can open/maintain the Setup page.