How to quickly build a scanning feature?

Introduction

Although the product offers out-of-the-box scanning capabilities, additional development is required to deploy them as an independent feature in a production environment. In the following section, I will demonstrate how I utilized the existing functionalities to rapidly develop a working scanning solution, along with a summary of the challenges faced during development.

Requirements

Shop floor operators currently need to navigate the full MES interface to perform the most frequent and time-critical operations at a Resource: Check-In, Check-Out, Dispatch, Track-In, and Track-Out, etc. On the shop floor, operators work with handheld Zebra devices that have a built-in scanner but a small screen, making the standard MES interface impractical to use efficiently. A lightweight, scan-driven Operator Cockpit tailored for the Zebra device enables operators to perform these core operations quickly, accurately, and without relying on a desktop terminal, reducing wait times, minimizing navigation steps, and keeping operators focused on the physical work at hand.
The main requirement points are as follows:

  1. Scan to retrieve Resource or Material:

    1. Resource Scan: Scanning a Resource must query and display all associated Materials. By default, only Materials in Queued status are shown. Other statuses can be queried via filters, and the status filter dropdown should only display the applicable/specific status values.
    2. Material Scan: If a Material is scanned, it should be selectable and deselectable.
  2. Barcode Input: Support for manual barcode entry.


    Image: Mobile Cockpit Page

1. How to ensure a Resource is scanned first?

Question:
Since a scan could be either a Resource or a Material, but scanning a Resource first is a prerequisite for scanning a Material to be meaningful, how can we ensure that the first scan is always a Resource?

Solution:
This feature implements the scanning logic via DEE. Upon the first scan, the result is forwarded to DEE, which evaluates the input through the following code to determine if it is a Resource, a Material, or an error.

/// <summary>
/// Gets the Resource or Material by name or barcode.
/// </summary>
/// <param name="input">The input.</param>
/// <returns>Resource or Material.</returns>
public (IResource, IMaterial) GetResourceOrMaterialByBarcode(string barcode)
{
    long? materialId = null;
    long? resourceId = null;
    IMaterial material = _entityFactory.Create<IMaterial>();
    IResource resource = _entityFactory.Create<IResource>();

    #region Resource query

    if (resource.ObjectExists(barcode))
    {
        resource.Load(barcode);
    }
    else if (material.ObjectExists(barcode))
    {
        material.Load(barcode);
        if (material.UniversalState != UniversalState.Terminated && !material.IsTemplate)
        {
            material.LoadReferences([Cmf.Navigo.Common.Constants.Product, Cmf.Navigo.Common.Constants.Step]);
        }
    }
    else
    {
        IEntityType materialEntityType = _entityFactory.Create<IEntityType>();
        materialEntityType.Load(Cmf.Navigo.Common.Constants.Material);

        IEntityType resourceEntityType = _entityFactory.Create<IEntityType>();
        resourceEntityType.Load(Cmf.Navigo.Common.Constants.Resource);

        bool isToSearchForBarcode = materialEntityType.AllowBarcodeIds || resourceEntityType.AllowBarcodeIds;
        if (isToSearchForBarcode)
        {
            IGenericTable entityTagGenericTable = _entityFactory.Create<IGenericTable>();
            entityTagGenericTable.Load("EntityTag");

            IFilterCollection entityTagFilters = new FilterCollection
            {
                new Filter
                {
                    Name = "Scope",
                    Operator = FieldOperator.IsEqualTo,
                    Value = (int) EntityTagScope.BarcodeId,
                    LogicalOperator = LogicalOperator.AND
                },
                new Filter
                {
                    Name = "Tag",
                    Operator = FieldOperator.IsEqualTo,
                    Value = barcode,
                },
            };

            entityTagGenericTable.LoadData(entityTagFilters);
            if (entityTagGenericTable.HasData)
            {
                DataSet entityTagDataSet = NgpDataSet.ToDataSet(entityTagGenericTable.Data);
                DataRow row = entityTagDataSet.Tables[0].Rows[0];
                string entityType = row.Field<string>("EntityType");
                if (entityType == Cmf.Navigo.Common.Constants.Material)
                {
                    materialId = row.Field<long>("EntityId");
                }
                else if (entityType == Cmf.Navigo.Common.Constants.Resource)
                {
                    resourceId = row.Field<long>("EntityId");
                }
                if (materialId.HasValue && material.ObjectExists(materialId.Value))
                {
                    material.Load(materialId.Value);
                    if (material.UniversalState != UniversalState.Terminated && !material.IsTemplate)
                    {
                        material.LoadReferences([Cmf.Navigo.Common.Constants.Product, Cmf.Navigo.Common.Constants.Step]);
                    }
                }
                else if (resourceId.HasValue && resource.ObjectExists(resourceId.Value))
                {
                    resource.Load(resourceId.Value);
                }
            }
        }
    }

    #endregion Barcode query

    return (resource, material);
}

