Pages

Friday, September 27, 2013

How to post a ledger transaction with help from LedgerJournalEngine

This is just some sample code I put together for somebody asking how to post customer payment line(s) using LedgerJournalEngine* classes.

LedgerJournalEngine* classes are mostly used by the forms to do work and execute code before/after events and datasource actions.

It may make more sense to just complete all of the LedgerJournalTrans fields, but using the engine can't really hurt and might be more helpful in some cases.


static void Job81(Args _args)
{
    LedgerJournalEngine_CustPayment ledgerJournalEngine;
    LedgerJournalTable              ledgerJournalTable;
    LedgerJournalTrans              ledgerJournalTrans;
    NumberSeq                       numberSeq;
    Voucher                         voucher;
    ;

    // This just selects the header you are inserting into
    select firstonly ledgerJournalTable where ledgerJournalTable.JournalNum == 'GB 0056226';

    if (!ledgerJournalTable)
        throw error ("Unable to find journal table record");


    ledgerJournalTrans.initValue();

    numberSeq = NumberSeq::newGetNumFromCode(ledgerJournalTable.VoucherSeries);

    if (numberSeq)
    {
        ledgerJournalTrans.Voucher      = numberSeq.num();
        voucher                         = ledgerJournalTrans.Voucher;
    }

    ledgerJournalTrans.JournalNum       = ledgerJournalTable.JournalNum;
    ledgerJournalTrans.TransDate        = SystemDateGet();
    ledgerJournalTrans.AccountType      = LedgerjournalACType::Cust;
    ledgerJournalTrans.AccountNum       = '100003';

    ledgerJournalEngine = LedgerJournalEngine::construct(LedgerJournalType::CustPayment);
    ledgerJournalEngine.newJournalActive(ledgerJournalTable);
    ledgerJournalEngine.accountModified(ledgerJournalTrans);
    ledgerJournalTrans.AmountCurCredit  = 10;
    ledgerJournalTrans.OffsetAccountType    = ledgerJournalTable.OffsetAccountType;
    ledgerJournalTrans.OffsetAccount        = ledgerJournalTable.OffsetAccount;
    ledgerJournalTrans.CurrencyCode         = CompanyInfo::standardCurrency();
    ledgerJournalEngine.currencyModified(ledgerJournalTrans);
    ledgerJournalTrans.insert();

    if (numberSeq   && ledgerJournalTrans.Voucher   == voucher)
    {
        numberSeq.used();
    }
    else
    {
        if (numberSeq)
            numberSeq.abort();
    }

    info("Done");
}

Thursday, September 26, 2013

How to properly send emails with the built in AX framework and templates

I often see developers reinventing the wheel or using copy/paste code to send emails from X++, when Microsoft has included a very robust emailing framework to use.  It's especially important to use once you start growing and sending many emails.  This is article is focused on AX 2009, but I'd like to think it applies to AX 2012.

A few of the features that I specifically like, that I think make it more valuable than simple X++ email sending routines are:

  • Traceability - You can choose if you want a static version of an email to be retained in the database.  This easily lets you know what emails were sent, when, and the contents of the message
  • Retry schedule - You can setup a retry schedule in case there are failures
  • Templates - You can setup HTML or XSLT templates where you pass arguments that are plugged into the subject/body. Developers can stay concerned with technical aspects and leave the look of the email to functional users.  There is also a 'pretty' editor if functional users don't want to learn HTML.
  • Multiple language support - The framework is designed around multiple languages and labels
  • Ease of use - Once you understand the framework, attachments, priority, traceability, etc., it becomes extremely easy to use all over the system and helps reduce failure points

Relevant configuration/setup forms:
  • Administration>Setup>E-mail Parameters
    • Here you setup your SMTP parameters that are specific to your organization
  • Administration>Periodic>E-mail processing>Retry Schedule
    • Here you setup your retry schedule
  • Administration>Periodic>E-mail processing>E-mail sending status
    • When you set emails to be 'traceable', they are queued here.  Take note of the checkbox to show sent emails
  • Administration>Periodic>E-mail processing>Batch
    • This batch is what will send emails that are awaiting in the above queue
    • This lets you control when emails get sent.  Some light customization and you could modify this if you wanted specific sending times for different groups of emails
