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

SysFlushAOD: Refresh SysExtension cache in D365FO

SysFlushAOD: Refresh SysExtension cache in D365FO

The SysExtension framework is heavily used in warehouse mobile device development within Dynamics 365 for Supply Chain Management (SCM)  (see Process guide framework – Supply Chain Management | Dynamics 365 | Microsoft Learn). You may also find a short, self-contained example in my earlier blog:  X++ Decorator pattern in Dynamics 365.

There is one thing that annoys me every time: newly created classes fail to get invoked. The class factory does not recognize the new class because of the intensive caching of class IDs and attributes. A mobile warehouse solution may return “Process guide is not yet supported for this flow.“, for example. Rebuilding the solution does not clear the cache, and the class factory keeps an outdated list of derived classes, failing to instantiate them. The cache apparently survives even an IISRESET. 

Solution: Execute the following command in the browser, then reload:  https://xxxprod.operations.dynamics.com/?mi=SysClassRunner&cls=SysFlushAOD

This clears the SysExtension factory cache and rescans the list of inherited classes.

D365 Petty cash review

D365 Petty cash review

Today I take a chance to review a development of my younger self: the Petty cash addon to the Bank and cash module. In central Europe, this “Petty cash” (de: Portokasse, fr: caisse) often looks like the box shown above. Inside of it there is… well… some cash. I designed, programmed this addon and even localized it for Ukraine back in 2002 mostly on my own in something that can be called a study. It has been there in the D365FO application core all these years, and in version 10.0.39 they finally released it worldwide. 

Configuration

To activate this add-on, enable Petty cash in the Feature management first. There is also a license key (what used to be called a configuration key) Country/Regional specific feature / Multiple countries/regions / General ledger – extensions for Eastern European countries / Petty cash, it is normally activated by default. Ultimately, you must activate it in Cash and bank management parameters with a slider Enable petty cash.

The setup is well described here: (1) Petty Cash Accounting – Microsoft Dynamics 365 Finance | LinkedIn. In essence, you have to create a symbolic cash account, in a Cash posting profile attach a general ledger account to it (there is usually a 1:1 relationship), then select both in Cash and bank management parameters:

D365 Petty Cash Setup

Then there is a balance check. In Eastern Europe the amount of cash you accumulate may not exceed a certain threshold. In Western Europe I know no such regulations but just common sense; some companies even used to stash money 5 years ago to escape the negative bank interest. In contrary, there is a EU-wide limit for cash transactions of 10 kEUR. This can be set as an Operations limit.

In addition, you must specify at least 2 number sequences for the cash slips (bons): reimbursement and disbursement. Finally, configure a GL Journal name of the Cash type. You are good to go.

Operation

Navigate to Cash and bank management > Petty cash > Slip journal, create a new journal header, and under Lines you may start registering cash operations.

Cash Slip Journal

For a sale, choose the cash account in debit and a revenue account in credit. Do not forget the VAT. Once you perform Documents approval / Approve of the line, it draws the next Cash reimbursement slipOrder number”. At this step, it is supposed to print this slip at once, but in Eastern Europe there are legal requirements to its form and content, while in Western Europe there are no such requirements and… no printout. 😊Actually we are talking about a “rudimentary invoice” of less than €400 (de: Kleinbetragsrechnung) with some goods/service description, an anonymous recipient and a VAT included. However, in this case a free text invoice is advised, or you may produce it outright at a retail POS, while field servicemen should use their CE mobile terminals.

Post the journal, and it is going to integrate the cash transactions into the GL.

To pay someone out in cash, the cash account is in credit and the expense account is in debit; again, do not forget the VAT. Use Documents approval / Approve first. Interestingly, the cash balance check (see Inquiries / Cash balance) is performed at the same time, and the balance is built of both posted and unposted slips. Again, it issues a new “Order” number for a Cash disbursement slip, and in an Eastern European country it is going to print it. Here it does not make much sense, since a handyman must issue his own slip / bon to testify that he/she has been paid in full, then we take this receipt and preserve it. One case when we need a printout of our own may be a cash advance to an employee.

Conclusion

Even though this addon remains a monument to my abilities in software design 😉 I am coming to a conclusion that it is pretty useless in Western Europe. The same level of comfort may be achieved with a separate bank account or a simple GL account (see Balance control accounts). In 20 years D365 for Finance has moved upmarket, and with the minimum of 20 licenses it is hard to imagine a company whose main source of liquidity may be cash sales. The module may find some usage in construction industry to pay temporary workers, though.