The page also passes the Resource parameter to the DEE. During the initial scan, this parameter is null. If the scanned information is not a Resource, an error will be returned. If a valid Resource is scanned, it will be returned and stored. For all subsequent scans, the Resource parameter passed to the DEE will be populated.

:warning: NOTE
When passing the Resource parameter to DEE, you must pass the Resource Name instead of the Resource entity object. Passing the object is prohibited because DEE reloads and returns a new Resource instance on every call. Even if it represents the same Resource, the object reference changes, which triggers continuous calls to DEE. Therefore, pass the Resource Name instead. Since it is a static string that remains unchanged as long as the Resource is not switched, it will not trigger redundant DEE calls.

2. How can a Material be selected or deselected via scanning?

This feature uses a Grid component to display data. The Grid component has a Selected input parameter and a Changed Selection output parameter, both of which are array types. The Selected input parameter is used to set the selected or unselected state of the data in the Grid, while the Changed Selection output parameter retrieves the currently selected data.

We need to pass both the Changed Selection output parameter and the scanned Material to DEE simultaneously. Within DEE, the Changed Selection array is evaluated: if the scanned Material is present in the array, it will be removed; if it is not present, it will be added. Finally, the updated Changed Selection array is returned from DEE to the page and reassigned to the Selected input parameter. This mechanism enables the select and deselect functionality via scanning.

:warning: NOTE
When passing the Changed Selection output parameter to DEE, we need to convert all Materials in the array into a single string. This can be achieved by concatenating the Material Names with a semicolon(‘;’) as the delimiter. This prevents DEE from being called in a continuous loop. If the Changed Selection array object were passed directly, any changes to the Selected input parameter would trigger a change in the Changed Selection output object, resulting in an infinite loop of DEE calls.

Solution:
A dedicated Converter component was created to concatenate the Material array into a single string using semicolons(‘;’) as delimiters. Upon receiving this parameter, DEE splits the string back by semicolons to determine whether the scanned Material is present in the array.

@Converter({
  name: $localize`:@@cmf-meddev-core/handle-materials-to-name-string#NAME:Handle Materials To Name String Converter`,
  input: [
    <ComplexType>{
      type: null,
      CollectionType: CollectionType.Array,
    },
  ],
  output: SimpleType.String,
})
@Pipe({
  standalone: true,
  name: 'handleMaterialsToNameString',
})
export class HandleMaterialsToNameStringConverter implements PipeTransform {
    transform(value: any[], ...args: any[]): any {
        
    let result = '';
    if (value && value.length > 0) {
      value.forEach((n) => {
        if (n instanceof Cmf.Navigo.BusinessObjects.Material) {
          result += n.Name + ';';
        }
      });
    }
    return result;
  }
}


Image: Material Name Converter

Trivial Issue:
In MES system v11.3.3, when operating in Mobile mode, scanning to select an item does not actually select the corresponding data in the interface. The result is shown in the figure below:


Image: Automatic select Issue

After communicating with the Product team, this has been confirmed as a product bug. However, the issus can be resolved using the following workaround: The results to be displayed in the Grid must be passed to DEE simultaneously. After logical processing, the Selected parameter is output. It is crucial to ensure that the Selected parameter shares the exact same reference as the data source displayed in the Grid. In other words, the Selected parameter must not be passed out after Grid’s data source has been loaded. This approach will resolve the current auto-selection issue.


Image: Selected Materials

3. How can add a insert barcode button?

User also require the option to enter information manually instead of scanning.
The Barcode Identification page under the Manufacturing menu has already implemented this feature.


