Code highlighting

Showing posts with label Dynamics AX. Show all posts
Showing posts with label Dynamics AX. Show all posts

Monday, October 15, 2018

Telemetry as part of the Dynamics 365 Finance and Operations life-cycle

How much telemetry are we collecting?

A lot, like, really a lot!

That includes kernel level information, like an online user session requesting web access to a particular AOS to perform business operations through the web UI, a web service request to handle a mobile device operation, an OData request for exporting or importing data, exceptions and other infolog messages displayed to the user, etc.

It also includes specific application-level information, like details about each step during an inventory update, or information about the different wave processing steps, the specific flow happening on the warehouse mobile device, etc.

And we keep adding to it to have more and more granular information about what exactly is happening in the system at any particular point in time.

How can I access all this telemetry?

You can find it under "View Raw Logs" under Environment Monitoring in LCS.
It's very well described in the following two articles, so please read through those at this point:
You can then use the different search options described above to query out the specific events you are interested in.

What about Warehouse - specific telemetry, say, Wave processing?

Depending on the version of the product you are running, it differs slightly. 

Here's how you would search for wave processing events on releases before Fall release of 2018:


Raw logs search criteria

Note The above query options is a preview feature, so is most probably not visible to you yet.

If you then wanted to filter on a specific wave, for example, you could add that to the search criteria as well. 
Note that due to compliance, all of the information is not exposed directly, but rather RecIds are used. So you'd need to retrieve the RecId of the wave you wanted to investigate.

Here's some of the fields you should take note of:
  • The TIMESTAMP column would tell you when exactly the event occurred
  • RoleInstance would show, which AOS the activity happened on. This could be useful when troubleshooting caching and other cross-AOS issues
  • ActivityId is a way for you to limit to only a specific smaller process (for example, a specific allocation thread), and dig deeper, for example, to analyze the slow queries that happened as part of that activity
  •  infoMessage would contain information about the actual wave step performed, as well as specific details about the step, like how many load lines were processed, how long it took, etc.
TaskName: WhsPerformanceTaskStop, ruleName: waveProcessing, actionPerformed: runWaveStep, durationInMilliSeconds: 0, details: {"allocatedLoadLines":"500","custom":"no","waveId":"5637815827","waveStep":"WhsPostEngineBase.allocateWave"}

From Fall release onward

You could search for WHSPerformanceTaskStart/Stop directly in the TaskName instead of as part of infoMessage. The rest still applies.

What about mobile device telemetry - do we capture that?

Of course we do :)

Just search for TaskName == WhsUserActivityEvent
This event contains the same basic information as mentioned above, as well as stuff specific to the mobile flow:
  • Company, Site and Warehouse, where the warehouse user is operating. (RecIds)
  • WorkExecuteMode and step for the specific flow. You'll need to lookup the actual step in code
  • GUID of the mobile device user session
  • WorkTransType, as well as WorkTable and WorkLine RecIds being processed
  • RequestXML - this is the actual request, except all the data and labels are scrubbed, so it shows just what controls are shown on the screen
This event, if you search for it in code, is invoked at the very end of processing the user input from the mobile app.

You can correlate these events with those of TaskName == RequestContext, where the url of the request contains "/api/services/WHSMobileAppServices/WHSMobileAppService/getNextFormHandHeld" - this is the actual web service call as it arrives at the AOS, so the point in time when the AOS starts handling the mobile app flow step. 

Everything that happens in between with the same ActivityID is part of the flow, whether that is slow queries, error messages or other relevant events.

I don't see these events - what should I do?

We have back-ported a lot of the telemetry through hotfixes. Search for it on LCS for your specific release. 
But I would also like to take this opportunity and move up to the latest release. There is soo much goodness in there!

Should partners be adding telemetry?

ABSOLUTELY!
  • If you are a client, I suggest that you start insisting your partners adds telemetry with any new code the push into production.
  • If you are a partner, I suggest you start adding it asap - you'll save yourself a lot of time going forward, if the customer has an issue with your code. Since you cannot just debug in production any longer, you'll need to rely much more on alternative ways of telling you what exactly happened. Telemetry is the solution.

Where do we start? Do we have some examples?

We are working on the guidance for this and will share this out very soon.


Yes, all of the application telemetry is added directly as part of the application code, so should not be too difficult to find.
I suggest that you rely on the same event "InfoLogMark" as shown below as well.
Consider adding a using statement to reduce the invocation.

