Building Warehouse Applications with the ProcessGuide API

In my previous posts I have showed how to build custom workflows for the Warehouse mobile app in Dynamics.  All my previous examples have used the standard WHSWorkExecuteDisplay class framework.  One of the common complaints about this framework is the difficulty in which partners have with extending existing functionality or workflows.  To address these extensibility challenges the product team refactored the core API into a new framework called ProcessGuide which provides more opportunities to extend functionality at different points in the lifecycle of a warehouse workflow.  The full details of this change can be found here: https://docs.microsoft.com/en-us/dynamics365/supply-chain/warehousing/process-guide-framework

I would like to walk through the process of creating a new workflow in the ProcessGuide framework.  I will revisit my previous example and show how we would accomplish the same outcome, but this time utilizing the ProcessGuide framework.

The requirement is that we want a user to scan a set of assets against a single container within the warehouse.  These assets might be serial number controlled items or unique catch weight tags that you need to track within the system.  This article (https://cloudblogs.microsoft.com/dynamics365/it/2018/12/20/customizing-the-warehouse-mobile-app-multi-scan-pages/) discussed how we can use the offline multi-scan controller pattern to allow the user to scan these assets without having to do a round trip to the server with every scan.  Customers are using this pattern to enable high performance scanning workflows.

Process Guide Classes

The key objects to create in the ProcessGuide framework are documented in the above article, but I will copy them here for reference:

  • ProcessGuideController– This class orchestrates the overall execution of the business process. It defines the factories that instantiate the step and the navigation agent, which subsequently constitute the process execution, as well as the clean-up logic for cancellation or exiting the process.
  • ProcessGuideStep– This class represents one single step in the business process. This class contains a definition of the factories that instantiate a page builder, actions, and data processors and is responsible for invoking them in the correct sequence.
  • ProcessGuideNavigationAgent– This class is responsible for navigation between the steps. When a step is completed, the navigation agent is responsible for defining the next step and passes any parameters that the previous step may need to communicate to the next one.
  • ProcessGuidePageBuilder– This class is responsible for instantiating the user interface.
  • ProcessGuideAction– This class represents an action, shown as a button to the user.
  • ProcessGuideDataProcessor– This class is responsible for processing the user entered data in a field.

This can be somewhat hard to visualize based on the above descriptions – so I have tried to represent the major objects below.  What is key to see here is that when you create a new workflow you will have 1 or more ProcessGuideStep classes, and each one will have to define a ProcessGuidePageBuilder if they present a UI to the user.  It is possible to have a processing step with no UI, in which case there will be no PageBuilder class defined – we will see an example of this below.  The ProcessGuideNavigationAgent will define the transitions (or if you want to be all computer science-y about it – the edges in the directed graph) between the steps.

WHSWorkActivity and WHSWorkExecuteMode Enumerations

These two key enumerations still drive the core sysExtension Framework mapping – so one of the first steps is to define the new enumerations as an extension on the existing enumerations.  This process is unchanged from the previous articles.

ProcessGuideController

The class that defines the overall state-machine logic in the ProcessGuide framework is the ProcessGuideController.  This class will determine the discrete steps and conceptually maps to the high-level WHSWorkExecuteDisplay<ProcessName> class you might have defined previously.  When you create a custom workflow you will first define this class – however you will need to define the several other classes before you can finish the definition of the class itself.  For now, we will define the code like this:

[WHSWorkExecuteMode(WHSWorkExecuteMode::AssetScan)]
public class WHSProcessGuideAssetScanController extends ProcessGuideController
{
    protected ProcessGuideStepName initialStepName()
    {
        return null;
    }

    protected ProcessGuideNavigationRoute initializeNavigationRoute()
    {
        return null;
    }
}

Note that we are associating the Controller class with our new WHSWorkExecuteMode enumeration value in the class attribute so this controller class will be instantiated when the mobile app constructs these type of menu items.

As discussed above the Controller defines the navigation steps between the various state machine “steps.”  These steps are defined as classes derived from the ProcessGuideStep class.  Based on our workflow diagram above, we will have three steps in this workflow – one for each of the “states” (Scan ContainerID, Capture Assets, and Process Assets).

Scan ContainerID

We can define the first ProcessGuideStep with the following structure:

[ProcessGuideStepName(classStr(WHSProcessGuideValidateAssetScanContainerIdStep))]
public class WHSProcessGuideValidateAssetScanContainerIdStep extends ProcessGuideStep
{
    protected ProcessGuidePageBuilderName pageBuilderName()
    {
        return null;
    }
}

The pageBuilderName is required – this is where you will define the specific PageBuilder used to render this step. A PageBuilder class roughly corresponds to the displayForm method in the WHSWorkExecuteDisplay framework.  In the previous article this was the code used to construct this step (via the WHSWorkExecute framework):

private container getContainerStep(container _ret)
{
    _ret = this.buildGetContainerId(_ret);
    step = conWeighContainerStep::EnterWeight;

    return _ret;
}

container buildGetContainerId(container _con)
{
    container ret = _con;

    ret += [this.buildControl(#RFLabel, #Scan, ‘Scan a container’, 1, ”, #WHSRFUndefinedDataType, ”, 0)];
    ret += [this.buildControl(#RFText, conWHSControls::ContainerId, "@WAX1422", 1, pass.lookupStr(conWHSControls::ContainerId), extendedTypeNum(WHSContainerId), ”, 0)];
    ret += [this.buildControl(#RFButton, #RFOK, "@SYS5473", 1, ”, #WHSRFUndefinedDataType, ”, 1)];
    ret += [this.buildControl(#RFButton, #RFCancel, "@SYS50163", 1, ”, #WHSRFUndefinedDataType, ”, 0)];

    return ret;
}

The equivalent code in the ProcessGuide framework is below.  Note that you are constructing a ProcessGuidePageBuilder class and the data controls and action controls are split into separate methods (which allows for better extensibility).

[ProcessGuidePageBuilderName(classStr(WHSProcessGuideValidateAssetScanContainerIdPageBuilder))]
public class WHSProcessGuideValidateAssetScanContainerIdPageBuilder extends ProcessGuidePageBuilder
{
    protected final void addDataControls(ProcessGuidePage _page)
    {
        _page.addLabel(ProcessGuideDataTypeNames::ContainerIdLabelName, "Scan a container", extendedTypeNum(WHSRFUndefinedDataType));
        _page.addTextBox(ProcessGuideDataTypeNames::ContainerId, "@WAX1422", extendedTypeNum(WHSContainerId));
    }

    protected final void addActionControls(ProcessGuidePage _page)
    {
        #ProcessGuideActionNames
        _page.addButton(step.createAction(#ActionOK), true);
        _page.addButton(step.createAction(#ActionCancelExitProcess));
    }
}

Now that we have defined the PageBuilder class we can go back and update the Step class with the necessary details.  Specifically, the pageBuilderName method can be updated to return the class we just defined.  In addition, we need to define criteria for when this step is considered “complete” – this is done by overriding the isComplete method.  For this example we will require the ContainerId textbox to have data in it.

[ProcessGuideStepName(classStr(WHSProcessGuideValidateAssetScanContainerIdStep))]
public class WHSProcessGuideValidateAssetScanContainerIdStep extends ProcessGuideStep
{
    protected final boolean isComplete()
    {
        WhsrfPassthrough pass = controller.parmSessionState().parmPass();
        WHSContainerId containerId = pass.lookup(ProcessGuideDataTypeNames::ContainerId);
        return (containerId != '');
    }

    protected ProcessGuidePageBuilderName pageBuilderName()
    {
        return classStr(WHSProcessGuideValidateAssetScanContainerIdPageBuilder);
    }
}

Control Names

Note that I am using the ProcessGuideDataTypeNames class to define all the control names – this can be easily done with an extension class like below.  It also could be defined in another class – this is not a hard requirement; it just matches what the core ProcessGuide API is doing.

[ExtensionOf(classStr(ProcessGuideDataTypeNames))]
final static class ProcessGuideDataTypeNamesWHSTestPG_Extension
{
    public static const str ContainerId = "ContainerId";
    public static const str ContainerIdLabelName = "ContainerIdLabel";
    public static const str AssetId = "AssetId";
}

Capture Assets

We can now build the next step in the workflow – which means we need both a ProcessGuideStep and ProcessGuidePageBuilder class.  Since this step will be using the multi-scan pattern there will be one small addition to these two classes. The page builder will be defined in a similar manner to the previous class:

[ProcessGuidePageBuilderName(classStr(WHSProcessGuideAssetScanMultiScanPageBuilder))]
public class WHSProcessGuideAssetScanMultiScanPageBuilder extends ProcessGuidePageBuilder
{
    protected void addDataControls(ProcessGuidePage _page)
    {
        WhsrfPassthrough pass = controller.parmSessionState().parmPass();
        str assetIdList = pass.lookupStr(ProcessGuideDataTypeNames::AssetId);
        
        _page.addLabel(ProcessGuideDataTypeNames::ContainerIdLabelName,"Scan Assets", extendedTypeNum(WHSRFUndefinedDataType));
        _page.addTextBox(ProcessGuideDataTypeNames::AssetId, "Asset Id", extendedTypeNum(AssetIdList), true, assetIdList);
    }

    protected void addActionControls(ProcessGuidePage _page)
    {
        #ProcessGuideActionNames
        _page.addButton(step.createAction(#ActionOK), true);
        _page.addButton(step.createAction(#ActionCancelResetProcess));
    }

}

And the ProcessGuideStep will simply indicate that the specific PageBuilder class should be used:

[ProcessGuideStepName(classStr(WHSProcessGuideAssetScanMultiScanStep))]
public class WHSProcessGuideAssetScanMultiScanStep extends ProcessGuideStep 
{
    protected final ProcessGuidePageBuilderName pageBuilderName()
    {
        return classStr(WHSProcessGuideAssetScanMultiScanPageBuilder);
    }
}

What makes this step unique is the client-side multi-scan mode that is enabled for the user.  This allows the customer to scan any number of Assets in an offline mode and not have to wait for the server-side round trip for each scan.  In order to tell the mobile app that we want to be in this “multi-scan” mode we need to create our own DectoratorFactory object and return the Multi-Scan decorator for this specific step.  This will work the same way it did in the previous example; we will define a new MultiScan DecoratorFactory and have it return a new multi-scan WHSMobileAppServiceXMLDecorator at the specific step in the workflow we want the user to be in the multi-scan UI.  Recall that the WHSMobileAppServiceXMLDecorator class is used to direct the mobile app UI to change into the different major “modalities” – such as the standard scan control UI, the card view used for the worklist, and the multi-scan model.

We still do not ship a native MultiScan decorator factory, but it can be easily created for this specific flow with the following code.  This detects which step we are currently executing and if we are on the multi-scan step it will return the multi-scan decorator.

[WHSWorkExecuteMode(WHSWorkExecuteMode::AssetScan)]
public class WHSMobileAppServiceXMLDecoratorFactoryMultiScan implements WHSIMobileAppServiceXMLDecoratorFactory
{
    #WHSRF

    public WHSMobileAppServiceXMLDecorator getDecorator(container _con)
    {
        if (this.isMultiScanScreen(_con))
        {
            return new WHSMobileAppServiceXMLDecoratorMultiScan();
        }
        
        return new WHSMobileAppServiceXMLDecoratorFactoryDefault().getDecorator(_con);
    }

    /// <summary>
    /// Extracts the current step from the container.
    /// the current step should be preceeded by a string "CurrentStep"
    /// </summary>
    /// <param name = "_con">
    /// Contains information about the context
    /// </param>
    /// <returns>
    /// The current step listed in the context
    /// </returns>
    private str getCurrentStep(container _con)
    {
        container subCon = conPeek(_con, 2);
        for (int i  = 1; i <= conLen(subCon); i++)
        {
            if (conPeek(subCon, i - 1) == "CurrentStep")
            {
                return conPeek(subCon, i);
            }
        }

        //Default behavior.
        return conPeek(subCon, 8);
    }

    private boolean isMultiScanScreen(container _con)
    {
        const str MultiStep = classstr(WHSProcessGuideAssetScanMultiScanStep);

        str currStep = this.getCurrentStep(_con);

        return currStep == MultiStep;
    }

}

Process Assets

The final step in the workflow is to take the data entered by the user and submit it to the database.  For this we don’t need to display a UI, so we will create a step that derives from ProcessGuideStepWithoutPrompt.  This means we just need to provide an implementation for the doExecute method.  We will leverage the same code from the previous article – as a reminder here is the overview of the API used by the multi-scan page pattern API.


Multi-Scan API

Now that we know how to enable the Multi-Scan UI through a Page Pattern, we need to understand the basic API for passing the scanned items back and forth.  Once the MultiScan Page Pattern is requested, the first input control registered on the page will be used for the multi-scan input.  Remember that most of the UI interaction is all done client-side – so the only thing the server X++ code needs to do is define this control and the data that it contains.

When the user clicks that “submit” check box and sends the multi-scan data back to the X++ code, this is formatted in a very specific way.  The actual parsing of the data is done using the same interaction patterns as before – it will be stored in the result pass object for the specific control defined as the primary input of this page.  But the data will be passed in this format:

   <scanned value>, <number of scans>|<scanned value>, <number of scans>|…

Thus, in my demo example above the data that the server would receive would be the following:

   BC-001,2|BC-002,1|BC-003,1

In the X++ code you would then be responsible for parsing this string and storing the data in the necessary constructs.  We will see a simple example in a moment of how to parse this data.


The following is the code necessary to build the processing step:

[ProcessGuideStepName(classStr(WHSProcessGuideAssetScanProcessAssetsStep))]
public class WHSProcessGuideAssetScanProcessAssetsStep extends ProcessGuideStepWithoutPrompt
{
    protected final void doExecute()
    {
        WhsrfPassthrough pass = controller.parmSessionState().parmPass();
        
        if(pass.lookupStr(ProcessGuideDataTypeNames::AssetId) == "")
        {
            throw error('No assets found');
        }
        else
        {
            List assets = strSplit(pass.lookupStr(ProcessGuideDataTypeNames::AssetId),"|");
            ListEnumerator enumerator = assets.getEnumerator();
            while(enumerator.moveNext())
            {
                //save assetId
                str assetString = enumerator.current();
                if (assetString != "")
                {
                    str AssetId = subStr(assetString,1,strScan(assetString,",",1,strLen(assetString))-1 );
                    WHSAsset newAsset;
                    newAsset.ContainerId = pass.lookupStr(ProcessGuideDataTypeNames::ContainerId);
                    newAsset.WHSAssetId = AssetId;
                    newAsset.insert();
                }
            }
             this.addAssetsSavedProcessCompletionMessage();     
        }

        super();

        pass.remove(ProcessGuideDataTypeNames::ContainerId);
        pass.remove(ProcessGuideDataTypeNames::AssetId);
    }

    private void addAssetsSavedProcessCompletionMessage()
    {
        ProcessGuideMessageData messageData = ProcessGuideMessageData::construct();
        messageData.message = "Assets Saved";
        messageData.level = WHSRFColorText::Success;

        navigationParametersFrom = ProcessGuideNavigationParameters::construct();
        navigationParametersFrom.messageData = messageData;
    }

}

ProcessGuideController Finale

We now have all the pieces necessary to flesh out the controller class – which we just had as a stub earlier.  Update the code with the references to the three new ProcessGuideStep classes.

[WHSWorkExecuteMode(WHSWorkExecuteMode::ValidateAssetScan)]
public class WHSProcessGuideValidateAssetScanController extends ProcessGuideController
{
    protected ProcessGuideStepName initialStepName()
    {
        return classStr(WHSProcessGuideValidateAssetScanContainerIdStep);
    }

    protected ProcessGuideNavigationRoute initializeNavigationRoute()
    {
        ProcessGuideNavigationRoute navigationRoute = new ProcessGuideNavigationRoute();
        navigationRoute.addFollowingStep(classStr(WHSProcessGuideValidateAssetScanContainerIdStep), classStr(WHSProcessGuideValidateAssetScanValidateScanStep));
        navigationRoute.addFollowingStep(classStr(WHSProcessGuideValidateAssetScanValidateScanStep), classStr(WHSProcessGuideValidateAssetScanProcessAssetsStep));
        navigationRoute.addFollowingStep(classStr(WHSProcessGuideValidateAssetScanProcessAssetsStep), classStr(WHSProcessGuideValidateAssetScanContainerIdStep));

        return navigationRoute;
    }

}

This code overrides the two required methods in order to provide a definition of the state machine for this workflow – again using the ProcessGuideStep classes we defined above.  It should be clear how this code is a clear match to the state machine we defined at the beginning of the article – we are defining the specific states and the transition (edges) between them.

Example Workflow

Now that you have seen the code to enable this in a custom workflow, let’s walk through the screens in the mobile app.  You can download the complete code for this project in the link at the bottom of this post – you just need to get it up and running on a dev environment and configure the necessary menu items to enable the workflow for your system.

The initial screen shows the Container ID scanning field.  Note that in the sample project I have included the necessary class to default this to the scanning mode – however you will need to set these up in Dynamics as defined here.

Scanning a container id (CONT-000000001 works if you are in USMF in the Contoso demo data) will navigate you to the next screen and enable the multi-scan Page Pattern.

Here you can enter any number of assets and the app will store them into the local buffer.  As we described above you can view the scanned assets by clicking the icon in the lower left.  After a few scans we would see the UI updated:

Clicking the list icon would show the scans we have performed offline:

Finally clicking the “submit” button on the main screen will push the items to the server, which will then be saved to the custom WHSAsset table and the UI will display the success message.

Conclusion

Hopefully this shows you how to utilize the new ProcessGuide framework for your warehouse customizations.  The code used for this demo is available to download here – please note that this code is demonstration code only and should not be used in a production system without extensive testing.

  • Sorry about the code syntax highlighting – I am still working out the details on how to get X++ code to display correctly. For now it is just using C# formatting.