Image: Barcode Identification

Since this page also built using UIPage, we can use it as a direct reference. This feature utilizes the native system button Manufacturing.BarcodeIdentification.ManualInsert to open a dialog box for barcode entry.


Image: Insert Barcode Button

It then uses the Barcode Actions widget to capture the input. The Barcode Actions widget provides a simple way to scan barcode(line or QR codes) that match specific objects within the system. Since we are only targeting Resource and Material, we just need to provide these two options in the Entities tab.


Image: Barcode Actions

Question:

  1. Since the Barcode Actions widget does not need to be displayed on the page and is only used to capture input, we need to hide it from the UI.
  2. The current feature uses the system Barcode task to retrieve scanned content as a string, which is then passed to DEE. However, the Barcode Actions widget outputs an entity. We need to convert this output into a string to maintain a consistent data format before passing it to DEE in the same manner.

Solution:

  1. To address the first issue, we can use the following Less code in the file /Cmf.MedDev.HTML/src/styles.less to hide the Barcode Actions component.

     cmf-core-dashboards-page[data-name="Mobile Cockpit"]
     div[data-content-path="cmf-core-dashboards/BarCodeIdentification"]
     {
         display:none;
     }
    
  2. For the second issue, we need to use a Converter Get Entity Name to retrieve the Name of the entity outputted by the Barcode Actions widget.


    Image: Get Entity Name Converter

Others

Since the title and subtitle of the UIPage cannot be displayed in Mobile mode, we need to develop a new widget to display this information. The front-end code is as follows:


Image: Entity Additional Info in ResourceView

HTML:

<div class="base-page-header">
  <div class="title-bar base-page-title">
    <cmf-core-controls-page-title>
      <div class="page-icon icon-mes-et-sm-resource"></div>
      <div class="page-label">{{ title }}</div>
      <div class="page-sublabel">{{ subTitle }}</div>
      <div class="page-title-additional-info" [innerHTML]="safeHtml"></div>
    </cmf-core-controls-page-title>
  </div>
</div>

TypeScript:

...
@Widget({
  name: $localize`:@@cmf-meddev-core/entity-additional-info-widget#NAME:Entity Additional Info Widget`,
  iconClass: 'icon-core-st-lg-generic',
  settingsComponent: {
    component: EntityAdditionalInfoWidgetSettingsComponent,
  },
  inputs: {
    title: {
      type: WidgetValueType.String,
    },
    subTitle: {
      type: WidgetValueType.String,
    },
    additionalInfo: {
      type: WidgetValueType.String,
    },
  },
})
@Component({
  standalone: true,
  selector: 'cmf-meddev-core-entity-additional-info-widget',
  imports: [CommonModule],
  templateUrl: './entity-additional-info-widget.component.html',
  styleUrl: './entity-additional-info-widget.component.less',
  inputs: ['title', 'subTitle', 'additionalInfo'],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class EntityAdditionalInfoWidget
  extends WidgetGeneric
  implements WidgetRepresentation
{
  /**
   * Defines how the widget will be represented
   */
  public uiWidgetViewMode: WidgetViewMode = WidgetViewMode.Normal;

  @Input() public title: string = '';
  @Input() public subTitle: string = '';
  @Input() public additionalInfo: string = '';

  /**
   * Constructor
   */
  constructor(
    viewContainerRef: ViewContainerRef,
    elementRef: ElementRef,
    feedback: FeedbackService,
    private sanitizer: DomSanitizer,
  ) {
    super(viewContainerRef, elementRef, feedback);
  }

  get safeHtml(): SafeHtml {
    return this.sanitizer.bypassSecurityTrustHtml(this.additionalInfo);
  }
}

Less:

cmf-core-controls-base-page .base-page-header {
  background-color: #fafafa;
  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}


Image: Entity Additional Info Widget

It looks like this:


Image: Entity Additional Info Widget

@Joao_Moreira and @Jorge_Bertocchini can you check if we can use this resolutions in the documentation maybe as a developer tutorial?

I’d say they’re good enough, I’m adding this to our backlog, excellent stuff! :slight_smile:

Sure, we’ll add it to our backlog and keep you updated on our progress.

Nice work, @Erchuang_Shang !