Microsoft.Dynamics.ApplicationPlatform.XppServices.Instrumentation.XppRuntimeEventSource::EventWriteInfoLogMark(
                                      Exception::Info, strFmt('TaskName: , etc.', );

What else should be captured out of the box that is critical for you?

Let me know in the comments!
Thanks

Monday, September 04, 2017

Development tutorial: insert_recordset using the Query class

Introduction

I am sure most of you are familiar with set-based X++ CUD operators: insert_recordset, update_recordset and delete_from. They allow performing database operations with a large number of records in a single roundtrip to the server, instead of a row-by-row type of operation, which depends on the number of rows being processed. As a result, they can provide a very significant boost in overall performance of a selected flow.

If not familiar or just need a refresher, you can read more about it by following the link to MSDN.

Problem statement

There's however one significant drawback with these operators - they are all compile-time constructs, so they lack the flexibility of the flow modifying the database request before it is set based on runtime state of the flow.

And, once the application is sealed, that will mean there is no way to modify the request even at compile-time, as X++ statements are not extensible.

Solution

In Microsoft Dynamics AX 2012 R3 a static method was added on the Query class, that allows to solve the two problems above for insert_recordset. This is of course now also available in Microsoft Dynamics 365 for Finance and Operations: Enterprise edition.

Note: update_recordset and delete_from are still not supported through the Query class.

Example

Imagine that we need to write a function that would copy selected sales order line information into a sales order line history table for one or more orders.

1. Data model

Here's how the data model for this table is defined in this example:

DEV_SalesLineHistory data model diagram
DEV_SalesLineHistory data model diagram

2. New DEV_SalesLineHistory table in Visual Studio

And here's how the new table looks in Visual Studio (I created a new model for it in a separate package dependent on Application Suite):

DEV_SalesLineHistory table in Visual Studio
DEV_SalesLineHistory table
Note I skipped all stuff non-essential to this example

3. Copy function through regular X++ insert_recordset statement

Let's first write the statement for inserting the history records using the regular insert_recordset operator:

public class DEV_Tutorial_InsertRecordset
{
    public static Counter insertXppInsert_Recordset(SalesId _salesId)
    {
        DEV_SalesLineHistory    salesLineHistory;
        SalesLine               salesLine;
        InventDim               inventDim;

        JournalPostedDateTime   postedDateTime = DateTimeUtil::utcNow();
        JournalPostedUserId     postedBy = curUserId();
        SalesDocumentStatus     postingType = DocumentStatus::PackingSlip;

        insert_recordset salesLineHistory
        (
            SalesId,
            LineNum,
            InventTransId,
            SalesQty,
            SalesUnit,
            InventSiteId,
            InventLocationId,
            PostedDateTime,
            PostedBy,
            PostingType
        )
        select SalesId, LineNum, InventTransId, SalesQty, SalesUnit from salesLine
            where salesLine.SalesId == _salesId
            join InventSiteId, InventLocationId, postedDateTime, postedBy, postingType from inventDim
                where inventDim.InventDimId == salesLine.InventDimId;

        return any2int(salesLineHistory.RowCount());
    }
}

As you can see, we do a simple select from SalesLine, specifying the exact fields, joined to selected fields from InventDim, where the field list also contains a few local variables to populate into the rows being inserted.
This is the standard syntax used with X++ insert_recordset statement, which all of you are familiar with.

4. Method signature for Query::insert_recordset()

Now let's convert the above to a Query, and call Query::insert_recordset() instead.
This method accepts three arguments:
  • An instance of a table record. This is where data will be inserted into. We can then use this variable to ask how many rows were inserted, for example.
  • An instance of a Map(Types::String, Types::Container), which defines the mapping of the fields to copy. In X++ operator, this had to be based on the specific order in the field selection lists in the select statement. 
    • The map key is the target field name.
    • The value is a container, which defines a pair of values:
      • the unique identifier of the QueryBuildDataSource object points to the table to copy the value from
      • the field name on the above data source to copy the value from
  • An instance of a Query class, which defines the select statement for the data, similar to what you see in the X++ version above.

As you can see from the above, it does not account for literals, as we did with variables in the X++ operator example.
That is currently not supported with this API.
We can however solve this through a use of a "temporary" table, as suggested below.

5. Define a new table to store calculable literals

Let us define a new table that will store the data required by our insert statement. That means it needs to contain four fields:
  • PostedDateTime
  • PostedBy
  • PostingType
  • SalesId - we'll use this to join to SalesLine. This could be sessionId or whatever is required to ensure concurrency and uniqueness
Here's how the table would look in Visual Studio designer:

DEV_SalesLineHistoryPostingDataTmp table definition

We can now populate this table with the required values and join it to our query.
After executing the bulk insert we can then delete the inserted row (if necessary).


Another possible implementation here could be to use a View, with computed  columns for the different literal values needed. You could select from a table that has only 1 row, like InventParameters or the like. This is however less flexible, as it'll be compiled in, while with a "temporary" table you could determine the values at runtime.

6. Write up the code using the Query::insert_recordset() method

Now we are all set to write the necessary code. It would look like below:

public class DEV_Tutorial_InsertRecordset
{
    public static Counter insertQueryInsert_Recordset(SalesId _salesId)
    {
        DEV_SalesLineHistory    salesLineHistory;
        
        Query query = new Query();
        QueryBuildDataSource qbdsSalesLine = query.addDataSource(tableNum(SalesLine));
        qbdsSalesLine.addSelectionField(fieldNum(SalesLine, SalesId));
        qbdsSalesLine.addSelectionField(fieldNum(SalesLine, LineNum));
        qbdsSalesLine.addSelectionField(fieldNum(SalesLine, InventTransId));
        qbdsSalesLine.addSelectionField(fieldNum(SalesLine, SalesQty));
        qbdsSalesLine.addSelectionField(fieldNum(SalesLine, SalesUnit));
        qbdsSalesLine.addRange(fieldNum(SalesLine, SalesId)).value(queryValue(_salesId));
        QueryBuildDataSource qbdsInventDim = qbdsSalesLine.addDataSource(tableNum(InventDim));
        qbdsInventDim.addSelectionField(fieldNum(InventDim, InventLocationId));
        qbdsInventDim.addSelectionField(fieldNum(InventDim, InventSiteId));
        qbdsInventDim.relations(true);
        QueryBuildDataSource qbdsPostingData = qbdsInventDim.addDataSource(tableNum(DEV_SalesLineHistoryPostingDataTmp));
        qbdsPostingData.addLink(fieldNum(SalesLine, SalesId), fieldNum(DEV_SalesLineHistoryPostingDataTmp, SalesId), qbdsSalesLine.name());
        qbdsPostingData.addSelectionField(fieldNum(DEV_SalesLineHistoryPostingDataTmp, PostedDateTime));
        qbdsPostingData.addSelectionField(fieldNum(DEV_SalesLineHistoryPostingDataTmp, PostedBy));
        qbdsPostingData.addSelectionField(fieldNum(DEV_SalesLineHistoryPostingDataTmp, PostingType));

        Map targetToSourceMap = new Map(Types::String, Types::Container);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, SalesId),           [qbdsSalesLine.uniqueId(), fieldStr(SalesLine, SalesId)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, LineNum),           [qbdsSalesLine.uniqueId(), fieldStr(SalesLine, LineNum)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, InventTransId),     [qbdsSalesLine.uniqueId(), fieldStr(SalesLine, InventTransId)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, SalesQty),          [qbdsSalesLine.uniqueId(), fieldStr(SalesLine, SalesQty)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, SalesUnit),         [qbdsSalesLine.uniqueId(), fieldStr(SalesLine, SalesUnit)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, InventLocationId),  [qbdsInventDim.uniqueId(), fieldStr(InventDim, InventLocationId)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, InventSiteId),      [qbdsInventDim.uniqueId(), fieldStr(InventDim, InventSiteId)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, PostedDateTime),    [qbdsPostingData.uniqueId(), fieldStr(DEV_SalesLineHistoryPostingDataTmp, PostedDateTime)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, PostedBy),          [qbdsPostingData.uniqueId(), fieldStr(DEV_SalesLineHistoryPostingDataTmp, PostedBy)]);
        targetToSourceMap.insert(fieldStr(DEV_SalesLineHistory, PostingType),       [qbdsPostingData.uniqueId(), fieldStr(DEV_SalesLineHistoryPostingDataTmp, PostingType)]);

        ttsbegin;
        
        DEV_SalesLineHistoryPostingDataTmp postingData;
        postingData.PostedDateTime = DateTimeUtil::utcNow();
        postingData.PostedBy = curUserId();
        postingData.PostingType = DocumentStatus::Invoice;
        postingData.SalesId = _salesId;
        postingData.insert();
        
        Query::insert_recordset(salesLineHistory, targetToSourceMap, query);

        delete_from postingData 
            where postingData.SalesId == _salesId;

        ttscommit;

        return any2int(salesLineHistory.RowCount());
    }
}