Email templates:

Basic>Setup>E-mail Templates
  • The hierarchy is you define your sender/priority and optional batch group, then set your various email/language templates
  • If you need a different priority/sender/batch group, you need to create a new emailId (header section)
Take note of the General tab at the top, where you can set a senders priority and batch group.  If you are frequently changing email priority from the same sender, you can just create "Sender" and "SenderHigh" for example, then switch between those two.

The bottom section is where you can create the actual email templates for the different languages.  In the subject, you can put a default subject, or a custom replaceable "variable" such as %subject% or %AnythingYouWant%.

There are two available layouts, HTML and XSLT.  I will be working with HTML, but I will comment on XSLT.

After clicking "Template", you see where you actually type up the email template you want to use.

The variables need to start and end with the % sign, and they can be anything you want.

Once these basic setups are done, the code is easy and very re-usable.  One thing to note about the "traceable" parameter is that when you set it to false, one of two things will happen.  If your email sender has a batch group, then it will create a batch job and immediately execute, or it will just immediately send the email if it does not.











Here is a copy/paste version of the code:

static void SendEmailIntoQueueSimple(Args _args)
{
    SysEmailTable   sysEmailTable;
    Map             mappings        = new Map(Types::String, Types::String);
    // str             xml;
    ;

    // Build your variable/text mappings
    mappings.insert('subject', 'My Subject Here');
    mappings.insert('word1', 'This replaces word1');
    mappings.insert('word2', 'This replaces word2');
    mappings.insert('body', 'This will replace body with this string and this ' +
                            SysLabel::labelId2String(literalstr('@SYS25058'), 'en-us'));
    mappings.insert('TheseCanBeAnythingYouWant', 'More text');

    sysEmailTable = SysEmailTable::find('Custom');


    /*
    // If you were using XSLT, here is an example of how to build the XML
    // but you would most likely create your own static method
    xml = EventActionEmail::createEmailParameterXml(mappings);
    */

    SysEmailTable::sendMail(sysEmailTable.EmailId,
                            'en-us', // Chosen language
                            'WhoYouSendThisTo@fakemail.com', // Who you're sending the email to
                            mappings, // Your variable mappings
                            '', // Location of file attachment (server/client matters) or none
                            '' , // XML if you're using XSLT
                            true, // Traceable or not?
                            'admin', // Sending user
                            true); // Use retries?

    info("Done");
}

This is the actual email I received, where you can see the substitutions were done.




Hopefully, this post will be informative, and if I left anything out, please comment.  Happy DAX'ing!

Tuesday, July 30, 2013

Minor bug with base journal posting framework and fix

This is a bit of a frustrating bug that took some time to figure out.  This bug occurs when you are trying to fork to different journal transaction forms.  You will see it most likely when you click the "lines" button and nothing happens after the first click, and you don't know why.

An example of forking to different transaction forms can be seen from the production journals located at AOT\Forms\ProdJournalTable, where depending on your journal type, a different ProdJournalTrans* form is launched.

The bug/logic failure is located at Classes\JournalFormTable\fieldModifiedJournalNameIdPost(), and it only happens when you create a new record, select your journal name, and click on "lines" without saving.

Essentially what happens is the JournalTableData object is instantiated inside the JournalFormTable with a blank record, but the blank journal type enum is 0, which is also the first enum.  So later on, when the controls are trying to be initiated, it will set the menu item for the "Lines" button based on what your *JournalStatic\menuItemStrLines returns.  And that will return whatever the default 0 enum is.

The quick fix is, in your [Table_ds].JournalNameId\modified method, the standard code is this:

void modified()
{
    super();

    journalFormTable.fieldModifiedJournalNameIdPost();
}

You can just call the datasourceActivePre() method to reinitialize the JournalTableData's JournalTable after the correct journal type is filled as seen below:

void modified()
{
    super();

    journalFormTable.datasourceActivePre();
   
    journalFormTable.fieldModifiedJournalNameIdPost();
}

I'm guessing that very few people will come across this bug, but hopefully it helps someone.

Tuesday, August 7, 2012

Database log throws error "Error executing code: Wrong argument types for comparison."

