Print a custom product label: a Template solution in Process Guide Framework, with detours

Print Product Label
Print Product Label

Print a custom product label: a Template solution in Process Guide Framework, with detours

I wanted to have a template code for a new Warehouse Management App menu item in Dynamics 365 for SCM. The guidance …/warehousing/process-guide-framework is good, but a few aspects are missing there. This sample takes an Item ID (released product), looks for a custom label for products, deducts a default printer and sends a requested number of product labels to the printer. It does not require any parameters but the mobile device menu item setup to perform this job, which is my credo: the less configuration I have, the less conversations with the customer must be led, and the less documentation must be written. As we all agree, writing documentation – including this one – is utterly boring.

ProcessGuideController and WHSWorkExecuteMode

The below mobile device menu item is an indirect one, i.e. it is not based on any warehouse work. A new menu item class is instantiated in connection with a new WHSWorkExecuteMode enumeration element, while an indirect menu item is driven by an Activity code in Warehouse management > Setup > Mobile device , which is a different enumeration: WHSWorkActivity:

This is why you have to extend each of the enums with a new element of the same name (here: WHSWorkExecuteMode::PrintProductLabel = WHSWorkActivity::PrintProductLabel). One is converted into another; you do not have to explicitly program the mapping anymore: it is enough for the elements to have precisely the same name.

The code in the controller is a no-brainer:

				
					[WHSWorkExecuteMode(WHSWorkExecuteMode::PrintProductLabel)]
public class ProcessGuideProductLabelController extends ProcessGuideController
{
    protected final ProcessGuideStepName initialStepName()
    {
        return classStr(ProcessGuideProductLabelItemIdStep);
    }

    protected ProcessGuideNavigationRoute initializeNavigationRoute()
    {
        ProcessGuideNavigationRoute navigationRoute = new ProcessGuideNavigationRoute();

        navigationRoute.addFollowingStep(classStr(ProcessGuideProductLabelItemIdStep), classStr(ProcessGuideProductLabelNoOfLabelsStep));
        navigationRoute.addFollowingStep(classStr(ProcessGuideProductLabelNoOfLabelsStep), classStr(ProcessGuideProductLabelItemIdStep));

        return navigationRoute;
    }
}
				
			

For the new process guide flow to come up, do not forget to SysFlushAOD: Refresh SysExtension cache in D365FO. For the Use process guide slider to automatically be set at the mobile device menu item (see above), we often use the following table extension:

				
					[ExtensionOf(tableStr(WHSRFMenuItemTable))]
internal final class WHSRFMenuItemTable_Extension
{ 
    protected boolean workActivityMustUseProcessGuideFramework()
    {
        boolean ret = next workActivityMustUseProcessGuideFramework();
        ret = ret || (this.WorkActivity == WHSWorkActivity::PrintProductLabel);
        return ret;
    }
}
				
			

Declarations to support detours

The menu item must be able not only to be called from the main mobile device menu, but also receive parameters from other menus / processes. It should be able to uptake the Item ID and print a product label right away. This is called a detour:  Configure detours for steps in mobile device menu items – Supply Chain Management | Dynamics 365 | Microsoft Learn.
We have to declare all possible steps and fields of the new menu item with a descendant of WHSMobileAppFlow:

				
					[WHSWorkExecuteMode(WHSWorkExecuteMode::PrintProductLabel)]
public final class WHSMobileAppFlowProductLabel extends WHSMobileAppFlow
{
    protected void initValues()
    {
        this.addStep(WHSMobileAppStepIds::ItemId);
        this.addStep(WHSMobileAppStepIds::WaveLblQty);

        this.addAvailableField(extendedTypeNum(ItemId));
        this.addAvailableField(extendedTypeNum(NumberOfLabels));
    }
}
				
			

Once declared and compiled, refresh the SysExtension cache again, open Warehouse management > Setup > Mobile device > Mobile device steps and use Create default setup there.

I also have a unique field “NumberOfLabels” to prompt the number of label copies. It must be declared with a class, a descendant of WHSField. It does not need a WHSControl descendant (see also my Input validation and messaging in the Process Guide Framework), because its behaviour is quite standard.

 

				
					[WHSFieldEDT(extendedTypeStr(NumberOfLabels))]
public class WHSFieldNumberOfLabels extends WHSField
{
    private const WHSFieldName             Name        = "@WAX:NumberOfLabels";
    private const WHSFieldDisplayPriority  Priority    = 10;
    private const WHSFieldDisplayPriority  SubPriority = 90;
    private const WHSFieldInputMode        InputMode   = WHSFieldInputMode::Manual;
    private const WHSFieldInputType        InputType   = WHSFieldInputType::Numeric;

