Quantcast
Channel: Microsoft Dynamics 365 Community
Viewing all articles
Browse latest Browse all 17314

Client Inception Using Soundex

$
0
0
This is Part 2 of a 2 part article where I build a “Client-inception” PoC within Dynamics that uses the principle of Soundex for entity-duplication-checking.  
 
There are a number of interface approaches that I could use and because this is primarily a “Wpf-blog” focusing on “different-approaches” to problems, I’ve decided to use the Telerik control suite for this PoC. Telerik offers an intuitive and feature rich set of user-controls that will allow you to implement some very advanced interfaces (in very little time).
 

·         More information on Telerik Wpf controls (and a trial) is available here: [http://www.telerik.com/products/wpf/controls.aspx]

 
I’m going to implement a Dynamics form that looks something like this:
 
 

·         Creating a Wpf UserControl means that I can re-use the “Client Inception” module in the Dynamics rich-client as well as Sharepoint EP and any other windows based application I choose.

 
The form is divided into 2 expander sections. In the first expander we have a search function that will allow you to perform entity searches using the Soundex algorithm (created in previous blog articles). The operator must enter a significant word from both the name and the address of the new entity (normally this would be the surname and the town). There are slider controls above each text-box that determine the variance allowed in the Soundex search. The sliders range from 0-100, where “0” represents an exact Soundex code match and “100” represents a Soundex code that is numerically ±100 within that range.
 

·         Note; a zero-value on the slider does not necessary mean an “exact-string-match”. It means that the search term must resolve to the same Soundex code.

 
In the above example, you can see that a large variance has been allowed for name searches and a smaller variance for addresses. The reason for this that names are more likely to be misspelled than towns. It goes without saying that the default settings for the ranges and variances can be changed to suit specific implementations.
 
The Xaml code for the above Wpf control is as follows:
 
Xaml
<UserControl x:Class="ClientInceptionUserControl.Startup"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
             mc:Ignorable="d" d:DesignHeight="500" d:DesignWidth="800">
    <Grid>
        <telerik:RadPanelBar Margin="10" Width="Auto" Height="Auto" Orientation="Vertical" ExpandMode="Multiple" telerik:StyleManager.Theme="Office_Blue" >
            <telerik:RadPanelBarItem Header="Search" VerticalAlignment="Top" IsExpanded="True">
                <Grid Height="70" Background="White">
                    <Grid.RowDefinitions>
                        <RowDefinition Height="5" />
                        <RowDefinition Height="30" />
                        <RowDefinition Height="30" />
                    </Grid.RowDefinitions>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto" />
                        <ColumnDefinition Width="200" />
                        <ColumnDefinition Width="20" />
                        <ColumnDefinition Width="Auto" />
                        <ColumnDefinition Width="200" />
                        <ColumnDefinition Width="20" />
                        <ColumnDefinition Width="Auto" />
                    </Grid.ColumnDefinitions>
                    <Label Grid.Row="1" Grid.Column="0" Content="Tolerance:"/>
                    <Label Grid.Row="1" Grid.Column="3" Content="Tolerance:"/>
                    <Label Grid.Row="2" Grid.Column="0" Content="Search name:"/>
                    <Label Grid.Row="2" Grid.Column="3" Content="Search address:"/>
                    <TextBox x:Name="SearchName" Grid.Row="2" Grid.Column="1" Margin="3" />
                    <TextBox x:Name="SearchAddress" Grid.Row="2" Grid.Column="4" Margin="3" />
                    <telerik:RadSlider x:Name="SearchNameTolerance" Grid.Row="1" Grid.Column="1" VerticalAlignment="Center" Maximum="100"
                                                Value="30" IsSnapToTickEnabled="True" TickFrequency="10" Margin="10 0" TickPlacement="Both" />
                    <telerik:RadSlider x:Name="SearchAddressTolerance" Grid.Row="1" Grid.Column="4" VerticalAlignment="Center" Maximum="100"
                                                Value="10" IsSnapToTickEnabled="True" TickFrequency="10" Margin="10 0" TickPlacement="Both" />
                    <telerik:RadButton x:Name="ProximitySearch" Grid.Row="2" Grid.Column="6" Width="100" Content="Search" Click="ProximitySearch_Click" CornerRadius="20" Height="25"></telerik:RadButton>
                </Grid>
                <telerik:RadGridView x:Name="SearchGrid" AutoGenerateColumns="True" IsReadOnly="True" />
            </telerik:RadPanelBarItem>
            <telerik:RadPanelBarItem Header="Details">
                <telerik:RadPanelBarItem Header="..." />
            </telerik:RadPanelBarItem>
        </telerik:RadPanelBar>
    </Grid>
</UserControl>
 
 
The code-behind for the “ProximitySearch” button click is as follows:
 
C#
privatevoid ProximitySearch_Click(object sender, RoutedEventArgs e)
{
    // ensure that both search terms have been entered
    if ((SearchName.Text == "") || (SearchAddress.Text == ""))
    {
        MessageBox.Show("Please supply a word from the name and the address of the new entity.","Client inception");
        return;
    }
    string searchNameSoundexCode = SoundexClass.createSoundexCodeEN(SearchName.Text);
    string searchAddressSoundexCode = SoundexClass.createSoundexCodeEN(SearchAddress.Text);
 
    // ensure that a soundex can be generated for both terms
    if ((searchNameSoundexCode == "") || (searchAddressSoundexCode == ""))
    {
        MessageBox.Show("Word fragments are too small to generate a soundex code, please extend.", "Client inception");
        return;
    }
               
    // calculate soundex threshold boundaries
    int searchNameLowerLimit = (int)searchNameSoundexCode.Substring(0, 1)[0] * 1000 + Convert.ToInt32(searchNameSoundexCode.Substring(1, 3)) - (int)SearchNameTolerance.Value;
    int searchNameUpperLimit = (int)searchNameSoundexCode.Substring(0, 1)[0] * 1000 + Convert.ToInt32(searchNameSoundexCode.Substring(1, 3)) + (int)SearchNameTolerance.Value;
    int searchAddressLowerLimit = (int)searchAddressSoundexCode.Substring(0, 1)[0] * 1000 + Convert.ToInt32(searchAddressSoundexCode.Substring(1, 3)) - (int)SearchAddressTolerance.Value;
    int searchAddressUpperLimit = (int)searchAddressSoundexCode.Substring(0, 1)[0] * 1000 + Convert.ToInt32(searchAddressSoundexCode.Substring(1, 3)) + (int)SearchAddressTolerance.Value;
 
    // X++ passthru query to get combined results
    string _dynamic = "";
    _dynamic += @"str getDataSet()
                {
                    Soundex objSoundexName, objSoundexAddress;
                    DirPartyTable objDirPartyTable;
                    DirPartyPostalAddressView obDirPartyPostalAddressView;
                    CustTable objCustTable;
 
                    int searchNameLowerLimit, searchNameUpperLimit;
                    int searchAddressLowerLimit, searchAddressUpperLimit;
                    str resultXml = '<?xml version=\'1.0\' encoding=\'UTF-8\' ?>';
 
                    searchNameLowerLimit = " + searchNameLowerLimit.ToString() + @";
                    searchNameUpperLimit = " + searchNameUpperLimit.ToString() + @";
                    searchAddressLowerLimit = " + searchAddressLowerLimit.ToString() + @";
                    searchAddressUpperLimit = " + searchAddressUpperLimit.ToString() + @";
 
                    resultXml += '<NewDataSet>';
                    while select Word, Position from objSoundexName
                        where objSoundexName.SoundexCode >= searchNameLowerLimit && objSoundexName.SoundexCode <= searchNameUpperLimit && objSoundexName.ContextTableId == tableName2id('DirPartyTable')
                            join Name from objDirPartyTable where objSoundexName.ContextRecId == objDirPartyTable.RecId
                                join AccountNum from objCustTable order by AccountNum where objDirPartyTable.RecId == objCustTable.Party
                                    join PostalAddress, Address from obDirPartyPostalAddressView where objDirPartyTable.RecId == obDirPartyPostalAddressView.Party
                                        join Word, Position from objSoundexAddress where objSoundexAddress.SoundexCode >= searchAddressLowerLimit && objSoundexAddress.SoundexCode <= searchAddressUpperLimit && objSoundexAddress.ContextTableId == tableName2id('LogisticsPostalAddress') && objSoundexAddress.ContextRecId == obDirPartyPostalAddressView.PostalAddress
                    {
                        resultXml += '<SearchResults>';
                        resultXml += '<AccountNum>' + objCustTable.AccountNum + '</AccountNum>';
                        if (DirOrganization::find(objDirPartyTable.RecId).RecId)
                            resultXml += '<Type>Organisation</Type>';
                        else
                            resultXml += '<Type>Individual</Type>';
                        resultXml += '<Search_name>' + objSoundexName.Word + ' (' + num2str(objSoundexName.Position,0,0,1,0) + ')' + '</Search_name>';
                        resultXml += '<Name>' + objDirPartyTable.Name + '</Name>';
                        resultXml += '<Search_address>' + objSoundexAddress.Word + ' (' + num2str(objSoundexAddress.Position,0,0,1,0) + ')' + '</Search_address>';
                        resultXml += '<Address>' + obDirPartyPostalAddressView.Address + '</Address>';
                        resultXml += '</SearchResults>';
                    }
                    resultXml += '</NewDataSet>';
                    return resultXml;
                }";
 
    // load passthru return results into grid
    Mouse.OverrideCursor = Cursors.Wait;
    try
    {
        DataSet objDataSet = newDataSet();
        objDataSet.ReadXml(newStringReader(PowerSQuirreL.runScript(_dynamic)), XmlReadMode.InferSchema);
        SearchGrid.ItemsSource = objDataSet.Tables[0].DefaultView;
    }
    catch (System.Exception ex)
    {
        SearchGrid.ItemsSource = null;
    }
    Mouse.OverrideCursor = null;            
}
 
 
You will need to have created and populated the [Soundex] table as per previous blog article.
There are a few things to note in the above:
 

·         The routine makes reference to a SoundexClass which is an assembly that needs to be included in this project. More details can be found here: [https://community.dynamics.com/ax/b/dynamicsax_wpfandnetinnovations/archive/2013/04/14/the-soundex-algorithm-en.aspx#.UYJ5k7XFX3Q]

·         The code assumes “English” context. Ideally, the default language should be picked up from Dynamics context or an override drop-down (I’ve omitted this enhancement to simplify the code).

·         Passthru X++ (with field substitution) is used to perform the querying (once the Soundex boundaries have been calculated). Ideally, this code needs to exist within an AOT class and be served via proxies or AIF. Again, this has been circumvented to “present” the solution in one listing.

·         Dyamics query data is returned as an Xml string which is then converted to a .Net DataSet object. This way, it can be bound directly to the UI grid.

 
There are a few enhancements that I want to apply to the UserControl to get some additional eventing functionality within Dynamics. When I double-click on a row within the grid then I want the Dynamics rich-client to open up the CustTable form and navigate to the selected record. This is achieved through DependencyProperties and RoutedEventHandlers. More information about RoutedEventHandlers for Wpf can be found here: [http://msdn.microsoft.com/en-us/library/ms742806.aspx].
 
The following code is required to notify Dynamics that a row has been double-clicked:
 
Define property
publicstring AccountNum
{
    get { return (string)GetValue(AccountNumProperty); }
    set { SetValue(AccountNumProperty, value); }
}
 
publicstaticDependencyProperty AccountNumProperty = DependencyProperty.Register(
    "AccountNum", typeof(string), typeof(Startup),
    newFrameworkPropertyMetadata(newPropertyChangedCallback(OnAccountNumChanged)),
    newValidateValueCallback(ValidateAccountNum));
 
privatestaticvoid OnAccountNumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    Startup control = (Startup)d;
    control.RaiseEvent(newRoutedEventArgs(AccountNumChangedEvent));
}
 
privatestaticbool ValidateAccountNum(object value)
{
    returntrue;
}
 
publiceventRoutedEventHandler AccountNumChanged
{
    add { AddHandler(AccountNumChangedEvent, value); }
    remove { RemoveHandler(AccountNumChangedEvent, value); }
}
publicstaticreadonlyRoutedEvent AccountNumChangedEvent = EventManager.RegisterRoutedEvent(
    "AccountNumChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Startup));
 
Add a double-click event handler to the UI grid
public Startup()
{
    InitializeComponent();
    SearchGrid.MouseDoubleClick += newMouseButtonEventHandler(this.SearchGrid_OnDoubleClick);
}
 
Set the dependency property on doule-click
privatevoid SearchGrid_OnDoubleClick(object sender, MouseButtonEventArgs e) 
{
    try { this.AccountNum = ((DataRowView)SearchGrid.SelectedItem)["AccountNum"].ToString(); }
    catch { }
}
 
 
Whenever the “AccountNum” is changed then the “AccountNumChanged” event will fire.
 
When you publish your WpfuserControl to the Dynamics-Server and incude it within the AOT you can add it to a form using a “ManagedHost” control. When you right-click on the ManagedHost control you’ll get a context menu. Click on the “Events” option as shown below:
 
 
Click the “Add” button next to the “AccountNumChanged” event. This will add a new X++ handler as follows:
 
 
The AOT will auto-generate some code (first two boxes below). In the actual event handler (third box below) set the X++ code as inidcated:
 
ClassDeclaration
publicclass FormRun extends ObjectRun
{
    ClientInceptionUserControl.Startup _managedHost;
}
 
Init
publicvoid init()
{
    super();
 
    _managedHost = ManagedHost.control();
    _managedHost.add_AccountNumChanged(new ManagedEventHandler(this, 'ManagedHost_AccountNumChanged'));
}
 
ManagedHost_AccountNumChanged
void ManagedHost_AccountNumChanged(System.Object sender, System.Windows.RoutedEventArgs e)
{
    FormRun formRun;
    Args args = new Args();
    ;
    args.name(formstr(CustTable));
    args.record(CustTable::find(_managedHost.get_AccountNum()));
 
    formRun = ClassFactory.formRunClass(args);
    formRun.init();
    formRun.run();
    formRun.wait();
}
 
 
The code in the event-handler will open the “CustTable” form using the DependencyProperty value “AccountNum” (from the UserControl).
 
The final part of this PoC is to create the customer entity using the second section expander:
 
 
The Xaml for this is pretty straightforward and simply consists of a series of Textboxes and ComboBoxes housed within a Grid. There are various ways that the customer can be created in Dynamics. Just to keep things consistent with the rest of the code samples, I’ll use Passthru X++ again.
 
The code-behind for the click event on the “Create” button above is as follows:
 
C#
privatevoid Create_Click(object sender, RoutedEventArgs e)
{
    // X++ passthru query to create customer
    string _dynamic = "";
    _dynamic += @"str writeCustomer()
                {
                    // table objects
                    DirParty                    objDirParty;
                    DirPerson                   objDirPerson;
                    DirPersonName               objDirPersonName;
                    DirOrganization             objDirOrganization;
                    DirOrganizationName         objDirOrganizationName;
                    DirPartyPostalAddressView   objDirPartyPostalAddressView;
                    CustTable                   objCustTable;
 
                    // substitution values
                    str firstName = '" + Forename.Text + @"';
                    str lastName = '" + Surname.Text + @"';
                    str organisationName = '" + OrganisationName.Text + @"';
                    str custGroup = '" + CustGroup.SelectedValue + @"';
                    str taxGroup = '" + TaxGroup.SelectedValue + @"';
                    str street = '" + Street.Text + @"';
                    str city = '" + City.SelectedValue + @"';
                    str county = '" + County.SelectedValue + @"';
                    str country = '" + Country.Text + @"';
                    str postCode = '" + Postcode.Text + @"';
       
                    // working variables
                    str result = '';
                    DirPartyNumber partyNumber;
                    int i;
 
                    infolog.clear();
                    try
                    {
                        ttsBegin;
 
                        // setup organisation
                        if (organisationName != '')
                        {
                            objDirOrganization.initValue();
                            objDirOrganization.Name = organisationName;
                            objDirOrganization.NameAlias = organisationName;
                            objDirOrganization.LanguageId = 'en-gb';
                            if (objDirOrganization.validateWrite())
                                objDirOrganization.insert();
                            else
                            {
                                throw error('Failed [DirOrganization] validation.');
                            }
                            objDirOrganizationName.initValue();
                            objDirOrganizationName.Organization = objDirOrganization.RecId;
                            objDirOrganizationName.Name = organisationName;
                            if (objDirOrganizationName.validateWrite())
                            {
                                objDirOrganizationName.insert();
                            }
                            else
                            {
                                throw error('Failed [DirOrganizationName] validation.');
                            }
                            partyNumber = objDirOrganization.PartyNumber;
                        }
                        else
                        {
                            // setup individual
                            objDirPerson.initValue();
                            objDirPerson.Name = firstName + ' ' + lastName;
                            objDirPerson.NameAlias = firstName;
                            objDirPerson.NameSequence = dirNameSequence::find('FirstLast').RecId;
                            objDirPerson.LanguageId = 'en-gb';
                            if (objDirPerson.validateWrite())
                                objDirPerson.insert();
                            else
                            {
                                throw error('Failed [DirPerson] validation.');
                            }
                            objDirPersonName.initValue();
                            objDirPersonName.Person = objDirPerson.RecId;
                            objDirPersonName.FirstName = firstName;
                            objDirPersonName.LastName = lastName;
                            objDirPersonName.ValidFrom = DateTimeUtil::getSystemDateTime();
                            objDirPersonName.ValidTo = DateTimeUtil::maxValue();
                            if (objDirPersonName.validateWrite())
                            {
                                objDirPersonName.insert();
                            }
                            else
                            {
                                throw error('Failed [DirPersonName] validation.');
                            }
                            partyNumber = objDirPerson.PartyNumber;
                        }
 
                        // setup customer
                        objCustTable.initValue();
                        objCustTable.Party = DirPartyTable::find(partyNumber).RecId;
                        objCustTable.AccountNum = NumberSeq::newGetNum(CustParameters::numRefCustAccount()).num();
                        objCustTable.CustGroup = custGroup;
                        objCustTable.TaxGroup = taxGroup;
                        if (objCustTable.validateWrite())
                            objCustTable.insert();
                        else
                        {
                            throw error('Failed [CustTable] validation.');
                        }
       
                        // setup addresss
                        objDirParty = DirParty::constructFromCommon(objCustTable);
                        objDirPartyPostalAddressView.LocationName = 'PRIMARY';
                        objDirPartyPostalAddressView.Street = street;
                        objDirPartyPostalAddressView.City = city;
                        objDirPartyPostalAddressView.County = county;
                        objDirPartyPostalAddressView.State = county;
                        objDirPartyPostalAddressView.CountryRegionId = country;
                        objDirPartyPostalAddressView.ZipCode = postCode;
                        objDirParty.createOrUpdatePostalAddress(objDirPartyPostalAddressView);
       
                        result = 'Customer created:' + objCustTable.AccountNum;
                        ttsCommit;
                    }
                    catch
                    {
                        ttsAbort;
                        for (i = 1; i <= infolog.line(); i++)
                        {
                            result += infolog.text(i) + '\n';
                        }
                    }
 
                    return result;
                }";
    Mouse.OverrideCursor = Cursors.Wait;
    string str1 = PowerSQuirreL.runScript(_dynamic);
    MessageBox.Show(str1);
    if (str1.Substring(0, 16) == "Customer created")
    {
        this.AccountNum = str1.Replace("Customer created:", "");
    }
    Mouse.OverrideCursor = null;
}
 
 
There is definitely more design and code involved with this type of solution. The up-side to all this effort is that the solution can be re-used within compatible .net applications (or platforms) with virtually no recoding whatsoever. In addition, bespoke CUS/USR layer customisations like this are often suited to customers that have a higher predisposition to .Net than X++ owing to resource constraints.
 
For those of you who are interested, a visual demo of the PoC functionality can be found here: [http://www.youtube.com/watch?v=unrspQUDpDA&hd=1]
 
 
REGARDS
 
 

Viewing all articles
Browse latest Browse all 17314

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>