As you can see, the first part of the code builds a query using the QueryBuild* class hierarchy. The query is identical to the above select statement, with the addition of another join to our "tmp" table to retrieve the literals.
The second part populates the target to source Map object, which maps the fields to insert into to their source.
The third part actually invokes the operation, making sure we have the record populated in our "tmp" table beforehand.

Note. Because the first argument is the record we insert into, we can use it to get the RowCount(), telling us how many records have been inserted.

7. Extensibility aspect

Leaving the code just like it is does not actually make it extensible, as the partners would not be able to add additional fields to copy, or additional conditions/joins to the query. To accomplish this, we'd need to break the logic out into smaller methods with specific responsibilities, or add inline delegates to do the same. Generally speaking, you should always favor breaking the code down into simpler methods over delegates.
I've not done this in the example, as that's not the purpose, but you should follow these guidelines in production code.

8. Execute and test the code

We can execute these methods now, but first we need to ensure we have a test subject, aka a Sales order to practice with. Here's the one I used in the example:

A sales order with 3 order lines
And here's the Runnable Class that invokes the two methods above:

class DEV_Tutorial_InsertRecordset_Runner
{        
    public static void main(Args _args)
    {        
        const SalesId SalesId = '000810';

        if (SalesTable::exist(SalesId))
        {
            Counter xppInsert = DEV_Tutorial_InsertRecordset::insertXppInsert_Recordset(SalesId);
            Counter queryInsert = DEV_Tutorial_InsertRecordset::insertQueryInsert_Recordset(SalesId);

            strFmt('Tutorial Insert_Recordset');
            info(strFmt('Inserted using X++ insert_recordset = %1', xppInsert));
            info(strFmt('Inserted using Query::insert_recordset() = %1', queryInsert));
        }
    }

}

You can download the full project here.


Hope this helps! Let me know if you have any comments.

Thursday, March 02, 2017

Extensible enums: Breaking change for .NET libraries that you need to be aware of

A while back I wrote a blog post describing the Extensible Enums - a new feature that is part of Dynamics 365 for Operations:
http://kashperuk.blogspot.com/2016/09/development-tutorial-extensible-base.html

I explained that when marking an enumeration as extensible, the representation of this enum under the hood (in CLR) changes. Here's the specific quote:
The extensible enums are represented in CLR as both an Enum and a Class, where each enum value is represented as a static readonlyfield. So accessing a specific value from the above enum, say, NumberSeqModule::Invent would under the hood look something like NumberSeqModule_Values.Invent, where Invent is of type NumberSeqModule which is an Enum. It would in turn call into AX to convert the specific named constant "Invent" to its integer enumeration value through a built-in function like the symbol2Value on DictEnum.
Something that was not super clear in the post is that this was actually a breaking change that might impact your .NET solutions relying on one of these base enumerations.

Problem statement

As part of enabling further extensibility in the application for the next release, we have made a number of additional base enums extensible.

Let's take enum TMSFeeType as an example. Assume we have made it extensible in X++. That means that in our C# project, where we use this enum, we will no longer be able to access it from Dynamics.AX.Application namespace by name, like so:

switch (accessorialFeeType)
{
    case TMSFeeType.Flat:
        // Do something
        break;

    case TMSFeeType.PerUOM:
        // Do something else
        break;
    // etc.
}

If you navigate to its definition, you will notice that the enum declaration is empty:

namespace Dynamics.AX.Application
{
    public enum TMSFeeType
    {
    }
}

The proper way to use the enum that is extensible is to reference the above mentioned class suffixed with _Values, which lives in the Dynamics.AX.Application.ExtensibleEnumValues namespace, like so:

if (accessorialFeeType == TMSFeeType_Values.Flat)
{
    // Do something
}
else if (accessorialFeeType == TMSFeeType_Values.PerUOM)
{
    // Do something else
}

Note: Because the values are now determined at runtime by going to the AOS and asking for the correct integer value of this enum, they cannot be used in a switch/case block, which expects constant expressions known at compile-time.

What's next

Obviously, this situation is not great. 
Let's hope that Microsoft will think of a good way to address this going forward.

Question to you

That leads to a question - how many of you actually have .NET libraries relying on application code in Dynamics 365 for Operations and might be impacted by us making some of the enums extensible in the next major release?

Wednesday, December 28, 2016

Configuration tip: Warehouse mobile devices portal Native application in Azure Active Directory

Setting up the Warehouse mobile devices portal is a relatively simple process, and is described in detail on the corresponding wiki:
https://ax.help.dynamics.com/en/wiki/warehouse-mobile-devices-portal-for-microsoft-dynamics-ax/

However, now and again I would get complaints around the authorization process not working properly, or asking for interactive login, both of which shouldn't be necessary for the authorization method used by WMDP.

So in this post I would like to give a small configuration tip that prevents the below error message:

Error: "The user or administrator has not consented to use the application with ID ''. Send an interactive authorization request for this user and resource.

The reason this error is shown is because the Native application setup in Azure for WMDP has access to resources that require the Azure admin to consent to that first.

WMDP app is configured to allow access to AAD resources
As you can see, some of the resources WMDP app has access to require prior Admin consent.

Recommendation: 

When configuring the Native application for WMDP in Azure, only give it permissions for the Microsoft Dynamics ERP resources.

This will ensure you don't get unexpected errors when trying to connect to WMDP.



Happy Holidays to all of you!

Stay tuned for more posts next year and subscribe if you haven't already to get updates on the latest tips and tutorials for Dynamics 365 for Operations aka Dynamics AX aka Axapta :)

Saturday, October 08, 2016

Tutorial: Visual Studio Debugger capabilities in Microsoft Dynamics AX '7', or the case-sensitive horror of C# syntax

Introduction

With the move to Visual Studio with the release of Microsoft Dynamics AX 7, the debugging experience also moved to use the standard Visual Studio debugger.
That means that we get some goodies that were previously not available in the MorphX debugger.

One example of that is the Immediate Window, which allows you to write expressions that are evaluated in the context of the currently hit breakpoint in X++ code.
This basically gives you the ability to call methods, look up variable values, ultimately allowing to change the current state. That is obviously a very useful feature.
Unfortunately, the current version does not fully support X++, meaning there are certain quirks when it comes to using it.

In this post I will describe the capabilities and syntax you need to use, so you can overcome some of the learning curve that comes with the new debugger.