    protected void initValues()
    {
        this.defaultName        = Name;
        this.defaultPriority    = Priority;
        this.defaultSubPriority = SubPriority;
        this.defaultInputMode   = InputMode;
        this.defaultInputType   = InputType;
    }
}
				
			

The extendedTypeNum property of the controls on “pages” (see below) must match exactly the above declaration. Once programmed, use the button Create default setup in Warehouse management > Setup > Mobile device > Warehouse app field names. The new field should appear in the list and can be used as a novel parameter in “Select fields to send” in a detour.  

Step 1: Screen to prompt the product number

The below 2 classes are trivial and I’ll keep them uncommented:

				
					[ProcessGuidePageBuilderName(classStr(ProcessGuideProductLabelItemIdPageBuilder))]
public class ProcessGuideProductLabelItemIdPageBuilder extends ProcessGuidePageBuilder
{
    protected final void addDataControls(ProcessGuidePage _page)
    {
        _page.addTextBox(ProcessGuideDataTypeNames::ItemId,
                         "@SYS14428",
                         extendedTypeNum(ItemId),
                         true,
                         controller.parmSessionState().parmPass().lookupStr(ProcessGuideDataTypeNames::ItemId));
    }
    protected final void addActionControls(ProcessGuidePage _page)
    {
        #ProcessGuideActionNames
        _page.addButton(step.createAction(#ActionOK), true);
        _page.addButton(step.createAction(#ActionCancelExitProcess));
    }
}

[ProcessGuideStepName(classStr(ProcessGuideProductLabelItemIdStep))]
public class ProcessGuideProductLabelItemIdStep extends ProcessGuideStep
{
    protected final ProcessGuidePageBuilderName pageBuilderName()
    {
        return classStr(ProcessGuideProductLabelItemIdPageBuilder);
    }
    protected final boolean isComplete()
    {
        WhsrfPassthrough pass = controller.parmSessionState().parmPass();
        return (pass.lookup(ProcessGuideDataTypeNames::ItemId) != "");
    }
}
				
			

Step 2: Screen to prompt the number of copies, then print

The next 2 classes are more complex.

Microsoft developers tend to design an additional step (here it would be the 3rd) which executes the core business logic, but a mobile app step comes with a UI – an additional screen looking like a confirmation. Yet every new screen is one click more for the worker to do, and we can initiate the printing right after the number of copies prompt.

It is essential to have super()  in front of the .doExecute() method, because it updates the “pass” with the latest user interaction (i.e. saves the “NumberOfLabels” in the session state).

				
					[ProcessGuidePageBuilderName(classStr(ProcessGuideProductLabelNoOfLabelsPageBuilder))]
public class ProcessGuideProductLabelNoOfLabelsPageBuilder extends ProcessGuidePageBuilder
{
    protected final void addDataControls(ProcessGuidePage _page)
    {
        WhsrfPassthrough pass = controller.parmSessionState().parmPass();

        if (! pass.exists(ProcessGuideDataTypeNames::NumberOfLabels))
        {
            pass.insert(ProcessGuideDataTypeNames::NumberOfLabels, 1);
        }
        _page.addTextBox(
            ProcessGuideDataTypeNames::NumberOfLabels,
            "@WAX:NumberOfLabels",
            extendedTypeNum(NumberOfLabels),
            true,
            WhsWorkExecuteDisplay::num2StrDisplay(pass.lookupNum(ProcessGuideDataTypeNames::NumberOfLabels)));

        _page.addLabel(
            ProcessGuideDataTypeNames::ItemInfo,
            InventProcessGuideInquiryItemHelper::generateItemInformation(pass.lookupStr(ProcessGuideDataTypeNames::ItemId), InventDim::findOrCreateBlank()),
            extendedTypeNum(WHSRFItemInformation));   
    }

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

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

    protected void doExecute()
    {
        super(); // process the controls

        // Identify the custom product label
        WHSLabelLayoutDataSource    labelDS;
        WHSLabelLayout              labelLayout;
        select firstonly labelLayout 
            where labelLayout.LayoutType == WHSLabelLayoutType::CustomLabel
        exists join labelDS
            where labelDS.CustomLabelRootDataSourceTable == tableStr(InventTable)
               && labelDS.LabelLayoutDataSourceId == labelLayout.LabelLayoutDataSource;
        if (! labelLayout)
        {
            return; 
        }        

        WhsrfPassthrough pass = controller.parmSessionState().parmPass();
        if (this.printCustomLabel(pass.lookupStr(ProcessGuideDataTypeNames::ItemId),
                                labelLayout,
                                WHSLabelPrinterSelector::construct()
                                    .withUserId(pass.parmUserId())
                                    .withWarehouseId(pass.parmInventLocationId())
                                    .selectPrinterForPrinterStockType(""),
                                pass.lookupNum(ProcessGuideDataTypeNames::NumberOfLabels)) > 0)
        {
            this.addReprintLabelProcessCompletionMessage();
        }
        
        this.passReset();
    }

    private int printCustomLabel(ItemId _itemId, WHSLabelLayout _labelLayout, WHSPrinterName _printerName, NumberOfLabels _noOfLabels)
    {
        InventTable     record = InventTable::find(_itemId);
        int             labelCount;
                        
        // Do not use a service to avoid a disruptive info message
        using (var batchPrintingContext = WhsBatchedDocumentRoutingContext::construct())
        {
            while (labelCount < _noOfLabels)
            {
                labelCount += WHSCustomLabelPrintCommandGenerator::printLabelsForRecord(_labelLayout, _printerName, record.RecId);
            }
            batchPrintingContext.printBatchedLabels();
        }
        return labelCount;
    }

    protected void passReset()
    {
        WhsrfPassthrough pass = controller.parmSessionState().parmPass();
        pass.remove(ProcessGuideDataTypeNames::ItemId);
        pass.remove(ProcessGuideDataTypeNames::NumberOfLabels);
    }

    private void addReprintLabelProcessCompletionMessage()
    {
        ProcessGuideMessageData messageData = ProcessGuideMessageData::construct();
        messageData.message = "@WAX3181";
        messageData.level = WHSRFColorText::Success;

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

Source code

You may download the example here: ProcessGuideProductLabel.axpp

Dissecting the Warehouse Management app layout

Dissecting the Warehouse Management app layout

The layout of the Dynamics 365 Warehouse Management app is managed by both the App code and the D365 SCM core to ensure a productive workflow. The app receives a state from the Dynamics 365 SCM core encapsulated in a “container,” comprising a list of controls to be presented on the current screen. These hints include the DisplayArea  assignment (the display area where controls that are awaiting input or confirmation are found, see Inspect details of active Warehouse Management mobile app sessions – Supply Chain Management | Dynamics 365 | Microsoft Learn) and the InstructionControl reference for item ID, quantity, location confirmations.

Typically, controls without default values are positioned in the Primary Input Area, awaiting user input through scanning or manual entry. In the above example, the next empty control is LP (license plate) i.e. the licence plate to pick the goods from. These controls which require a user input are presented one after another following the order of controls in the “container” or the explicit Display Priority, if provided by the programmer of the warehouse menu item UI. This is perceived by the user as a series of screens; internally, it remains the same screen.

To determine the DisplayArea, the D365 core employs heuristics. Controls with default values slide down to the Info and Secondary Input Area, except for confirmation fields like Confirm Location, which appear in the Primary Input Area despite seemingly having a default value. The initial value is still empty, but the InstructionControl let the warehouse app present a hint from a different field and display it in italic (here: BULK-010).

Upon assigning values to all controls, the screen is considered complete, triggering the button in the Primary Action Area.

Control placement on the Warehouse Management app screen can be influenced through the WHSMobileAppServiceDecoratorRule class family. For instance, a control with a non-empty default value can still be prompted in the primary input area, as demonstrated in the below code snippet:

[SubscribesTo(classStr(WHSMobileAppServiceDecoratorRuleDefaultDisplayArea), staticDelegateStr(WHSMobileAppServiceDecoratorRuleDefaultDisplayArea, isTextInPrimaryInputAreaDelegate))]
public static void WHSMobileAppServiceDecorator_isTextInPrimaryInputAreaDelegate(boolean _enabled, Map _controlMap, str _data, WHSMenuItemName _menuItemName, EventHandlerResult _result)
{
   if (_enabled && _controlMap.lookup(#XMLControlName) == #MyControl)
   {
      _result.result(true);
   }
}

It is important to remove (to be exact, not to rebuild after the last user interaction) such a control from the screen once it receives its input, otherwise it will be prompted repeatedly in an infinite loop.

The second example showcases a custom control with an InstructionControl reference for requesting confirmations while presenting a hint in italic:

[ExtensionOf(classStr(WHSMobileAppServiceDecoratorRuleInstruction))]
final class WHSMobileAppServiceDecoratorRuleInstruction_Extension
{
   #WHSRF
   protected WHSMobileAppControlName getInstructionControlName(WHSMobileAppControlName _controlName)
   {
      WHSMobileAppControlName confirmationControl = _controlName;
      WHSMobileAppControlName masterControl;
      masterControl = next getInstructionControlName(confirmationControl);
      if (confirmationControl == #MyControl)
      {
         masterControl = #MyMasterControl;
      }
      return masterControl;
   }
}

Consumable “Kanban” parts in D365 Warehouse management

Small consumable items (Kanban)
Small consumable items (Kanban)

Consumable “Kanban” parts in D365 Warehouse management

Low value parts such as bolts, screws, gaskets, clamps, and other fasteners, as well as small electric parts, are rarely retrieved from the main warehouse in the precise quantities by production order. Instead, they are stored in surplus at the workbenches, typically organized in boxes of various sizes. The usage of these parts is not actively monitored; rather, they are automatically deducted from the inventory based on the production bill of materials. Additionally, there is no manual counting of these items at the workbenches. Once the quantity reaches a predefined minimum level, the boxes must be promptly restocked, that’s why they may call them “Kanban” items.

The “canonical” solution in the Dynamics 365 Warehouse management is the Min/Max replenishment: https://learn.microsoft.com/en-us/dynamics365/supply-chain/warehousing/replenishment#minmax-replenishment  The locations to replenish are the input locations of the resources. One box typically stores one item. One screw may be needed at multiple machines = locations. From the list of items at every location you derive the list of locations per item, and these become the fixed locations of the items at the production warehouse. In accordance with the Set up a min-max replenishment process – Supply Chain Management | Dynamics 365 | Microsoft Learn guidance, you choose Replenish empty fixed locations (because a voluntary non-fixed location must have a quantity > 0 to get initially replenished, but this condition is not necessarily met) and Replenish only fixed locations.

In practise, this canonical solution does not always work well. First, the min/max replenishment must be set up to happen often, every 5-20 minutes to simulate an immediate response to the demand. Yet the processes in the Dynamics 365 Warehouse management are essentially PULL processes, and the replenishment is as fast as the warehouse personnel pulls the new work from the queue. It is a different sense of emergency as opposed to seeing a red Kanban card brought by the production worker. If the warehouse workers fall behind, the D365 system is going to detect the critical shortage and include the small parts into the regular production waves, and pick them for the production order(s) in small quantities.

Second, the min/max replenishment relies on the stock level monitoring in the boxes, and the backflushed quantity should better be exact. But the workers sometimes use more parts than stipulated in the BOM. The stock levels in the locations are going to start accumulating errors, and the actual quantity in the boxes is likely to be less than expected, while manual triggering of the replenishment is not possible. In the end, some sort of a spot counting will be needed.

Moreover, at the factory I am consulting right now some of the items are vendor-managed (VMI): a salesperson visits the factory regularly and replenishes the boxes; only a few days later the invoice for the goods is received by the accounting. This makes the internal stock level monitoring an impossible task.

One may suggest deleting these items from the BOM altogether, but this is going to (1) remove the “sink” for the quantities so that the stock levels will continuously grow, (2) distort the price of the product and (3) confuse the workers who use the BOM as a reference. Another suggestion was to make these items non-stocked [in the item model group], but a non-stocked item may not be added to a BOM in Dynamics 365. One may also declare a product not eligible to the Warehouse management, but this choice of the Storage dimension group may only be made once at the beginning of the product lifecycle, while the list of “Kanban” items undergo optimization often.

The below solution that really works relies on the negative picking from a non-advanced warehouse. The negative stock and the auto-consumption at the machine will deplete the stock. The usual purchase delivery note / VMI invoice will increase the stock. Sometimes the stock level recorded in D365 will be higher than the real one, sometimes it will be lower, sometimes it may even be negative, but the production process will not stop because of one allegedly missing screw. The replenishment happens outside of the system in a manual Kanban process triggered by the worker at the machine, or it may be vendor managed, or driven by the warehouse personnel regularly looking for empty boxes.

The setup is as follows:

  1. Create a new warehouse (e. g. NA) where the mode Use warehouse management processes is turned OFF. Set some Default input and production input locations.
  2. Choose this warehouse in the Default order settings of the item as the default purchase and inventory warehouse.
  3. Create if needed a dedicated Item model group, where the Physical negative inventory = Yes. Apply it to the item.
  4. At the released product, keep the usual options Flushing principle = Finish, Material picking in license plate locations = Order picking. You may also want to set the coverage group to Manual.
  5. Make sure the production parameter Journals / Picking list journal / Pick negative is ON, because the negative picking is only allowed when both Physical negative inventory + Pick negative are ON.
  6. In the BOM, use the “Kanban” items mostly as usual, but do NOT select the Resource consumption as we want to consume them from the virtual warehouse NA.

Kanban Items In The BOM

Refer to the blog The case of a missing flushing principle for more insight on the backflushing.