So we have had our database log turned on since AX 3.0, and we've upgraded to AX 4.0, then AX 2009.  An ongoing issue has been with the database log throwing "Wrong argument types for comparison", and I've finally gotten around to diagnosing it, and it seems like it could be a very common issue caused by an upgrade.

Error executing code: Wrong argument types for comparison.
(C)\Data Dictionary\Tables\SysDataBaseLog\Methods\getDataAslist - line 28
(C)\Forms\SysDatabaseLog\Methods\buildContents - line 30
(C)\Forms\SysDatabaseLog\Data Sources\DBHistory\Methods\active - line 7


The actual issue is this, when we were in AX 3.0, we made a chance to a record, which was logged to Tables\SysDatabaseLog and had the ModifiedDate stored as a type of Date.  Somewhere along the AX 3.0 to AX 4.0 to AX 2009, this data wasn't upgraded properly.

When the database log compare form tries to work with it, it's expecting to work with two utcDateTime objects for comparison, but it gets a Date and fails.

You can put this code in Tables\SysDatabaseLog\getDataAslist at around line 30 and see the issue in the debugger:

if (typeOf(conpeek(tmp, 2)) == Types::Date      &&
    dictField.baseType()    == Types::UtcDateTime)
{
    breakpoint;
}

You can write a hack with conPoke(...) and DateTimeUtil::newDateTime(...) to replace the Date with a utcDateTime, but you'll run into issues in other methods.  The best thing to do is correct the data and I wrote a job to do just that.  One specific thing to note, this is just proof of concept code...for our actual corrective actions, I wrote a class and table to do batches for various reasons since we have nearly 4 million records.  As always, run at your own risk and test.  Enjoy:




static void DatabaseLogFix(Args _args)
{
    SysDataBaseLog  log;
    container       tmp;
    container       newData;
    SysDictField   dictField;
    fieldId         fieldId;
    int             i, idx;
    boolean         updateCon;
    boolean         updateData;
    Counter         counter;
    #Properties;
    ;

    //select firstonly log where log.RecId == 5638018469;
    
    ttsbegin;

    while select forupdate log
        order by CreatedDateTime, RecId
    {
        updateData = false;
        if (typeof(conpeek(log.Data, 1)) == Types::Container)   //Version 3.0 format
        {
            newData = log.Data;

            for (i=conlen(log.Data); i; i--)
            {
                updateCon = false;
                tmp = conpeek(log.Data, i);
                fieldId = conpeek(tmp, 1);
                dictField = new SysDictField(log.Table, fieldId);
                if (dictField)
                {
                    if (dictField.name() == #PropertyModifiedTime
                    || dictField.name() == #PropertyCreatedTime)
                    {
                        continue;
                    }

                    // Type mismatch for "New" value
                    if (typeOf(conPeek(tmp, 2)) != dictField.baseType())
                    {
                        // If it's a date that should be a utcDateTime, upgrade it
                        if (typeOf(conpeek(tmp, 2)) == Types::Date      &&
                            dictField.baseType()    == Types::UtcDateTime)
                        {
                            tmp = conPoke(tmp, 2, DateTimeUtil::newDateTime(conPeek(tmp, 2), 0));
                            updateCon = true;
                        }
                        // Otherwise throw an error
                        else
                        {
                            info(strfmt("[RecId %1][TableID %5][FieldId %2][2 NEW] Type mismatch (%3/%4)", log.RecId, fieldId, typeOf(conPeek(tmp, 2)), dictField.baseType(), log.table));
                        }
                    }

                    // Type mismatch for "Old" value
                    if (typeOf(conPeek(tmp, 3)) != dictField.baseType())
                    {
                        // If it's a date that should be a utcDateTime, upgrade it
                        if (typeOf(conpeek(tmp, 3)) == Types::Date      &&
                            dictField.baseType()    == Types::UtcDateTime)
                        {
                            tmp = conPoke(tmp, 3, DateTimeUtil::newDateTime(conPeek(tmp, 3), 0));
                            updateCon = true;
                        }
                        // Otherwise throw an error
                        else
                        {
                            info(strfmt("[RecId %1][TableID %5][FieldId %2][3 OLD] Type mismatch (%3/%4)", log.RecId, fieldId, typeOf(conPeek(tmp, 3)), dictField.baseType(), log.table));
                        }
                    }

                    if (updateCon)
                    {
                        updateData = true;
                        newData = conPoke(newData, i, tmp);
                    }
                }
            }

            if (updateData)
            {
                counter++;
                log.Data = newData;
                log.update();
            }
        }
    }
    ttscommit;
    
    select count(RecId) from log;

    info(strfmt("%1 updates made against %2 records", counter, log.RecId));


}

Tuesday, June 26, 2012

How to find the maximum number of logged on users on a given day...

A very typical question that every client faces during their yearly Microsoft renewal is "How many user licenses are we actually using?"  There is no sense in paying for an extra 30 users that you don't need.

I wrote this simple job to find the maximum number of users logged in during a working day for the last 90 days.  You will most likely need to tweak the "addHours" functions for your working days.  I was having all sorts of problems with UTC and timezone offset, so I just added numbers until it was right.

This checks max users for every hour because my original task was to show user count by hour.  You can easily modify this to check every second or whatever if you need a much more detailed number.



static void JobFindMaxUsersLoggedInPerDay(Args _args)
{
    SysUserLog      sysUserLog;
    utcDateTime     utc = DateTimeUtil::addHours(DateTimeUtil::newDateTime(systemDateGet(), 0), 8);
    int             i;
    int             n;
    int             iUsers;
    ;

    utc = DateTimeUtil::newDateTime(systemDateGet(), 0);

    utc = DateTimeUtil::addDays(DateTimeUtil::addHours(utc, 13), -13);
//    utc = DateTimeUtil::applyTimeZoneOffset(utc, DateTimeUtil::getUserPreferredTimeZone());

    for (i=1; i<=90; i++)
    {
        iUsers = 0;
        for (n=1; n<=11; n++)
        {
            // Find the number of users logged in
            select count(recId) from sysUserLog
                where sysUserLog.createdDateTime < DateTimeUtil::addHours(utc, n)      &&
                      sysUserLog.LogoutDateTime  > DateTimeUtil::addHours(utc, n)      &&
                      sysUserLog.LogoutDateTime;

            if (!iUsers || sysUserLog.RecId > iUsers)
                iUsers = sysUserLog.RecId;
        }

        info(strfmt("%1, %2", DateTimeUtil::date(utc), iUsers));
        utc = DateTimeUtil::addDays(utc, -1);
    }
}

Tuesday, March 27, 2012

Job to export all AX 2009 security groups to files

I have been changing security a bunch lately and it's nice to have a backup of your previous settings if you make a mistake.  Here is a quick job I wrote to export your existing security settings to a folder.



static void ExportSecurityGroups(Args _args)
{
    SysSecurityUserGroup    sysSecurity = SysSecurityUserGroup::construct();
    UserGroupInfo           userGroupInfo;
    DomainInfo              domainInfo;

    #file
    Dialog                dialog  = new Dialog("@SYS14863");
    DialogField           dialogFileName;
    DialogField           dialogDomain;
    Object                formdialog;
    ;

    dialogFileName = dialog.addField(typeid(FilePath),"@SYS16423");
    dialogDomain    = dialog.addField(typeid(DomainId), "@SYS89852");
    dialog.doInit();
    formdialog = dialog.formRun();
    formdialog.filenameLookupTitle("@SYS53669");

    if (dialog.run() && WinAPI::pathExists(dialogFileName.value())  &&
        (dialogDomain.value() == ''     ||
         (select firstonly domainInfo where domainInfo.Id == dialogDomain.value()).recId != 0))
    {
        if (dialogDomain.value() == '')
            dialogDomain.value('Admin');
        
        while select userGroupInfo
        {
            sysSecurity.parmUserGroupId(userGroupInfo.id);
            sysSecurity.parmDomainId(dialogDomain.value());
            sysSecurity.load();
            sysSecurity.export(dialogFileName.value() + @'\' + dialogDomain.value() + '_' + userGroupInfo.id + '.asg');
        }
    }
    else
        warning("No action taken...");

    info("Done");
}

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()));
    }
}