Restrictions

Here's a list of the quirks you'll have to account for:

  • There is no native support for X++, so you need to use C# syntax.
  • Intellisense for X++ is not provided. This is a consequence of the way the expression evaluator works.
  • X++ is case insensitive, while C# is not. This means that references made to identifiers need to be in the case that was used at the place of definition.
  • Not all expressions allowed in X++ are applicable. One unfortunate example is select statements. You can however use static find() methods if they exist
  • Since it's not X++, you cannot use X++ types, like str, boolean, utcdatetime. Instead, use the C# equivalents. EDTs are not preserved during compilation either, so, again, use base types. Base enums is the only exception, but, again, you need to use C# syntax
  • When invoking methods from class Global, you will need to use the full notation, Global.methodName()
  • Single quotes are used to represent characters in C#, so you should only use double quotes for representing strings
  • The Expression evaluator has no knowledge of labels, so you will need to use workarounds, like SysLabel.labelId2String("@WHS1399"), if necessary.
  • Intrinsic functions like fieldNum() are not available - you'll need to use a workaround, as I will show below, using Microsoft.Dynamics.Ax.Xpp.PredefinedFunctions
  • You may end in a situation where the types you want to use are not loaded. You can use the ReflectionCallHelper to load these types – As soon as they are loaded you will be able to use them normally. Use the following command in the immediate window to load a particular type: Microsoft.Dynamics.Ax.Xpp.ReflectionCallHelper.getType("Global")
Now, with that out of the way, let's look at some examples.

Examples

Immediate Window capabilities in Microsoft Visual Studio for Dynamics AX 7
Let's walk through these examples line my line, and I'll explain what happened in each case:
  1. worKLine - as you can see, it's not a problem for the compiler, because X++ is not case sensitive, but it is a problem for the debugger, which is. So worKLine with capital K will not be recognized, while workLine will be treated just fine. This is the reason for one of the most confusing moments with the new debugger - hovering over the worKLine variable in the code editor will not show its value, even though everything looks fine and compiles. 
  2. Even more evident is the following example, where workLine.wMSLocationId value cannot be shown when hovering over it. Nor can it be recognized as an existing field in the Immediate Window. That's because it was defined as WMSLocationId on the table. Again, casing is very important in the new debugger, so pay attention when you write code
  3. Finally, success, we use the right record variable name and the right field name - so we got our result, the value of that field in the current record.
  4. We are trying to invoke a method which resides on the Global class exactly the same way it is done in the code we are debugging, but that won't work, the method is not recognized.
  5. Now we try to invoke it using the full notation, Global::exceptionTextFallThrough(); - That does not work either, because we must use C# syntax, and :: is only X++
  6. Finally, we use the right notation, invoking Global.exceptionTextFallThrough() - that works. The method does nothing and returns no result, and we are informed about that
  7. Trying to get the value of a Base Enum using X++ notation will not work
  8. Using the "correct" C# notation will return the right result, WHSWorkStatus.Open
  9. Microsoft.Dynamics.Ax.Xpp contains a number of helpful classes to compensate for lack of full X++ support. TrueFalseHelper is one of them, and its method TrueFalse() will use the X++ logic for evaluating if an expression is true or false. We use it here and pass in the record buffer. It returns true, because the record has been selected. In real C# that would fail, as the record cannot be implicitly converted to bool, along with most other X++ types, like str, integer, etc. 
    1. Another example from this namespace is EqualHelper.Equal() which can compare two X++ types
  10. Yet another example is the PredefinedFunctions class. You can see all the available methods in the Appendix. Here we invoke the tableName2Id(), passing in the string containing WHSWorkLine. Remember 'single quotes' do not work, only "double quotes". In this case all looks good, but the function is not recognized. That's again because of the casing. This class is very inconsistent about the casing of its methods - so you just have to remember the ones you commonly use, or use the robust "trial-and-error" approach.
  11. Finally, using the right casing we get the expected result, the ID of WHSWorkLine table

I have on purpose taken the full screenshot, so you could see some of the other windows open in Visual Studio while debugging:
  • Locals window, which is similar to the Watch window, but shows the values for all local variables without you first adding them to the list. 
  • The Infolog window will show all the infolog messages, which is very convenient
  • The Callstack is pretty much the same as in X++, with the downside of showing the full types, meaning you see a lot of useless type namespace information which X++ developers are not used to
  • The Breakpoints window shows all of your breakpoints, and you can for each one decide to configure it further, disable it or remove it. You can now make the breakpoints conditional, however since it uses the same Expression evaluator, I had trouble with it, so I stopped using it after a while. The counter condition works fine though, so you can use that in various complex loops and stuff, setting the breakpoint inside the loop.
  • Autos window, which is supposed to show the current line variables plus any from the previous line is useful, because it shows the global state variables on top of that, ttslevel in particular. Company, Partition and UserId are of lower interest.
  • Watch window - that's as expected, you add variables, their values are shown and can be edited on the fly. Note all the above restrictions apply here as well, so watch the casing and syntax.

Conclusion

As you can see, the Visual Studio debugger is much more powerful than what we had in AX 2012 and prior, however it also has a number of limitations due to lack of support for X++ language. Note, that it's not just X++, other languages which you can use in VS also have problems here and there.

Let me know how you find the new debugger. What features do you like? Something you miss from the old days?

Appendix

This appendix lists the predefined functions in the Microsoft.Dynamics.Ax.Xpp.PredefinedFunctions class. Pay special attention to the casing for the below methods.

