Pages

Wednesday, February 22, 2012

Reflection and recursion on the AOT to compare projects.

I wrote this really cool job a year or two ago to solve a fairly common, specific problem.  This job solves many more problems I never anticipated though.

The consulting company I worked for made it their best practice to make sure that every object they modified was in a project.  Our "Customization" project...the problem here is that not ever developer consistently remembers to put every object in the project 100% of the time, so import/export between environments would sometimes be missing objects.

So I use the base layer compare tool via Tools>Development tools>Version update>Compare layers to create a project of the "CUS" layer (normally the VAR layer for me).  This gets all of the objects modified in the CUS layer in one project.  Then I set that in the #layerCompareProject macro, then add my projects I want to check against in the lower lines of code.

I've used this tool countless times to compare two projects.  Another use I had was during an upgrade to Roll Up 7 from Roll Up 1.  Somehow I had deleted a modification to an obscure table...this made me worried that I could have accidentally deleted other objects that I would have no idea about.  To check this, I went into Live and created a layer compare project of the CUS layer, then went into my upgraded RU7 environment and made the same layer compare project.  Then all I had to do was run the job and it output the objects that were missing.

I think it's clever/fun the way I wrote it too using recursion to reflect on the AOT.  It's basic recursion for traversal.




/*
    How to use:
    Create a compare project of the layer you want to check and make it SHARED!

    MAKE IT SHARED
    MAKE IT SHARED
    MAKE IT SHARED

    Change the constant "layerCompareProject" to the name of the SHARED layer project
        you just created

    Modify the first line from the main body of the job to be the projects you're searching
    customProject           = infoLog.projectRootNode().AOTfindChild('Shared').AOTfindChild('Customizations');
    traverseAndUpdateMap    (customProject.getRunNode().AOTiterator());

    Just repeat those two lines to add more projects...it will update and search them
*/
static void ProjReflection(Args _args)
{
    #define.layerCompareProject('CUS_Live')

    ProjectNode layerCompareProject;
    ProjectNode customProject;

    Map map = new Map(Types::String, Types::Integer);
    MapEnumerator enumerator;

    void traverseAndBuildMap(TreeNodeIterator _tni)
    {
        ProjectNode pn = _tni.next();
        ;

        while (pn)
        {
            if (!pn.applObjectType())
            {
                // Recursively traverse the project
                traverseAndBuildMap(pn.AOTiterator());
            }
            else
            {
                // Fill the map with every object we found
                map.insert(pn.treeNodePath(), 0);
            }

            pn = _tni.next();
        }
    }

    void traverseAndUpdateMap(TreeNodeIterator _tni)
    {
        ProjectNode pn = _tni.next();
        ;

        while (pn)
        {
            if (!pn.applObjectType())
            {
                // Recrusively traverse the project
                traverseAndUpdateMap(pn.AOTiterator());
            }
            else
            {
                if (map.exists(pn.treeNodePath()))
                {
                    // We found an object in a project that already exists in the map
                    // ...mark it as found [1]
                    map.insert(pn.treeNodePath(), 1);
                }
            }

            pn = _tni.next();
        }
    }

    ;

    layerCompareProject = infoLog.projectRootNode().AOTfindChild('Shared').AOTfindChild(#layerCompareProject);
    traverseAndBuildMap(layerCompareProject.getRunNode().AOTiterator());


    // Modify these lines to search projects
    customProject           = infoLog.projectRootNode().AOTfindChild('Shared').AOTfindChild('Customization');
    traverseAndUpdateMap    (customProject.getRunNode().AOTiterator());

    /*
    // Add more lines if you want to search more
    customProject           = infoLog.projectRootNode().AOTfindChild('Shared').AOTfindChild('InstallProj');
    traverseAndUpdateMap    (customProject.getRunNode().AOTiterator());

    customProject           = infoLog.projectRootNode().AOTfindChild('Shared').AOTfindChild('BoltOn');
    traverseAndUpdateMap    (customProject.getRunNode().AOTiterator());
    */

    enumerator = map.getEnumerator();

    info("The following objects from [" + #layerCompareProject + "] were not found in searched projects.");
    while (enumerator.moveNext())
    {
        if (!enumerator.currentValue())
            info(strfmt("%1", enumerator.currentKey()));
    }
}

Monday, January 30, 2012

How to run AX 2012 HyperV on VirtualBox

In order to run the AX 2012 Beta VPC, it usually requires HyperV, or attaching the disk locally.  This is a great solution I was told about to run it locally.

http://dynamicsnavax.blogspot.com/2011/04/how-to-run-ax2012-hyperv-on-virtualbox.html

The basic idea is you just create a virtual machine, mount the three *.vhd's, and start it.  This blog post highlights two important caveats that you will come across.

  1. If you use the "Create new virtual machine wizard" and check the box to use an existing disk, it defaults the VHD as a SATA controller, which will result in the attached blue screen error over and over.  You need to attach all three disks as IDE controllers
  2. Sharepoint apparently doesn't work without adding to the hosts file
This is the blue screen I kept hitting over and over...very frustrating before seeing this blog post for the solution.

Wednesday, January 25, 2012

How to send emails from AX without requiring Outlook

Sending emails from AX has been somewhat of a pain when it tries to use Outlook.  This post is a simple code modification to one method in \Classes\Info\reportSendMail.  I did not develop this code, I merely tweaked it.  The original poster's blog has disappeared, and I can only find non-working remnants all around the web of this, but it is just too useful not to repost.

If you have Outlook 64bit edition, you might get the "Either there is no default mail client or the current mail client cannot fulfill the messaging request.  Please run Microsoft Outlook and set it as the default mail client."  Followed by an AX MAPI error.

Or sometimes you may get the "A program is trying to access e-mail address information stored in Outlook."...Allow/Deny/Hlep.

This change basically bypasses outlook.  Put it in and give it a shot.



void reportSendMail(PrintJobSettings p1)
{
    //SysINetMail m = new SysINetMail();
    System.Net.Mail.MailMessage mailMessage;
    System.Net.Mail.Attachment attachment;
    System.Net.Mail.AttachmentCollection attachementCollection;
    System.Net.Mail.SmtpClient myMail;
    System.Net.Mail.MailAddress mailFrom;
    System.Net.Mail.MailAddress mailTo;
    str userMailAddress;
    str receiverMailAddress;
    str mailBody;
    str smtpServer;
    fileNameOpen fileNameForEmail;
    str mail;
    FileIOPermission perm;
    userinfo userInfo;
    //end Declaration
    str fileName = 'axaptareport';

    ;
    if (p1.format() == PrintFormat::ASCII)
        fileNameForEmail = subStr(p1.fileName(),strLen(p1.fileName())-3,-999)+'TXT';
    else if (p1.format() == PrintFormat::RTF)
        fileNameForEmail = subStr(p1.fileName(),strLen(p1.fileName())-3,-999)+'RTF';
    else if (p1.format() == PrintFormat::HTML)
        fileNameForEmail = subStr(p1.fileName(),strLen(p1.fileName())-3,-999)+'HTM';
    else if (p1.format() == PrintFormat::PDF || p1.format() == PrintFormat::PDF_EMBED_FONTS)
        fileNameForEmail = subStr(p1.fileName(),strLen(p1.fileName())-3,-999)+'PDF';

    mail = subStr(fileNameforEmail,(strlen(fileNameforEmail)-8),9);

    select firstonly name from userInfo where userInfo.id == SysuserInfo::find().Id; // to find the user name

    if (isRunningOnServer())
        fileNameforEmail = winApiServer::getTempPath() + mail; // store attachment in a temp location
    else
        fileNameforEmail = winApi::getTempPath() + mail; // store attachment in a temp location


    perm = new FileIOPermission(fileNameforEmail,'w');
    if(!perm)
    {
        throw error("Cannot move attachment to temp location.");
        return;
    }
    try
    {
        perm.assert();
    }
    catch
    {
        throw error("Cannot gain access to Temp location.");
        return;
    }

    userMailAddress = SysUserInfo::find().Email; // find current users email address setup up in user //options
    receiverMailAddress = p1.mailTo();

    mailFrom = new System.Net.Mail.MailAddress(userMailAddress,userInfo.name);

    mailTo = new System.Net.Mail.MailAddress(receiverMailAddress,"");

    mailBody = "Email sent from " + CompanyInfo::name() + ", using Dynamics AX";

    smtpServer = SysEmaiLParameters::find(false).SMTPRelayServerName;// using the SMTP server ip //setup in email Parameters

    mailMessage = new System.Net.Mail.MailMessage(mailFrom,mailTo);
    mailmessage.set_Subject(p1.mailSubject());
    mailmessage.set_Body(mailBody);

    //move attachment file to Temp folder, might need to create WinAPIServer::moveFile
    winapi::moveFile(p1.fileName(), fileNameforEmail);

    attachementCollection = mailMessage.get_Attachments();
    attachment = new System.Net.Mail.Attachment(fileNameforEmail);
    attachementCollection.Add(attachment);

    myMail = new System.Net.Mail.SmtpClient(smtpServer);
    mymail.Send(mailmessage);

    mailmessage.Dispose();
    attachment.Dispose();

    if (isRunningOnServer())
        WinAPIServer::deleteFile(fileNameForEmail);
    else
        winApi::deleteFile(fileNameforEmail);

    CodeAccessPermission::revertAssert();
}

/*
void reportSendMail(PrintJobSettings p1)
{
    SysINetMail m = new SysINetMail();

    str fileName = 'axaptareport';

    if (p1.format() == PrintFormat::ASCII || p1.format() == PrintFormat::TEXTUTF8)
        fileName = fileName + '.txt';
    else if (p1.format() == PrintFormat::RTF)
        fileName = fileName + '.rtf';
    else if (p1.format() == PrintFormat::HTML)
        fileName = fileName + '.htm';
    else if (p1.format() == PrintFormat::PDF || p1.format() == PrintFormat::PDF_EMBED_FONTS)
        fileName = fileName + '.pdf';

    m.sendMailAttach(p1.mailTo(),p1.mailCc(), p1.mailSubject(),'Medulla Report', true, p1.fileName(), fileName);
}
*/

Wednesday, November 16, 2011

How Alerts in AX work with templates and emails

Alerts in AX can be very useful, and I wanted to touch on how they work, along with some explanations, tips, and tricks.  Much of this post is basic, but there are some caveats explained as well.  I mainly use alerts to notify a user when a field or record is created, modified, deleted, etc.  A user specifically wanted to know whenever a credit limit changed for a customer...so this is the scenario I will be covering.

You first need to make sure you have an alert email template setup.  This can be done from Basic>Setup>Email Templates.  Here you will create your header alert record, and your line records are for different languages should you need them.



After you create the basic one as pictured, you click the "Template" button where you define your template.  I suggest putting in the following HTML code under the HTML tab from http://technet.microsoft.com/en-us/library/aa834376(AX.50).aspx

You then need to configure the alerts parameters at Basic>Setup>Alerts.  You want to choose the "Alerts" email id template you just created.


The "Drill-down target" can be confusing...but here is my interpretation of it.  You basically put something different for each of your AX environments (Dev/Test/Prod).  When you have an alert email sent automatically with hyperlinks, it will be something like Dynamics://DEV... (instead of http://DEV...) that will be clickable, and open an AX client and go directly to the alert or the alert's origin.

When you click the link, it basically tries to go to your default client config, and then it confirms that the "DEV" found in the URL also is found in the alerts setup.  If it is not, it just doesn't open the client.  So if you are getting alerts from Dev/Test/Prod, only the ones that have the correct Dynamics://[this part] will work.  This obviously means you must have a local client installed.

The types of alerts:
  • Changed based alerts
    • Alerts that occur when something changes, like a customer name, or customer credit limit
  • Due date alerts
    • Alerts that occur when something is due, typically workflow related

These both have batch jobs, located at Basic>Periodic>Alerts that should be running in order to actually create the alerts that meet the rule criteria.  If you aren't getting the alert bell pictures in the lower corner of your session, then one of these is probably not running correctly.

Now let's setup the alert rule to notify us when the credit limit has changed for a customer.  You can set alerts on anything pretty much you right click on.  Go to your customer details form, then the general tab, then right click on the credit limit field and click "Create alert rule" as pictured:


Now base AX only lets you alert one user.  You can create a user that has a distribution list for an email account, or you can do some simple modifications in X++ to support overwriting the alert email, or alerting multiple emails/users.  For now, you can just click OK here and then close the window behind it.

If we go to a customer and change their credit rating, an alert will be generated (provided the batch is running as stated earlier or it is run manually).

For the email portion, the way alerts are setup is to have traceable emails.  So what it does is dump it into an email sending queue that can be found at Administration>Periodic>E-mail processing>E-mail sending status.


This emailing subsystem is very handy, and open for many useful modifications if you're feeling creative.  Emails in this queue are picked up for delivery by the e-mail processing batch, which is located at Administration>Periodic>E-mail processing>Batch.  This must be running for the emails to go out.

You can view sent emails with the body of the message, and also resend them should you choose to do so.

There is also a retry schedule that is located in the same place where you can set your retry intervals for failed email attempts.


The framework is here behind the scenes to open it up for some very useful modifications.  For example, if you periodically email customers a survey, and you want to send them reminders to fill it out, or you are waiting for any other sort of user input and you want to send emails until they perform their action.

This just briefly outlines and touches on alerts and emails.  If you have any questions, feel free to comment.  I will be elaborating on email templates later on.

Tuesday, November 15, 2011

How to call a form from code and make it modal

This is how to open a form from X++ and set it modal.  Modal means it is on top of the other windows and you can not click on the ones behind it.  I have this just in a clicked event on a button.


void clicked()
{
    CustTable   custTable;
    FormRun     formRun;
    Args args = new Args();
    ;
    
    // Just selecting a random record in CustTable
    select firstonly custTable;

    // This is the name of the form you are opening
    args.name(formstr(CustTable));
    
    // This is the optional record you are passing it
    args.record(custTable);

    // This builds the formrun
    formRun = ClassFactory.formRunClass(args);
    formRun.init();
    formRun.run();

    // This waits until the form closes
    if (!formRun.closed())
        formrun.wait(true);
}

Monday, November 7, 2011

Changing dimensions on an item the easy way using xRecord.merge?

Many of us have hit the same issue at some point, where a dimension needs to be changed on an item, and going about doing it can be a very big hassle.

This post is purely experimental, and I am currently assessing the feasibility of it, so please be very critical of it.  I'm looking for feedback on issues I've not yet thought of.

A project I'm working on is changing/removing the serialization dimension on many different items. The customer has gone from AX 3.0 to 4.0 to 2009, so there is a good share of old/bad data, old open transactions, etc.

Our goal is to turn on "blank issue allowed" and "blank receipt allowed" to make transfers/movements easier.

There are two basic ways that I can think of to change the dimensions on the items, and these are very brief steps that would actually require more thought/elaboration in a real scenario:

  • Scrap out all inventory, close all open transactions, then change the dimension
    • Difficulty: High
  • Create a new item with desired dimension, transfer old item to new item, then rename primary key to get the ItemId back to old value
    • Difficulty: Low, but you lose item history, so this often won't work.

I tried to get creative and think out of the box on this one a bit because the amount of work required to close all open transactions is huge, plus could mean several days, maybe even weeks of downtime because of the amount of transactions constantly going on.  This customer can not be shut down for more than a few days too.

My idea is to create a new item with the desired dimension, then use the Kernel xRecord.Merge functionality to merge the item with the old dimension into the newly configured item, then use the primary key functionality to rename the put the ItemId back.  I just tried this yesterday, so I've barely looked at system functionality beyond some simple tests.  The transactions came over, the BOMs came, etc...so it has some promise.

Some initial thoughts where this might have some holes...since we are moving the old item into the new item, the new item will have a new recId.  So if there are any tag tables that reference by RecId, this would clearly cause an issue.  I believe if the correct relation is setup though, that it might propagate correctly.  Tables like the Address or DocuRef (document handling) however that use commons and reference by Table and RecId might have some issues.  I have yet to test this.

There are some other minor steps that will need to be done in this code, such as confirming that those tables I delete have the previous values passed over...things such as item price.  If you have other custom code, you may need to add/remove some of the tables I deleted.  The discovery process on this was pretty simple, I ran it, then the validate would fail and I'd delete records out of the table, and rerun until it worked.

The "Master" item here is the new item with the new dimension.  The "child" item is the original item with the dimension we want to change.

Anyway, here is the proof of concept code you've been waiting for.  Please comment with thoughts/concerns/experience:




static void ItemMerge(Args _args)
{
    ItemId      iMaster = '26101A'; // New item with newly configured dimension
    ItemId      iChild  = '26101';  // Original item with old dimension

    InventTable master;
    InventTable child;
    CUSInventTable          t1;
    InventTableModule       t2;
    InventItemLocation      t3;
    InventItemPurchSetup    t4;
    InventItemSalesSetup    t5;
    ConfigTable             t6;
    ;


    ttsbegin;
    delete_from t1
        where t1.ItemId == iMaster;

    delete_from t2
        where t2.ItemId == iMaster;

    delete_from t3
        where t3.ItemId == iMaster;

    delete_from t4
        where t4.ItemId == iMaster;

    delete_from t5
        where t5.ItemId == iMaster;

    delete_from t6
        where t6.ItemId == iMaster;

    child = InventTable::find(iChild, true);
    master = InventTable::find(iMaster, true);

    child.merge(master);
    master.doUpdate();
    child.doDelete();
    ttscommit;

    info("Done");
}

Thursday, August 4, 2011

How to change a customer's party type

I often see customers that are created wrong in client's systems.  They will have a party type of "organization" when they should really have "person" or vice versa.  The difference between the two, as far as I can tell, is that organizations are permitted to have multiple contacts, and other little things like that, while person types are supposed to be simpler.

Here is a little static method I wrote to change their party type.  One thing to worry about, when you go from Organization to Person, you may want to check if there are multiple contacts before you make the change.  Not exactly sure what it will do.




static server boolean changeCustPartyType(CustTable _custTable, DirPartyType _dirPartyType)
{
    CustTable       custTable;
    ;

    if (_custTable && _custTable.PartyType != _dirPartyType)
    {
        ttsbegin;

        custTable.selectForUpdate(true);
        custTable.data(_custTable);

        custTable.PartyType = _dirPartyType;

        custTable.setNameAlias();

        DirParty::updatePartyFromCommon(custTable.PartyId, custTable, DirSystemPrivacyGroupType::Public, true);

        custTable.doUpdate();

        smmBusRelTable::updateFromCustTableSFA2(custTable);

        custTable.setAccountOnVend(custTable.orig());

        smmTransLog::initTrans(custTable, smmLogAction::update);

        ttscommit;

        return true;
    }

    return false;
}