One of the major innovative features we introduced with the R3 release of Microsoft Dynamics AX 2012 was the advanced warehousing system with the warehouse mobile devices portal and the subsequent support for using embedded devices for efficient warehouse workflows. The key assumption with this technology was that warehouse workers would be scanning barcodes for various tasks, including, but not limited to, item identification, license plate selection, location verification, etc.
Specifically for item identification, whenever an ItemID field is presented in the mobile device screen, we allow the user to scan (or manually enter if necessary) a barcode. That barcode is then used to lookup several different values to try to identify which product has been scanned. However – this simple functionality did not support product variants and was not very extensible for other product identifiers or barcode types. As such, in the CU8 release we re-imagined this barcode scanning framework and came up with a solution that supports product variants and partner/customer customizations in a more robust way. This blog post will discuss this framework and show how it can be extended.
Purchase Order Receiving with Barcodes
First let's walk through two common scenarios. In the first you have a Purchase Order with multiple items you want to receive, such as the following:
When you receive this Purchase Order in the warehouse using the mobile device interface you will be asked which item you are receiving. To ensure productivity we want the warehouse worker to scan a barcode and not key in data on the device.
So how can we ensure that the warehouse worker can scan a barcode and have the system recognize the appropriate item? As you will see shortly, there are multiple identifiers that can be used here, but let us first examine how the actual barcode functionality within AX would be used. Within the Product information management module we can configure barcodes for our items. In the image below you can see that I have setup a barcode for each of the items we are concerned with.
With this configuration I can now scan (or manually enter) the barcodes and the system will know which item I am receiving.
Note that this process would also work with a product variant, and the barcode can be used to identify exactly which product variant I am receiving.
Product Confirmation with Barcodes
Another scenario that we have enabled is the ability to perform product confirmation through the mobile device interface. This is detailed in a blog post here. The basic idea is that you want the warehouse worker to be forced to scan something to ensure they have picked the correct product and/or from the correct location. This can help reduce errors in the warehouse – but we still want to ensure productivity is as high as possible. In this vein we have enabled the same barcode scanning framework discussed above into this process – allowing the warehouse worker to scan a barcode which is then used to lookup the product in a variety of contexts.
For example, if we have product confirmation enabled and we have some Sales Order picking work to complete on the mobile device, we would see the following screen:
In that second Item field, we could scan the barcode we defined above (even if this was a variant), to proceed with the pick. However – we are not just limited to the barcodes defined with AX, we actually have other identifiers which might make more sense to uniquely identify this product.
Item Scanning Framework
To enable the above scenarios, and others like it, we released a set of classes as part of CU8 that manage our item identification search process. This is designed as a flexible and extensible framework – with individual search processors chained together, each one looking for data in a particular area. Once one of them finds matching data, a "SearchResult" object is returned to the caller.
On a CU8 box, if you look at all the classes that start with InventInventItemSearch* you will find this small framework:
- InventInventItemSearch
- Main entry point to the search functionality – also creates the child search processors and iterates through the chain
- InventInventItemSearchProcessor
- Base class for creating new search processor functionality
- InventInventItemSearchBarcode, InventInventItemSearchExternal, InventInventItemSearchGTIN, InventInventItemSearchItemAlias, InventInventItemSearchItemId
- Search processors we ship in CU8 for looking up item identifiers in various locations in the application
- InventInventItemSearchResult
- Rich object used to return the data found
When the search method is invoked on the InventInventItemSearch class, the following sequence of SearchProcessors is used to look for a matching item (I have also included the field used to lookup the data):
- InventInventItemSearchItemId
- InventTable.ItemId
- InventInventItemSearchItemAlias
- InventTable.NameAlias
- InventInventItemSearchBarcode
- InventItemBarcode.ItemBarCode
- InventInventItemSearchExternal
- CustVendExternalItem.ExternalItemId
- InventInventItemSearchGTIN
- InventItemGTIN.GlobalTradeItemNumber
When a warehouse user scans a barcode to lookup a product, we don't simply look in the barcode table. Instead we iterate over the set of defined search processors and look for a matching record in the various defined tables. Practically speaking, this means the warehouse worker could scan/enter identifiers such as the Item Alias or External identifier – which may be more appropriate for certain items.
This is utilized on the mobile device within the WHSRFControlData::processControl method:
case #ItemId:
if (_data != "@WAX402")
{
localInventItemSearch = InventInventItemSearch::construct();
localInventItemSearchResult = localInventItemSearch.search(_data);
if (localInventItemSearchResult)
{
itemId = localInventItemSearchResult.parmItemId();
itemInventDim = InventDim::find(localInventItemSearchResult.parmInventDimId());
}
else
{
errorMessage = "@SYP5070015";
hasError = true;
break;
}
if (_data != "@WAX402")
{
localInventItemSearch = InventInventItemSearch::construct();
localInventItemSearchResult = localInventItemSearch.search(_data);
if (localInventItemSearchResult)
{
itemId = localInventItemSearchResult.parmItemId();
itemInventDim = InventDim::find(localInventItemSearchResult.parmInventDimId());
}
else
{
errorMessage = "@SYP5070015";
hasError = true;
break;
}
Note that if a barcode has been scanned that supports identifying the product dimensions the InventDim will also be returned – and thus can be used to determine the exact variant being scanned.
Extension and Customization
While this is interesting in itself, what I really want to discuss is the ability for our partners and customers to extend this framework to suit their specific business needs. We will cover two areas of customization that are available to you: 1. adding a new search processor to handle a specific barcode format, and 2. Extending the search result object to enable more information to be retrieved from barcode formats.
Creating a New Search Processor
A common request we see is the ability to read out product identifiers from within the GSI-128 barcode format. In these cases the barcode could be encoding a lot of other information related to the product, such as batch, expiration dates, etc – and we may only want to extract the product identifier out of a fixed location within the barcode format. This is a perfect example of something that is easy to add to AX via a custom search processor.
Let's say that our products have long barcodes such as the following example: 02050450004144213736922724210090801. Perhaps the 13-character product identifier is embedded within this barcode format starting at the 4th character (5045000414421). How would we build a processor to parse this data correctly?
We would first add a new class to AX extending the InventInventItemSearchProcessor base class. We would then need to implement the search method, which is what is invoked by the search framework as it iterates over the available SearchProcessors. The actual code for performing the string slicing is very simple – and the entire SearchProcessor is below:
public class InventInventItemSearchBarcodeEAN128 extends InventInventItemSearchProcessor
{
public InventInventItemSearchResult search(InventInventItemSearchItem _searchValue)
{
InventItemBarcode inventItemBarcode;
if (strLen(_searchValue) > 16)
{
inventItemBarcode = InventItemBarcode::findBarcode(subStr(_searchValue, 4, 13), false,true);
}
return inventItemBarcode.RecId == 0 ?
null :
InventInventItemSearchResult::newFromParams(inventItemBarcode.ItemId,
InventDim::find(inventItemBarcode.InventDimId),
inventItemBarcode.UnitID);
}
}
{
public InventInventItemSearchResult search(InventInventItemSearchItem _searchValue)
{
InventItemBarcode inventItemBarcode;
if (strLen(_searchValue) > 16)
{
inventItemBarcode = InventItemBarcode::findBarcode(subStr(_searchValue, 4, 13), false,true);
}
return inventItemBarcode.RecId == 0 ?
null :
InventInventItemSearchResult::newFromParams(inventItemBarcode.ItemId,
InventDim::find(inventItemBarcode.InventDimId),
inventItemBarcode.UnitID);
}
}
The other change that is required is to add our new SearchProcessor to the list of available SearchProcessors. This is done in the InventInventItemSearch::createSearchProcessors method. Note that when you customize this list you can choose where in the sequence your new SearchProcessor should process the data – and if you want to remove SearchProcessors that might not be relevant to your specific scenario. In this case I am adding our new SearcgProcessor to the head of the list:
protected void createSearchProcessors()
{
searchProcessors = new List(Types::Class);
searchProcessors.addEnd(new InventInventItemSearchBarcodeEAN128());
searchProcessors.addEnd(new InventInventItemSearchItemId());
searchProcessors.addEnd(new InventInventItemSearchItemAlias());
searchProcessors.addEnd(new InventInventItemSearchBarcode());
searchProcessors.addEnd(new InventInventItemSearchExternal());
searchProcessors.addEnd(new InventInventItemSearchGTIN());
return;
}
{
searchProcessors = new List(Types::Class);
searchProcessors.addEnd(new InventInventItemSearchBarcodeEAN128());
searchProcessors.addEnd(new InventInventItemSearchItemId());
searchProcessors.addEnd(new InventInventItemSearchItemAlias());
searchProcessors.addEnd(new InventInventItemSearchBarcode());
searchProcessors.addEnd(new InventInventItemSearchExternal());
searchProcessors.addEnd(new InventInventItemSearchGTIN());
return;
}
Extending the Result Data
Another common scenario customers ask for is the ability to extract more information from a barcode than what we currently support. Barcode formats such as GS1-128/EAN128 can encode a cavalcade of data, such as batch number, expiration dates, physical dimensions, etc. We have some support in the system for these format in our BarcodeEAN128 class – which includes a decode method to parse this barcode format. We will use this functionality to perform the parsing of the barcode.
It all starts with the InventInventItemSearchResult class. This is a small class that is returned from the search framework when matching data is found. Currently this class contains the following fields:
- ItemId
- Always filled in by the SearchProcessors when a matching item is found within AX
- InventDimId
- Filled in when a matching variant is found for a GTIN/Barcode lookup
- UnitOfMeasureSymbol
- Filled in when a matching variant is found for a GTIN lookup
In our simple example we will have a barcode that encodes the batch information after the product identifier. The product identifier will be a fixed 14-character field followed by the batch identifier. For example, product 0000000161_202, batch number B123 would have a barcode such as the following:
0000000161_202B123
The human-readable format for this barcode would be the following (in the GS-128 format):
(01) 0000000161_202 (10) B123
And the actual scanned text of this barcode would look something like this:
]C1010000000161_20210B123]C1
In the AX BarcodeEAN128 implementation the character string "]C1" is designated as the Function Code 1 or FNC1. This is used in the GS-128 spec to indicate application identifiers and variable length fields (such as the batch id). There is much more to GS-128 parsing than can be covered in this blog post – for more information start here.
To get the batch information extracted, first we will add the new data to the InventInventItemSearchResult result object. Using the same pattern as the existing parameter fields gives us the following code:
/// <summary>
/// Class used to return data from the <c>InventInventItemSearch</c> search functionality.
/// </summary>
public class InventInventItemSearchResult
{
ItemId itemId;
InventDimId inventDimId;
UnitOfMeasureSymbol unitOfMeasureSymbol;
InventBatchId batchId;
}
public InventBatchId parmBatchId(InventBatchId _batchId = batchId)
{
batchId = _batchId;
return batchId;
}
/// Class used to return data from the <c>InventInventItemSearch</c> search functionality.
/// </summary>
public class InventInventItemSearchResult
{
ItemId itemId;
InventDimId inventDimId;
UnitOfMeasureSymbol unitOfMeasureSymbol;
InventBatchId batchId;
}
public InventBatchId parmBatchId(InventBatchId _batchId = batchId)
{
batchId = _batchId;
return batchId;
}
Now I can make a new SearchProcessor to extract the embedded Batch and ItemId information. This might look something like this:
public class InventInventItemSearchEmbeddedBatch extends InventInventItemSearchProcessor
{
public InventInventItemSearchResult search(InventInventItemSearchItem _searchValue)
{
InventTable inventTable;
InventInventItemSearchResult result = null;
BarcodeEAN128 barcode = BarcodeEAN128::construct();
barcode.decode(_searchValue);
inventTable = inventTable::find(barcode.itemId());
if (inventTable.RecId != 0)
{
result = InventInventItemSearchResult::newFromItemId(barcode.itemId());
result.parmBatchId(barcode.batch());
}
return result;
}
}
{
public InventInventItemSearchResult search(InventInventItemSearchItem _searchValue)
{
InventTable inventTable;
InventInventItemSearchResult result = null;
BarcodeEAN128 barcode = BarcodeEAN128::construct();
barcode.decode(_searchValue);
inventTable = inventTable::find(barcode.itemId());
if (inventTable.RecId != 0)
{
result = InventInventItemSearchResult::newFromItemId(barcode.itemId());
result.parmBatchId(barcode.batch());
}
return result;
}
}
Consuming the returned batch information is done by checking for the batch parameter data and processing the data as if the user has entered it directly into the batch text control. This can be done by simply modifying the code we saw earlier in the WHSRFControlData::processControl method:
case #ItemId:
if (_data != "@WAX402")
{
localInventItemSearch = InventInventItemSearch::construct();
localInventItemSearchResult = localInventItemSearch.search(_data);
if (localInventItemSearchResult)
{
itemId = localInventItemSearchResult.parmItemId();
itemInventDim = InventDim::find(localInventItemSearchResult.parmInventDimId());
if (localInventItemSearchResult.parmBatchId() != '')
{
this.processControl(#BatchId, localInventItemSearchResult.parmBatchId());
}
}
else
{
errorMessage = "@SYP5070015";
hasError = true;
break;
}
if (_data != "@WAX402")
{
localInventItemSearch = InventInventItemSearch::construct();
localInventItemSearchResult = localInventItemSearch.search(_data);
if (localInventItemSearchResult)
{
itemId = localInventItemSearchResult.parmItemId();
itemInventDim = InventDim::find(localInventItemSearchResult.parmInventDimId());
if (localInventItemSearchResult.parmBatchId() != '')
{
this.processControl(#BatchId, localInventItemSearchResult.parmBatchId());
}
}
else
{
errorMessage = "@SYP5070015";
hasError = true;
break;
}
To make this work I also had to modify the processData method to not overwrite the Batch information.
case #BatchId:
if (inventBatchId == '')
{
if (mode == WHSWorkExecuteMode::AdjustmentIn)
{
if (licensePlateId)
{
if (WHSLicensePlate::exist(#LicensePlateId))
{
inventBatchId = this.getBatchId();
}
}
}
elseif(workLine.WorkType != WHSWorkType::Count)
{
inventBatchId = this.getBatchId();
}
}
fieldValues.insert(#BatchId, inventBatchId);
break;
if (inventBatchId == '')
{
if (mode == WHSWorkExecuteMode::AdjustmentIn)
{
if (licensePlateId)
{
if (WHSLicensePlate::exist(#LicensePlateId))
{
inventBatchId = this.getBatchId();
}
}
}
elseif(workLine.WorkType != WHSWorkType::Count)
{
inventBatchId = this.getBatchId();
}
}
fieldValues.insert(#BatchId, inventBatchId);
break;
This will now allow our users to automatically select the batch by scanning one of our specially formatted barcodes. As an example, suppose we have a location containing a number of batch-controlled items and we want to move some items from the location using the mobile device. Using the standard functionality, we would need to scan or enter the location, then scan or enter the product identifier, and then enter the batch number. Using the embedded batch barcode we defined we can skip one of these steps.
Note that I ran into a slight implementation problem with this example due to the ItemId EDT length definition. When we build the mobile device UI we use the EDT definitions as the maximum length of allowable input. Since the ItemId is only allowed to be 20 characters long, I was not able to scan these barcodes that could end up much longer than 20 characters. Instead of modifying every page in the mobile interface, I updated the WHSWorkExecuteDisplay::buildControl method to extend the maximum allowed length when building an ItemId-related field.
container buildControl(str _controlType,
str _name,
str _label,
int _newLine,
str _data,
ExtendedTypeId _inputType,
str _error,
int _defaultButton,
boolean _enabled = true,
str _selected = '',
WHSRFColorText _color = WHSRFColorText::Default)
{
container ret = conNull();
int length = -1;
str typeStr = #TypeUndefined;
SysDictType sysTypeDict = new SysDictType(_inputType);
Types type;
if (_inputType == extendedTypeNum(ItemId))
{
length = 100;
}
else if (_inputType != #WHSRFUndefinedDataType && sysTypeDict)
{
type = sysTypeDict.isTime() ? Types::Time : sysTypeDict.baseType();
typeStr = enum2Symbol(enumNum(Types), type);
if (type == Types::String)
{
length = sysTypeDict.stringLen();
}
}
str _name,
str _label,
int _newLine,
str _data,
ExtendedTypeId _inputType,
str _error,
int _defaultButton,
boolean _enabled = true,
str _selected = '',
WHSRFColorText _color = WHSRFColorText::Default)
{
container ret = conNull();
int length = -1;
str typeStr = #TypeUndefined;
SysDictType sysTypeDict = new SysDictType(_inputType);
Types type;
if (_inputType == extendedTypeNum(ItemId))
{
length = 100;
}
else if (_inputType != #WHSRFUndefinedDataType && sysTypeDict)
{
type = sysTypeDict.isTime() ? Types::Time : sysTypeDict.baseType();
typeStr = enum2Symbol(enumNum(Types), type);
if (type == Types::String)
{
length = sysTypeDict.stringLen();
}
}
No comments:
Post a Comment