Note. The methods starting with q deal with containers.
  • decimal Abs(decimal arg);
  • decimal AcceleratedDepreciation(decimal price, decimal scrap, decimal life, int period);
  • decimal Acos(decimal arg);
  • void AddToContainer(object element, int index, object[] container);
  • object Any2Enum(object a);
  • Guid any2guid(object input);
  • Date Anytodate(object arg);
  • decimal Asin(decimal arg);
  • object[] AssignPlusToContainer(object element, object[] container);
  • decimal Atan(decimal arg);
  • void Beep();
  • void catchUCDK(int ttsCount);
  • IDisposable changecompany(string newCompany);
  • int Char2Num(string text, int position);
  • int classget(object value, int classIdByType);
  • string ClassId2Name(int classId);
  • int classidget(XppObjectBase obj, int objId);
  • int ClassName2Id(string className);
  • int CompareStrings(string l, string r);
  • int ConfigurationKeyNum(string configurationKey);
  • object ContainerPack(object element);
  • object ContainerUnpack(object element);
  • decimal ContributionRatio(decimal sale, decimal purchase);
  • decimal corrflagset(decimal real, int arg);
  • decimal Cos(decimal arg);
  • decimal Cosh(decimal arg);
  • string curext();
  • string curusrid();
  • int Date2Num(Date date);
  • string Date2Str(Date date, int sequence, int day, int separator1, int month, int separator2, int year);
  • string Date2StrConvert(Date date, int sequence, int day, int separator1, int month, int separator2, int year, int convert_to_calendar);
  • string Datetime2Str(utcdatetime d, int f);
  • string DayName(int number);
  • int Dayofmth(Date d);
  • int DayOfWeek(Date arg);
  • int Dayofyr(Date d);
  • decimal Decround(decimal figure, int decimals);
  • object DefaultValue(Types t);
  • string dellspc(string text);
  • IntPtr delprefix(IntPtr value);
  • string delrspc(string text);
  • string delstr(string text, int position, int number);
  • decimal Depreciation(decimal price, decimal scrap, decimal life, int period);
  • int Dimof(object o);
  • Date EndMonth(Date arg);
  • string Enum2Str(object e);
  • string EnumExtension2Str(object value, string enumTypeName);
  • int Enumname2id(string enumName);
  • int EnumSymbol2EnumValue(string enumName, string enumValueName);
  • string EnumTypeToString(Types type);
  • decimal Exp(decimal arg);
  • decimal Exp10(decimal arg);
  • string Fieldid2name(int tableId, int field, int arrayIndex);
  • string Fieldid2pname(int tableId, int field, int arrayIndex);
  • int Fieldname2id(int tableId, string fieldName);
  • void FillArray(int size, T value, Dictionary array, T zeroValue);
  • void FillEdtArray(int size, T value, EdtArray array);
  • string FldPNam(int dataset, int fieldnum);
  • void Flush(int dataset);
  • decimal formattedstr2num(string text);
  • decimal Frac(decimal arg);
  • decimal FutureValue(decimal Payment, decimal Interest, decimal Life);
  • string getbuildversion();
  • string getcurrentauthor();
  • string getcurrentbranchname();
  • string getcurrentcustomerid([Optional, DefaultParameterValue(0)] int dbFlag);
  • string getcurrentdevicename();
  • string getcurrentipaddress();
  • string getcurrentmachinename();
  • long getcurrentpartitionrecid();
  • Guid getcurrentrequestid();
  • string getcurrentruntimemessage();
  • string getcurrentserviceunitid();
  • string getcurrentserviceunittype();
  • string getcurrentsessionid();
  • string getcurrenttenant();
  • string getcurrentuserid();
  • string getcurrentuserlanguage();
  • Dictionary GetFieldQCollection();
  • T GetFromArray(int position, Dictionary array, T zeroValue);
  • Date GetNullDate();
  • utcdatetime GetNullDateTime();
  • string GetNullString();
  • string getprefix();
  • void GroupQ(Dictionary collection);
  • string Guid2Str(Guid value);
  • decimal Idg(decimal purchase, decimal contribution_ratio);
  • string image(object o);
  • string Indexid2name(int tableId, int index);
  • int Indexname2id(int tableId, string indexName);
  • string insstr(string text1, string text2, int position);
  • string int2str(int param);
  • string int642str(long param);
  • int IntervalMax(DateTime inputDate, DateTime refDate, int func);
  • string IntervalName(DateTime refDate, int col, int func);
  • int IntervalNo(DateTime inputDate, DateTime refDate, int func);
  • DateTime IntervalNorm(DateTime inputDate, DateTime refDate, int func);
  • int intvmax(Date input_date, Date ref_date, int func);
  • string intvname(Date d, int col, int func);
  • int intvno(Date input_date, Date ref_date, int func);
  • Date intvnorm(Date input_date, Date ref_date, int func);
  • bool IsNonEmpty(string s);
  • int LicenseCodeNum(string licenseCode);
  • bool Like(string arg1, string arg2);
  • decimal Log10(decimal arg);
  • decimal Logn(decimal arg);
  • string LookupLabel(string pattern);
  • int Match(string pattern, string text);
  • object Max(object[] args);
  • object Min(object[] args);
  • Date Mkdate(int day, int month, int year);
  • string MonthName(int number);
  • int Mthofyr(Date d);
  • Date NextMonth(Date arg);
  • Date NextQuarter(Date arg);
  • int nextTraceSequence();
  • Date NextYear(Date arg);
  • bool NullDate(Date d);
  • bool NullDateTime(utcdatetime d);
  • bool NullGuid(Guid g);
  • string Num2char(int figure);
  • Date Num2Date(int days);
  • string Num2Str(decimal number, int character, int decimals, int separator1, int separator2);
  • string ObjectToString(object o);
  • void OrderQ(Dictionary collection);
  • decimal PercentAdd(decimal amount, decimal percentage);
  • decimal Periods(decimal payment, decimal interest, decimal future_value);
  • decimal PeriodsRequired(decimal Interest, decimal FutValue, decimal PresValue);
  • decimal Power(decimal arg, decimal exponent);
  • decimal PresentValue(decimal Paym, decimal Interest, decimal Life);
  • Date PreviousMonth(Date arg);
  • Date PreviousQuarter(Date arg);
  • Date PreviousYear(Date arg);
  • decimal PricePerPeriod(decimal principal, decimal interest, decimal life);
  • object[] qdel(object c, int position, int numElements);
  • int qfind(object c, object[] parameters);
  • object[] qins(object c, int position, object[] parameters);
  • int qlen(object[] container);
  • object qpeek(object c, int position);
  • object[] qpoke(object c, int position, object[] parameters);
  • decimal Rate(decimal future_value, decimal current_value, decimal terms);
  • string remove(string text1, string text2);
  • decimal Round(decimal dbl0, decimal dbl1);
  • void SecAuthzCheck(string className, string methodName);
  • int sessionid();
  • void SetInArray(int position, T value, Dictionary array, T zeroValue);
  • int setprefix(string prefix, ref IntPtr ptr);
  • decimal Sin(decimal arg);
  • decimal Sinh(decimal arg);
  • int Sleep(int duration);
  • decimal Sln(decimal cost, decimal salvage, decimal life);
  • int Sound(int frequency, int duration);
  • Date Str2Date(string text, int sequence);
  • utcdatetime Str2Datetime(string text, int sequence);
  • object Str2Enum(object e, string s);
  • object Str2EnumExtension(object dummyParm, string valueName, string enumTypeName);
  • Guid str2guid(string input);
  • int str2int(string text);
  • long str2int64(string text);
  • decimal Str2Num(string text);
  • int str2time(string text);
  • string StrAlpha(string text);
  • string strcolseq(string text);
  • int StrFind(string text, string characters, int position, int number);
  • string strfmt(string text, object[] parameters);
  • Types StringToType(string t);
  • string StrKeep(string text1, string text2);
  • int Strlen(string text);
  • string StrLine(string s, int count);
  • string Strlwr(string text);
  • int StrNFind(string text, string characters, int position, int number);
  • string StrPoke(string arg1, string arg2, int position);
  • string StrPrompt(string _string, int _len);
  • string StrRep(string text, int number);
  • int StrScan(string text1, string text2, int position, int number);
  • string Strupr(string text);
  • string Substr(string text, int position, int number);
  • Date systemdateget();
  • Date systemdateset(Date d);
  • string Tableid2name(int tableId);
  • string Tableid2pname(int tableId);
  • int Tablename2id(string table);
  • string TabPNam(int dataset);
  • decimal Tan(decimal arg);
  • decimal Tanh(decimal arg);
  • string Time2str(int time, int separator1, int separator2);
  • int Timenow();
  • Date Today();
  • decimal Trunc(decimal arg);
  • void truncate_infolog();
  • int TryStart(ref IntPtr ptr);
  • void ttsabort();
  • void ttsbegin();
  • void ttscommit();
  • int ttscount();
  • int Typename2id(string typeName);
  • Types Typeof(object o);
  • string uint2str(int param);
  • IDisposable Unchecked(int uncheckValue, string className, string methodName);
  • int WeekOfYear(Date arg);
  • void Where(exprNode node, Common table);
  • int Year(Date d);

Thursday, October 06, 2016

Development tool: Copy 'Find references' results to clipboard (to Excel) in Visual Studio for AX 7

Problem

With the release of Microsoft Dynamics AX 7 the development moved to the Visual Studio environment.

This had a lot of advantages, like the ability to use any 3rd party add-ons of various sort (which we actually have not seen that many of being applied to AX so far), all the VS goodies that come out of the box, a more familiar IDE for new developers, etc.

But it also has its disadvantages, like the Cross-References display window.

  • First of all, the indicator of whether a particular reference is writing or reading is gone. 
  • And we now display the xRefs using the standard Visual Studio "Find Symbols Results" window, which has one huge drawback as well - no way to filter on the data displayed, or copy that data somewhere else to do that.

Solution

I was bothered by this lack of functionality for a while, so I went out to find if there's an existing solution. I came across this post, which seems like a sufficient solution for the problem, in my opinion. Kudos to the author!

I have modified the project a bit to better suite AX needs, as I planned to browse the data in Excel, which has rich filtering capabilities and more convenience in navigation, and uploaded it to GitHub so anyone can use and extend it. You can also just download the executable, if are OK with the out-of-the-box functionality I will describe below.

Project on GitHub:

Executable on my OneDrive:

Installation guide

Once you have the executable, place it in a folder on your environment running Visual Studio for AX, say, C:\Tools or whatever you prefer.

Now, from Visual Studio, go to Tools and select External tools..., as shown below:

External Tools... under Tools menu in Visual Studio

Now add a new tool by clicking Add, and specify the Title, Command and Initial directory.
The Command  should contain the path to the CopyFindReferencesToClipboard executable

Add the CopyFindReferencesToClipboard tool

User guide

Using the tool is very simple. Say I wanted to find all references to the WHSLoadLine.Qty field.
I would navigate to that field and select Find references from the context menu. 

Find references to the WHSLoadLine.Qty field
This would bring up the standard Visual Studio dockable window Find Symbol Results, containing all the references to the selected table field.

Now, all you need to do is go to Tools and select the newly added tool from the list, as shown below:

Run the tool to copy the references to clipboard
After a few seconds you will get a message box to pop up telling you the references have been copied to clipboard successfully, which means you can now to and paste the data to Microsoft Excel.



Note. Since the tool uses UI-level automation to copy the cross-references, the Find references window needs to be open and visible for the tool to work.

Now, you can do whatever you want with that data in Excel.
The way I typically use it is by just showing the data as a Table, after which:
- Exclude test related files (Actual tests and Test frameworks we used)
- Filter out only elements in a certain area, like WHS
- Build pivot tables/charts, if I am doing complexity analysis for a change / feature
- etc.

Here's how it looks:

Analyze the cross-references in Microsoft Excel

Feeback

Give it a try and let me know what you think!

Sunday, October 02, 2016

Tutorial: Extending the label printing functionality in Microsoft Dynamics AX 7

Today I would like to shed a bit more light onto how to extend the label printing functionality, specifically, show how to add a new field from the work line to be displayed on the label.

This is something we have created as a demo for Tech Conf a while back, and you can still see the recording where Per and Zach from our team showcases the different label printing scenarios.
You can view the different Microsoft conference videos here.

Just as a refresher, here is a guide for how to set up label printing in Dynamics AX 7:
http://kashperuk.blogspot.dk/2016/10/tutorial-label-printing-in-microsoft.html

Technical introduction

The label printing "framework" consists of three main components:

  • WHSLicensePlateLabel table, which contains the information for the label, which is substituted into the label through the variables.
  • WHSLicensePlateLabelBuild class, which is responsible for populating the WHSLicensePlateLabel table with data for a particular work order (line).
  • WHSDocumentRouting class, which is responsible for the actual printing of the label, as well as the substitution of variable values, using the below methods:
    • initMenuFields() method is responsible for building the list of substitute fields based on the WHSLicensePlateLabel table fields
    • printDocument() method finds the specific document routing record that matches the flow criteria (e.g., warehouse, work order type, carrier, etc.), performs the translation of the label and sends it to the selected printer (Note that the label can be printed to multiple printers and you can for each printer decide, which layout to use).
    • translate() method is responsible for actually replacing any variables in the label layout with the corresponding values from the WHSLicensePlateLabel table
Let's take a closer look at the fields available on the WHSLicensePlateLabel table:

WHSLicensePlateLabel table fields
These fields represent the full list of potential variables that can be put into the label in the Document Routing layout. Depending on the item flow, some of these fields are not populated. Note that when setting up the placeholders in the Document Routing Layout form, only visible fields are displayed. That allows for storing some "plumbing" information for each record, like the PrinterSettings.

Adding placeholder variables to a label layout

Fragile Example extension

For some scenarios you might want to display some additional fields on the label, which are not part of the list shown above.

Imagine a scenario where we want to display a "Fragile" message on the label if the items on the work line are accordingly marked. 

Since there is no such field in the label, we will need to extend this functionality. And because we are working in AX 7, we will try to use Extension instead of Overlayering the elements, as we would do in AX 2012 R3.

Step 0 - Create a new model for our changes

First of all, let's create a new model, which will contain all the elements we add through extension.

Create a new model in Visual Studio
We will put this model into a new package, as we do not intent to overlayer existing AOT elements. We will reference the Application Suite package, so that we can access the elements defined in it, such as the ones mentioned above. 
Note that by default the new package will also reference Application Platform.

Select referenced packages for model FragileExample
Confirm the creation of the new model and create a new project for it. Name it FragileExample.

Step 1 - Extend the WHSLicensePlateLabel table and add a new field FragileCode

Since we want to display an extra field in the label, we need to add it to the WHSLicensePlateLabel table. In order to do that, let's create an extension of this table in the new model:

Adding a new table extension from Application Explorer
You need to have selected the right project in your Solution Explorer before you attempt creating an extension. This is to ensure that the extension is actually created in the right model.


We are now going to add a new field of base type String, and call it FragileCode with the corresponding label.

Note I am not going to bother with labels or Extended Data types for sake of simplifying the demo

Table extension for WHSLicensePlateLabel in FragileExample model
New field FragileCode on table extension WHSLicensePlateLabel.Extension
In order for the field to appear in the Document routing layouts form all we need to do is compile the new code and synchronize it against the database. I have decided to do that at build time by configuring it accordingly, as shown below:

Synchronize on build = true for FragileExample project
If we now reopen the Document routing layouts form, we can see that our new placeholder is available in the list and we can add it to the label:

Fragile code placeholder available in Document routing layouts form

Step 2 - Populating the Fragile code field on WHSLicensePlateLabel record

Now that we have the field available on the label, we need to populate it. 
As described in the Technical information section above, the label details are populated in the class WHSLicensePlateLabelBuild. Let's quickly examine how this happens:

Sequence of calls to build a license plate label
As you all know, any and all work creation and execution in Dynamics AX happens through the WHSWorkExecuteDisplay* classes, e.g., purchase order registration can execute WHSWorkExecuteDisplayPOItemReceiving, while general work execution flows can execute WHSWorkExecuteDisplayUserDirected, etc.
Within these classes the WHSLicensePlateLabelBuild class is initialized, and the buildLicensePlateLabels() public method is called, when a label needs to be generated and printed.
Depending on the flow, either the insertSingleLabelPrintLine or the insertSingleLabelMenuItem method will be invoked internally, after which the generated label is printed through the printDocument API of the WHSDocumentRouting class, as described above.

Note. insertSingleLabelPrintLine supports the flow where the Print step is part of the work template lines, while insertSingleLabelMenuItem is for the case, where the Print is configured through the mobile device menu item.

So, in order for us to populate the extra new field we need to look into these insert* methods. With the recent AX 2012 R3 changes these methods have been refactored in a way, where most of the logic for populating the WHSLicensePlateLabel table has been pulled out into a new private initLabel() method.

In Dynamics AX 7 this method has been extended (who could have done that? :)) and now has a delegate which is invoked at the end of the method, which means that event handlers can be created for it. With these minor changes, we can now extend this class by subscribing to the event of the initLabel method being invoked, as shown below:

Copy event handler code for labelInitialized delegate
We can now put this generated event handler signature into a new class in our model, as shown below:

Signature of the event handling method for labelInitialized delegate
This is awesome and is one of the new extensibility language features in Dynamics AX 7 - I have subscribed to the event of this method being called without touching the original class at all.
As you can see, instead I have a SubscribesTo attribute that subscribes me to the corresponding event in a static fashion. 

Now all we need to do is actually populate the Fragile code on the label. Let's do that:

Implementation for the event handling method for labelInitialized delegate
As you see above, the code is super simple, all we do is get the Filter code value from the corresponding item, and populate it into the FragileCode field.

Let's now compile everything and give it a go in AX web client.

You can download the VS project file from my OneDrive here.

AX flow steps and result


  1. Modify item A0001, setting Filter Code 4 to a new value called FRAGILE
  2. Create a new purchase order for 10 pcs of A0001 to WH 24
  3. Modify the Mobile device menu item for Purchase receipt to "Print label"
  4. Ensure you have a Document routing record matching WH 24 for Purchase orders
    1. Ensure you have a document routing layout being sent to a label printer
  5. Receive the above purchase order through the WMDP
As a result, you should get a new label created, which will have the Fragile code populated with the value FRAGILE, as below (I've personalized the form to show the new field):

License plate label with the populated Fragile code

Depending on your label layout you would have this message printed on the label as well.

Conclusion


With this blog post I hoped to show you how to extend the existing set of fields available on the license plate labels.

At the same time we took a look at some of the new language constructs, design paradigms and tooling that allows for a much cleaner approach to extending existing functionality, as compared to overlayering, which many are used to.

Consider extending instead of overlayering next time you need to make a change!

Saturday, October 01, 2016

Tutorial: Label printing in Microsoft Dynamics AX 7

A while back I posted a tutorial on how to configure and use the label printing functionality in Microsoft Dynamics AX 2012 R3:

http://kashperuk.blogspot.dk/2014/09/printing-labels-with-new-warehouse.html

With the release of Microsoft Dynamics AX 7 and the move to the Cloud, printing has become a bit more complicated than before, since the printers are not on the same domain / network as it used to be when everything was installed on-premise.

The major change that I will talk about in this post is the way we set up printers now.

Installing & configuring the Document Routing Agent

The wiki article describing the installation and configuration of the Document routing agent is very well written and contains a lot of details about the restrictions and requirements for this to work, so I will not repeat it here. Here is the link:


For example, one of the unexpected requirements is that Adobe Acrobat Reader is installed.

Note, that with a recent update of the AX platform, the Document Routing Agent can now be run in the background as a Windows service, which adds a number of benefits. Read more about that in the below wiki article:



Now that the document routing agent application / service is installed and can sign in to AX, you can select the printers you want to expose, activate them in the Network printers form, as described in the above wiki article.

After that, they will show up in the Printer name lookup on the Document Routing form as before.

Everything else is pretty much the same from a user standpoint, nothing changed in terms of document routing configuration or the WMDP configuration.

So when a label needs to be printed, here's what happens now:
  1. The label is generated in the ZPL code, as before, containing all the replaced variables from the document routing layout
  2. It is then saved in a file in the Azure Blob Storage (since the AOS does not have access to the client machine, we cannot just go and do something with it) in a pre-configured folder, including all the relevant settings, specifically, which printer to use for this label
  3. The Document Routing Agent application / service on the network printer server or just as a local application one of the network PCs will periodically query if there are any pending files to be printed, downloads them from the Azure Blob storage and, depending on the printer settings defined for the specific file, re-routes the file to be printed, whether that is to a Zebra printer, or, in the case of regular SSRS reports, to a PDF document, or a regular printer.

Note that before Platform Update 2 there was an unpleasant bug in this framework, which prevented printing of labels from the Warehouse Mobile Devices Portal. That has since been fixed, and you should be able to print labels without any problems (Workaround for people on earlier installations is to use the WMDP enumlator form from AX web client)

Give it a try and report back here in case you find some of the instructions unclear, or if something is not working according to your expectations.

Thanks!

Friday, September 30, 2016

Announcement: Dynamics 365 general availability is just around the corner

On Wednesday, July 6 we announced Microsoft Dynamics 365, the next generation of intelligent business applications in the cloud.

Now it is time for the Dynamics 365 public launch event.

Join online on Tuesday, October 11 at 11:30 AM Pacific Time (UTC-7) and be among the first to see our next generation of business applications—helping your organization to grow, adapt, and evolve.

https://www.microsoft.com/en-us/dynamics/dynamics-365-first-look

We hope you love it as much as we do!

Tutorial: Location directive failures - Common mistake #1 - Multi SKU

Preface

A lot of people that are getting to know the new Warehouse solution are struggling to set up the location directives correctly. They very often end up scheduling some work and not getting the location automatically selected by the system.

A good example is this post on the AX community where I attempt remote location directive setup data troubleshooting: https://community.dynamics.com/ax/f/33/p/141641/309594#309594

In this next series of posts I want to cover some of the more common errors people do when setting up the location directives and explain ways you can try to figure out why a location was not pre-selected automatically, if that happens.

You can read the basic information about creating location directives on MSDN, in these posts I will assume good knowledge of the concept.

Common mistake #1 - Multi SKU

This is one of the most common problems reported as "location directives are not working and I can't figure out why, everything seems right".

As most of you have probably seen on your installations, you needed to setup 2 location directives for Put for each configuration, one with Multi SKU, the other without. Often, because people don't really understand when this setting comes into play, they don't create both, but only the single sku, or only the multi sku, thus resulting in location not being assigned (allocation failing).

So here is a brief explanation:

As you know, when work is being created for an order, whether it is a sales, a purchase or any other supported order, the location directives are used to determine WHERE to pick/put the item(s). So for each individual work line location directives will be evaluated, and a location will be selected based on the work line details. The location directives will be evaluated sequentially from all the ones that meet the requirements for the particular work line (e.g., they are for the right warehouse, have the right details based on the query, etc.)

Note. There are special cases where the location to Pick/Put is pre-selected. For example, during PO registration the first pick is always from the RECV location. Another example is the Inventory movement by template, where the location to Pick from is selected by the worker himself, and only the puts are done through location directives.

One of the criteria evaluated on the location directive is the Multi SKU flag. The location directive which has Multi SKU flag selected will only be considered, if the corresponding work line being processed right now has Item number = 'Multiple' (it shows up as blank in the work details, but as "Multiple" on the mobile device). This is typically the case for when you need to put items down to a Staging / Pack / QMS areas, after having picked up multiple items from the different picking locations in the warehouse.

Therefore, if Multi SKU flag is selected for a location directive, you cannot edit the query for it. That makes certain sense, since this location directive would only apply to multiple items, thus you cannot really check if they fall withing the conditions of the query, because there are more items than one.

The Multi SKU flag mostly makes sense for Put location directives, because for most of the picks we are in a situation where it is:

  • an initial pick (thus, pick of 1 particular item)
  • a special pick of one or more items from a pre-defined location (like RECV I mentioned above)
  • a special pick where we continue work execution after having previously put the item(s) down to a certain location (STAGE is the typical example, since we'll need to continue to BAYDOOR after that)

Location directives do not prevent you from setting the Multi SKU flag for Picks, however, so if there's a certain scenario where you do need this, it'll be available.

Short summary

In short, if your work orders are always small, 1 item per order, then you do not need a Multi SKU location directive.
If, however, you have work orders with single items, and then some with multiple items being picked as part of one work order, you will need to have 2 separate location directives, one with and one without the Multi SKU flag, and then you can decide if you want the goods travelling to a different location in such cases.

Example walkthrough

To show the difference between the behavior of the location directives with and without Multi SKU, I have created the following location directives, which are identical apart from the flag, as well as the location to Put, so we can see the result difference. Again, only the two Put location directives are really of interest. I have chosen to show a Sales scenario, but the same applies for all other flows.

Location directive for sales Pick work lines on WH 24
Location directive for single-item sales Put work lines on WH 24

Location directive for multi-item sales Put work lines on WH 24
To summarize, the intention is that work order with single items will be put to BAYDOOR location, while multi-item orders will be put down at BAY_MULTI location.

Note. Both Put location directives have the Directive code set to Baydoor. Discussing the behavior of this field is outside the scope of this blog post, but will be covered in one of the following posts.

Now, I have created the following 4 sales orders and released each one individually to create work. I have created sufficient on-hand for picking these items. Sales orders have the following configuration:

  1. 1 line for a single item
    1. 10 pcs of A0001
  2. 2 lines for different items
    1. 10 pcs of A0001
    2. 10 pcs of A0002
  3. 2 lines for the same item
    1. 10 pcs of A0001
    2. 10 pcs of A0001
  4. 2 lines for different product variants (meaning, same item number, but different product dimensions)
    1. 10 pcs of P0004, Size = L
    2. 10 pcs of P0004, Size = S 
You can see the screenshots for work orders created, in the above order, below:

Work for sales order with a single item
Here, location BAYDOOR was used. That is correct, since the Put work line is for a single item, A0001.

Work for sales order with multiple different items
Here, location BAY_MULTI was used. That is correct, since the Put work line is for two items. You can see the Item number is blanked out on the work order line in this case.

Work for sales order with multiple lines for same item
Here, location BAYDOOR was used. That is correct, since the Put work line is for a single item, even though the quantity for this line is a combination of more than 1 work line.

Work for sales order with multiple product variants
Here, location BAY_MULTI was used. That is correct, since the Put work line is for multiple product variants. So even though the Item number stamped on the Put line is just 1 item, the product dimensions are blanked out since there is more than one.

This is one of the "confusing" parts, as the rule is not just a simple "item number is not filled in".
We treat product variants as different products in this regard, so it is important to note this particular difference.

Next step

Stay tuned for the next common mistake in one of the next posts.